fix(csr): drive tab url restore by announcement, anchor nav submenus
Two mechanisms tried and rejected while testing this against the live
showcase, both failing the same way on a second history step:
- synthesising a click on the matching tab: a re-render replaces the tab
buttons, and clicking a freshly replaced node that has not been bound
does nothing at all
- tracking the last applied value in the runtime: that state drifts out of
step with the component and silently swallows real changes
The runtime is now stateless. It announces the value the URL names via a
wrnexus:tabs:restore event and the component applies it, comparing against
its own selection rather than a DOM attribute a re-render owns.
Nav submenus are anchored so the viewport clamp keeps them on screen.
Comments in the runtime template trimmed to stay inside the size budget
rather than raising the ceiling again.
Known limitation: a second consecutive back/forward does not update the
selection, because the re-render replaces the component root without
rebinding its declarative listeners. That is a framework defect, not a Tabs
one, and needs its own fix.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -5,7 +5,7 @@
|
|||||||
"name": "component-showcase",
|
"name": "component-showcase",
|
||||||
"runtimeExecutable": "bun",
|
"runtimeExecutable": "bun",
|
||||||
"runtimeArgs": ["run", "--cwd", "examples/component-showcase", "dev"],
|
"runtimeArgs": ["run", "--cwd", "examples/component-showcase", "dev"],
|
||||||
"port": 3112
|
"port": 3000
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,7 +89,7 @@
|
|||||||
"packages/ui/components/StrongPassword.wrn": "c7c5ef26ece6170dd7db9882f0dc98cb2e4607f1e5d43eb3fd39e5d15bbd3a2e",
|
"packages/ui/components/StrongPassword.wrn": "c7c5ef26ece6170dd7db9882f0dc98cb2e4607f1e5d43eb3fd39e5d15bbd3a2e",
|
||||||
"packages/ui/components/StyledIcon.wrn": "4a4504e357dee9dd0418edbe85fc90b824ccdb3752123a2125da95b77bf0b336",
|
"packages/ui/components/StyledIcon.wrn": "4a4504e357dee9dd0418edbe85fc90b824ccdb3752123a2125da95b77bf0b336",
|
||||||
"packages/ui/components/Switch.wrn": "ccb74599fab72b0d68b09a7f1f90b7732cb2f9cbed67a84219e897575ce30c28",
|
"packages/ui/components/Switch.wrn": "ccb74599fab72b0d68b09a7f1f90b7732cb2f9cbed67a84219e897575ce30c28",
|
||||||
"packages/ui/components/Tabs.wrn": "29c0aacca730616002b7988308426e20b7a34abb9b249212d59d76190d89572f",
|
"packages/ui/components/Tabs.wrn": "37e125c6f6ec2c3f58a22cd9203cf75de40289c6be39cdbe17afd73e2d82b566",
|
||||||
"packages/ui/components/TextLink.wrn": "30782039293eb36d63b7b3a4f32a71a47177a3a68c7e90184be7cf7b4385eb19",
|
"packages/ui/components/TextLink.wrn": "30782039293eb36d63b7b3a4f32a71a47177a3a68c7e90184be7cf7b4385eb19",
|
||||||
"packages/ui/components/Textarea.wrn": "ddf0b4f124b2cf0c0ab3d820d3ac0085f7c20466e977231949be264cd0cee8cf",
|
"packages/ui/components/Textarea.wrn": "ddf0b4f124b2cf0c0ab3d820d3ac0085f7c20466e977231949be264cd0cee8cf",
|
||||||
"packages/ui/components/TimePicker.wrn": "2e8e7a90f6b6069a07e7ffd2725ba1e1031e84d55a4f1254025befbb314fa695",
|
"packages/ui/components/TimePicker.wrn": "2e8e7a90f6b6069a07e7ffd2725ba1e1031e84d55a4f1254025befbb314fa695",
|
||||||
|
|||||||
@@ -102,7 +102,6 @@ export interface Routes {
|
|||||||
"/components/strong-password": Record<string, never>;
|
"/components/strong-password": Record<string, never>;
|
||||||
"/components/styled-icon": Record<string, never>;
|
"/components/styled-icon": Record<string, never>;
|
||||||
"/components/switch": Record<string, never>;
|
"/components/switch": Record<string, never>;
|
||||||
"/components/table": Record<string, never>;
|
|
||||||
"/components/tabs": Record<string, never>;
|
"/components/tabs": Record<string, never>;
|
||||||
"/components/text-link": Record<string, never>;
|
"/components/text-link": Record<string, never>;
|
||||||
"/components/textarea": Record<string, never>;
|
"/components/textarea": Record<string, never>;
|
||||||
@@ -246,7 +245,6 @@ export interface RouteNames {
|
|||||||
"components.strong.password": "/components/strong-password";
|
"components.strong.password": "/components/strong-password";
|
||||||
"components.styled.icon": "/components/styled-icon";
|
"components.styled.icon": "/components/styled-icon";
|
||||||
"components.switch": "/components/switch";
|
"components.switch": "/components/switch";
|
||||||
"components.table": "/components/table";
|
|
||||||
"components.tabs": "/components/tabs";
|
"components.tabs": "/components/tabs";
|
||||||
"components.text.link": "/components/text-link";
|
"components.text.link": "/components/text-link";
|
||||||
"components.textarea": "/components/textarea";
|
"components.textarea": "/components/textarea";
|
||||||
@@ -459,7 +457,6 @@ export function route<N extends RouteName>(
|
|||||||
"components.strong.password": "/components/strong-password",
|
"components.strong.password": "/components/strong-password",
|
||||||
"components.styled.icon": "/components/styled-icon",
|
"components.styled.icon": "/components/styled-icon",
|
||||||
"components.switch": "/components/switch",
|
"components.switch": "/components/switch",
|
||||||
"components.table": "/components/table",
|
|
||||||
"components.tabs": "/components/tabs",
|
"components.tabs": "/components/tabs",
|
||||||
"components.text.link": "/components/text-link",
|
"components.text.link": "/components/text-link",
|
||||||
"components.textarea": "/components/textarea",
|
"components.textarea": "/components/textarea",
|
||||||
|
|||||||
@@ -3070,11 +3070,9 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
var ROVING_ITEM_SELECTOR = "[data-wrn-roving-item]";
|
var ROVING_ITEM_SELECTOR = "[data-wrn-roving-item]";
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* These attributes are written by templates, so they arrive stringified:
|
* Templates stringify these, so data-wrn-roving="" and
|
||||||
* data-wrn-roving="" or data-wrn-roving-item="false" is how a component
|
* data-wrn-roving-item="false" mean "not this time". A bare [attr] selector
|
||||||
* says "not this time". A bare [attr] selector matches either, so the value
|
* matches either, so the value must be checked.
|
||||||
* 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.
|
// A bare data-wrn-roving-item means yes; only an explicit "false" opts out.
|
||||||
function rovingItemOff(value) {
|
function rovingItemOff(value) {
|
||||||
@@ -3102,11 +3100,8 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
if (candidate.closest(ROVING_SELECTOR) !== container) continue;
|
if (candidate.closest(ROVING_SELECTOR) !== container) continue;
|
||||||
if (candidate.hasAttribute("disabled")) continue;
|
if (candidate.hasAttribute("disabled")) continue;
|
||||||
if (candidate.getAttribute("aria-disabled") === "true") continue;
|
if (candidate.getAttribute("aria-disabled") === "true") continue;
|
||||||
/*
|
// Markers, not measurement: the test DOM reports every element as
|
||||||
* Deliberately not a size check. The test DOM reports every element as
|
// zero-sized, which is what left the dialog trap uncovered.
|
||||||
* 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.hasAttribute("hidden")) continue;
|
||||||
if (candidate.closest('[data-show="false"]')) continue;
|
if (candidate.closest('[data-show="false"]')) continue;
|
||||||
found.push(candidate);
|
found.push(candidate);
|
||||||
@@ -3199,16 +3194,10 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Back/forward support for tabs that mirror their selection into the URL.
|
* Back/forward for tabs that mirror their selection into the URL. The
|
||||||
*
|
* runtime never touches component state -- a popstate listener assigning to
|
||||||
* The runtime deliberately does not touch component state. A popstate
|
* it would write after the client function returned, and that write is
|
||||||
* listener that assigned to it would be writing after the client function
|
* dropped. It announces the value instead and the component applies it.
|
||||||
* 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() {
|
function syncTabsFromUrl() {
|
||||||
var groups = document.querySelectorAll("[data-wrn-tabs-param]");
|
var groups = document.querySelectorAll("[data-wrn-tabs-param]");
|
||||||
@@ -3217,16 +3206,16 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
var param = group.getAttribute("data-wrn-tabs-param");
|
var param = group.getAttribute("data-wrn-tabs-param");
|
||||||
if (!param) continue;
|
if (!param) continue;
|
||||||
var value = new URLSearchParams(window.location.search).get(param);
|
var value = new URLSearchParams(window.location.search).get(param);
|
||||||
if (value === null) continue;
|
// Back past the first click lands on a URL with no parameter at all;
|
||||||
var tab = group.querySelector('[role="tab"][data-value="' + value + '"]');
|
// the selection the component started with is the answer there.
|
||||||
if (!tab || !tab.click) continue;
|
if (value === null) value = group.getAttribute("data-wrn-tabs-default");
|
||||||
if (tab.getAttribute("aria-selected") === "true") continue;
|
if (value === null || value === "") continue;
|
||||||
group.setAttribute("data-wrn-tabs-restoring", "true");
|
if (!group.querySelector('[role="tab"][data-value="' + value + '"]')) continue;
|
||||||
try {
|
// Announce the value; synthesising a click hits nodes a re-render may
|
||||||
tab.click();
|
// have replaced and left unbound.
|
||||||
} finally {
|
group.dispatchEvent(
|
||||||
group.removeAttribute("data-wrn-tabs-restoring");
|
new CustomEvent("wrnexus:tabs:restore", { detail: { value: value } }),
|
||||||
}
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1053,21 +1053,20 @@ test("an empty or false roving attribute opts the group out entirely", () => {
|
|||||||
expect(doc.activeElement!.id).toBe("a");
|
expect(doc.activeElement!.id).toBe("a");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("popstate activates the tab named by the query parameter without re-pushing", () => {
|
test("popstate announces the value the url names instead of clicking a tab", () => {
|
||||||
const win = mount(
|
const win = mount(
|
||||||
`<section data-wrn-tabs-param="tab">
|
`<section data-wrn-tabs-param="tab" data-wrn-tabs-default="one">
|
||||||
<div role="tablist">
|
<div role="tablist">
|
||||||
<button role="tab" data-value="one" aria-selected="true" id="t1">One</button>
|
<button role="tab" data-value="one" id="t1">One</button>
|
||||||
<button role="tab" data-value="two" aria-selected="false" id="t2">Two</button>
|
<button role="tab" data-value="two" id="t2">Two</button>
|
||||||
</div>
|
</div>
|
||||||
</section>`,
|
</section>`,
|
||||||
);
|
);
|
||||||
const doc = win.document;
|
const doc = win.document;
|
||||||
let restoringAtClick: string | null = "not-clicked";
|
const group = doc.querySelector("[data-wrn-tabs-param]") as unknown as HTMLElement;
|
||||||
(doc.querySelector("#t2") as unknown as HTMLElement).addEventListener("click", () => {
|
const seen: string[] = [];
|
||||||
restoringAtClick = doc
|
group.addEventListener("wrnexus:tabs:restore", (event) => {
|
||||||
.querySelector("[data-wrn-tabs-param]")!
|
seen.push((event as CustomEvent).detail.value);
|
||||||
.getAttribute("data-wrn-tabs-restoring");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
win.location.search = "?tab=two";
|
win.location.search = "?tab=two";
|
||||||
@@ -1076,13 +1075,49 @@ test("popstate activates the tab named by the query parameter without re-pushing
|
|||||||
"popstate",
|
"popstate",
|
||||||
) as unknown as Parameters<Window["dispatchEvent"]>[0],
|
) as unknown as Parameters<Window["dispatchEvent"]>[0],
|
||||||
);
|
);
|
||||||
|
expect(seen).toEqual(["two"]);
|
||||||
|
|
||||||
// The runtime activates the matching tab and marks the round trip, so the
|
// Back past the first click lands on a url with no parameter at all; the
|
||||||
// component can tell a history navigation from a real click and skip
|
// component's starting selection is the right answer there.
|
||||||
// pushing another entry.
|
win.location.search = "";
|
||||||
expect(restoringAtClick).toBe("true");
|
win.dispatchEvent(
|
||||||
// The marker is cleaned up once the activation is done.
|
new (win as unknown as { Event: new (t: string) => unknown }).Event(
|
||||||
expect(
|
"popstate",
|
||||||
doc.querySelector("[data-wrn-tabs-param]")!.getAttribute("data-wrn-tabs-restoring"),
|
) as unknown as Parameters<Window["dispatchEvent"]>[0],
|
||||||
).toBeNull();
|
);
|
||||||
|
expect(seen).toEqual(["two", "one"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the url sync announces on every popstate and leaves idempotence to the component", () => {
|
||||||
|
const win = mount(
|
||||||
|
`<section data-wrn-tabs-param="tab" data-wrn-tabs-default="one">
|
||||||
|
<div role="tablist">
|
||||||
|
<button role="tab" data-value="one" id="t1">One</button>
|
||||||
|
<button role="tab" data-value="two" id="t2">Two</button>
|
||||||
|
</div>
|
||||||
|
</section>`,
|
||||||
|
);
|
||||||
|
const doc = win.document;
|
||||||
|
const group = doc.querySelector("[data-wrn-tabs-param]") as unknown as HTMLElement;
|
||||||
|
const seen: string[] = [];
|
||||||
|
group.addEventListener("wrnexus:tabs:restore", (event) => {
|
||||||
|
seen.push((event as CustomEvent).detail.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
win.location.search = "?tab=two";
|
||||||
|
for (let i = 0; i < 3; i += 1) {
|
||||||
|
win.dispatchEvent(
|
||||||
|
new (win as unknown as { Event: new (t: string) => unknown }).Event(
|
||||||
|
"popstate",
|
||||||
|
) as unknown as Parameters<Window["dispatchEvent"]>[0],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Deliberately not deduplicated here. Tracking what was last applied meant
|
||||||
|
* the runtime held state that drifted out of step with the component, which
|
||||||
|
* silently swallowed real changes. The component compares against its own
|
||||||
|
* selection instead, which cannot drift.
|
||||||
|
*/
|
||||||
|
expect(seen).toEqual(["two", "two", "two"]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -49,6 +49,16 @@ component Tabs {
|
|||||||
return list.length ? valueOf(list[0], 0) : ""
|
return list.length ? valueOf(list[0], 0) : ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The selection this instance started with. The runtime falls back to it
|
||||||
|
// when the back button lands on a URL that has no tab parameter at all.
|
||||||
|
shared function defaultValue() {
|
||||||
|
if (active) {
|
||||||
|
return active
|
||||||
|
}
|
||||||
|
var list = itemList()
|
||||||
|
return list.length ? valueOf(list[0], 0) : ""
|
||||||
|
}
|
||||||
|
|
||||||
shared function isSelected(item, index) {
|
shared function isSelected(item, index) {
|
||||||
return valueOf(item, index) === currentValue()
|
return valueOf(item, index) === currentValue()
|
||||||
}
|
}
|
||||||
@@ -57,6 +67,34 @@ component Tabs {
|
|||||||
return orientation === "vertical" ? "vertical" : "horizontal"
|
return orientation === "vertical" ? "vertical" : "horizontal"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Back and forward arrive here. The runtime announces the value the URL
|
||||||
|
// now names; applying it must not write history, or stepping back would
|
||||||
|
// push a new entry and trap the user.
|
||||||
|
client function applyUrlValue(sourceEvent) {
|
||||||
|
var detail = sourceEvent ? sourceEvent.detail : null
|
||||||
|
var value = detail ? detail.value : ""
|
||||||
|
if (!value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var list = itemList()
|
||||||
|
var found = -1
|
||||||
|
for (var index = 0; index < list.length; index += 1) {
|
||||||
|
if (valueOf(list[index], index) === value) {
|
||||||
|
found = index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (found === -1) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Idempotent against our own state rather than a DOM attribute the
|
||||||
|
// re-render owns: popstate can fire for a value already selected.
|
||||||
|
if (value === currentValue()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
activeValue = value
|
||||||
|
output.change({ value: value, item: list[found], index: found })
|
||||||
|
}
|
||||||
|
|
||||||
client function selectTab(item, index, sourceEvent) {
|
client function selectTab(item, index, sourceEvent) {
|
||||||
if (item.disabled) {
|
if (item.disabled) {
|
||||||
return
|
return
|
||||||
@@ -64,12 +102,7 @@ component Tabs {
|
|||||||
var value = valueOf(item, index)
|
var value = valueOf(item, index)
|
||||||
activeValue = value
|
activeValue = value
|
||||||
|
|
||||||
// A click that came from the back button must not push another entry.
|
if (mode === "url" && window.history && window.history.pushState) {
|
||||||
var target = sourceEvent ? sourceEvent.currentTarget : null
|
|
||||||
var group = target && target.closest ? target.closest("[data-wrn-tabs-param]") : null
|
|
||||||
var restoring = group && group.getAttribute("data-wrn-tabs-restoring") === "true"
|
|
||||||
|
|
||||||
if (mode === "url" && !restoring && window.history && window.history.pushState) {
|
|
||||||
var url = new URL(window.location.href)
|
var url = new URL(window.location.href)
|
||||||
url.searchParams.set(param || "tab", value)
|
url.searchParams.set(param || "tab", value)
|
||||||
window.history.pushState({}, "", url.toString())
|
window.history.pushState({}, "", url.toString())
|
||||||
@@ -91,6 +124,8 @@ component Tabs {
|
|||||||
data-mode='{mode}'
|
data-mode='{mode}'
|
||||||
data-param='{param}'
|
data-param='{param}'
|
||||||
data-wrn-tabs-param='{mode === "url" ? param : ""}'
|
data-wrn-tabs-param='{mode === "url" ? param : ""}'
|
||||||
|
data-wrn-tabs-default='{defaultValue()}'
|
||||||
|
@wrnexus:tabs:restore='applyUrlValue(event)'
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="wire-tabs__list"
|
class="wire-tabs__list"
|
||||||
|
|||||||
Reference in New Issue
Block a user