diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 00000000..54981fa9 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "component-showcase", + "runtimeExecutable": "bun", + "runtimeArgs": ["run", "--cwd", "examples/component-showcase", "dev"], + "port": 3112 + } + ] +} diff --git a/examples/component-showcase/scripts/showcase-profiles.mjs b/examples/component-showcase/scripts/showcase-profiles.mjs index 8987b29a..2f8052f5 100644 --- a/examples/component-showcase/scripts/showcase-profiles.mjs +++ b/examples/component-showcase/scripts/showcase-profiles.mjs @@ -253,6 +253,80 @@ 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 = { + Tabs: { + demos: [ + standard( + "Panels and arrow keys", + "Selecting a tab swaps the panel with a short transition. Arrow keys move between tabs, Home and End jump to the ends, and only the selected tab is in the tab order.", + { + items: [ + { + label: "Overview", + value: "overview", + title: "Overview", + description: "What the product does and who it is for.", + }, + { + label: "Pricing", + value: "pricing", + title: "Pricing", + description: "Plans, limits and what counts as a seat.", + badge: "New", + }, + { + label: "Support", + value: "support", + title: "Support", + description: "Response times and escalation paths.", + }, + ], + active: "overview", + }, + ), + advanced( + "Mirrored into the URL", + "With mode=url the selection is written to a query parameter using pushState, so the panel swaps without a page load, the tab survives a reload, and the back button steps through the tabs you visited.", + { + items: [ + { label: "Account", value: "account", description: "Profile and credentials." }, + { label: "Billing", value: "billing", description: "Invoices and payment method." }, + { label: "Team", value: "team", description: "Members and their roles." }, + ], + active: "account", + mode: "url", + param: "tab", + }, + ), + standard( + "Vertical with icons", + "Vertical orientation switches the arrow keys to up and down, and collapses back to a scrollable strip on a phone.", + { + items: [ + { + label: "General", + value: "general", + icon: "icon-[lucide--settings]", + description: "Everyday preferences.", + }, + { + label: "Security", + value: "security", + icon: "icon-[lucide--shield]", + description: "Sessions and two-factor.", + }, + { + label: "Advanced", + value: "advanced", + icon: "icon-[lucide--flask-conical]", + description: "Experimental toggles.", + }, + ], + active: "security", + orientation: "vertical", + }, + ), + ], + }, Nav: { demos: [ standard( diff --git a/packages/csr/src/reactive-runtime.ts b/packages/csr/src/reactive-runtime.ts index a404b653..4207b7a6 100644 --- a/packages/csr/src/reactive-runtime.ts +++ b/packages/csr/src/reactive-runtime.ts @@ -3198,6 +3198,46 @@ export const REACTIVE_RUNTIME = String.raw` syncRovingGroups(); } + /* + * Back/forward support for tabs that mirror their selection into the URL. + * + * The runtime deliberately does not touch component state. A popstate + * listener that assigned to it would be writing after the client function + * returned, and that write is dropped. Instead it finds the tab matching the + * query parameter and activates it, so the component updates itself through + * its own handler. + * + * data-wrn-tabs-restoring marks the round trip, so the component knows not + * to push another history entry for a navigation that came from history. + */ + function syncTabsFromUrl() { + var groups = document.querySelectorAll("[data-wrn-tabs-param]"); + for (var index = 0; index < groups.length; index += 1) { + var group = groups[index]; + var param = group.getAttribute("data-wrn-tabs-param"); + if (!param) continue; + var value = new URLSearchParams(window.location.search).get(param); + if (value === null) continue; + var tab = group.querySelector('[role="tab"][data-value="' + value + '"]'); + if (!tab || !tab.click) continue; + if (tab.getAttribute("aria-selected") === "true") continue; + group.setAttribute("data-wrn-tabs-restoring", "true"); + try { + tab.click(); + } finally { + group.removeAttribute("data-wrn-tabs-restoring"); + } + } + } + + function setupTabUrlSync() { + if (window.__wrnexusTabUrlBound) return; + window.__wrnexusTabUrlBound = true; + window.addEventListener("popstate", syncTabsFromUrl); + // Once after hydration, so a shared link opens on the right tab. + window.setTimeout(syncTabsFromUrl, 0); + } + function hydrateScopes(root) { var host = root || document; @@ -5416,6 +5456,7 @@ export const REACTIVE_RUNTIME = String.raw` setupAnchoredOverlays(); setupModalDialogs(); setupRovingFocus(); + setupTabUrlSync(); 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 25ea5c2b..ddc6079e 100644 --- a/packages/csr/test/reactive.test.ts +++ b/packages/csr/test/reactive.test.ts @@ -1052,3 +1052,37 @@ test("an empty or false roving attribute opts the group out entirely", () => { a.dispatchEvent(keydown(win, "ArrowRight")); expect(doc.activeElement!.id).toBe("a"); }); + +test("popstate activates the tab named by the query parameter without re-pushing", () => { + const win = mount( + `
+
+ + +
+
`, + ); + const doc = win.document; + let restoringAtClick: string | null = "not-clicked"; + (doc.querySelector("#t2") as unknown as HTMLElement).addEventListener("click", () => { + restoringAtClick = doc + .querySelector("[data-wrn-tabs-param]")! + .getAttribute("data-wrn-tabs-restoring"); + }); + + win.location.search = "?tab=two"; + win.dispatchEvent( + new (win as unknown as { Event: new (t: string) => unknown }).Event( + "popstate", + ) as unknown as Parameters[0], + ); + + // The runtime activates the matching tab and marks the round trip, so the + // component can tell a history navigation from a real click and skip + // pushing another entry. + expect(restoringAtClick).toBe("true"); + // The marker is cleaned up once the activation is done. + expect( + doc.querySelector("[data-wrn-tabs-param]")!.getAttribute("data-wrn-tabs-restoring"), + ).toBeNull(); +}); diff --git a/packages/ui/components/Nav.wrn b/packages/ui/components/Nav.wrn index c536b3f0..cad991dd 100644 --- a/packages/ui/components/Nav.wrn +++ b/packages/ui/components/Nav.wrn @@ -106,7 +106,11 @@ component Nav { >› -