diff --git a/ui/src/components/panel-tab-strip.test.ts b/ui/src/components/panel-tab-strip.test.ts index dcf0455e27bd..2d0bff8aab6b 100644 --- a/ui/src/components/panel-tab-strip.test.ts +++ b/ui/src/components/panel-tab-strip.test.ts @@ -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("wa-tab-group"); + if (!group) { + throw new Error("expected rendered tab group"); + } + const tabs = [...container.querySelectorAll("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, + }); + }); + }); }); diff --git a/ui/src/components/panel-tab-strip.ts b/ui/src/components/panel-tab-strip.ts index 55e495db7aaa..176dd3c80b70 100644 --- a/ui/src/components/panel-tab-strip.ts +++ b/ui/src/components/panel-tab-strip.ts @@ -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` - { - if (event.button === 1) { - event.preventDefault(); - params.onClose(tab.id); - } - }} - > - ${tab.icon == null || tab.icon === nothing - ? nothing - : html``} - ${tab.label} - ${tab.badge ? html`${tab.badge}` : nothing} - ${tab.statusLabel - ? html`${tab.statusLabel}` - : nothing} - - - `, + ${repeat( + params.tabs, + (tab) => tab.id, + (tab) => { + const selected = tab.id === params.activeId; + return html` + { + if (event.button === 1) { + event.preventDefault(); + params.onClose(tab.id); + } + }} + > + ${tab.icon == null || tab.icon === nothing + ? nothing + : html``} + ${tab.label} + ${tab.badge ? html`${tab.badge}` : nothing} + ${tab.statusLabel + ? html`${tab.statusLabel}` + : nothing} + + + `; + }, )} ${newButton(true)}