diff --git a/packages/client/ui-dockkit/src/components/TabLayout.tsx b/packages/client/ui-dockkit/src/components/TabLayout.tsx index 36f37a4ab2..1bb41b353d 100644 --- a/packages/client/ui-dockkit/src/components/TabLayout.tsx +++ b/packages/client/ui-dockkit/src/components/TabLayout.tsx @@ -46,23 +46,24 @@ function TabHost({ state, callbacks, intents, tab, pane, column, floats, focusRe const host = useRef(null) const body = useRef(null) useLayoutEffect(() => { - if (host.current !== null) host.current.inert = !visible + // Both refs target unconditional descendants attached before these effects. + const section = host.current as HTMLElement + section.inert = !visible const focused = document.activeElement - if (!visible && focused instanceof HTMLElement && host.current?.contains(focused)) focused.blur() + if (!visible && focused instanceof HTMLElement && section.contains(focused)) focused.blur() const request = focusRequest.current if (!visible || request?.tabId !== tab.id) return focusRequest.current = undefined // Preserve deliberate focus taken by another control or the newly shown body. if (focused !== null && focused !== document.body && focused !== document.documentElement && focused !== request.origin) return - const strip = host.current?.querySelector('[data-dockkit-strip]') + const strip = section.querySelector('[data-dockkit-strip]') const chip = [...strip?.querySelectorAll('[data-dockkit-tab]') ?? []] .find(element => element.dataset.dockkitTab === tab.id) chip?.focus({ preventScroll: true }) }, [visible, tab.id, focusRequest]) // Electron emits a non-bubbling focus event on the webview element. useEffect(() => { - const element = body.current - if (element === null) return + const element = body.current as HTMLDivElement const focus = (): void => { if (!visible) return if (floating) floats.raise(pane.id) diff --git a/packages/client/ui-dockkit/tests/tab-layout.client.spec.tsx b/packages/client/ui-dockkit/tests/tab-layout.client.spec.tsx index 7874de6c37..8a884ba72a 100644 --- a/packages/client/ui-dockkit/tests/tab-layout.client.spec.tsx +++ b/packages/client/ui-dockkit/tests/tab-layout.client.spec.tsx @@ -84,9 +84,17 @@ it('keeps body ancestors through pane moves, floating, raising and docking', () fireEvent.pointerDown(h.node('[data-dockkit-divider]'), { button: 0, pointerId: 1 }) expect(h.callbacks.onDividerPressed).toHaveBeenCalledOnce() h.redraw({ preview: undefined }) + const secondPane = getPane(state, split.children[1]!).id + fireEvent.click(h.node(`[data-dockkit-content="${tab}"] [data-dockkit-tab="${tab}"]`)) + expect(controller.getSnapshot().state.activePaneId).toBe(split.children[0]) + act(() => { controller.focusPane(secondPane); h.redraw() }) + fireEvent.focus(body) + expect(controller.getSnapshot().state.activePaneId).toBe(split.children[0]) + act(() => { controller.focusPane(secondPane); h.redraw() }) + fireEvent.pointerDown(h.node(`[data-dockkit-content="${tab}"]`), { button: 0, pointerId: 1 }) + fireEvent.click(body) fireEvent.focus(body) expect(controller.getSnapshot().state.activePaneId).toBe(getPane(state, split.children[0]!).id) - const secondPane = getPane(state, split.children[1]!).id act(() => { controller.placeTab(tab, secondPane, 0); h.redraw() }) expect(h.body(tab)).toBe(body) expect(body.closest('[data-dockkit-host]')).toBe(host) @@ -97,6 +105,7 @@ it('keeps body ancestors through pane moves, floating, raising and docking', () act(() => { controller.setExpanded(false); h.redraw() }) expect(body.closest('[data-dockkit-content]')?.getAttribute('aria-hidden')).toBeNull() fireEvent.pointerDown(h.node(`[data-dockkit-float="${floating}"]`), { pointerId: 1, button: 0 }) + fireEvent.click(h.node(`[data-dockkit-float="${floating}"]`)) fireEvent.focus(body) const resize = h.node('[data-dockkit-float-resize]') fireEvent.pointerDown(resize, { pointerId: 2, button: 0, clientX: 500, clientY: 400 }) @@ -125,6 +134,47 @@ it('unmounts unretained bodies and restores focus to their replacement strip', ( expect(focus).toHaveBeenCalled() }) +it.for(['new-control', 'origin-control', 'document', 'no-focus'] as const)('transfers tab focus without overriding a new owner: %s', (kind, test) => { + const controller = seededController() + controller.setExpanded(true) + const first = getPane(controller.getSnapshot().state, controller.getSnapshot().state.rootId).tabs[0]! + const second = controller.openContent({ kind: 'file', contentId: 'file:b', title: 'B' }) + controller.focusTab(first) + const h = mounted(controller) + const control = document.createElement('button') + document.body.append(control) + test.onTestFinished(() => { control.remove() }) + if (kind === 'origin-control') control.focus() + vi.spyOn(h.callbacks, 'onFocusTab').mockImplementation((id) => { + if (kind === 'new-control') control.focus() + if (kind === 'document' || kind === 'no-focus') { + vi.spyOn(document, 'activeElement', 'get').mockReturnValue(kind === 'document' ? document.documentElement : null) + } + controller.focusTab(id) + h.redraw() + }) + const focus = vi.spyOn(HTMLElement.prototype, 'focus') + fireEvent.click(h.node(`[data-dockkit-tab="${second}"]`)) + if (kind === 'new-control') expect(document.activeElement).toBe(control) + else expect(focus).toHaveBeenCalledWith({ preventScroll: true }) +}) + +it('does not require a docked strip when a selected tab becomes floating in the same update', () => { + const controller = seededController() + controller.setExpanded(true) + const first = getPane(controller.getSnapshot().state, controller.getSnapshot().state.rootId).tabs[0]! + const second = controller.openContent({ kind: 'file', contentId: 'file:b', title: 'B' }) + controller.focusTab(first) + const h = mounted(controller) + vi.spyOn(h.callbacks, 'onFocusTab').mockImplementation((id) => { + controller.focusTab(id) + controller.floatTab(id) + h.redraw() + }) + fireEvent.click(h.node(`[data-dockkit-tab="${second}"]`)) + expect(h.node(`[data-dockkit-content="${second}"]`).hasAttribute('data-dockkit-float')).toBe(true) +}) + it('renders an empty pane and rejects unsupported docked trees', () => { const controller = new DockController() controller.setExpanded(true) diff --git a/packages/client/ui-sidebar-browser/src/client/browser/BrowserController.ts b/packages/client/ui-sidebar-browser/src/client/browser/BrowserController.ts index 521bd0d761..45d17bb259 100644 --- a/packages/client/ui-sidebar-browser/src/client/browser/BrowserController.ts +++ b/packages/client/ui-sidebar-browser/src/client/browser/BrowserController.ts @@ -233,7 +233,6 @@ export function createBrowserControllers(actions: BoundActions, cr } const created = new BrowserController({ ...request, actions: currentActions, createPage }) const forget = (): void => { - if (controllers.get(tabId)?.controller !== created) return controllers.delete(tabId) // Plugin unload also aborts occurrences; only layout removal deletes saved navigation. if (!isTabOpen(tabId)) currentActions.forget(tabId) diff --git a/packages/client/ui-sidebar-browser/src/client/browser/IframeImpl.ts b/packages/client/ui-sidebar-browser/src/client/browser/IframeImpl.ts index 2de462ef1e..0692b80997 100644 --- a/packages/client/ui-sidebar-browser/src/client/browser/IframeImpl.ts +++ b/packages/client/ui-sidebar-browser/src/client/browser/IframeImpl.ts @@ -78,9 +78,7 @@ export class IframeImpl implements BrowserFrame { this.store.set({ ...this.store.getSnapshot(), sandboxEnabled: enabled }) return } - const request = this.navigation.reload() - if (request === undefined) this.publish() - else this.load(request) + this.load(this.navigation.reload()) } private load(request: ReturnType | undefined): void { diff --git a/packages/client/ui-sidebar-browser/src/client/view/BrowserBody.tsx b/packages/client/ui-sidebar-browser/src/client/view/BrowserBody.tsx index 9ea53f03eb..7c5a7e455b 100644 --- a/packages/client/ui-sidebar-browser/src/client/view/BrowserBody.tsx +++ b/packages/client/ui-sidebar-browser/src/client/view/BrowserBody.tsx @@ -94,7 +94,7 @@ export function BrowserBody(props: BrowserBodyProps): ReactNode { {sandboxed !== undefined &&