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 props2 slots1 outputsTheme readyResponsive
+
16 props3 slots5 outputsTheme readyResponsive
-
+
Interactive playground

Configure Stepper

Change any prop and inspect the server-rendered component immediately.

Live preview
-
+
Component code
<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 outputInteract with the preview to inspect the typed payload.
-
Component props8 controls
+
Component props16 controls
+]
@@ -277,7 +293,7 @@ page StepperDetail {
Live preview
-
+
Component usage.wrn
@@ -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 {
Live preview
-
+
Component usage.wrn
@@ -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> +
+ + +
+
+
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. nextDisabled is how a form holds the step: the component never validates anything itself, so the page sets it while the step is incomplete and clears it once the step passes.

+ 03 +
+
+
+
Live preview
+ +
+
+
+
Component usage.wrn
+
<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>
+
+
+
+
+
+
Advanced

Held until the step is valid

The same wizard with nextDisabled set. Next stays held, so a form can keep the reader on the step until it validates.

+ 04 +
+
+
+
Live preview
+ +
+
+
+
Component usage.wrn
+
<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 {
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.

- 03 + 05
-
+
Live preview
-
+
Component usage.wrn
@@ -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.
Declarative handlers.wrn
<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.
Declarative handlers.wrn
<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.

PropTypeDefaultRequired
colorstring"primary"No
sizestring"default"No
stepsunknown[][]No
activenumber0No
orientationstring"horizontal"No
clickablebooleanfalseNo
labelstring"Progress"No
classstring""No
+
Component API

Props and configuration

All content and behavior shown above is supplied through these props and slots.

PropTypeDefaultRequired
colorstring"primary"No
sizestring"default"No
stepsunknown[][]No
activenumber0No
orientationstring"horizontal"No
clickablebooleanfalseNo
labelstring"Progress"No
showPanelbooleanfalseNo
controlsbooleanfalseNo
allowSkipbooleanfalseNo
nextDisabledbooleanfalseNo
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 componentsMultiple use cases28 live configurations
+
10 componentsMultiple use cases30 live configurations
@@ -96,11 +96,11 @@ page NavigationShowcase {
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} +
    + +
    + + + + +
    + + +
} 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(); +});