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:
@@ -253,6 +253,44 @@ const DATATABLE_ROWS =
|
|||||||
'[{"id": 1, "name": "Northwind", "plan": "Scale", "owner": "A. Okafor", "seats": 6, "status": "Active", "statusHtml": "<span class=\\"showcase-pill showcase-pill--success\\">Active</span>"}, {"id": 2, "name": "Acme Industrial", "plan": "Team", "owner": "R. Silva", "seats": 13, "status": "Trial", "statusHtml": "<span class=\\"showcase-pill showcase-pill--info\\">Trial</span>"}, {"id": 3, "name": "Globex", "plan": "Enterprise", "owner": "M. Chen", "seats": 20, "status": "Past due", "statusHtml": "<span class=\\"showcase-pill showcase-pill--danger\\">Past due</span>"}, {"id": 4, "name": "Initech", "plan": "Starter", "owner": "J. Dubois", "seats": 27, "status": "Active", "statusHtml": "<span class=\\"showcase-pill showcase-pill--success\\">Active</span>"}, {"id": 5, "name": "Umbrella", "plan": "Scale", "owner": "P. Novak", "seats": 34, "status": "Trial", "statusHtml": "<span class=\\"showcase-pill showcase-pill--info\\">Trial</span>"}, {"id": 6, "name": "Stark Labs", "plan": "Team", "owner": "A. Okafor", "seats": 41, "status": "Past due", "statusHtml": "<span class=\\"showcase-pill showcase-pill--danger\\">Past due</span>"}, {"id": 7, "name": "Wayne Foods", "plan": "Enterprise", "owner": "R. Silva", "seats": 48, "status": "Active", "statusHtml": "<span class=\\"showcase-pill showcase-pill--success\\">Active</span>"}, {"id": 8, "name": "Soylent", "plan": "Starter", "owner": "M. Chen", "seats": 55, "status": "Trial", "statusHtml": "<span class=\\"showcase-pill showcase-pill--info\\">Trial</span>"}, {"id": 9, "name": "Hooli", "plan": "Scale", "owner": "J. Dubois", "seats": 62, "status": "Past due", "statusHtml": "<span class=\\"showcase-pill showcase-pill--danger\\">Past due</span>"}, {"id": 10, "name": "Vehement", "plan": "Team", "owner": "P. Novak", "seats": 69, "status": "Active", "statusHtml": "<span class=\\"showcase-pill showcase-pill--success\\">Active</span>"}, {"id": 11, "name": "Massive Dynamic", "plan": "Enterprise", "owner": "A. Okafor", "seats": 76, "status": "Trial", "statusHtml": "<span class=\\"showcase-pill showcase-pill--info\\">Trial</span>"}, {"id": 12, "name": "Cyberdyne", "plan": "Starter", "owner": "R. Silva", "seats": 83, "status": "Past due", "statusHtml": "<span class=\\"showcase-pill showcase-pill--danger\\">Past due</span>"}, {"id": 13, "name": "Tyrell", "plan": "Scale", "owner": "M. Chen", "seats": 90, "status": "Active", "statusHtml": "<span class=\\"showcase-pill showcase-pill--success\\">Active</span>"}, {"id": 14, "name": "Aperture", "plan": "Team", "owner": "J. Dubois", "seats": 97, "status": "Trial", "statusHtml": "<span class=\\"showcase-pill showcase-pill--info\\">Trial</span>"}, {"id": 15, "name": "Black Mesa", "plan": "Enterprise", "owner": "P. Novak", "seats": 104, "status": "Past due", "statusHtml": "<span class=\\"showcase-pill showcase-pill--danger\\">Past due</span>"}]';
|
'[{"id": 1, "name": "Northwind", "plan": "Scale", "owner": "A. Okafor", "seats": 6, "status": "Active", "statusHtml": "<span class=\\"showcase-pill showcase-pill--success\\">Active</span>"}, {"id": 2, "name": "Acme Industrial", "plan": "Team", "owner": "R. Silva", "seats": 13, "status": "Trial", "statusHtml": "<span class=\\"showcase-pill showcase-pill--info\\">Trial</span>"}, {"id": 3, "name": "Globex", "plan": "Enterprise", "owner": "M. Chen", "seats": 20, "status": "Past due", "statusHtml": "<span class=\\"showcase-pill showcase-pill--danger\\">Past due</span>"}, {"id": 4, "name": "Initech", "plan": "Starter", "owner": "J. Dubois", "seats": 27, "status": "Active", "statusHtml": "<span class=\\"showcase-pill showcase-pill--success\\">Active</span>"}, {"id": 5, "name": "Umbrella", "plan": "Scale", "owner": "P. Novak", "seats": 34, "status": "Trial", "statusHtml": "<span class=\\"showcase-pill showcase-pill--info\\">Trial</span>"}, {"id": 6, "name": "Stark Labs", "plan": "Team", "owner": "A. Okafor", "seats": 41, "status": "Past due", "statusHtml": "<span class=\\"showcase-pill showcase-pill--danger\\">Past due</span>"}, {"id": 7, "name": "Wayne Foods", "plan": "Enterprise", "owner": "R. Silva", "seats": 48, "status": "Active", "statusHtml": "<span class=\\"showcase-pill showcase-pill--success\\">Active</span>"}, {"id": 8, "name": "Soylent", "plan": "Starter", "owner": "M. Chen", "seats": 55, "status": "Trial", "statusHtml": "<span class=\\"showcase-pill showcase-pill--info\\">Trial</span>"}, {"id": 9, "name": "Hooli", "plan": "Scale", "owner": "J. Dubois", "seats": 62, "status": "Past due", "statusHtml": "<span class=\\"showcase-pill showcase-pill--danger\\">Past due</span>"}, {"id": 10, "name": "Vehement", "plan": "Team", "owner": "P. Novak", "seats": 69, "status": "Active", "statusHtml": "<span class=\\"showcase-pill showcase-pill--success\\">Active</span>"}, {"id": 11, "name": "Massive Dynamic", "plan": "Enterprise", "owner": "A. Okafor", "seats": 76, "status": "Trial", "statusHtml": "<span class=\\"showcase-pill showcase-pill--info\\">Trial</span>"}, {"id": 12, "name": "Cyberdyne", "plan": "Starter", "owner": "R. Silva", "seats": 83, "status": "Past due", "statusHtml": "<span class=\\"showcase-pill showcase-pill--danger\\">Past due</span>"}, {"id": 13, "name": "Tyrell", "plan": "Scale", "owner": "M. Chen", "seats": 90, "status": "Active", "statusHtml": "<span class=\\"showcase-pill showcase-pill--success\\">Active</span>"}, {"id": 14, "name": "Aperture", "plan": "Team", "owner": "J. Dubois", "seats": 97, "status": "Trial", "statusHtml": "<span class=\\"showcase-pill showcase-pill--info\\">Trial</span>"}, {"id": 15, "name": "Black Mesa", "plan": "Enterprise", "owner": "P. Novak", "seats": 104, "status": "Past due", "statusHtml": "<span class=\\"showcase-pill showcase-pill--danger\\">Past due</span>"}]';
|
||||||
|
|
||||||
export const componentProfiles = {
|
export const componentProfiles = {
|
||||||
|
Stepper: {
|
||||||
|
demos: [
|
||||||
|
standard(
|
||||||
|
"Horizontal progress",
|
||||||
|
"Steps before the active one read as complete, the active one is highlighted, and the rest are muted.",
|
||||||
|
{
|
||||||
|
steps: [
|
||||||
|
{ label: "Account", description: "Your details" },
|
||||||
|
{ label: "Billing", description: "Payment method" },
|
||||||
|
{ label: "Confirm", description: "Review and submit" },
|
||||||
|
],
|
||||||
|
active: 1,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
standard(
|
||||||
|
"Vertical with icons",
|
||||||
|
"Vertical orientation suits a sidebar or a narrow column. Any step may carry an iconify class instead of its number.",
|
||||||
|
{
|
||||||
|
steps: [
|
||||||
|
{ label: "Cloned", icon: "icon-[lucide--git-branch]" },
|
||||||
|
{ label: "Built", icon: "icon-[lucide--hammer]" },
|
||||||
|
{ label: "Deployed", icon: "icon-[lucide--rocket]" },
|
||||||
|
],
|
||||||
|
active: 2,
|
||||||
|
orientation: "vertical",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
advanced(
|
||||||
|
"Clickable steps",
|
||||||
|
"With clickable the steps emit a change output and take arrow-key roving focus. Without it the stepper is read-only and stays out of the tab order.",
|
||||||
|
{
|
||||||
|
steps: [{ label: "One" }, { label: "Two" }, { label: "Three" }],
|
||||||
|
active: 0,
|
||||||
|
clickable: true,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
},
|
||||||
Pagination: {
|
Pagination: {
|
||||||
demos: [
|
demos: [
|
||||||
standard(
|
standard(
|
||||||
|
|||||||
@@ -3069,11 +3069,35 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
var ROVING_SELECTOR = "[data-wrn-roving]";
|
var ROVING_SELECTOR = "[data-wrn-roving]";
|
||||||
var ROVING_ITEM_SELECTOR = "[data-wrn-roving-item]";
|
var ROVING_ITEM_SELECTOR = "[data-wrn-roving-item]";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* These attributes are written by templates, so they arrive stringified:
|
||||||
|
* data-wrn-roving="" or data-wrn-roving-item="false" is how a component
|
||||||
|
* says "not this time". A bare [attr] selector matches either, so the value
|
||||||
|
* has to be checked -- otherwise a stepper with clickable=false still takes
|
||||||
|
* arrow-key focus.
|
||||||
|
*/
|
||||||
|
// A bare data-wrn-roving-item means yes; only an explicit "false" opts out.
|
||||||
|
function rovingItemOff(value) {
|
||||||
|
return value === null || value === "false";
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The container is stricter: it must name an axis. An empty value is what a
|
||||||
|
* template emits for {cond ? "horizontal" : ""}, so empty means off rather
|
||||||
|
* than defaulting to horizontal.
|
||||||
|
*/
|
||||||
|
function rovingOrientation(container) {
|
||||||
|
var value = container.getAttribute("data-wrn-roving");
|
||||||
|
if (value === null || value === "" || value === "false") return "";
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
function rovingItems(container) {
|
function rovingItems(container) {
|
||||||
var found = [];
|
var found = [];
|
||||||
var candidates = container.querySelectorAll(ROVING_ITEM_SELECTOR);
|
var candidates = container.querySelectorAll(ROVING_ITEM_SELECTOR);
|
||||||
for (var index = 0; index < candidates.length; index += 1) {
|
for (var index = 0; index < candidates.length; index += 1) {
|
||||||
var candidate = candidates[index];
|
var candidate = candidates[index];
|
||||||
|
if (rovingItemOff(candidate.getAttribute("data-wrn-roving-item"))) continue;
|
||||||
// A nested group owns its own items; do not steal them.
|
// A nested group owns its own items; do not steal them.
|
||||||
if (candidate.closest(ROVING_SELECTOR) !== container) continue;
|
if (candidate.closest(ROVING_SELECTOR) !== container) continue;
|
||||||
if (candidate.hasAttribute("disabled")) continue;
|
if (candidate.hasAttribute("disabled")) continue;
|
||||||
@@ -3107,6 +3131,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
}
|
}
|
||||||
|
|
||||||
function syncRovingGroup(container) {
|
function syncRovingGroup(container) {
|
||||||
|
if (!rovingOrientation(container)) return;
|
||||||
var items = rovingItems(container);
|
var items = rovingItems(container);
|
||||||
if (!items.length) return;
|
if (!items.length) return;
|
||||||
applyRovingTabindex(items, rovingActiveIndex(items));
|
applyRovingTabindex(items, rovingActiveIndex(items));
|
||||||
@@ -3129,7 +3154,8 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
var index = items.indexOf(item);
|
var index = items.indexOf(item);
|
||||||
if (index === -1) return;
|
if (index === -1) return;
|
||||||
|
|
||||||
var orientation = container.getAttribute("data-wrn-roving") || "horizontal";
|
var orientation = rovingOrientation(container);
|
||||||
|
if (!orientation) return;
|
||||||
var horizontal = orientation === "horizontal" || orientation === "both";
|
var horizontal = orientation === "horizontal" || orientation === "both";
|
||||||
var vertical = orientation === "vertical" || orientation === "both";
|
var vertical = orientation === "vertical" || orientation === "both";
|
||||||
var key = event.key;
|
var key = event.key;
|
||||||
|
|||||||
@@ -1030,3 +1030,18 @@ test("a hidden dialog does not trap Tab", () => {
|
|||||||
outside.dispatchEvent(new win.KeyboardEvent("keydown", { key: "Tab", bubbles: true }));
|
outside.dispatchEvent(new win.KeyboardEvent("keydown", { key: "Tab", bubbles: true }));
|
||||||
expect(doc.activeElement!.id).toBe("outside");
|
expect(doc.activeElement!.id).toBe("outside");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("an empty or false roving attribute opts the group out entirely", () => {
|
||||||
|
const win = mount(
|
||||||
|
`<div data-wrn-roving="">
|
||||||
|
<button data-wrn-roving-item="false" id="a">A</button>
|
||||||
|
<button data-wrn-roving-item="false" id="b">B</button>
|
||||||
|
</div>`,
|
||||||
|
);
|
||||||
|
const doc = win.document;
|
||||||
|
const a = doc.querySelector("#a") as unknown as HTMLElement;
|
||||||
|
expect(a.getAttribute("tabindex")).toBeNull();
|
||||||
|
a.focus();
|
||||||
|
a.dispatchEvent(new win.KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }));
|
||||||
|
expect(doc.activeElement!.id).toBe("a");
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 {
|
component Stepper {
|
||||||
outputs {
|
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)
|
change(payload: { index: number; step: object })
|
||||||
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 })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
props {
|
props {
|
||||||
size: string = "default"
|
steps: unknown[] = []
|
||||||
color: string = "primary"
|
active: number = 0
|
||||||
label: string = "Stepper"
|
|
||||||
items: unknown[] = []
|
|
||||||
active: string = ""
|
|
||||||
orientation: string = "horizontal"
|
orientation: string = "horizontal"
|
||||||
|
clickable: boolean = false
|
||||||
|
label: string = "Progress"
|
||||||
class: string = ""
|
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 {
|
view {
|
||||||
<nav class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--stepper wire-next--{orientation} {class}" aria-label="{label}">
|
<ol
|
||||||
{#each items as item}<a href="{item.href}" aria-current="{item.value === active ? 'page' : ''}">{item.label}</a>{/each}
|
{...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 />
|
<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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2493,3 +2493,50 @@ test("pagination survives an empty dataset", async () => {
|
|||||||
const dom = mountHtml(html);
|
const dom = mountHtml(html);
|
||||||
expect(dom.querySelector(".wire-pagination")).not.toBeNull();
|
expect(dom.querySelector(".wire-pagination")).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("stepper marks complete, current and upcoming steps", async () => {
|
||||||
|
const source = readFileSync(uiComponentPath("Stepper"), "utf8");
|
||||||
|
const html = await renderComponent(source, {
|
||||||
|
steps: [
|
||||||
|
{ label: "Account", description: "Your details" },
|
||||||
|
{ label: "Billing", description: "Payment method" },
|
||||||
|
{ label: "Confirm", description: "Review and submit" },
|
||||||
|
],
|
||||||
|
active: 1,
|
||||||
|
});
|
||||||
|
const dom = mountHtml(html);
|
||||||
|
const items = [...dom.querySelectorAll(".wire-stepper__step")];
|
||||||
|
|
||||||
|
expect(items).toHaveLength(3);
|
||||||
|
expect(items[0]!.getAttribute("data-status")).toBe("complete");
|
||||||
|
expect(items[1]!.getAttribute("data-status")).toBe("current");
|
||||||
|
expect(items[2]!.getAttribute("data-status")).toBe("upcoming");
|
||||||
|
expect(items[1]!.getAttribute("aria-current")).toBe("step");
|
||||||
|
expect(dom.querySelector(".wire-stepper")!.tagName.toLowerCase()).toBe("ol");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("stepper renders vertically and clamps an out-of-range active index", async () => {
|
||||||
|
const source = readFileSync(uiComponentPath("Stepper"), "utf8");
|
||||||
|
const html = await renderComponent(source, {
|
||||||
|
steps: [{ label: "One" }, { label: "Two" }],
|
||||||
|
active: 99,
|
||||||
|
orientation: "vertical",
|
||||||
|
});
|
||||||
|
const dom = mountHtml(html);
|
||||||
|
const root = dom.querySelector(".wire-stepper") as HTMLElement;
|
||||||
|
expect(root.getAttribute("data-orientation")).toBe("vertical");
|
||||||
|
const items = [...dom.querySelectorAll(".wire-stepper__step")];
|
||||||
|
expect(items[1]!.getAttribute("data-status")).toBe("current");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clickable stepper opts into roving focus", async () => {
|
||||||
|
const source = readFileSync(uiComponentPath("Stepper"), "utf8");
|
||||||
|
const html = await renderComponent(source, {
|
||||||
|
steps: [{ label: "One" }, { label: "Two" }],
|
||||||
|
active: 0,
|
||||||
|
clickable: true,
|
||||||
|
});
|
||||||
|
const dom = mountHtml(html);
|
||||||
|
expect(dom.querySelector('[data-wrn-roving="horizontal"]')).not.toBeNull();
|
||||||
|
expect(dom.querySelectorAll("[data-wrn-roving-item]").length).toBe(2);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user