fix(ui): release the scroll lock on closed drawers, add stepper wizard controls, slide tabs
The scroll lock was mine, and it broke every page carrying a Drawer or Modal. Making dialog visibility testable, I replaced a size check with a data-show check -- but a Drawer animates open, so its panel cannot be hidden with data-show at all: display:none is not transitionable. Every closed Drawer therefore looked open, took the body scroll lock and never released it, and the page could not be scrolled. Both components publish data-open, which is the signal that actually means open, and that is what is read now. Stepper gains the wizard surface: showPanel renders each step body and shows only the active one, the same contract Tabs uses, and controls adds Back, Skip and Next, which becomes Finish on the last step. nextDisabled lets a form hold the step; the component never validates anything itself, since the page owns the form. Stepper also gets a single root. The panels and controls were siblings of the list, so the component had several roots and anything scoped to data-ui-component missed most of it. Tabs panels now slide in the direction of travel rather than fading upward. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
`<div data-ui-component="Drawer" data-open="false">
|
||||
<div class="layer">
|
||||
<section role="dialog" aria-modal="true" tabindex="-1">
|
||||
<button id="inside">Inside</button>
|
||||
</section>
|
||||
</div>
|
||||
</div>`,
|
||||
);
|
||||
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(
|
||||
`<div data-ui-component="Drawer" data-open="true">
|
||||
<section role="dialog" aria-modal="true" tabindex="-1">
|
||||
<button id="first">First</button>
|
||||
<button id="last">Last</button>
|
||||
</section>
|
||||
</div>`,
|
||||
);
|
||||
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");
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 {
|
||||
<ol
|
||||
<div
|
||||
{...attrs}
|
||||
data-ui-component="Stepper"
|
||||
class='wire-stepper {class}'
|
||||
@@ -80,6 +139,9 @@ component Stepper {
|
||||
data-color='{color}'
|
||||
data-size='{size}'
|
||||
data-clickable='{clickable}'
|
||||
>
|
||||
<ol
|
||||
class="wire-stepper__list"
|
||||
data-wrn-roving='{rovingAxis()}'
|
||||
aria-label='{label}'
|
||||
>
|
||||
@@ -111,13 +173,62 @@ component Stepper {
|
||||
</span>
|
||||
</li>
|
||||
{/each}
|
||||
<slot />
|
||||
</ol>
|
||||
|
||||
<div class="wire-stepper__panels" data-show="showPanel">
|
||||
{#each stepList() as step, index}
|
||||
<div
|
||||
class="wire-stepper__panel"
|
||||
data-show='isActiveStep(index)'
|
||||
aria-hidden='{index === activeIndex() ? "false" : "true"}'
|
||||
>
|
||||
<h4 class="wire-stepper__panel-title" data-show="step.title">{step.title}</h4>
|
||||
<p class="wire-stepper__panel-body" data-show="step.content">{step.content}</p>
|
||||
<slot name="panel-{index}"></slot>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="wire-stepper__controls" data-show="controls">
|
||||
<button
|
||||
type="button"
|
||||
class="wire-stepper__button-control wire-stepper__back"
|
||||
disabled='{activeIndex() === 0}'
|
||||
@click='goBack()'
|
||||
>
|
||||
{backLabel}
|
||||
</button>
|
||||
<span class="wire-stepper__controls-spacer"></span>
|
||||
<button
|
||||
type="button"
|
||||
class="wire-stepper__button-control wire-stepper__skip"
|
||||
data-show="allowSkip && !onLastStep()"
|
||||
@click='goSkip()'
|
||||
>
|
||||
{skipLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="wire-stepper__button-control wire-stepper__next"
|
||||
data-primary="true"
|
||||
disabled='{nextDisabled}'
|
||||
@click='goNext()'
|
||||
>
|
||||
{onLastStep() ? finishLabel : nextLabel}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<slot />
|
||||
</div>
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user