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:
+//
+//
+//