mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-24 19:35:28 -06:00
fix: preserve shadow-root tab focus
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}}
|
||||
|
||||
Reference in New Issue
Block a user