feat(ui): build the Stepper component
Replaces a scaffold that rendered bare anchors with ordered steps: complete, current and upcoming status derived from the active index, horizontal or vertical, optional icons, and indexed named slots (step-0, step-1, ...) for authoring a step body by hand. Also tightens the roving contract from the previous commit. A template writes data-wrn-roving="" or data-wrn-roving-item="false" to mean not this time, but a bare [attr] selector matches either, so a read-only stepper would still have taken arrow-key focus. The container now requires a named axis, while a bare item marker still counts as opted in. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
`<div data-wrn-roving="">
|
||||
<button data-wrn-roving-item="false" id="a">A</button>
|
||||
<button data-wrn-roving-item="false" id="b">B</button>
|
||||
</div>`,
|
||||
);
|
||||
const doc = win.document;
|
||||
const a = doc.querySelector("#a") as unknown as HTMLElement;
|
||||
expect(a.getAttribute("tabindex")).toBeNull();
|
||||
a.focus();
|
||||
a.dispatchEvent(new win.KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }));
|
||||
expect(doc.activeElement!.id).toBe("a");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user