fix: preserve shadow-root tab focus

This commit is contained in:
Shakker
2026-08-11 17:16:50 +02:00
parent c402b41eb3
commit eeffd6153d
3 changed files with 42 additions and 30 deletions
@@ -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<string, Promise<unknown>>
>();
private readonly navigationCommits = new WeakMap<
GatewayBrowserClient,
Map<string, BrowserNavigationCommit>
>();
private readonly navigationCommits = new WeakMap<GatewayBrowserClient, Set<string>>();
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 {
@@ -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 () => {
+30 -10
View File
@@ -22,6 +22,30 @@ const PLUS_GLYPH = svg`<svg viewBox="0 0 16 16" width="13" height="13" fill="non
const reconciledTabOrders = new WeakMap<Element, string>();
const keyboardCloseActivations = new WeakSet<Element>();
function activeElementFor(element: Element): Element | null {
const root = element.getRootNode();
return root instanceof ShadowRoot
? (root.activeElement ?? document.activeElement)
: document.activeElement;
}
function deepestActiveElement(): Element | null {
let active = document.activeElement;
while (active instanceof HTMLElement && active.shadowRoot?.activeElement) {
active = active.shadowRoot.activeElement;
}
return active;
}
function focusNeedsRecovery(element: Element, current: Element | null): boolean {
const root = element.getRootNode();
return (
current === document.body ||
current === document.documentElement ||
(root instanceof ShadowRoot && current === root.host)
);
}
function reconcileSelectedTabElement(
element: Element | undefined,
tabOrder: string,
@@ -50,8 +74,8 @@ function reconcileSelectedTabElement(
return;
}
element.scrollIntoView?.({ block: "nearest", inline: "nearest" });
const current = document.activeElement;
if (restoreFocus && (current === document.body || current === document.documentElement)) {
const current = activeElementFor(element);
if (restoreFocus && focusNeedsRecovery(element, current)) {
element.focus({ preventScroll: true });
}
});
@@ -86,7 +110,7 @@ export function renderPanelTabStrip(params: {
// visible. Keep the new-session control outside the group until one exists.
return newButton(false);
}
const activeElement = document.activeElement;
const activeElement = deepestActiveElement();
const focusedTabDomId =
activeElement instanceof HTMLElement &&
params.tabs.some((tab) => 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<HTMLElement>("wa-tab") ?? []),
].find((candidate) => candidate.getAttribute("panel") === tab.id);
const fallback = settledGroup?.querySelector<HTMLElement>("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 });
}
}}