diff --git a/examples/component-showcase/scripts/showcase-profiles.mjs b/examples/component-showcase/scripts/showcase-profiles.mjs index 93ab74e7..0b6923ce 100644 --- a/examples/component-showcase/scripts/showcase-profiles.mjs +++ b/examples/component-showcase/scripts/showcase-profiles.mjs @@ -253,6 +253,44 @@ const DATATABLE_ROWS = '[{"id": 1, "name": "Northwind", "plan": "Scale", "owner": "A. Okafor", "seats": 6, "status": "Active", "statusHtml": "Active"}, {"id": 2, "name": "Acme Industrial", "plan": "Team", "owner": "R. Silva", "seats": 13, "status": "Trial", "statusHtml": "Trial"}, {"id": 3, "name": "Globex", "plan": "Enterprise", "owner": "M. Chen", "seats": 20, "status": "Past due", "statusHtml": "Past due"}, {"id": 4, "name": "Initech", "plan": "Starter", "owner": "J. Dubois", "seats": 27, "status": "Active", "statusHtml": "Active"}, {"id": 5, "name": "Umbrella", "plan": "Scale", "owner": "P. Novak", "seats": 34, "status": "Trial", "statusHtml": "Trial"}, {"id": 6, "name": "Stark Labs", "plan": "Team", "owner": "A. Okafor", "seats": 41, "status": "Past due", "statusHtml": "Past due"}, {"id": 7, "name": "Wayne Foods", "plan": "Enterprise", "owner": "R. Silva", "seats": 48, "status": "Active", "statusHtml": "Active"}, {"id": 8, "name": "Soylent", "plan": "Starter", "owner": "M. Chen", "seats": 55, "status": "Trial", "statusHtml": "Trial"}, {"id": 9, "name": "Hooli", "plan": "Scale", "owner": "J. Dubois", "seats": 62, "status": "Past due", "statusHtml": "Past due"}, {"id": 10, "name": "Vehement", "plan": "Team", "owner": "P. Novak", "seats": 69, "status": "Active", "statusHtml": "Active"}, {"id": 11, "name": "Massive Dynamic", "plan": "Enterprise", "owner": "A. Okafor", "seats": 76, "status": "Trial", "statusHtml": "Trial"}, {"id": 12, "name": "Cyberdyne", "plan": "Starter", "owner": "R. Silva", "seats": 83, "status": "Past due", "statusHtml": "Past due"}, {"id": 13, "name": "Tyrell", "plan": "Scale", "owner": "M. Chen", "seats": 90, "status": "Active", "statusHtml": "Active"}, {"id": 14, "name": "Aperture", "plan": "Team", "owner": "J. Dubois", "seats": 97, "status": "Trial", "statusHtml": "Trial"}, {"id": 15, "name": "Black Mesa", "plan": "Enterprise", "owner": "P. Novak", "seats": 104, "status": "Past due", "statusHtml": "Past due"}]'; 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: { demos: [ standard( diff --git a/packages/csr/src/reactive-runtime.ts b/packages/csr/src/reactive-runtime.ts index 0d232256..a404b653 100644 --- a/packages/csr/src/reactive-runtime.ts +++ b/packages/csr/src/reactive-runtime.ts @@ -3069,11 +3069,35 @@ export const REACTIVE_RUNTIME = String.raw` var ROVING_SELECTOR = "[data-wrn-roving]"; 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) { var found = []; var candidates = container.querySelectorAll(ROVING_ITEM_SELECTOR); for (var index = 0; index < candidates.length; index += 1) { var candidate = candidates[index]; + if (rovingItemOff(candidate.getAttribute("data-wrn-roving-item"))) continue; // A nested group owns its own items; do not steal them. if (candidate.closest(ROVING_SELECTOR) !== container) continue; if (candidate.hasAttribute("disabled")) continue; @@ -3107,6 +3131,7 @@ export const REACTIVE_RUNTIME = String.raw` } function syncRovingGroup(container) { + if (!rovingOrientation(container)) return; var items = rovingItems(container); if (!items.length) return; applyRovingTabindex(items, rovingActiveIndex(items)); @@ -3129,7 +3154,8 @@ export const REACTIVE_RUNTIME = String.raw` var index = items.indexOf(item); 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 vertical = orientation === "vertical" || orientation === "both"; var key = event.key; diff --git a/packages/csr/test/reactive.test.ts b/packages/csr/test/reactive.test.ts index b71c47e2..bb429489 100644 --- a/packages/csr/test/reactive.test.ts +++ b/packages/csr/test/reactive.test.ts @@ -1030,3 +1030,18 @@ test("a hidden dialog does not trap Tab", () => { outside.dispatchEvent(new win.KeyboardEvent("keydown", { key: "Tab", bubbles: true })); expect(doc.activeElement!.id).toBe("outside"); }); + +test("an empty or false roving attribute opts the group out entirely", () => { + const win = mount( + `
+ + +
`, + ); + 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"); +}); diff --git a/packages/ui/components/Stepper.wrn b/packages/ui/components/Stepper.wrn index 94a18af2..5acd07b0 100644 --- a/packages/ui/components/Stepper.wrn +++ b/packages/ui/components/Stepper.wrn @@ -1,24 +1,222 @@ +// Stepper -- ordered progress through a sequence. +// +// +// +// Each step can be authored by hand instead of using the built-in body, by +// passing a slot named for its index: +// +// +//
...anything...
+//
+// +// 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; 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 { - + + } + + 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; + } + } } } diff --git a/packages/ui/test/ui.test.ts b/packages/ui/test/ui.test.ts index 1e214c1f..579b0e3e 100644 --- a/packages/ui/test/ui.test.ts +++ b/packages/ui/test/ui.test.ts @@ -2493,3 +2493,50 @@ test("pagination survives an empty dataset", async () => { const dom = mountHtml(html); 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); +});