feat(ui): build the Stepper component
Replaces a scaffold that rendered bare anchors with ordered steps: complete, current and upcoming status derived from the active index, horizontal or vertical, optional icons, and indexed named slots (step-0, step-1, ...) for authoring a step body by hand. Also tightens the roving contract from the previous commit. A template writes data-wrn-roving="" or data-wrn-roving-item="false" to mean not this time, but a bare [attr] selector matches either, so a read-only stepper would still have taken arrow-key focus. The container now requires a named axis, while a bare item marker still counts as opted in. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,24 +1,222 @@
|
||||
// Stepper -- ordered progress through a sequence.
|
||||
//
|
||||
// <Stepper steps='[{"label":"Account"},{"label":"Billing"}]' active={1} />
|
||||
//
|
||||
// Each step can be authored by hand instead of using the built-in body, by
|
||||
// passing a slot named for its index:
|
||||
//
|
||||
// <Stepper steps={steps} active={1}>
|
||||
// <div data-slot="step-1">...anything...</div>
|
||||
// </Stepper>
|
||||
//
|
||||
// NOTE: the style block uses /* */ comments only -- // is not a CSS comment
|
||||
// and silently swallows the rule that follows it.
|
||||
component Stepper {
|
||||
outputs {
|
||||
change(payload: { value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)
|
||||
previous(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
|
||||
next(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
|
||||
complete(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
|
||||
change(payload: { index: number; step: object })
|
||||
}
|
||||
|
||||
props {
|
||||
size: string = "default"
|
||||
color: string = "primary"
|
||||
label: string = "Stepper"
|
||||
items: unknown[] = []
|
||||
active: string = ""
|
||||
steps: unknown[] = []
|
||||
active: number = 0
|
||||
orientation: string = "horizontal"
|
||||
clickable: boolean = false
|
||||
label: string = "Progress"
|
||||
class: string = ""
|
||||
}
|
||||
|
||||
functions {
|
||||
shared function stepList() {
|
||||
return Array.isArray(steps) ? steps : []
|
||||
}
|
||||
|
||||
shared function activeIndex() {
|
||||
var count = stepList().length
|
||||
if (count < 1) {
|
||||
return 0
|
||||
}
|
||||
var value = Number(active)
|
||||
if (!value || value < 0) {
|
||||
return 0
|
||||
}
|
||||
return Math.min(value, count - 1)
|
||||
}
|
||||
|
||||
shared function statusFor(index) {
|
||||
if (index < activeIndex()) {
|
||||
return "complete"
|
||||
}
|
||||
if (index === activeIndex()) {
|
||||
return "current"
|
||||
}
|
||||
return "upcoming"
|
||||
}
|
||||
|
||||
// An empty orientation is how the roving runtime is told to stay out of
|
||||
// the way, so a read-only stepper never takes arrow-key focus.
|
||||
shared function rovingAxis() {
|
||||
if (!clickable) {
|
||||
return ""
|
||||
}
|
||||
return orientation === "vertical" ? "vertical" : "horizontal"
|
||||
}
|
||||
|
||||
client function selectStep(index, step) {
|
||||
if (!clickable) {
|
||||
return
|
||||
}
|
||||
output.change({ index: index, step: step })
|
||||
}
|
||||
}
|
||||
|
||||
view {
|
||||
<nav class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--stepper wire-next--{orientation} {class}" aria-label="{label}">
|
||||
{#each items as item}<a href="{item.href}" aria-current="{item.value === active ? 'page' : ''}">{item.label}</a>{/each}
|
||||
<ol
|
||||
{...attrs}
|
||||
data-ui-component="Stepper"
|
||||
class='wire-stepper {class}'
|
||||
data-orientation='{orientation}'
|
||||
data-clickable='{clickable}'
|
||||
data-wrn-roving='{rovingAxis()}'
|
||||
aria-label='{label}'
|
||||
>
|
||||
{#each stepList() as step, index}
|
||||
<li
|
||||
class="wire-stepper__step"
|
||||
data-status='{statusFor(index)}'
|
||||
aria-current='{statusFor(index) === "current" ? "step" : "false"}'
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="wire-stepper__button"
|
||||
data-wrn-roving-item='{clickable ? "true" : "false"}'
|
||||
@click='selectStep(index, step)'
|
||||
>
|
||||
<span class="wire-stepper__marker" aria-hidden="true">
|
||||
<span class='wire-stepper__icon {step.icon}' data-show="step.icon"></span>
|
||||
<span class="wire-stepper__number" data-show="!step.icon">{index + 1}</span>
|
||||
</span>
|
||||
<span class="wire-stepper__body">
|
||||
<span class="wire-stepper__label">{step.label}</span>
|
||||
<span class="wire-stepper__description" data-show="step.description">
|
||||
{step.description}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<span class="wire-stepper__custom">
|
||||
<slot name="step-{index}"></slot>
|
||||
</span>
|
||||
</li>
|
||||
{/each}
|
||||
<slot />
|
||||
</nav>
|
||||
</ol>
|
||||
}
|
||||
|
||||
style {
|
||||
.wire-stepper {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.wire-stepper[data-orientation="vertical"] {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.wire-stepper__step {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.wire-stepper__button {
|
||||
appearance: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border: 0;
|
||||
border-radius: var(--wire-radius-sm);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.wire-stepper[data-clickable="true"] .wire-stepper__button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wire-stepper[data-clickable="true"] .wire-stepper__button:hover {
|
||||
background: var(--wire-color-surface-soft);
|
||||
}
|
||||
|
||||
.wire-stepper__button:focus-visible {
|
||||
outline: 2px solid var(--wire-color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.wire-stepper__marker {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 1px solid var(--wire-color-border);
|
||||
border-radius: 999px;
|
||||
background: var(--wire-color-surface);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.wire-stepper__step[data-status="complete"] .wire-stepper__marker {
|
||||
border-color: var(--wire-color-primary);
|
||||
background: var(--wire-color-primary);
|
||||
color: var(--wire-color-primary-contrast);
|
||||
}
|
||||
|
||||
.wire-stepper__step[data-status="current"] .wire-stepper__marker {
|
||||
border-color: var(--wire-color-primary);
|
||||
color: var(--wire-color-primary);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--wire-color-primary) 22%, transparent);
|
||||
}
|
||||
|
||||
.wire-stepper__step[data-status="upcoming"] .wire-stepper__marker {
|
||||
color: var(--wire-color-text-muted);
|
||||
}
|
||||
|
||||
.wire-stepper__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wire-stepper__label {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.wire-stepper__description {
|
||||
color: var(--wire-color-text-muted);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.wire-stepper__step[data-status="upcoming"] .wire-stepper__label {
|
||||
color: var(--wire-color-text-muted);
|
||||
}
|
||||
|
||||
/* A horizontal stepper cannot stay side by side on a phone. */
|
||||
@media (max-width: 639px) {
|
||||
.wire-stepper {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user