mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-24 19:35:28 -06:00
fix: keep panel tab identity stable across reorder
This commit is contained in:
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user