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.Latest orders
| # | Customer | Plan | Seats | Add-ons | City |
|---|---|---|---|---|---|
| 10 | Kittipong Srisuk | BUSINESS | 80 | Priority support, Onboarding | Bangkok |
| 9 | Anna Lee | STARTER | 1 | Chiang Mai | |
| 8 | Somchai Jaidee | TEAM | 12 | Priority support | Bangkok |
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;
}