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 {
>›
-