Files
WRNexusJS/packages/ui/components/Stepper.wrn
T
ClintchizandClaude Opus 5.5 1bed8e4b74 feat(ui): component redesign pass and fixes, release ui 0.8.87
Redesign
- Shared field foundation rules (duplicated across 15 form components and
  emitted globally) are now identical everywhere, so later copies no longer
  silently override earlier redesigns.
- Drawn checkbox and radio controls, white switch thumbs (the thumb was the
  text colour, a black blob in light mode), recessed field wells, keycap
  actions in Modal, Drawer, Popover, Toaster, Pagination, Stepper, DataTable.
- Menus (Dropdown, ContextMenu, Popover, Select dropdown, Navbar panels) share
  one flat raised panel with neutral hover rows and mono section labels.
- Alerts with an accent rail and icon chip; card headers without the grey
  band; accordion, collapse, list, list group, timeline, stats, metrics,
  chat, skeleton, avatar group, clipboard, splitter, section, sidebar,
  navbar and upload progress restyled.
- Chart rebuilt: nice y-axis ticks, gridlines, rounded bars, line and area
  modes, hover tooltips, hidden series leave the scale.

Fixes
- Checkbox and radio labels toggle their input again (empty for="" broke it).
- TimePicker kept every clicked time pressed; only the chosen hour and
  minute are pressed now, and nothing is pressed before a value exists.
- ColorPicker hex readout now follows the picked colour.
- Disabled FileInput no longer shows the native file-input text.
- Date and time panels float instead of pushing content.
- ContextMenu opened at the pointer closes on scroll.
- Drawer left/top/bottom placements slide from their own edge again.
- Image-only Card no longer renders an empty body strip.
- Timeline supports item.time, current/upcoming states, sizes and
  compact/alternate variants; showcase demos use distinct variants.
- ChatBubble: delivery receipts (sending/sent/delivered/read/failed/error)
  and meta aligned to its bubble.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 16:18:13 +05:30

522 lines
16 KiB
Plaintext

// 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: { index: number; step: object })
back(payload: { index: number; step: object })
next(payload: { index: number; step: object })
skip(payload: { index: number; step: object })
finish(payload: { index: number; step: object })
}
props {
color: string = "primary"
size: string = "default"
steps: unknown[] = []
active: number = 0
controlled: boolean = false
orientation: string = "horizontal"
clickable: boolean = false
label: string = "Progress"
// Render each step body, showing only the active one -- same contract as
// Tabs, so a wizard does not have to hand-roll the panel switching.
showPanel: boolean = false
controls: boolean = false
allowSkip: boolean = false
// Lets a form hold the step. The component never validates anything
// itself: the page owns the form, so it sets this while the step is
// incomplete and clears it once the step passes.
nextDisabled: boolean = false
backLabel: string = "Back"
nextLabel: string = "Next"
skipLabel: string = "Skip"
finishLabel: string = "Finish"
class: string = ""
}
state currentStep = active
functions {
shared function stepList() {
return Array.isArray(steps) ? steps : []
}
shared function activeIndex() {
var count = stepList().length
if (count < 1) {
return 0
}
var value = Number(controlled ? active : currentStep)
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"
}
shared function isActiveStep(index) {
return index === activeIndex()
}
shared function lastIndex() {
return Math.max(0, stepList().length - 1)
}
shared function onLastStep() {
return activeIndex() >= lastIndex()
}
shared function activeStep() {
var list = stepList()
return list.length ? list[activeIndex()] : {}
}
client function goBack() {
var target = Math.max(0, activeIndex() - 1)
output.back({ index: target, step: stepList()[target] || {} })
currentStep = target
output.change({ index: target, step: stepList()[target] || {} })
}
client function goNext() {
if (nextDisabled) {
return
}
if (onLastStep()) {
output.finish({ index: activeIndex(), step: activeStep() })
return
}
var target = Math.min(lastIndex(), activeIndex() + 1)
output.next({ index: target, step: stepList()[target] || {} })
currentStep = target
output.change({ index: target, step: stepList()[target] || {} })
}
client function goSkip() {
var target = Math.min(lastIndex(), activeIndex() + 1)
output.skip({ index: target, step: stepList()[target] || {} })
currentStep = target
output.change({ index: target, step: stepList()[target] || {} })
}
client function selectStep(index, step) {
if (!clickable) {
return
}
currentStep = index
output.change({ index: index, step: step })
}
}
view {
<div
{...attrs}
data-ui-component="Stepper"
class='wrn-stepper {class}'
data-orientation='{orientation}'
data-color='{color}'
data-size='{size}'
data-clickable='{clickable}'
>
<ol
class="wrn-stepper__list"
data-wrn-roving='{rovingAxis()}'
aria-label='{label}'
>
{#each stepList() as step, index}
<li
class="wrn-stepper__step"
data-status='{statusFor(index)}'
aria-current='{statusFor(index) === "current" ? "step" : "false"}'
>
<button
type="button"
class="wrn-stepper__button"
data-wrn-roving-item='{clickable ? "true" : "false"}'
@click='selectStep(index, step)'
>
<span class="wrn-stepper__marker" aria-hidden="true">
<span class='wrn-stepper__icon {step.icon}' data-show="step.icon"></span>
<span class="wrn-stepper__number" data-show="!step.icon">{index + 1}</span>
</span>
<span class="wrn-stepper__body">
<span class="wrn-stepper__label">{step.label}</span>
<span class="wrn-stepper__description" data-show="step.description">
{step.description}
</span>
</span>
</button>
<span class="wrn-stepper__custom">
<slot name="step-{index}"></slot>
</span>
</li>
{/each}
</ol>
<div class="wrn-stepper__panels" data-show="showPanel">
{#each stepList() as step, index}
<div
class="wrn-stepper__panel"
data-show='isActiveStep(index)'
aria-hidden='{index === activeIndex() ? "false" : "true"}'
>
<h4 class="wrn-stepper__panel-title" data-show="step.title">{step.title}</h4>
<p class="wrn-stepper__panel-body" data-show="step.content">{step.content}</p>
<slot name="panel-{index}"></slot>
</div>
{/each}
</div>
<div class="wrn-stepper__controls" data-show="controls">
<button
type="button"
class="wrn-stepper__button-control wrn-stepper__back"
disabled='{activeIndex() === 0}'
@click='goBack()'
>
{backLabel}
</button>
<span class="wrn-stepper__controls-spacer"></span>
<button
type="button"
class="wrn-stepper__button-control wrn-stepper__skip"
data-show="allowSkip && !onLastStep()"
@click='goSkip()'
>
{skipLabel}
</button>
<button
type="button"
class="wrn-stepper__button-control wrn-stepper__next"
data-primary="true"
disabled='{nextDisabled}'
@click='goNext()'
>
{onLastStep() ? finishLabel : nextLabel}
</button>
</div>
<slot />
</div>
}
style {
.wrn-stepper {
--stepper-accent: var(--wrn-color-primary);
max-width: 100%;
}
.wrn-stepper__list {
display: flex;
gap: 0.5rem;
margin: 0;
padding: 0;
list-style: none;
max-width: 100%;
}
.wrn-stepper[data-color="secondary"] {
--stepper-accent: var(--wrn-color-secondary);
}
.wrn-stepper[data-color="success"] {
--stepper-accent: var(--wrn-color-success);
}
.wrn-stepper[data-color="danger"] {
--stepper-accent: var(--wrn-color-danger);
}
.wrn-stepper[data-color="info"] {
--stepper-accent: var(--wrn-color-info);
}
.wrn-stepper[data-size="sm"] {
font-size: 0.82rem;
}
.wrn-stepper[data-size="lg"] {
font-size: 1rem;
}
.wrn-stepper[data-orientation="vertical"] .wrn-stepper__list {
flex-direction: column;
}
.wrn-stepper__step {
display: flex;
flex-direction: column;
flex: 1 1 0;
min-width: 0;
gap: 0.35rem;
}
.wrn-stepper__button {
appearance: none;
display: flex;
align-items: center;
gap: 0.6rem;
width: 100%;
padding: 0.5rem;
border: 0;
border-radius: var(--wrn-radius-sm);
background: transparent;
color: inherit;
font: inherit;
text-align: left;
cursor: default;
}
.wrn-stepper[data-clickable="true"] .wrn-stepper__button {
cursor: pointer;
}
.wrn-stepper[data-clickable="true"] .wrn-stepper__button:hover {
background: var(--wrn-color-surface-soft);
}
.wrn-stepper__button:focus-visible {
outline: 2px solid var(--stepper-accent);
outline-offset: 2px;
}
.wrn-stepper__marker {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
width: 1.75rem;
height: 1.75rem;
border: 1.5px solid var(--wrn-color-border-strong, var(--wrn-color-border));
border-radius: 999px;
background: var(--wrn-color-surface);
color: var(--wrn-color-text-muted);
font-family: var(--wrn-font-mono, monospace);
font-size: 0.75rem;
font-weight: 650;
font-variant-numeric: tabular-nums;
transition: background-color 160ms ease, border-color 160ms ease, box-shadow 160ms ease;
}
.wrn-stepper__step[data-status="complete"] .wrn-stepper__marker {
border-color: color-mix(in srgb, var(--stepper-accent) 82%, black);
background: var(--stepper-accent);
color: var(--wrn-color-primary-contrast);
box-shadow: var(--wrn-highlight-edge, none);
}
.wrn-stepper__step[data-status="current"] .wrn-stepper__marker {
border-color: var(--stepper-accent);
background: var(--wrn-color-surface);
color: var(--wrn-color-primary-text, var(--stepper-accent));
box-shadow: 0 0 0 4px color-mix(in srgb, var(--stepper-accent) 16%, transparent);
}
.wrn-stepper__step[data-status="upcoming"] .wrn-stepper__marker {
color: var(--wrn-color-text-muted);
}
.wrn-stepper__body {
display: flex;
flex-direction: column;
min-width: 0;
}
.wrn-stepper__label {
font-size: 0.875rem;
font-weight: 600;
letter-spacing: -0.005em;
}
.wrn-stepper__description {
color: var(--wrn-color-text-muted);
font-size: 0.78rem;
line-height: 1.45;
}
.wrn-stepper__step[data-status="upcoming"] .wrn-stepper__label {
color: var(--wrn-color-text-muted);
}
.wrn-stepper__panels {
margin-top: 1rem;
}
.wrn-stepper__panel {
padding: 1.125rem 1.25rem;
border: 1px solid var(--wrn-color-border);
border-radius: var(--wrn-radius);
background: var(--wrn-color-surface);
box-shadow: var(--wrn-shadow-1);
animation: wrn-stepper-in 200ms ease both;
}
.wrn-stepper__panel-title {
margin: 0 0 0.25rem;
font-size: 0.9375rem;
font-weight: 620;
letter-spacing: -0.01em;
}
.wrn-stepper__panel-body {
margin: 0;
color: var(--wrn-color-text-muted);
line-height: 1.6;
}
.wrn-stepper__controls {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.85rem;
}
.wrn-stepper__controls-spacer {
flex: 1 1 auto;
}
.wrn-stepper__button-control {
appearance: none;
min-height: var(--wrn-control-height, 2.5rem);
padding: 0 0.95rem;
border: 1px solid var(--wrn-color-border);
border-bottom-color: var(--wrn-color-border-strong, var(--wrn-color-border));
border-radius: var(--wrn-radius-sm);
background: linear-gradient(180deg, var(--wrn-color-surface), var(--wrn-color-surface-soft, var(--wrn-color-surface)));
box-shadow: var(--wrn-shadow-1);
color: var(--wrn-color-text);
font: inherit;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
}
.wrn-stepper__button-control:hover:not(:disabled) {
background: var(--wrn-color-surface);
}
.wrn-stepper__button-control:focus-visible {
outline: 2px solid var(--stepper-accent);
outline-offset: 2px;
}
.wrn-stepper__button-control[data-primary="true"] {
border-color: color-mix(in srgb, var(--stepper-accent) 82%, black);
border-bottom-color: color-mix(in srgb, var(--stepper-accent) 64%, black);
background: linear-gradient(180deg, color-mix(in srgb, var(--stepper-accent) 92%, white), var(--stepper-accent));
box-shadow: var(--wrn-highlight-edge, none), 0 1px 2px color-mix(in srgb, var(--stepper-accent) 30%, transparent);
color: var(--wrn-color-primary-contrast);
}
/* A held step has to look held, or the button reads as broken. */
.wrn-stepper__button-control:disabled {
opacity: 0.45;
cursor: not-allowed;
}
@keyframes wrn-stepper-in {
from {
opacity: 0;
transform: translateX(0.75rem);
}
to {
opacity: 1;
transform: none;
}
}
/* A horizontal stepper cannot stay side by side on a phone. */
@media (max-width: 639px) {
.wrn-stepper__list {
flex-direction: column;
}
}
/* --- Connectors ---------------------------------------------------------
Horizontal: a line fills the rest of each step after its text.
Vertical: a rail runs down from each marker to the next. Both fill with
the accent once the step is complete. :last-of-type, not :last-child,
because the loop leaves <template> markers after the last step. */
.wrn-stepper:not([data-orientation="vertical"]) .wrn-stepper__button::after {
flex: 1 1 1.5rem;
min-width: 1rem;
height: 2px;
margin-inline-start: 0.25rem;
border-radius: 999px;
background: var(--wrn-color-border);
content: "";
transition: background-color 200ms ease;
}
.wrn-stepper:not([data-orientation="vertical"]) .wrn-stepper__step:last-of-type .wrn-stepper__button::after {
display: none;
}
.wrn-stepper[data-orientation="vertical"] .wrn-stepper__step {
position: relative;
}
.wrn-stepper[data-orientation="vertical"] .wrn-stepper__step:not(:last-of-type)::before {
position: absolute;
top: calc(0.5rem + 1.75rem + 0.25rem);
bottom: calc(-0.5rem + 0.25rem);
inset-inline-start: calc(0.5rem + 0.875rem - 1px);
width: 2px;
border-radius: 999px;
background: var(--wrn-color-border);
content: "";
}
.wrn-stepper__step[data-status="complete"] .wrn-stepper__button::after,
.wrn-stepper[data-orientation="vertical"] .wrn-stepper__step[data-status="complete"]::before {
background: var(--stepper-accent);
}
.wrn-stepper[data-orientation="vertical"] .wrn-stepper__list {
gap: 0.5rem;
}
/* Completed numbered steps show a check instead of the number. */
.wrn-stepper__step[data-status="complete"] .wrn-stepper__number {
display: none;
}
.wrn-stepper__step[data-status="complete"] .wrn-stepper__marker:has(.wrn-stepper__number:not([data-show="false"]))::after,
.wrn-stepper__step[data-status="complete"] .wrn-stepper__marker:not(:has(.wrn-stepper__icon:not([data-show="false"])))::after {
width: 0.8rem;
height: 0.8rem;
background: currentColor;
content: "";
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='2.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3.5 8.5l3 3 6-7'/%3E%3C/svg%3E") center / contain no-repeat;
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='2.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3.5 8.5l3 3 6-7'/%3E%3C/svg%3E") center / contain no-repeat;
}
.wrn-stepper__icon {
width: 0.9rem;
height: 0.9rem;
}
}
}