diff --git a/docs/ui-visual-contract-0.8.json b/docs/ui-visual-contract-0.8.json
index d151b50f..8adf52b8 100644
--- a/docs/ui-visual-contract-0.8.json
+++ b/docs/ui-visual-contract-0.8.json
@@ -85,11 +85,11 @@
"packages/ui/components/Sidebar.wrn": "03f1bbce75ca6d50ed940e62df39c539610e89eadd2064fe24abc7d5470f98bf",
"packages/ui/components/SplitHero.wrn": "70e843565ff869bcf413b11b6d9830b2e2bd229863a00904596f71e2dff0e76d",
"packages/ui/components/StatsBar.wrn": "c7d1df3895f25b6d3a2e1012a18b97592c7f33e65418b880d486f20c2d490686",
- "packages/ui/components/Stepper.wrn": "09f216a44f888421091bd1bce760a076927500a573550d5b2b10c4f27f608e92",
+ "packages/ui/components/Stepper.wrn": "df9093e4222ef3250963a8c6fbfec74477242735d23eabe896c29731c54fc14d",
"packages/ui/components/StrongPassword.wrn": "c7c5ef26ece6170dd7db9882f0dc98cb2e4607f1e5d43eb3fd39e5d15bbd3a2e",
"packages/ui/components/StyledIcon.wrn": "4a4504e357dee9dd0418edbe85fc90b824ccdb3752123a2125da95b77bf0b336",
"packages/ui/components/Switch.wrn": "ccb74599fab72b0d68b09a7f1f90b7732cb2f9cbed67a84219e897575ce30c28",
- "packages/ui/components/Tabs.wrn": "4fa0c0854700532960043290700d7cf99663ec41692068101f0aae4c2b1a1181",
+ "packages/ui/components/Tabs.wrn": "e38336d28876caa4e97da32a569749e3803b030284a85ce9f35fa788e51cc5b0",
"packages/ui/components/TextLink.wrn": "30782039293eb36d63b7b3a4f32a71a47177a3a68c7e90184be7cf7b4385eb19",
"packages/ui/components/Textarea.wrn": "ddf0b4f124b2cf0c0ab3d820d3ac0085f7c20466e977231949be264cd0cee8cf",
"packages/ui/components/TimePicker.wrn": "2e8e7a90f6b6069a07e7ffd2725ba1e1031e84d55a4f1254025befbb314fa695",
diff --git a/examples/component-showcase/app/pages/components/stepper.wrn b/examples/component-showcase/app/pages/components/stepper.wrn
index 0aa41b2a..a4e09469 100644
--- a/examples/component-showcase/app/pages/components/stepper.wrn
+++ b/examples/component-showcase/app/pages/components/stepper.wrn
@@ -8,6 +8,14 @@ page StepperDetail {
state playground_orientation = ctx.url.searchParams.get("pg_orientation") ?? "horizontal"
state playground_clickable = (ctx.url.searchParams.get("pg_clickable") ?? "false") === "true"
state playground_label = ctx.url.searchParams.get("pg_label") ?? "Stepper"
+ state playground_showPanel = (ctx.url.searchParams.get("pg_showPanel") ?? "true") === "true"
+ state playground_controls = (ctx.url.searchParams.get("pg_controls") ?? "false") === "true"
+ state playground_allowSkip = (ctx.url.searchParams.get("pg_allowSkip") ?? "false") === "true"
+ state playground_nextDisabled = (ctx.url.searchParams.get("pg_nextDisabled") ?? "false") === "true"
+ state playground_backLabel = ctx.url.searchParams.get("pg_backLabel") ?? "Stepper"
+ state playground_nextLabel = ctx.url.searchParams.get("pg_nextLabel") ?? "Stepper"
+ state playground_skipLabel = ctx.url.searchParams.get("pg_skipLabel") ?? "Stepper"
+ state playground_finishLabel = ctx.url.searchParams.get("pg_finishLabel") ?? "Stepper"
state playground_class = ctx.url.searchParams.get("pg_class") ?? "showcase-instance showcase-instance--1"
seo {
title = "Stepper"
@@ -22,16 +30,16 @@ page StepperDetail {
Navigation component
Stepper
Theme-aware, responsive stepper component.
- 8 props 2 slots 1 outputs Theme ready Responsive
+ 16 props 3 slots 5 outputs Theme ready Responsive
-
+
Interactive playground Configure Stepper Change any prop and inspect the server-rendered component immediately.
Live preview
-
+
<Stepper
@@ -143,17 +151,25 @@ page StepperDetail {
]
}
]'
- label="Stepper">
+ label="Stepper"
+ showPanel="true"
+ backLabel="Stepper"
+ nextLabel="Stepper"
+ skipLabel="Stepper"
+ finishLabel="Stepper">
<div data-slot="step-{ index} ">
<div class="showcase-slot">step-{ index} slot</div>
</div>
+ <div data-slot="panel-{ index} ">
+ <div class="showcase-slot">panel-{ index} slot</div>
+ </div>
</Stepper>
Event output Interact with the preview to inspect the typed payload.
@@ -277,7 +293,7 @@ page StepperDetail {
@@ -301,6 +317,9 @@ page StepperDetail {
<div data-slot="step-{ index} ">
<div class="showcase-slot">step-{ index} slot</div>
</div>
+ <div data-slot="panel-{ index} ">
+ <div class="showcase-slot">panel-{ index} slot</div>
+ </div>
</Stepper>
@@ -314,7 +333,7 @@ page StepperDetail {
@@ -340,6 +359,97 @@ page StepperDetail {
<div data-slot="step-{ index} ">
<div class="showcase-slot">step-{ index} slot</div>
</div>
+ <div data-slot="panel-{ index} ">
+ <div class="showcase-slot">panel-{ index} slot</div>
+ </div>
+</Stepper>
+
+
+
+
+
+
+
+
+
+ <Stepper
+ size="lg"
+ steps='[
+ {
+ "label": "Account",
+ "title": "Your details",
+ "content": "Name, email and a password."
+ } ,
+ {
+ "label": "Billing",
+ "title": "How you pay",
+ "content": "Card or invoice, plus a billing address."
+ } ,
+ {
+ "label": "Confirm",
+ "title": "Review",
+ "content": "Check everything before submitting."
+ }
+ ]'
+ active="1"
+ label="Advanced example"
+ showPanel="true"
+ controls="true"
+ allowSkip="true">
+ <div data-slot="step-{ index} ">
+ <div class="showcase-slot">step-{ index} slot</div>
+ </div>
+ <div data-slot="panel-{ index} ">
+ <div class="showcase-slot">panel-{ index} slot</div>
+ </div>
+</Stepper>
+
+
+
+
+
+
+
+
+
+ <Stepper
+ size="lg"
+ steps='[
+ {
+ "label": "Account",
+ "title": "Your details",
+ "content": "This step has not been completed yet."
+ } ,
+ {
+ "label": "Billing",
+ "title": "How you pay",
+ "content": "Card or invoice."
+ }
+ ]'
+ label="Advanced example"
+ showPanel="true"
+ controls="true"
+ nextDisabled="true">
+ <div data-slot="step-{ index} ">
+ <div class="showcase-slot">step-{ index} slot</div>
+ </div>
+ <div data-slot="panel-{ index} ">
+ <div class="showcase-slot">panel-{ index} slot</div>
+ </div>
</Stepper>
@@ -347,13 +457,13 @@ page StepperDetail {
-
+
@@ -375,23 +485,46 @@ page StepperDetail {
<div data-slot="step-{ index} ">
<div class="showcase-slot">step-{ index} slot</div>
</div>
+ <div data-slot="panel-{ index} ">
+ <div class="showcase-slot">panel-{ index} slot</div>
+ </div>
</Stepper>
- Component outputs Receive every typed component output Use declarative output handlers in .wrn files or register a direct output handler from JavaScript. The canonical API exposes payload and does not require event.detail.
@changeFires when the component's committed value or selection changes.
<Stepper
+ Component outputs Receive every typed component output Use declarative output handlers in .wrn files or register a direct output handler from JavaScript. The canonical API exposes payload and does not require event.detail.
@changeFires when the component's committed value or selection changes.
@backFires when the component emits the back event.
@nextFires when the component emits the next event.
@skipFires when the component emits the skip event.
@finishFires when the component emits the finish event.
<Stepper
@change='console.log(payload)'
+ @back='console.log(payload)'
+ @next='console.log(payload)'
+ @skip='console.log(payload)'
+ @finish='console.log(payload)'
/> Direct output handlers.js import { registerOutputHandler } from "@wrnexus/csr/outputs"
const component = document.querySelector("[data-ui-component=\"Stepper\"], [data-component=\"Stepper\"]")
if (component) registerOutputHandler(component, "change", (payload) => {
console.log("change", payload)
+} )
+
+if (component) registerOutputHandler(component, "back", (payload) => {
+ console.log("back", payload)
+} )
+
+if (component) registerOutputHandler(component, "next", (payload) => {
+ console.log("next", payload)
+} )
+
+if (component) registerOutputHandler(component, "skip", (payload) => {
+ console.log("skip", payload)
+} )
+
+if (component) registerOutputHandler(component, "finish", (payload) => {
+ console.log("finish", payload)
} )
- Component API Props and configuration All content and behavior shown above is supplied through these props and slots.
Prop Type Default Required colorstring "primary"No sizestring "default"No stepsunknown[] []No activenumber 0No orientationstring "horizontal"No clickableboolean falseNo labelstring "Progress"No classstring ""No
+ Component API Props and configuration All content and behavior shown above is supplied through these props and slots.
Prop Type Default Required colorstring "primary"No sizestring "default"No stepsunknown[] []No activenumber 0No orientationstring "horizontal"No clickableboolean falseNo labelstring "Progress"No showPanelboolean falseNo controlsboolean falseNo allowSkipboolean falseNo nextDisabledboolean falseNo backLabelstring "Back"No nextLabelstring "Next"No skipLabelstring "Skip"No finishLabelstring "Finish"No classstring ""No
}
}
diff --git a/examples/component-showcase/app/pages/navigation.wrn b/examples/component-showcase/app/pages/navigation.wrn
index 0ccd9b2a..923e4e80 100644
--- a/examples/component-showcase/app/pages/navigation.wrn
+++ b/examples/component-showcase/app/pages/navigation.wrn
@@ -10,7 +10,7 @@ page NavigationShowcase {
Component category
Navigation
10 unique, responsive, theme-aware components. Open a component to inspect multiple live configurations and its complete props API.
- 10 components Multiple use cases 28 live configurations
+ 10 components Multiple use cases 30 live configurations
@@ -96,11 +96,11 @@ page NavigationShowcase {
Navigation Stepper Theme-aware, responsive stepper component.
-
+
diff --git a/examples/component-showcase/scripts/showcase-profiles.mjs b/examples/component-showcase/scripts/showcase-profiles.mjs
index e28f5941..99f7289b 100644
--- a/examples/component-showcase/scripts/showcase-profiles.mjs
+++ b/examples/component-showcase/scripts/showcase-profiles.mjs
@@ -558,6 +558,15 @@ export const componentProfiles = {
{ label: "Confirm", description: "Review and submit" },
],
active: 1,
+ clickable: false,
+ showPanel: false,
+ controls: false,
+ allowSkip: false,
+ nextDisabled: false,
+ backLabel: "Back",
+ nextLabel: "Next",
+ skipLabel: "Skip",
+ finishLabel: "Finish",
},
),
standard(
@@ -571,6 +580,56 @@ export const componentProfiles = {
],
active: 2,
orientation: "vertical",
+ clickable: false,
+ showPanel: false,
+ controls: false,
+ allowSkip: false,
+ nextDisabled: false,
+ backLabel: "Back",
+ nextLabel: "Next",
+ skipLabel: "Skip",
+ finishLabel: "Finish",
+ },
+ ),
+ advanced(
+ "Wizard with content and controls",
+ "showPanel renders each step body and shows only the active one, the same contract Tabs uses. controls adds Back, Skip and Next, which becomes Finish on the last step.",
+ {
+ steps: [
+ { label: "Account", title: "Your details", content: "Name, email and a password." },
+ { label: "Billing", title: "How you pay", content: "Card or invoice." },
+ { label: "Confirm", title: "Review", content: "Check everything before submitting." },
+ ],
+ active: 1,
+ clickable: false,
+ showPanel: true,
+ controls: true,
+ allowSkip: true,
+ nextDisabled: false,
+ backLabel: "Back",
+ nextLabel: "Next",
+ skipLabel: "Skip",
+ finishLabel: "Finish",
+ },
+ ),
+ advanced(
+ "Held until the step is valid",
+ "The same wizard with nextDisabled set. The component never validates anything itself: the page owns the form, sets this while the step is incomplete, and clears it once the step passes.",
+ {
+ steps: [
+ { label: "Account", title: "Your details", content: "This step is not complete yet." },
+ { label: "Billing", title: "How you pay", content: "Card or invoice." },
+ ],
+ active: 0,
+ clickable: false,
+ showPanel: true,
+ controls: true,
+ allowSkip: false,
+ nextDisabled: true,
+ backLabel: "Back",
+ nextLabel: "Next",
+ skipLabel: "Skip",
+ finishLabel: "Finish",
},
),
advanced(
@@ -580,6 +639,14 @@ export const componentProfiles = {
steps: [{ label: "One" }, { label: "Two" }, { label: "Three" }],
active: 0,
clickable: true,
+ showPanel: false,
+ controls: false,
+ allowSkip: false,
+ nextDisabled: false,
+ backLabel: "Back",
+ nextLabel: "Next",
+ skipLabel: "Skip",
+ finishLabel: "Finish",
},
),
],
diff --git a/examples/component-showcase/showcase-manifest.json b/examples/component-showcase/showcase-manifest.json
index a0ddb272..f8428d98 100644
--- a/examples/component-showcase/showcase-manifest.json
+++ b/examples/component-showcase/showcase-manifest.json
@@ -2,7 +2,7 @@
"generatedFrom": "packages/ui/component-reference.json",
"componentCount": 108,
"categoryCount": 11,
- "totalDemoCount": 419,
+ "totalDemoCount": 421,
"components": [
{
"name": "Accordion",
@@ -1110,10 +1110,10 @@
"slug": "stepper",
"category": "navigation",
"purpose": "Theme-aware, responsive stepper component.",
- "demoCount": 3,
- "propCount": 8,
- "slots": ["step-{index}", "default"],
- "events": ["change"],
+ "demoCount": 5,
+ "propCount": 16,
+ "slots": ["step-{index}", "panel-{index}", "default"],
+ "events": ["change", "back", "next", "skip", "finish"],
"profiled": true
},
{
diff --git a/packages/csr/src/reactive-runtime.ts b/packages/csr/src/reactive-runtime.ts
index 8e76eca3..129a7931 100644
--- a/packages/csr/src/reactive-runtime.ts
+++ b/packages/csr/src/reactive-runtime.ts
@@ -2953,6 +2953,15 @@ export const REACTIVE_RUNTIME = String.raw`
if (!dialog || !dialog.isConnected) return false;
if (dialog.hasAttribute("hidden")) return false;
if (dialog.closest('[data-show="false"]')) return false;
+ /*
+ * data-open is the signal Modal and Drawer actually publish, and it is the
+ * only one Drawer can publish: its panel animates open, so it cannot be
+ * toggled with data-show, which sets display:none. Treating every dialog
+ * without a data-show="false" ancestor as open meant a closed Drawer held
+ * the body scroll lock forever and the page could not be scrolled.
+ */
+ var owner = dialog.closest("[data-open]");
+ if (owner) return owner.getAttribute("data-open") === "true";
return true;
}
diff --git a/packages/csr/test/reactive.test.ts b/packages/csr/test/reactive.test.ts
index 4cfacea2..19718295 100644
--- a/packages/csr/test/reactive.test.ts
+++ b/packages/csr/test/reactive.test.ts
@@ -1067,3 +1067,46 @@ test("json inside a textarea is left alone, not read as mustaches", () => {
// evaluated {"id":"a-1","count":3} away and left it empty.
expect(doc.querySelector("#editor")!.textContent).toBe('{"id":"a-1","count":3}');
});
+
+test("a closed drawer does not hold the body scroll lock", () => {
+ const win = mount(
+ ``,
+ );
+ const doc = win.document;
+ /*
+ * A drawer animates open, so its panel cannot be hidden with data-show --
+ * display:none is not transitionable. It publishes data-open instead. Reading
+ * only data-show made every closed drawer look open, which locked the body
+ * and left the page unscrollable.
+ */
+ expect(doc.body.style.overflow).not.toBe("hidden");
+
+ const outside = doc.querySelector("#inside") as unknown as HTMLElement;
+ outside.focus();
+ outside.dispatchEvent(keydown(win, "Tab"));
+ // No trap either: focus is free to leave a closed dialog.
+ expect(doc.activeElement!.id).toBe("inside");
+});
+
+test("an open drawer locks the body scroll and traps Tab", () => {
+ const win = mount(
+ `
+
+
`,
+ );
+ const doc = win.document;
+ expect(doc.body.style.overflow).toBe("hidden");
+ const last = doc.querySelector("#last") as unknown as HTMLElement;
+ last.focus();
+ last.dispatchEvent(keydown(win, "Tab"));
+ expect(doc.activeElement!.id).toBe("first");
+});
diff --git a/packages/ui/COMPONENTS.md b/packages/ui/COMPONENTS.md
index 3326cdb4..8171b514 100644
--- a/packages/ui/COMPONENTS.md
+++ b/packages/ui/COMPONENTS.md
@@ -916,9 +916,9 @@ Theme-aware, responsive sidebar component.
Theme-aware, responsive stepper component.
- Mount: `data-component="Stepper"`
-- Props: `color: string = "primary"`, `size: string = "default"`, `steps: unknown[] = []`, `active: number = 0`, `orientation: string = "horizontal"`, `clickable: boolean = false`, `label: string = "Progress"`, `class: string = ""`
-- Slots: `step-{index}`, `default`
-- Outputs: `change({ index: number; step: object })`
+- Props: `color: string = "primary"`, `size: string = "default"`, `steps: unknown[] = []`, `active: number = 0`, `orientation: string = "horizontal"`, `clickable: boolean = false`, `label: string = "Progress"`, `showPanel: boolean = false`, `controls: boolean = false`, `allowSkip: boolean = false`, `nextDisabled: boolean = false`, `backLabel: string = "Back"`, `nextLabel: string = "Next"`, `skipLabel: string = "Skip"`, `finishLabel: string = "Finish"`, `class: string = ""`
+- Slots: `step-{index}`, `panel-{index}`, `default`
+- Outputs: `change({ index: number; step: object })`, `back({ index: number; step: object })`, `next({ index: number; step: object })`, `skip({ index: number; step: object })`, `finish({ index: number; step: object })`
### Tabs
diff --git a/packages/ui/component-reference.json b/packages/ui/component-reference.json
index efa543da..1fa2b8c5 100644
--- a/packages/ui/component-reference.json
+++ b/packages/ui/component-reference.json
@@ -11783,6 +11783,62 @@
"default": "\"Progress\"",
"options": []
},
+ {
+ "name": "showPanel",
+ "type": "boolean",
+ "required": false,
+ "default": "false",
+ "options": []
+ },
+ {
+ "name": "controls",
+ "type": "boolean",
+ "required": false,
+ "default": "false",
+ "options": []
+ },
+ {
+ "name": "allowSkip",
+ "type": "boolean",
+ "required": false,
+ "default": "false",
+ "options": []
+ },
+ {
+ "name": "nextDisabled",
+ "type": "boolean",
+ "required": false,
+ "default": "false",
+ "options": []
+ },
+ {
+ "name": "backLabel",
+ "type": "string",
+ "required": false,
+ "default": "\"Back\"",
+ "options": []
+ },
+ {
+ "name": "nextLabel",
+ "type": "string",
+ "required": false,
+ "default": "\"Next\"",
+ "options": []
+ },
+ {
+ "name": "skipLabel",
+ "type": "string",
+ "required": false,
+ "default": "\"Skip\"",
+ "options": []
+ },
+ {
+ "name": "finishLabel",
+ "type": "string",
+ "required": false,
+ "default": "\"Finish\"",
+ "options": []
+ },
{
"name": "class",
"type": "string",
@@ -11791,14 +11847,30 @@
"options": []
}
],
- "slots": ["step-{index}", "default"],
+ "slots": ["step-{index}", "panel-{index}", "default"],
"outputs": [
{
"name": "change",
"payloadType": "{ index: number; step: object }"
+ },
+ {
+ "name": "back",
+ "payloadType": "{ index: number; step: object }"
+ },
+ {
+ "name": "next",
+ "payloadType": "{ index: number; step: object }"
+ },
+ {
+ "name": "skip",
+ "payloadType": "{ index: number; step: object }"
+ },
+ {
+ "name": "finish",
+ "payloadType": "{ index: number; step: object }"
}
],
- "events": ["change"],
+ "events": ["change", "back", "next", "skip", "finish"],
"source": "components/Stepper.wrn"
},
{
diff --git a/packages/ui/components/Stepper.wrn b/packages/ui/components/Stepper.wrn
index dc4d4c30..d51623c3 100644
--- a/packages/ui/components/Stepper.wrn
+++ b/packages/ui/components/Stepper.wrn
@@ -14,6 +14,10 @@
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 {
@@ -24,6 +28,19 @@ component Stepper {
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 = ""
}
@@ -63,6 +80,48 @@ component Stepper {
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] || {} })
+ 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] || {} })
+ output.change({ index: target, step: stepList()[target] || {} })
+ }
+
+ client function goSkip() {
+ var target = Math.min(lastIndex(), activeIndex() + 1)
+ output.skip({ index: target, step: stepList()[target] || {} })
+ output.change({ index: target, step: stepList()[target] || {} })
+ }
+
client function selectStep(index, step) {
if (!clickable) {
return
@@ -72,7 +131,7 @@ component Stepper {
}
view {
-
+
@@ -111,13 +173,62 @@ component Stepper {
{/each}
-
+
+
+ {#each stepList() as step, index}
+
+
{step.title}
+
{step.content}
+
+
+ {/each}
+
+
+
+
+ {backLabel}
+
+
+
+ {skipLabel}
+
+
+ {onLastStep() ? finishLabel : nextLabel}
+
+
+
+
+
}
style {
.wire-stepper {
--stepper-accent: var(--wire-color-primary);
+ max-width: 100%;
+ }
+
+ .wire-stepper__list {
display: flex;
gap: 0.5rem;
margin: 0;
@@ -150,7 +261,7 @@ component Stepper {
font-size: 1rem;
}
- .wire-stepper[data-orientation="vertical"] {
+ .wire-stepper[data-orientation="vertical"] .wire-stepper__list {
flex-direction: column;
}
@@ -241,9 +352,89 @@ component Stepper {
color: var(--wire-color-text-muted);
}
+ .wire-stepper__panels {
+ margin-top: 1rem;
+ }
+
+ .wire-stepper__panel {
+ padding: 1rem;
+ border: 1px solid var(--wire-color-border);
+ border-radius: var(--wire-radius-md);
+ background: var(--wire-color-surface);
+ animation: wire-stepper-in 200ms ease both;
+ }
+
+ .wire-stepper__panel-title {
+ margin: 0 0 0.35rem;
+ font-size: 0.95rem;
+ font-weight: 700;
+ }
+
+ .wire-stepper__panel-body {
+ margin: 0;
+ color: var(--wire-color-text-muted);
+ line-height: 1.6;
+ }
+
+ .wire-stepper__controls {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ margin-top: 0.85rem;
+ }
+
+ .wire-stepper__controls-spacer {
+ flex: 1 1 auto;
+ }
+
+ .wire-stepper__button-control {
+ appearance: none;
+ padding: 0.45rem 0.9rem;
+ border: 1px solid var(--wire-color-border);
+ border-radius: var(--wire-radius-sm);
+ background: var(--wire-color-surface);
+ color: var(--wire-color-text);
+ font: inherit;
+ font-size: 0.86rem;
+ font-weight: 600;
+ cursor: pointer;
+ }
+
+ .wire-stepper__button-control:hover:not(:disabled) {
+ background: var(--wire-color-surface-soft);
+ }
+
+ .wire-stepper__button-control:focus-visible {
+ outline: 2px solid var(--stepper-accent);
+ outline-offset: 2px;
+ }
+
+ .wire-stepper__button-control[data-primary="true"] {
+ border-color: var(--stepper-accent);
+ background: var(--stepper-accent);
+ color: var(--wire-color-primary-contrast);
+ }
+
+ /* A held step has to look held, or the button reads as broken. */
+ .wire-stepper__button-control:disabled {
+ opacity: 0.45;
+ cursor: not-allowed;
+ }
+
+ @keyframes wire-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) {
- .wire-stepper {
+ .wire-stepper__list {
flex-direction: column;
}
}
diff --git a/packages/ui/components/Tabs.wrn b/packages/ui/components/Tabs.wrn
index ac309768..ece0d4ea 100644
--- a/packages/ui/components/Tabs.wrn
+++ b/packages/ui/components/Tabs.wrn
@@ -28,6 +28,9 @@ component Tabs {
}
state activeValue = ""
+ // Which way the panel should slide in. Set on every change so the animation
+ // follows the direction of travel rather than always coming from one side.
+ state slideFrom = "forward"
functions {
shared function itemList() {
@@ -80,6 +83,14 @@ component Tabs {
return
}
var value = valueOf(item, index)
+ var list = itemList()
+ var previous = -1
+ for (var scan = 0; scan < list.length; scan += 1) {
+ if (valueOf(list[scan], scan) === currentValue()) {
+ previous = scan
+ }
+ }
+ slideFrom = previous > index ? "back" : "forward"
activeValue = value
if (mode === "url" && window.history && window.history.pushState) {
@@ -101,6 +112,7 @@ component Tabs {
data-orientation='{orientation}'
data-color='{color}'
data-size='{size}'
+ data-slide='{slideFrom}'
data-mode='{mode}'
data-param='{param}'
>
@@ -272,7 +284,16 @@ component Tabs {
.wire-tabs__panel {
outline: none;
- animation: wire-tabs-in 200ms ease both;
+ animation: wire-tabs-slide-forward 220ms cubic-bezier(0.22, 1, 0.36, 1) both;
+ }
+
+ .wire-tabs[data-slide="back"] .wire-tabs__panel {
+ animation-name: wire-tabs-slide-back;
+ }
+
+ /* The panels clip their own slide so it never widens the page. */
+ .wire-tabs__panels {
+ overflow-x: clip;
}
.wire-tabs__panel:focus-visible {
@@ -297,10 +318,21 @@ component Tabs {
margin-top: 0.6rem;
}
- @keyframes wire-tabs-in {
+ @keyframes wire-tabs-slide-forward {
from {
opacity: 0;
- transform: translateY(4px);
+ transform: translateX(1.25rem);
+ }
+ to {
+ opacity: 1;
+ transform: none;
+ }
+ }
+
+ @keyframes wire-tabs-slide-back {
+ from {
+ opacity: 0;
+ transform: translateX(-1.25rem);
}
to {
opacity: 1;
diff --git a/packages/ui/test/ui.test.ts b/packages/ui/test/ui.test.ts
index 351413d2..d0235282 100644
--- a/packages/ui/test/ui.test.ts
+++ b/packages/ui/test/ui.test.ts
@@ -2522,7 +2522,10 @@ test("stepper marks complete, current and upcoming steps", async () => {
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");
+ // One root wraps the list, the panels and the controls; the steps
+ // themselves are still an ordered list, which is what carries the semantics.
+ expect(dom.querySelector(".wire-stepper")!.tagName.toLowerCase()).toBe("div");
+ expect(dom.querySelector(".wire-stepper__list")!.tagName.toLowerCase()).toBe("ol");
});
test("stepper renders vertically and clamps an out-of-range active index", async () => {
@@ -2887,3 +2890,57 @@ test("mega menu bridges the gap between its trigger and panel", async () => {
expect(source).toContain(".wire-mega__panel::before");
expect(source).toMatch(/\.wire-mega__panel::before \{[^}]*bottom: 100%/s);
});
+
+test("stepper shows only the active step content and offers back, next and skip", async () => {
+ const source = readFileSync(uiComponentPath("Stepper"), "utf8");
+ const html = await renderComponent(source, {
+ steps: [
+ { label: "Account", content: "ACCOUNT BODY" },
+ { label: "Billing", content: "BILLING BODY" },
+ { label: "Confirm", content: "CONFIRM BODY" },
+ ],
+ active: 1,
+ showPanel: true,
+ controls: true,
+ allowSkip: true,
+ });
+ const dom = mountHtml(html);
+
+ const panels = [...dom.querySelectorAll(".wire-stepper__panel")];
+ expect(panels).toHaveLength(3);
+ /*
+ * Asserted through aria-hidden rather than data-show. data-show is emitted
+ * verbatim for the client runtime to evaluate, so its server-rendered value
+ * is not a reliable statement about which step is showing; aria-hidden is
+ * interpolated at render and says exactly that.
+ */
+ expect(panels.map((p) => p.getAttribute("aria-hidden"))).toEqual(["true", "false", "true"]);
+
+ expect(dom.querySelector(".wire-stepper__back")).not.toBeNull();
+ expect(dom.querySelector(".wire-stepper__next")).not.toBeNull();
+ expect(dom.querySelector(".wire-stepper__skip")).not.toBeNull();
+});
+
+test("stepper back is disabled on the first step and next becomes finish on the last", async () => {
+ const source = readFileSync(uiComponentPath("Stepper"), "utf8");
+ const steps = [{ label: "One" }, { label: "Two" }];
+
+ const first = mountHtml(await renderComponent(source, { steps, active: 0, controls: true }));
+ expect(first.querySelector(".wire-stepper__back")!.getAttribute("disabled")).not.toBeNull();
+ expect(first.querySelector(".wire-stepper__next")!.textContent).toContain("Next");
+
+ const last = mountHtml(await renderComponent(source, { steps, active: 1, controls: true }));
+ expect(last.querySelector(".wire-stepper__next")!.textContent).toContain("Finish");
+});
+
+test("stepper next can be gated so a form can hold it until the step validates", async () => {
+ const source = readFileSync(uiComponentPath("Stepper"), "utf8");
+ const html = await renderComponent(source, {
+ steps: [{ label: "One" }, { label: "Two" }],
+ active: 0,
+ controls: true,
+ nextDisabled: true,
+ });
+ const dom = mountHtml(html);
+ expect(dom.querySelector(".wire-stepper__next")!.getAttribute("disabled")).not.toBeNull();
+});