I have made this small checkout stepper with Vue (v 2.x.x):
var app = new Vue({
el: "#cart",
data: {
stepCounter: 1,
steps: [{
step: 1,
completed: false,
text: "Cart"
},
{
step: 2,
completed: false,
text: "Shipping"
},
{
step: 3,
completed: false,
text: "Payment"
},
{
step: 4,
completed: false,
text: "Confirmation"
}
]
},
methods: {
doPrev: function() {
if (this.stepCounter > 1) {
this.stepCounter--;
this.doCompleted();
}
},
doNext: function() {
if (this.stepCounter <= this.steps.length) {
this.stepCounter++;
this.doCompleted();
}
},
doCompleted: function() {
this.steps.forEach(item => {
item.completed = item.step < this.stepCounter;
});
}
}
});
* {
margin: 0;
padding: 0;
font-family: "Poppins", sans-serif;
}
.progressbar {
display: flex;
list-style-type: none;
counter-reset: steps;
padding-top: 50px;
justify-content: space-between;
}
.progressbar li {
font-size: 13px;
text-align: center;
position: relative;
flex-grow: 1;
flex-basis: 0;
color: rgba(0, 0, 0, 0.5);
font-weight: 600;
}
.progressbar li.completed {
color: #ccc;
}
.progressbar li.active {
color: #4caf50;
}
.progressbar li::after {
counter-increment: steps;
content: counter(steps, decimal);
display: block;
width: 30px;
height: 30px;
line-height: 30px;
border: 2px solid rgba(0, 0, 0, 0.5);
background: #fff;
border-radius: 50%;
position: absolute;
left: 50%;
margin-left: -15px;
margin-top: -60px;
}
.progressbar li.active::after,
.progressbar li.completed::after {
background: #4caf50;
border-color: rgba(0, 0, 0, 0.15);
color: #fff;
}
.progressbar li.completed::after {
content: '2713円';
}
.progressbar li::before {
content: "";
position: absolute;
top: -26px;
left: -50%;
width: 100%;
height: 2px;
background: rgba(0, 0, 0, 0.5);
z-index: -1;
}
.progressbar li.active::before,
.progressbar li.completed::before,
.progressbar li.active+li::before {
background: #4caf50;
}
.progressbar li:first-child::before {
display: none;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/vue/2.6.11/vue.min.js"></script>
<link href="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/4.4.1/css/bootstrap.min.css" rel="stylesheet" />
<div id="cart" class="mt-2">
<div class="container">
<ul class="progressbar">
<li v-for="(step, index) in steps" v-bind:class="{ active: index + 1 === stepCounter, completed: step.completed === true }">{{step.text}}</li>
</ul>
</div>
<div class="container px-4 mt-5 text-center">
<div class="d-flex justify-content-between">
<button type="button" class="btn btn-sm btn-success" v-bind:class="{ disabled : stepCounter === 1}" @click="doPrev()">Prev</button>
<button type="button" class="btn btn-sm btn-success" v-bind:class="{ disabled : stepCounter > steps.length}" @click="doNext()">Next</button>
</div>
</div>
</div>
Questions
- Is there any room for "shortening" the code?
- Anything inconsistent at the logic level?
2 Answers 2
Using step
as a property inside each step is redundant and can lead to bugs if the value is incorrect. Consider what would happen if you had this:
data: {
stepCounter: 1,
steps: [{
step: 5,
completed: false,
text: "Cart"
},
{
step: 3,
completed: false,
text: "Shipping"
},
{
step: 7,
completed: false,
text: "Payment"
},
{
step: 1,
completed: false,
text: "Confirmation"
}
]
},
To solve this issue, remove the step
from the data completely.
data: {
stepCounter: 1,
steps: [{
completed: false,
text: "Cart"
},
{
completed: false,
text: "Shipping"
},
{
completed: false,
text: "Payment"
},
{
completed: false,
text: "Confirmation"
}
]
},
A similar mistake can happen if the completed
property is entered incorrectly. Instead, use the current stepCounter
property to determine when to mark a step as completed.
To make the component reusable, I would recommend using some props instead of only data. Suggested props would be the stepCounter
and the text for each step.
Preferably, I would like to see it possible to use your component like this:
<Stepper :steps="['step one', 'step two', 'step three']" currentStep="2" />
Is there any room for "shortening" the code?
Shorthands
Shorthands can simplify the markup. The click handlers already use the shorthand @click
instead of v-on:click
. The bindings can be simplified as well. For example - instead of:
v-bind:class="..."
the v-bind
can be omitted:
:class="..."
Watch out!
Yesterday there was an answer to your TODO app post which suggested using computed properties. With the current architecture using a computed property doesn't seem feasible, but a watcher method could be employed. Instead of setting up a method like doCompleted()
that must be called manually, a watcher method can be used to adjust the values whenever that value changes.
watch: {
stepCounter: function(newValue, oldValue) {
this.steps.forEach(item => {
item.completed = item.step < this.stepCounter;
});
}
},
Note that newValue
could be used instead of this.stepCounter
.
While it would occupy the same number of lines as the current code, it takes the burden off the methods doPrev
and doNext
of having to call the method doCompleted
. See the demo below for an illustration of this.
const makeStep = (text, index) => {return {text, step: ++index, completed: false}};
const steps = ['Cart', 'Shipping', 'Payment', 'Confirmation'].map(makeStep);
var app = new Vue({
el: "#cart",
data: {
stepCounter: 1,
steps
},
watch: {
stepCounter: function(newValue, oldValue) {
this.steps.forEach(item => {
item.completed = item.step < this.stepCounter;
});
}
},
methods: {
doPrev: function() {
if (this.stepCounter > 1) {
this.stepCounter--;
}
},
doNext: function() {
if (this.stepCounter <= this.steps.length) {
this.stepCounter++;
}
}
}
});
* {
margin: 0;
padding: 0;
font-family: "Poppins", sans-serif;
}
.progressbar {
display: flex;
list-style-type: none;
counter-reset: steps;
padding-top: 50px;
justify-content: space-between;
}
.progressbar li {
font-size: 13px;
text-align: center;
position: relative;
flex-grow: 1;
flex-basis: 0;
color: rgba(0, 0, 0, 0.5);
font-weight: 600;
}
.progressbar li.completed {
color: #ccc;
}
.progressbar li.active {
color: #4caf50;
}
.progressbar li::after {
counter-increment: steps;
content: counter(steps, decimal);
display: block;
width: 30px;
height: 30px;
line-height: 30px;
border: 2px solid rgba(0, 0, 0, 0.5);
background: #fff;
border-radius: 50%;
position: absolute;
left: 50%;
margin: -60px 0 0 -15px;
}
.progressbar li.active::after,
.progressbar li.completed::after {
background: #4caf50;
border-color: rgba(0, 0, 0, 0.15);
color: #fff;
}
.progressbar li.completed::after {
content: '2713円';
}
.progressbar li::before {
content: "";
position: absolute;
top: -26px;
left: -50%;
width: 100%;
height: 2px;
background: rgba(0, 0, 0, 0.5);
z-index: -1;
}
.progressbar li.active::before,
.progressbar li.completed::before,
.progressbar li.active+li::before {
background: #4caf50;
}
.progressbar li:first-child::before {
display: none;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/vue/2.6.11/vue.min.js"></script>
<link href="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/4.4.1/css/bootstrap.min.css" rel="stylesheet" />
<div id="cart" class="mt-2">
<div class="container">
<ul class="progressbar">
<li v-for="(step, index) in steps" :class="{ active: index + 1 === stepCounter, completed: step.completed }">{{step.text}}</li>
</ul>
</div>
<div class="container px-4 mt-5 text-center">
<div class="d-flex justify-content-between">
<button type="button" class="btn btn-sm btn-success" :class="{ disabled : stepCounter === 1}" @click="doPrev()">Prev</button>
<button type="button" class="btn btn-sm btn-success" :class="{ disabled : stepCounter > steps.length}" @click="doNext()">Next</button>
</div>
</div>
</div>
If the data structure can be modified such that the completed property isn't needed, then the markup could use the condition step.step < stepCounter
to determine when the completed
class is added to each list item. See the snippet below for a demonstration of this.
const makeStep = (text, index) => {return {text, step: ++index}};
const steps = ['Cart', 'Shipping', 'Payment', 'Confirmation'].map(makeStep);
const app = new Vue({
el: "#cart",
data: {
stepCounter: 1,
steps
},
methods: {
doPrev: function() {
if (this.stepCounter > 1) {
this.stepCounter--;
}
},
doNext: function() {
if (this.stepCounter <= this.steps.length) {
this.stepCounter++;
}
}
}
});
* {
margin: 0;
padding: 0;
font-family: "Poppins", sans-serif;
}
.progressbar {
display: flex;
list-style-type: none;
counter-reset: steps;
padding-top: 50px;
justify-content: space-between;
}
.progressbar li {
font-size: 13px;
text-align: center;
position: relative;
flex-grow: 1;
flex-basis: 0;
color: rgba(0, 0, 0, 0.5);
font-weight: 600;
}
.progressbar li.completed {
color: #ccc;
}
.progressbar li.active {
color: #4caf50;
}
.progressbar li::after {
counter-increment: steps;
content: counter(steps, decimal);
display: block;
width: 30px;
height: 30px;
line-height: 30px;
border: 2px solid rgba(0, 0, 0, 0.5);
background: #fff;
border-radius: 50%;
position: absolute;
left: 50%;
margin: -60px 0 0 -15px;
}
.progressbar li.active::after,
.progressbar li.completed::after {
background: #4caf50;
border-color: rgba(0, 0, 0, 0.15);
color: #fff;
}
.progressbar li.completed::after {
content: '2713円';
}
.progressbar li::before {
content: "";
position: absolute;
top: -26px;
left: -50%;
width: 100%;
height: 2px;
background: rgba(0, 0, 0, 0.5);
z-index: -1;
}
.progressbar li.active::before,
.progressbar li.completed::before,
.progressbar li.active+li::before {
background: #4caf50;
}
.progressbar li:first-child::before {
display: none;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/vue/2.6.11/vue.min.js"></script>
<link href="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/4.4.1/css/bootstrap.min.css" rel="stylesheet" />
<div id="cart" class="mt-2">
<div class="container">
<ul class="progressbar">
<li v-for="(step, index) in steps" v-bind:class="{ active: index + 1 === stepCounter, completed: step.step < stepCounter }">{{step.text}}</li>
</ul>
</div>
<div class="container px-4 mt-5 text-center">
<div class="d-flex justify-content-between">
<button type="button" class="btn btn-sm btn-success" v-bind:class="{ disabled : stepCounter === 1}" @click="doPrev()">Prev</button>
<button type="button" class="btn btn-sm btn-success" v-bind:class="{ disabled : stepCounter > steps.length}" @click="doNext()">Next</button>
</div>
</div>
</div>
CSS: margin rules
The ruleset .progressbar li::after {
contains these rules:
margin-left: -15px; margin-top: -60px;
Presuming there is no margin inherited from other elements for the right and bottom, those can be combined into a single rule:
margin: -60px 0 0 -15px;
-
\$\begingroup\$ Nitpick: wouldn't your
margin: -60px 0 0 -15px;
override some right/bottom margin defined earlier in the chain, while the two separate definition's don't? \$\endgroup\$Guntram Blohm– Guntram Blohm2021年05月21日 05:17:13 +00:00Commented May 21, 2021 at 5:17 -
\$\begingroup\$ Good catch- I considered it and should have mentioned it. I have updated that section accordingly. Thanks! \$\endgroup\$2021年05月21日 05:34:42 +00:00Commented May 21, 2021 at 5:34
Explore related questions
See similar questions with these tags.