From eeffd6153d990db50a1234ff4861d417caae3e69 Mon Sep 17 00:00:00 2001 From: Shakker Date: Tue, 11 Aug 2026 17:16:50 +0200 Subject: [PATCH] fix: preserve shadow-root tab focus --- .../browser-panel-operation-ownership.ts | 19 ++------- .../panel-tab-strip.browser.test.ts | 13 +++--- ui/src/components/panel-tab-strip.ts | 40 ++++++++++++++----- 3 files changed, 42 insertions(+), 30 deletions(-) diff --git a/ui/src/components/browser/browser-panel-operation-ownership.ts b/ui/src/components/browser/browser-panel-operation-ownership.ts index 4fcd82a6d552..4642328f288e 100644 --- a/ui/src/components/browser/browser-panel-operation-ownership.ts +++ b/ui/src/components/browser/browser-panel-operation-ownership.ts @@ -26,11 +26,6 @@ type BrowserPanelInvocation = { isCurrent(): boolean; }; -type BrowserNavigationCommit = { - committed: number; - reconciled: number; -}; - export type BrowserPanelSnapshotOutcome = "accepted" | "rejected" | "failed"; /** Owns the panel lifecycle, tab snapshots, captures, and pointer operations. */ @@ -46,10 +41,7 @@ export class BrowserPanelOperationOwnership { GatewayBrowserClient, Map> >(); - private readonly navigationCommits = new WeakMap< - GatewayBrowserClient, - Map - >(); + private readonly navigationCommits = new WeakMap>(); constructor(private readonly host: BrowserPanelControllerHost) {} @@ -110,19 +102,16 @@ export class BrowserPanelOperationOwnership { if (!client || !targetId) { return false; } - const state = this.navigationCommits.get(client)?.get(targetId); - return state !== undefined && state.committed !== state.reconciled; + return this.navigationCommits.get(client)?.has(targetId) ?? false; } markNavigationCommitted(client: GatewayBrowserClient, targetId: string): void { let commits = this.navigationCommits.get(client); if (!commits) { - commits = new Map(); + commits = new Set(); this.navigationCommits.set(client, commits); } - const state = commits.get(targetId) ?? { committed: 0, reconciled: 0 }; - state.committed += 1; - commits.set(targetId, state); + commits.add(targetId); } markNavigationReconciled(client: GatewayBrowserClient, targetId: string): void { diff --git a/ui/src/components/panel-tab-strip.browser.test.ts b/ui/src/components/panel-tab-strip.browser.test.ts index 3f8db9236875..28852892920d 100644 --- a/ui/src/components/panel-tab-strip.browser.test.ts +++ b/ui/src/components/panel-tab-strip.browser.test.ts @@ -28,7 +28,7 @@ function tab(id: string): PanelTabStripTab { } function renderControlledStrip(params: { - container: HTMLElement; + container: HTMLElement | DocumentFragment; tabs: PanelTabStripTab[]; activeId: string; onSelect: (id: string) => void; @@ -84,14 +84,17 @@ afterEach(() => { describe.skipIf(!hasBrowserLayout)("panel tab strip browser lifecycle", () => { it("preserves the focused active element when tabs reorder", async () => { - const container = document.createElement("div"); - document.body.append(container); + const host = document.createElement("div"); + const container = host.attachShadow({ mode: "open" }); + document.body.append(host); const tabs = [tab("a"), tab("b"), tab("c")]; renderControlledStrip({ container, tabs, activeId: "b", onSelect: vi.fn() }); const initialActive = await expectControlledSelection(container, "b"); initialActive.focus(); - expect(document.activeElement).toBe(initialActive); + expect(document.activeElement).toBe(host); + expect(container.activeElement).toBe(initialActive); + queueMicrotask(() => initialActive.blur()); renderControlledStrip({ container, tabs: [tabs[2]!, { ...tabs[1]!, label: "Tab B navigated" }, tabs[0]!], @@ -101,7 +104,7 @@ describe.skipIf(!hasBrowserLayout)("panel tab strip browser lifecycle", () => { const reorderedActive = await expectControlledSelection(container, "b"); expect(reorderedActive).toBe(initialActive); - expect(document.activeElement).toBe(initialActive); + await vi.waitFor(() => expect(container.activeElement).toBe(initialActive)); }); it("keeps arrow and mouse activation controlled and the selected overflow tab visible", async () => { diff --git a/ui/src/components/panel-tab-strip.ts b/ui/src/components/panel-tab-strip.ts index 8f9d2764e44c..cad77db242a1 100644 --- a/ui/src/components/panel-tab-strip.ts +++ b/ui/src/components/panel-tab-strip.ts @@ -22,6 +22,30 @@ const PLUS_GLYPH = svg` tab.domId === activeElement.id) @@ -161,7 +185,7 @@ export function renderPanelTabStrip(params: { : null; const restoreFocus = button instanceof Element && - (keyboardCloseActivations.delete(button) || document.activeElement === button); + (keyboardCloseActivations.delete(button) || activeElementFor(button) === button); await params.onClose(tab.id); if (!restoreFocus) { return; @@ -182,12 +206,8 @@ export function renderPanelTabStrip(params: { ...(settledGroup?.querySelectorAll("wa-tab") ?? []), ].find((candidate) => candidate.getAttribute("panel") === tab.id); const fallback = settledGroup?.querySelector("wa-tab[active]"); - const current = document.activeElement; - if ( - !closingTab && - fallback && - (current === document.body || current === document.documentElement) - ) { + const current = fallback ? activeElementFor(fallback) : null; + if (!closingTab && fallback && focusNeedsRecovery(fallback, current)) { fallback.focus({ preventScroll: true }); } }}