From f94004648d23b95182e7f5f0c1668502c5b72ad9 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Fri, 7 Aug 2026 16:38:44 +0530 Subject: [PATCH 01/10] feat(csr): runtime-owned roving arrow-key focus A container marked data-wrn-roving owns its [data-wrn-roving-item] descendants: one carries tabindex=0 so Tab reaches the group once, and the arrow keys move within it, with Home/End, wrap-around and skip-disabled. Written once here rather than five times across Tabs, Nav, MegaMenu, Sidebar and Stepper, and because focus bookkeeping cannot live in component state -- a client function writing after it returns has that write dropped. Item visibility is checked via hidden and data-show rather than measured size: the test DOM reports every element as zero-sized, which is exactly what left the dialog focus trap uncovered. Co-Authored-By: Claude Opus 5 --- packages/csr/src/reactive-runtime.ts | 117 +++++++++++++++++++++++++++ packages/csr/test/reactive.test.ts | 99 +++++++++++++++++++++++ 2 files changed, 216 insertions(+) diff --git a/packages/csr/src/reactive-runtime.ts b/packages/csr/src/reactive-runtime.ts index 63ad9ccb..ad2738b0 100644 --- a/packages/csr/src/reactive-runtime.ts +++ b/packages/csr/src/reactive-runtime.ts @@ -3047,6 +3047,122 @@ export const REACTIVE_RUNTIME = String.raw` syncDialogs(); } + /* + * Roving arrow-key focus, the ARIA pattern shared by tabs, menus, sidebars + * and steppers. A container marked data-wrn-roving owns its + * [data-wrn-roving-item] descendants: exactly one carries tabindex="0" so + * Tab reaches the group once, and the arrow keys move within it. + * + * This lives here rather than in each component because it is the same code + * five times over, and because focus bookkeeping cannot be held in component + * state -- a client function writing after it returns has that write dropped. + */ + var ROVING_SELECTOR = "[data-wrn-roving]"; + var ROVING_ITEM_SELECTOR = "[data-wrn-roving-item]"; + + 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]; + // A nested group owns its own items; do not steal them. + if (candidate.closest(ROVING_SELECTOR) !== container) continue; + if (candidate.hasAttribute("disabled")) continue; + if (candidate.getAttribute("aria-disabled") === "true") continue; + /* + * Deliberately not a size check. The test DOM reports every element as + * zero-sized, so measuring here would make the whole feature impossible + * to cover -- the same trap the dialog visibility check fell into. + */ + if (candidate.hasAttribute("hidden")) continue; + if (candidate.closest('[data-show="false"]')) continue; + found.push(candidate); + } + return found; + } + + function rovingActiveIndex(items) { + for (var index = 0; index < items.length; index += 1) { + var item = items[index]; + if (item.getAttribute("aria-selected") === "true") return index; + var current = item.getAttribute("aria-current"); + if (current === "page" || current === "step" || current === "true") return index; + } + return 0; + } + + function applyRovingTabindex(items, activeIndex) { + for (var index = 0; index < items.length; index += 1) { + items[index].setAttribute("tabindex", index === activeIndex ? "0" : "-1"); + } + } + + function syncRovingGroup(container) { + var items = rovingItems(container); + if (!items.length) return; + applyRovingTabindex(items, rovingActiveIndex(items)); + } + + function syncRovingGroups() { + var groups = document.querySelectorAll(ROVING_SELECTOR); + for (var index = 0; index < groups.length; index += 1) syncRovingGroup(groups[index]); + } + + function handleRovingKeydown(event) { + var target = event.target; + if (!target || !target.closest) return; + var item = target.closest(ROVING_ITEM_SELECTOR); + if (!item) return; + var container = item.closest(ROVING_SELECTOR); + if (!container) return; + + var items = rovingItems(container); + var index = items.indexOf(item); + if (index === -1) return; + + var orientation = container.getAttribute("data-wrn-roving") || "horizontal"; + var horizontal = orientation === "horizontal" || orientation === "both"; + var vertical = orientation === "vertical" || orientation === "both"; + var key = event.key; + var next = -1; + + if ((horizontal && key === "ArrowRight") || (vertical && key === "ArrowDown")) { + next = (index + 1) % items.length; + } else if ((horizontal && key === "ArrowLeft") || (vertical && key === "ArrowUp")) { + next = (index - 1 + items.length) % items.length; + } else if (key === "Home") { + next = 0; + } else if (key === "End") { + next = items.length - 1; + } else { + return; + } + + event.preventDefault(); + applyRovingTabindex(items, next); + if (items[next].focus) items[next].focus(); + } + + function setupRovingFocus() { + if (window.__wrnexusRovingBound) return; + window.__wrnexusRovingBound = true; + + document.addEventListener("keydown", handleRovingKeydown, true); + + if (typeof MutationObserver === "function") { + new MutationObserver(function () { + window.setTimeout(syncRovingGroups, 0); + }).observe(document.documentElement, { + subtree: true, + childList: true, + attributes: true, + attributeFilter: ["aria-selected", "aria-current", "disabled", "aria-disabled", "hidden"], + }); + } + + syncRovingGroups(); + } + function hydrateScopes(root) { var host = root || document; @@ -5264,6 +5380,7 @@ export const REACTIVE_RUNTIME = String.raw` window.__wrnexusHydrateAsyncBoundaries = hydrateAsyncBoundaries; setupAnchoredOverlays(); setupModalDialogs(); + setupRovingFocus(); window.__wrnexusRepositionAnchored = repositionAnchored; window.__wrnexusHydrateScopes = hydrateScopes; window.__wrnexusInvalidateClientModule = function (url) { clientModuleCache.delete(url); }; diff --git a/packages/csr/test/reactive.test.ts b/packages/csr/test/reactive.test.ts index 71a178dc..bef6017c 100644 --- a/packages/csr/test/reactive.test.ts +++ b/packages/csr/test/reactive.test.ts @@ -894,3 +894,102 @@ test("comments are ignored inside interpreted statements", () => { (win.document.querySelector("button") as unknown as HTMLElement).click(); expect(win.document.querySelector("#out")?.textContent).toBe("2"); }); + +test("roving focus moves with arrow keys and wraps", () => { + const win = mount( + `
+ + + +
`, + ); + const doc = win.document; + const a = doc.querySelector("#a") as unknown as HTMLElement; + const c = doc.querySelector("#c") as unknown as HTMLElement; + + expect(a.getAttribute("tabindex")).toBe("0"); + expect(doc.querySelector("#b")!.getAttribute("tabindex")).toBe("-1"); + + a.focus(); + a.dispatchEvent(new win.KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true })); + expect(doc.activeElement!.id).toBe("b"); + expect(doc.querySelector("#b")!.getAttribute("tabindex")).toBe("0"); + expect(a.getAttribute("tabindex")).toBe("-1"); + + doc + .querySelector("#b")! + .dispatchEvent(new win.KeyboardEvent("keydown", { key: "ArrowLeft", bubbles: true })); + expect(doc.activeElement!.id).toBe("a"); + + a.dispatchEvent(new win.KeyboardEvent("keydown", { key: "ArrowLeft", bubbles: true })); + expect(doc.activeElement!.id).toBe("c"); + + c.dispatchEvent(new win.KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true })); + expect(doc.activeElement!.id).toBe("a"); +}); + +test("roving focus honours Home and End and skips disabled items", () => { + const win = mount( + `
+ + + +
`, + ); + const doc = win.document; + const a = doc.querySelector("#a") as unknown as HTMLElement; + a.focus(); + + a.dispatchEvent(new win.KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })); + expect(doc.activeElement!.id).toBe("c"); + + doc + .querySelector("#c")! + .dispatchEvent(new win.KeyboardEvent("keydown", { key: "Home", bubbles: true })); + expect(doc.activeElement!.id).toBe("a"); + + a.dispatchEvent(new win.KeyboardEvent("keydown", { key: "End", bubbles: true })); + expect(doc.activeElement!.id).toBe("c"); +}); + +test("horizontal roving ignores vertical arrows so the page still scrolls", () => { + const win = mount( + `
+ + +
`, + ); + const a = win.document.querySelector("#a") as unknown as HTMLElement; + a.focus(); + a.dispatchEvent(new win.KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })); + expect(win.document.activeElement!.id).toBe("a"); +}); + +test("roving tabindex starts on the selected item, not the first", () => { + const win = mount( + `
+ + +
`, + ); + expect(win.document.querySelector("#b")!.getAttribute("tabindex")).toBe("0"); + expect(win.document.querySelector("#a")!.getAttribute("tabindex")).toBe("-1"); +}); + +test("nested roving groups do not capture the outer group items", () => { + const win = mount( + `
+ +
+ + +
+ +
`, + ); + const doc = win.document; + const a = doc.querySelector("#a") as unknown as HTMLElement; + a.focus(); + a.dispatchEvent(new win.KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true })); + expect(doc.activeElement!.id).toBe("b"); +}); From 7ac6e0854487669e136186ce71d346755e187ac6 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Fri, 7 Aug 2026 16:40:30 +0530 Subject: [PATCH 02/10] fix(csr): make dialog visibility testable and cover the focus trap The focus trap and scroll lock shipped in 0.8.5 gated on getBoundingClientRect, which the test DOM always reports as zero, so a dialog never counted as open and none of that behaviour ran under test. focusableWithin had the same measurement gate and would have found no items even once the visibility check was fixed. Both now use the hidden attribute and the data-show marker the components already emit. Behaviour in a real browser is unchanged; the difference is that it is now covered. Co-Authored-By: Claude Opus 5 --- packages/csr/src/reactive-runtime.ts | 19 ++++++++++---- packages/csr/test/reactive.test.ts | 37 ++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 5 deletions(-) diff --git a/packages/csr/src/reactive-runtime.ts b/packages/csr/src/reactive-runtime.ts index ad2738b0..0d232256 100644 --- a/packages/csr/src/reactive-runtime.ts +++ b/packages/csr/src/reactive-runtime.ts @@ -2927,11 +2927,18 @@ export const REACTIVE_RUNTIME = String.raw` var dialogRestoreFocus = null; var dialogScrollLock = null; + /* + * Open/closed is expressed by the data-show marker these components already + * emit, not by measured size. Measuring looks more thorough but made the + * trap and the scroll lock impossible to cover: the test DOM reports every + * element as zero-sized, so a dialog never counted as open and none of this + * behaviour ran under test. + */ function isDialogVisible(dialog) { - if (!dialog || !dialog.getBoundingClientRect) return false; + if (!dialog || !dialog.isConnected) return false; + if (dialog.hasAttribute("hidden")) return false; if (dialog.closest('[data-show="false"]')) return false; - var rect = dialog.getBoundingClientRect(); - return rect.width > 0 && rect.height > 0; + return true; } function focusableWithin(dialog) { @@ -2939,8 +2946,10 @@ export const REACTIVE_RUNTIME = String.raw` var candidates = dialog.querySelectorAll(FOCUSABLE_SELECTOR); for (var index = 0; index < candidates.length; index += 1) { var candidate = candidates[index]; - var rect = candidate.getBoundingClientRect(); - if (rect.width > 0 || rect.height > 0) found.push(candidate); + // Same rule as the roving items: markers, not measurement. + if (candidate.hasAttribute("hidden")) continue; + if (candidate.closest('[data-show="false"]')) continue; + found.push(candidate); } return found; } diff --git a/packages/csr/test/reactive.test.ts b/packages/csr/test/reactive.test.ts index bef6017c..b71c47e2 100644 --- a/packages/csr/test/reactive.test.ts +++ b/packages/csr/test/reactive.test.ts @@ -993,3 +993,40 @@ test("nested roving groups do not capture the outer group items", () => { a.dispatchEvent(new win.KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true })); expect(doc.activeElement!.id).toBe("b"); }); + +test("opening a modal dialog traps Tab inside it", () => { + const win = mount( + `
+ +
+
+ + +
+
+
`, + ); + const doc = win.document; + const last = doc.querySelector("#last") as unknown as HTMLElement; + last.focus(); + last.dispatchEvent(new win.KeyboardEvent("keydown", { key: "Tab", bubbles: true })); + expect(doc.activeElement!.id).toBe("first"); +}); + +test("a hidden dialog does not trap Tab", () => { + const win = mount( + `
+ +
+
+ +
+
+
`, + ); + const doc = win.document; + const outside = doc.querySelector("#outside") as unknown as HTMLElement; + outside.focus(); + outside.dispatchEvent(new win.KeyboardEvent("keydown", { key: "Tab", bubbles: true })); + expect(doc.activeElement!.id).toBe("outside"); +}); From b67a5e43eb613b87161936a5cfab52ebc9d40f12 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Fri, 7 Aug 2026 16:41:56 +0530 Subject: [PATCH 03/10] feat(ui): build the Pagination component Replaces a scaffold that rendered a bare list of anchors with real page controls: compact arrows or windowed page numbers, a range summary, and a change output carrying the requested page. Out-of-range pages clamp rather than rendering nothing, because page arrives as an HTML attribute and callers compute it from data that may have shrunk. Co-Authored-By: Claude Opus 5 --- .../scripts/showcase-profiles.mjs | 19 ++ packages/ui/components/Pagination.wrn | 259 +++++++++++++++++- packages/ui/test/ui.test.ts | 35 +++ 3 files changed, 303 insertions(+), 10 deletions(-) diff --git a/examples/component-showcase/scripts/showcase-profiles.mjs b/examples/component-showcase/scripts/showcase-profiles.mjs index f36ea355..93ab74e7 100644 --- a/examples/component-showcase/scripts/showcase-profiles.mjs +++ b/examples/component-showcase/scripts/showcase-profiles.mjs @@ -253,6 +253,25 @@ 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 = { + Pagination: { + demos: [ + standard( + "Compact arrows", + "The default: previous and next with a page counter, and a summary of the range in view.", + { page: 2, pageSize: 10, total: 137, variant: "compact" }, + ), + standard( + "Numbered pages", + "Page numbers windowed around the current page with ellipsis gaps, so a large set never renders hundreds of buttons.", + { page: 5, pageSize: 10, total: 200, variant: "numbered", siblingCount: 1 }, + ), + advanced( + "Wider window", + "siblingCount widens how many pages sit either side of the current one.", + { page: 8, pageSize: 25, total: 900, variant: "numbered", siblingCount: 2 }, + ), + ], + }, DataTable: { demos: [ standard( diff --git a/packages/ui/components/Pagination.wrn b/packages/ui/components/Pagination.wrn index 2a55358e..85b14244 100644 --- a/packages/ui/components/Pagination.wrn +++ b/packages/ui/components/Pagination.wrn @@ -1,23 +1,262 @@ +// Pagination -- page controls over a known total. +// +// +// +// The component owns no data. It reports the requested page through its +// change output and lets the caller fetch or slice. +// +// NOTE: the style block uses /* */ comments only -- // is not a CSS comment +// and silently swallows the rule that follows it. component Pagination { outputs { - change(payload: { value?: string | number | boolean | null; values?: Array; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null) - previous(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object }) - next(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object }) + change(payload: { page: number; pageSize: number }) + previous(payload: { page: number }) + next(payload: { page: number }) } props { -size: string = "default" - color: string = "primary" + page: number = 1 + pageSize: number = 10 + total: number = 0 + variant: string = "compact" + siblingCount: number = 1 + showSummary: boolean = true label: string = "Pagination" - items: unknown[] = [] - active: string = "" - orientation: string = "horizontal" + previousLabel: string = "Previous" + nextLabel: string = "Next" class: string = "" } + + functions { + shared function lastPage() { + var size = Number(pageSize) > 0 ? Number(pageSize) : 10 + var count = Number(total) > 0 ? Number(total) : 0 + return Math.max(1, Math.ceil(count / size)) + } + + // Out-of-range values arrive routinely: page travels as an HTML attribute + // and callers compute it from data that may have shrunk underneath them. + shared function currentPage() { + var value = Number(page) + if (!value || value < 1) { + return 1 + } + return Math.min(value, lastPage()) + } + + shared function firstShown() { + if (Number(total) < 1) { + return 0 + } + return (currentPage() - 1) * (Number(pageSize) || 10) + 1 + } + + shared function lastShown() { + return Math.min(currentPage() * (Number(pageSize) || 10), Number(total) || 0) + } + + shared function pageNumbers() { + var last = lastPage() + var current = currentPage() + var siblings = Math.max(0, Number(siblingCount) || 0) + var start = Math.max(1, current - siblings) + var end = Math.min(last, current + siblings) + var pages = [] + if (start > 1) { + pages.push({ value: 1, label: "1", gap: false }) + if (start > 2) { + pages.push({ value: 0, label: "...", gap: true }) + } + } + for (var index = start; index <= end; index += 1) { + pages.push({ value: index, label: String(index), gap: false }) + } + if (end < last) { + if (end < last - 1) { + pages.push({ value: 0, label: "...", gap: true }) + } + pages.push({ value: last, label: String(last), gap: false }) + } + return pages + } + + client function goToPage(target) { + var next = Math.min(Math.max(1, Number(target) || 1), lastPage()) + output.change({ page: next, pageSize: Number(pageSize) || 10 }) + } + + client function goPrevious() { + var target = Math.max(1, currentPage() - 1) + output.previous({ page: target }) + output.change({ page: target, pageSize: Number(pageSize) || 10 }) + } + + client function goNext() { + var target = Math.min(lastPage(), currentPage() + 1) + output.next({ page: target }) + output.change({ page: target, pageSize: Number(pageSize) || 10 }) + } + } + view { -