Feature 322 Content

Stepper แสดงขั้นตอน

A four-step order wizard with per-step validation; the final step saves the order in SQL Server.

Live demo

Everything here talks to the real Spring Boot + SQL Server back end.

Who is ordering?

Step 1 of 4

Latest orders

#CustomerPlanSeatsAdd-onsCity
10Kittipong SrisukBUSINESS 80Priority support, OnboardingBangkok
9Anna LeeSTARTER 1Chiang Mai
8Somchai JaideeTEAM 12Priority supportBangkok

What to notice

  • Each step is validated in the browser before Next is allowed.
  • The server validates the whole order again with Bean Validation (@Valid).
  • Done steps can be clicked to go back and edit.

Try it

Source code

7 parts, read from the files in this project
src/main/java/com/study/uiux/web/ApiExceptionHandler.java Field errors from @Valid as JSON
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, Object>> invalid(MethodArgumentNotValidException e) {
    Map<String, String> errors = new LinkedHashMap<>();
    for (FieldError fe : e.getBindingResult().getFieldErrors()) errors.putIfAbsent(fe.getField(), fe.getDefaultMessage());
    return ResponseEntity.badRequest().body(Map.of("message", "Please check the highlighted fields.", "errors", errors));
}
src/main/java/com/study/uiux/web/BoardApi.java POST /api/orders: the last step of the wizard
@PostMapping("/orders")
@ResponseStatus(HttpStatus.CREATED)
public OrderRequest createOrder(@Valid @RequestBody OrderForm f) {
    String addons = f.addons() == null ? "" : String.join(", ", f.addons());
    return orders.create(new OrderRequest(f.customerName().strip(), f.email().strip(), f.phone(), f.planCode(), f.seats(),
            addons, f.addressLine().strip(), f.city().strip(), f.postalCode(), f.notes() == null ? "" : f.notes().strip()));
}
src/main/java/com/study/uiux/web/Dtos.java The full order is validated again on the server
public record OrderForm(
        @NotBlank(message = "Name is required.") @Size(max = 100) String customerName,
        @NotBlank(message = "E-mail is required.") @Email(message = "Enter a valid e-mail address.") @Size(max = 150) String email,
        @NotBlank(message = "Phone is required.") @Pattern(regexp = "0[0-9]{8,9}", message = "Phone must be 9-10 digits and start with 0.") String phone,
        @NotBlank @Pattern(regexp = "STARTER|TEAM|BUSINESS", message = "Choose a plan.") String planCode,
        @NotNull(message = "Seats are required.") @Min(value = 1, message = "At least 1 seat.") @Max(value = 500, message = "At most 500 seats.") Integer seats,
        List<@Pattern(regexp = "Priority support|Onboarding|Extra storage") String> addons,
        @NotBlank(message = "Address is required.") @Size(max = 200) String addressLine,
        @NotBlank(message = "City is required.") @Size(max = 80) String city,
        @NotBlank(message = "Postal code is required.") @Pattern(regexp = "[0-9]{5}", message = "Postal code is 5 digits.") String postalCode,
        @Size(max = 500) String notes) {
}
src/main/resources/db/migration/V1__schema.sql order_request: written by the stepper, deleted with a confirm dialog
CREATE TABLE order_request (
    id            INT IDENTITY(1,1) NOT NULL CONSTRAINT pk_order_request PRIMARY KEY,
    customer_name NVARCHAR(100) NOT NULL,
    email         NVARCHAR(150) NOT NULL,
    phone         NVARCHAR(20)  NOT NULL,
    plan_code     NVARCHAR(20)  NOT NULL,
    seats         INT           NOT NULL,
    addons        NVARCHAR(200) NOT NULL CONSTRAINT df_order_addons DEFAULT N'',
    address_line  NVARCHAR(200) NOT NULL,
    city          NVARCHAR(80)  NOT NULL,
    postal_code   NVARCHAR(10)  NOT NULL,
    notes         NVARCHAR(500) NOT NULL CONSTRAINT df_order_notes DEFAULT N'',
    created_at    DATETIME2(0)  NOT NULL CONSTRAINT df_order_created DEFAULT SYSUTCDATETIME(),
    CONSTRAINT ck_order_plan  CHECK (plan_code IN (N'STARTER', N'TEAM', N'BUSINESS')),
    CONSTRAINT ck_order_seats CHECK (seats BETWEEN 1 AND 500)
);
src/main/resources/templates/demos/l322.html Stepper markup: one <li> per step, one panel per step
<ol class="stepper" id="stepper" aria-label="Order steps">
  <li class="step current" data-step="0"><button type="button" aria-current="step">Customer</button></li>
  <li class="step" data-step="1"><button type="button" disabled>Plan</button></li>
  <li class="step" data-step="2"><button type="button" disabled>Address</button></li>
  <li class="step" data-step="3"><button type="button" disabled>Review</button></li>
</ol>
<form id="wizard" novalidate>
  <div class="step-panel" data-panel="0">
    <h3>Who is ordering?</h3>
    <div class="form-grid">
      <label class="field">Full name <input class="input" name="customerName" required maxlength="100" autocomplete="name"><span class="field-error" data-error="customerName"></span></label>
      <label class="field">E-mail <input class="input" name="email" type="email" required autocomplete="email"><span class="field-error" data-error="email"></span></label>
      <label class="field">Phone <small>(starts with 0, 9-10 digits)</small><input class="input" name="phone" required inputmode="tel" pattern="0[0-9]{8,9}" autocomplete="tel"><span class="field-error" data-error="phone"></span></label>
    </div>
  </div>
  <div class="step-panel" data-panel="1" hidden>
    <h3>Choose a plan</h3>
    <div class="plan-cards">
      <label class="plan-card"><input type="radio" name="planCode" value="STARTER" required><b>Starter</b><span class="price">$9</span><small>1 seat, community help</small></label>
      <label class="plan-card"><input type="radio" name="planCode" value="TEAM" checked><b>Team</b><span class="price">$29</span><small>Up to 50 seats</small></label>
      <label class="plan-card"><input type="radio" name="planCode" value="BUSINESS"><b>Business</b><span class="price">$99</span><small>Up to 500 seats, SSO</small></label>
    </div>
    <span class="field-error" data-error="planCode"></span>
    <div class="form-grid" style="margin-top:1rem">
      <label class="field">Seats <input class="input" name="seats" type="number" min="1" max="500" value="5" required><span class="field-error" data-error="seats"></span></label>
      <fieldset class="field" style="border:0;padding:0;margin:0"><legend style="margin-bottom:.4rem">Add-ons</legend>
        <label class="row" style="gap:.4rem;font-weight:500"><input type="checkbox" class="check" name="addons" value="Priority support"> Priority support</label>
        <label class="row" style="gap:.4rem;font-weight:500"><input type="checkbox" class="check" name="addons" value="Onboarding"> Onboarding</label>
        <label class="row" style="gap:.4rem;font-weight:500"><input type="checkbox" class="check" name="addons" value="Extra storage"> Extra storage</label>
      </fieldset>
    </div>
  </div>
  <div class="step-panel" data-panel="2" hidden>
    <h3>Where should we send the invoice?</h3>
    <div class="form-grid">
      <label class="field" style="grid-column:1/-1">Address <input class="input" name="addressLine" required maxlength="200" autocomplete="street-address"><span class="field-error" data-error="addressLine"></span></label>
      <label class="field">City <input class="input" name="city" required maxlength="80" autocomplete="address-level2"><span class="field-error" data-error="city"></span></label>
      <label class="field">Postal code <input class="input" name="postalCode" required inputmode="numeric" pattern="[0-9]{5}" maxlength="5" autocomplete="postal-code"><span class="field-error" data-error="postalCode"></span></label>
      <label class="field" style="grid-column:1/-1">Notes <small>(optional)</small><textarea class="textarea" name="notes" maxlength="500" rows="2"></textarea></label>
    </div>
  </div>
  <div class="step-panel" data-panel="3" hidden>
    <h3>Check and confirm</h3>
    <dl class="review-list" id="review"></dl>
    <p class="field-error" id="wizardError"></p>
  </div>
  <div class="card-foot">
    <button class="btn btn-ghost" type="button" id="wizBack" disabled><svg class="i"><use href="#i-chevron-left"/></svg>Back</button>
    <span class="muted small" id="wizInfo">Step 1 of 4</span>
    <button class="btn btn-primary" type="submit" id="wizNext">Next<svg class="i"><use href="#i-chevron-right"/></svg></button>
  </div>
</form>
src/main/resources/static/css/app.css Stepper: circles joined by a line that fills as you go
.stepper { display: flex; list-style: none; margin: 0 0 1.5rem; padding: 0; counter-reset: step; }
.step { flex: 1; position: relative; display: flex; flex-direction: column; align-items: center; text-align: center; gap: .4rem; font-size: .8rem; color: var(--text-3); }
.step::before {
  counter-increment: step; content: counter(step); width: 2.3rem; height: 2.3rem; border-radius: 50%; display: grid; place-items: center; font-weight: 700;
  background: var(--surface); border: 2px solid var(--border-strong); color: var(--text-3); position: relative; z-index: 1; transition: all .25s;
}
.step + .step::after { content: ""; position: absolute; top: 1.15rem; right: 50%; width: 100%; height: 2px; background: var(--border-strong); }
.step.done::before { content: "✓"; background: var(--primary); border-color: var(--primary); color: var(--on-primary); }
.step.done, .step.current { color: var(--text); }
.step.current::before { border-color: var(--primary); color: var(--primary-text); box-shadow: 0 0 0 5px var(--ring); }
.step.done + .step::after, .step.current::after { background: var(--primary); }
.step button { all: unset; cursor: pointer; font-weight: 600; }
.step button:focus-visible { outline: 3px solid var(--ring); border-radius: .3rem; }
.step-panel { animation: step-in .3s var(--ease); }
@keyframes step-in { from { opacity: 0; transform: translateX(14px); } }
.plan-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 11rem), 1fr)); gap: .75rem; }
.plan-card { border: 2px solid var(--border); border-radius: var(--radius); padding: 1rem; cursor: pointer; display: flex; flex-direction: column; gap: .2rem; position: relative; }
.plan-card:has(input:checked) { border-color: var(--primary); background: var(--primary-soft); }
.plan-card input { position: absolute; opacity: 0; }
.plan-card:has(input:focus-visible) { box-shadow: 0 0 0 4px var(--ring); }
.plan-card b { font-size: 1rem; }
.plan-card .price { font-size: 1.35rem; font-weight: 800; }
.plan-card small { color: var(--text-3); }
.review-list { display: grid; grid-template-columns: max-content 1fr; gap: .45rem 1.25rem; margin: 0; font-size: .9rem; }
.review-list dt { color: var(--text-3); }
.review-list dd { margin: 0; font-weight: 600; word-break: break-word; }
@media (max-width: 560px) { .step { font-size: 0; } .step::before { font-size: .85rem; } }
src/main/resources/static/js/demos.js Validate only the fields of the current step
function validateStep(i) {
  let ok = true, firstBad = null;
  $$('input, textarea, select', panels[i]).forEach((el) => {
    const slot = $(`[data-error="${el.name}"]`, form);
    if (!el.checkValidity()) {
      ok = false;
      firstBad = firstBad || el;
      el.setAttribute('aria-invalid', 'true');
      if (slot) slot.textContent = el.validity.valueMissing ? 'This field is required.' : el.validity.patternMismatch ? (el.title || 'Please check the format.') : el.validationMessage;
    } else {
      el.removeAttribute('aria-invalid');
      if (slot && el.type !== 'radio' && el.type !== 'checkbox') slot.textContent = '';
    }
  });
  firstBad?.focus();
  return ok;
}

Are you sure?

Rename

↑↓ moveEnter openEsc close