4
\$\begingroup\$

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

  1. Is there any room for "shortening" the code?
  2. Anything inconsistent at the logic level?
\$\endgroup\$

2 Answers 2

4
\$\begingroup\$

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" />

\$\endgroup\$
0
4
\$\begingroup\$

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!

1

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;
\$\endgroup\$
2
  • \$\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\$ 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\$ May 21, 2021 at 5:34

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.