fix: keep panel tab identity stable across reorder

This commit is contained in:
Shakker
2026-08-11 14:42:15 +02:00
parent 1383144b02
commit c8185605f6
2 changed files with 137 additions and 39 deletions
+93 -4
View File
@@ -1,7 +1,7 @@
/* @vitest-environment jsdom */
import { render } from "lit";
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
panelTabStripStyles,
renderPanelTabStrip,
@@ -15,18 +15,30 @@ const TAB: PanelTabStripTab = {
closeLabel: "Close tab: First tab",
};
type RenderedTab = HTMLElement & {
active: boolean;
panel: string;
};
type RenderedTabGroup = HTMLElement & {
active: string;
};
function renderStrip(options: {
tabs?: PanelTabStripTab[];
activeId?: string | null;
onClose?: (id: string) => void;
onNew?: () => void;
onSelect?: (id: string) => void;
container?: HTMLDivElement;
}) {
const container = document.createElement("div");
const container = options.container ?? document.createElement("div");
render(
renderPanelTabStrip({
tabs: options.tabs ?? [],
activeId: options.tabs?.[0]?.id ?? null,
activeId: options.activeId ?? options.tabs?.[0]?.id ?? null,
ariaControls: "test-tab-panel",
onSelect: vi.fn(),
onSelect: options.onSelect ?? vi.fn(),
onClose: options.onClose ?? vi.fn(),
onNew: options.onNew ?? vi.fn(),
newLabel: "New tab",
@@ -36,6 +48,22 @@ function renderStrip(options: {
return container;
}
function readTabStrip(container: ParentNode): {
group: RenderedTabGroup;
tabs: RenderedTab[];
} {
const group = container.querySelector<RenderedTabGroup>("wa-tab-group");
if (!group) {
throw new Error("expected rendered tab group");
}
const tabs = [...container.querySelectorAll<RenderedTab>("wa-tab")];
return { group, tabs };
}
afterEach(() => {
document.body.replaceChildren();
});
describe("renderPanelTabStrip", () => {
it("keeps the new-tab control from shrinking when the strip overflows", () => {
expect(panelTabStripStyles.cssText).toMatch(/\.tabstrip-new\s*\{[^}]*flex:\s*none/u);
@@ -76,4 +104,65 @@ describe("renderPanelTabStrip", () => {
container.querySelector("wa-tab")?.dispatchEvent(new MouseEvent("auxclick", { button: 1 }));
expect(onClose).toHaveBeenCalledWith(TAB.id);
});
it("keeps the controlled active tab coherent when stateful tab elements reorder", async () => {
const first: PanelTabStripTab = {
...TAB,
id: "a",
domId: "test-tab-a",
label: "Alpha",
title: "https://example.com/a",
};
const second: PanelTabStripTab = {
...TAB,
id: "b",
domId: "test-tab-b",
label: "Beta",
title: "https://example.com/b",
};
const container = renderStrip({ tabs: [first, second], activeId: first.id });
document.body.append(container);
const initial = readTabStrip(container);
const firstElement = initial.tabs.find((tab) => tab.panel === first.id);
const secondElement = initial.tabs.find((tab) => tab.panel === second.id);
if (!firstElement || !secondElement) {
throw new Error("expected initial active tab");
}
firstElement.active = true;
firstElement.setAttribute("active", "");
firstElement.setAttribute("aria-selected", "true");
firstElement.tabIndex = 0;
secondElement.active = false;
secondElement.removeAttribute("active");
secondElement.setAttribute("aria-selected", "false");
secondElement.tabIndex = -1;
renderStrip({
container,
tabs: [
{ ...second, label: "Beta navigated", title: "https://example.com/b/next" },
{ ...first, label: "Alpha navigated", title: "https://example.com/a/next" },
],
activeId: first.id,
});
await vi.waitFor(() => {
const reordered = readTabStrip(container);
const active = reordered.tabs.filter(
(tab) =>
tab.active || tab.hasAttribute("active") || tab.getAttribute("aria-selected") === "true",
);
const reorderedFirst = reordered.tabs.find((tab) => tab.panel === first.id);
expect(reorderedFirst).toBe(firstElement);
expect(reordered.group.active).toBe(first.id);
expect(active).toEqual([firstElement]);
expect(firstElement.getAttribute("aria-selected")).toBe("true");
expect(firstElement.tabIndex).toBe(0);
expect(reordered.tabs.find((tab) => tab.panel === second.id)).toMatchObject({
active: false,
tabIndex: -1,
});
});
});
});
+44 -35
View File
@@ -1,4 +1,5 @@
import { css, html, nothing, svg, type TemplateResult } from "lit";
import { repeat } from "lit/directives/repeat.js";
import "./web-awesome-tabs.ts";
export type PanelTabStripTab = {
@@ -53,41 +54,49 @@ export function renderPanelTabStrip(params: {
without-scroll-controls
@wa-tab-show=${(event: CustomEvent<{ name: string }>) => params.onSelect(event.detail.name)}
>
${params.tabs.map(
(tab) => html`
<wa-tab
id=${tab.domId}
class=${`tabstrip-tab ${tab.className ?? ""}`}
panel=${tab.id}
aria-controls=${params.ariaControls}
title=${tab.title || nothing}
@auxclick=${(event: MouseEvent) => {
if (event.button === 1) {
event.preventDefault();
params.onClose(tab.id);
}
}}
>
${tab.icon == null || tab.icon === nothing
? nothing
: html`<span class="tabstrip-tab__icon" aria-hidden="true">${tab.icon}</span>`}
<span class="tabstrip-tab__label">${tab.label}</span>
${tab.badge ? html`<span class="tabstrip-tab__badge">${tab.badge}</span>` : nothing}
${tab.statusLabel
? html`<span class="tabstrip-tab__status">${tab.statusLabel}</span>`
: nothing}
</wa-tab>
<button
slot="nav"
class="tabstrip-tab__close"
type="button"
title=${tab.closeLabel}
aria-label=${tab.closeLabel}
@click=${() => params.onClose(tab.id)}
>
<span class="tabstrip-tab__close-box">${CLOSE_GLYPH}</span>
</button>
`,
${repeat(
params.tabs,
(tab) => tab.id,
(tab) => {
const selected = tab.id === params.activeId;
return html`
<wa-tab
id=${tab.domId}
class=${`tabstrip-tab ${tab.className ?? ""}`}
panel=${tab.id}
aria-controls=${params.ariaControls}
aria-selected=${selected ? "true" : "false"}
title=${tab.title || nothing}
?active=${selected}
.tabIndex=${selected ? 0 : -1}
@auxclick=${(event: MouseEvent) => {
if (event.button === 1) {
event.preventDefault();
params.onClose(tab.id);
}
}}
>
${tab.icon == null || tab.icon === nothing
? nothing
: html`<span class="tabstrip-tab__icon" aria-hidden="true">${tab.icon}</span>`}
<span class="tabstrip-tab__label">${tab.label}</span>
${tab.badge ? html`<span class="tabstrip-tab__badge">${tab.badge}</span>` : nothing}
${tab.statusLabel
? html`<span class="tabstrip-tab__status">${tab.statusLabel}</span>`
: nothing}
</wa-tab>
<button
slot="nav"
class="tabstrip-tab__close"
type="button"
title=${tab.closeLabel}
aria-label=${tab.closeLabel}
@click=${() => params.onClose(tab.id)}
>
<span class="tabstrip-tab__close-box">${CLOSE_GLYPH}</span>
</button>
`;
},
)}
${newButton(true)}
</wa-tab-group>