diff --git a/packages/dockview-core/src/__tests__/dockview/accessibilityDocking.spec.ts b/packages/dockview-core/src/__tests__/dockview/accessibilityDocking.spec.ts index 010ae3e14..5b236a124 100644 --- a/packages/dockview-core/src/__tests__/dockview/accessibilityDocking.spec.ts +++ b/packages/dockview-core/src/__tests__/dockview/accessibilityDocking.spec.ts @@ -380,3 +380,318 @@ describe('accessibility: spatial group focus', () => { expect(dockview.activeGroup).toBe(groupOf('tl')); }); }); + +/** + * L4 focus management — closing a panel/group that holds focus must hand it to + * a deterministic neighbour, never drop it on . The service snapshots + * focus before the remove and restores after if it was pulled out of the dock. + */ +describe('accessibility: focus restore on close', () => { + let container: HTMLElement; + let dockview: DockviewComponent; + + const make = (keyboardNavigation: boolean): void => { + container = document.createElement('div'); + document.body.appendChild(container); + dockview = new DockviewComponent(container, { + createComponent: () => new TestPanel(), + keyboardNavigation, + }); + dockview.layout(1000, 1000); + }; + + // one group, two tabs — only the active tab carries aria-selected="true" + const twoTabs = (): void => { + dockview.addPanel({ id: 'p1', component: 'default', title: 'P1' }); + dockview.addPanel({ id: 'p2', component: 'default', title: 'P2' }); + }; + + const activeTab = (): HTMLElement => + container.querySelector('.dv-tab[aria-selected="true"]') as HTMLElement; + + const remove = (id: string): void => + dockview.removePanel(dockview.panels.find((p) => p.id === id)!); + + afterEach(() => { + dockview.dispose(); + container.remove(); + }); + + test('returns focus to a neighbour when the close pulls it out of the dock', () => { + make(true); + twoTabs(); // p2 active + activeTab().focus(); // focus p2's tab — a real in-dock focusable element + expect(container.contains(document.activeElement)).toBe(true); + + const group = dockview.activeGroup!; + const spy = jest.spyOn(group.model, 'focusContent'); + + remove('p2'); // p2's tab removed → focus falls to + + expect(dockview.activePanel?.id).toBe('p1'); // neighbour activated + expect(spy).toHaveBeenCalled(); // and focus restored to it + }); + + test('does not steal focus when focus was outside the dock', () => { + make(true); + twoTabs(); + const outside = document.createElement('button'); + document.body.appendChild(outside); + outside.focus(); + expect(container.contains(document.activeElement)).toBe(false); + + const spy = jest.spyOn(dockview.activeGroup!.model, 'focusContent'); + remove('p1'); // closing a background tab while focused elsewhere + + expect(spy).not.toHaveBeenCalled(); + outside.remove(); + }); + + test('off when keyboardNavigation is disabled', () => { + make(false); + twoTabs(); + activeTab().focus(); + + const spy = jest.spyOn(dockview.activeGroup!.model, 'focusContent'); + remove('p2'); + + expect(spy).not.toHaveBeenCalled(); + }); +}); + +/** + * L4 — focus across maximize/restore. Maximizing hides sibling groups via + * visibility toggling and leaves the maximized group's DOM in place, so the + * active panel keeps focus across the transition. This guards against a future + * change that re-renders on maximize (which would silently drop focus). + */ +describe('accessibility: focus across maximize', () => { + let container: HTMLElement; + let dockview: DockviewComponent; + + const make = (): void => { + container = document.createElement('div'); + document.body.appendChild(container); + dockview = new DockviewComponent(container, { + createComponent: () => new TestPanel(), + keyboardNavigation: true, + }); + dockview.layout(1000, 1000); + }; + + afterEach(() => { + dockview.dispose(); + container.remove(); + }); + + test('maximize and restore keep focus on the active group', () => { + make(); + dockview.addPanel({ id: 'p1', component: 'default', title: 'P1' }); + dockview.addPanel({ + id: 'p2', + component: 'default', + title: 'P2', + position: { direction: 'right' }, + }); + const group = dockview.activeGroup!; + const tab = group.element.querySelector('.dv-tab') as HTMLElement; + tab.focus(); + expect(container.contains(document.activeElement)).toBe(true); + + group.api.maximize(); + expect(group.api.isMaximized()).toBe(true); + expect(container.contains(document.activeElement)).toBe(true); + + group.api.exitMaximized(); + expect(group.api.isMaximized()).toBe(false); + expect(container.contains(document.activeElement)).toBe(true); + }); +}); + +/** + * L4 — Esc inside a floating group returns focus to the invoking control (the + * last thing focused in the main dock). Runs in the bubble phase and respects + * defaultPrevented so panel content that uses Esc keeps priority. + */ +describe('accessibility: floating group Esc returns focus', () => { + let container: HTMLElement; + let dockview: DockviewComponent; + + const make = (keyboardNavigation: boolean): void => { + container = document.createElement('div'); + document.body.appendChild(container); + dockview = new DockviewComponent(container, { + createComponent: () => new TestPanel(), + keyboardNavigation, + }); + dockview.layout(1000, 1000); + }; + + const setup = (): void => { + dockview.addPanel({ id: 'main', component: 'default', title: 'Main' }); + dockview.addPanel({ + id: 'float', + component: 'default', + title: 'Float', + floating: true, + }); + }; + + const mainTab = (): HTMLElement => + Array.from(container.querySelectorAll('.dv-tab')).find( + (t) => !(t as HTMLElement).closest('[role="dialog"]') + ) as HTMLElement; + const floatTab = (): HTMLElement => + container.querySelector('[role="dialog"] .dv-tab') as HTMLElement; + + afterEach(() => { + dockview.dispose(); + container.remove(); + }); + + test('Esc inside a float returns focus to the invoking control', () => { + make(true); + setup(); + const invoker = mainTab(); + invoker.focus(); // tracked as the invoking control + floatTab().focus(); // now focused inside the float + expect(floatTab().closest('[role="dialog"]')).toBeTruthy(); + + fireEvent.keyDown(floatTab(), { key: 'Escape', bubbles: true }); + expect(document.activeElement).toBe(invoker); + }); + + test('does not hijack Esc that panel content handles (defaultPrevented)', () => { + make(true); + setup(); + mainTab().focus(); + const ft = floatTab(); + ft.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + e.preventDefault(); + } + }); + ft.focus(); + + fireEvent.keyDown(ft, { key: 'Escape', bubbles: true }); + expect(document.activeElement).toBe(ft); // stayed in the float + }); + + test('off when keyboardNavigation is disabled', () => { + make(false); + setup(); + mainTab().focus(); + const ft = floatTab(); + ft.focus(); + + fireEvent.keyDown(ft, { key: 'Escape', bubbles: true }); + expect(document.activeElement).toBe(ft); // not restored + }); +}); + +class ButtonPanel implements IContentRenderer { + element = document.createElement('div'); + init(): void { + const b1 = document.createElement('button'); + b1.textContent = 'b1'; + const b2 = document.createElement('button'); + b2.textContent = 'b2'; + this.element.append(b1, b2); + } + layout(): void { + // noop + } + dispose(): void { + // noop + } +} + +/** + * L4 — Tab is trapped within a floating group: at the last tabbable Tab wraps + * to the first, at the first Shift+Tab wraps to the last, so focus doesn't leak + * to the grid behind the (non-modal) float. + */ +describe('accessibility: floating group Tab containment', () => { + let container: HTMLElement; + let dockview: DockviewComponent; + + const make = (keyboardNavigation: boolean): void => { + container = document.createElement('div'); + document.body.appendChild(container); + dockview = new DockviewComponent(container, { + createComponent: () => new ButtonPanel(), + keyboardNavigation, + }); + dockview.layout(1000, 1000); + dockview.addPanel({ id: 'main', component: 'default', title: 'Main' }); + dockview.addPanel({ + id: 'float', + component: 'default', + title: 'Float', + floating: true, + }); + }; + + // mirror the service's tabbable query so the test is robust to float chrome + const tabbables = (): HTMLElement[] => { + const float = container.querySelector('[role="dialog"]')!; + return Array.from( + float.querySelectorAll( + 'a[href], button:not([disabled]), input:not([disabled]), ' + + 'select:not([disabled]), textarea:not([disabled]), [tabindex]' + ) + ).filter((el) => el.tabIndex >= 0); + }; + + afterEach(() => { + dockview.dispose(); + container.remove(); + }); + + test('Tab at the last tabbable wraps to the first', () => { + make(true); + const t = tabbables(); + expect(t.length).toBeGreaterThan(1); + const first = t[0]; + const last = t[t.length - 1]; + + last.focus(); + fireEvent.keyDown(last, { key: 'Tab', bubbles: true }); + expect(document.activeElement).toBe(first); + }); + + test('Shift+Tab at the first tabbable wraps to the last', () => { + make(true); + const t = tabbables(); + const first = t[0]; + const last = t[t.length - 1]; + + first.focus(); + fireEvent.keyDown(first, { key: 'Tab', shiftKey: true, bubbles: true }); + expect(document.activeElement).toBe(last); + }); + + test('Tab from non-tabbable plumbing (content container) stays in the float', () => { + // the real-browser leak: focus on the tabindex="-1" content container + // matched no tabbable, so default Tab escaped to the grid behind + make(true); + const float = container.querySelector('[role="dialog"]')!; + const content = float.querySelector( + '.dv-content-container' + ) as HTMLElement; + content.focus(); + + fireEvent.keyDown(content, { key: 'Tab', bubbles: true }); + expect(document.activeElement).toBe(tabbables()[0]); // not the grid + }); + + test('off when keyboardNavigation is disabled', () => { + make(false); + const t = tabbables(); + const last = t[t.length - 1]; + + last.focus(); + fireEvent.keyDown(last, { key: 'Tab', bubbles: true }); + expect(document.activeElement).toBe(last); // not wrapped + }); +}); diff --git a/packages/dockview-core/src/dockview/accessibilityService.ts b/packages/dockview-core/src/dockview/accessibilityService.ts index 3d1cdb6d8..29c737abd 100644 --- a/packages/dockview-core/src/dockview/accessibilityService.ts +++ b/packages/dockview-core/src/dockview/accessibilityService.ts @@ -1,7 +1,9 @@ import { CompositeDisposable, IDisposable } from '../lifecycle'; +import { Event } from '../events'; import { Position } from '../dnd/droptarget'; import { DockviewGroupPanel } from './dockviewGroupPanel'; import { IDockviewPanel } from './dockviewPanel'; +import { DockviewLayoutMutationEvent } from './dockviewComponent'; import { DockviewComponentOptions, DockviewKeybindings, @@ -37,6 +39,9 @@ export interface IAccessibilityHost { group: DockviewGroupPanel, reverse: boolean ): DockviewGroupPanel | undefined; + /** Fires before / after a structural layout change — used to restore focus on close. */ + readonly onWillMutateLayout: Event; + readonly onDidMutateLayout: Event; showDropPreview(group: DockviewGroupPanel, position: Position): IDisposable; announce(message: string): void; dockPanel( @@ -109,8 +114,16 @@ function matchesBinding(e: KeyboardEvent, binding: string): boolean { * 2. PICK EDGE — arrows choose a split edge (left/right/top/bottom) or the * centre (tab-into); `Enter` commits, `Escape` steps back. * `Escape` from the target phase cancels. + * - **Focus restore on close** (L4) — when removing a panel/group pulls focus + * out of the dock, focus returns to the neighbour the layout just activated + * instead of being stranded on ``. + * - **Floating `Esc`** (L4) — `Esc` inside a floating group returns focus to + * the control that had it before entering the float (polite: bubble phase, + * respects `defaultPrevented`, so panel content keeps `Esc`). + * - **Floating Tab-containment** (L4) — Tab wraps within the floating group so + * focus doesn't leak to the grid behind it. * - * Float / popout terminals and cross-window focus management are later phases. + * Cross-window (popout) focus is a later phase. */ export class AccessibilityService extends CompositeDisposable @@ -118,6 +131,8 @@ export class AccessibilityService { private _move: MoveState | null = null; private _preview: IDisposable | undefined; + private _focusWasInside = false; + private _lastNonFloatFocus: HTMLElement | undefined; constructor(private readonly host: IAccessibilityHost) { super(); @@ -130,13 +145,154 @@ export class AccessibilityService const onKeyDown = (e: KeyboardEvent): void => this._onKeyDown(e); doc.addEventListener('keydown', onKeyDown, true); + // Remember the last control focused in the main dock (outside any + // float) so Esc inside a floating group can return focus to its + // invoking control. Observe-only — never consumes. + const onFocusIn = (e: FocusEvent): void => { + const t = e.target; + if ( + t instanceof HTMLElement && + this.host.rootElement.contains(t) && + !t.closest('[role="dialog"]') + ) { + this._lastNonFloatFocus = t; + } + }; + doc.addEventListener('focusin', onFocusIn, true); + + // Esc-from-float restore runs in the BUBBLE phase and respects + // defaultPrevented, so panel content that uses Esc keeps priority. + const onEscape = (e: KeyboardEvent): void => this._onFloatingEscape(e); + doc.addEventListener('keydown', onEscape, false); + this.addDisposables( { dispose: () => this._clearPreview() }, { dispose: () => doc.removeEventListener('keydown', onKeyDown, true), - } + }, + { + dispose: () => + doc.removeEventListener('focusin', onFocusIn, true), + }, + { + dispose: () => + doc.removeEventListener('keydown', onEscape, false), + }, + // L4 focus management — when a close pulls focus out of the dock, + // return it to the neighbour the component just activated rather + // than leaving it stranded on . Snapshot before the teardown + // (focus still on the closing panel), restore after. + host.onWillMutateLayout((e) => { + if (e.kind === 'remove' && this._nav) { + this._focusWasInside = this._isFocusInside(); + } + }), + host.onDidMutateLayout((e) => { + if ( + e.kind === 'remove' && + this._nav && + this._focusWasInside && + !this._isFocusInside() + ) { + this._restoreFocus(); + } + }) + ); + } + + private _isFocusInside(): boolean { + const active = this.host.rootElement.ownerDocument.activeElement; + return active instanceof Node && this.host.rootElement.contains(active); + } + + private _onFloatingEscape(e: KeyboardEvent): void { + if ( + !this._nav || + this._move || + e.defaultPrevented || + e.key !== 'Escape' + ) { + return; + } + const target = e.target; + if (!(target instanceof Element)) { + return; + } + // Only when focus is inside one of *this* dock's floating groups. + const float = target.closest('[role="dialog"]'); + if (!float || !this.host.rootElement.contains(float)) { + return; + } + e.preventDefault(); + e.stopPropagation(); + this._returnFocusFromFloat(); + } + + /** + * Keep Tab inside the floating group that holds focus: at the last tabbable + * Tab wraps to the first, at the first Shift+Tab wraps to the last. Returns + * true if it handled the event. No-op outside a float. + */ + private _trapFloatTab(e: KeyboardEvent): boolean { + const target = e.target; + if (!(target instanceof Element)) { + return false; + } + const float = target.closest('[role="dialog"]'); + if (!float || !this.host.rootElement.contains(float)) { + return false; + } + // Always manage Tab inside a float, never just at the boundary: focus + // often sits on non-tabbable plumbing (the content container, which is + // tabindex="-1"), and the browser's default Tab from there escapes to + // the grid behind. Drive the cursor through the float's tabbables + // ourselves and swallow the default so it can't leak out. + e.preventDefault(); + const tabbables = this._tabbables(float); + if (tabbables.length === 0) { + return true; + } + const active = float.ownerDocument.activeElement; + const index = + active instanceof HTMLElement ? tabbables.indexOf(active) : -1; + const n = tabbables.length; + const next = + index === -1 + ? e.shiftKey + ? n - 1 + : 0 + : (index + (e.shiftKey ? -1 : 1) + n) % n; + tabbables[next].focus(); + return true; + } + + private _tabbables(root: Element): HTMLElement[] { + const nodes = root.querySelectorAll( + 'a[href], button:not([disabled]), input:not([disabled]), ' + + 'select:not([disabled]), textarea:not([disabled]), [tabindex]' ); + // tabIndex >= 0 keeps naturally-focusable controls and roving anchors + // (the active tab) while dropping tabindex="-1" plumbing (content + // containers, inactive tabs). + return Array.from(nodes).filter((el) => el.tabIndex >= 0); + } + + private _returnFocusFromFloat(): void { + const prev = this._lastNonFloatFocus; + if ( + prev && + prev.isConnected && + this.host.rootElement.contains(prev) && + !prev.closest('[role="dialog"]') + ) { + prev.focus(); + return; + } + // Invoking control is gone — fall back to a grid group's content. + this.host.groups + .find((g) => g.api.location.type === 'grid') + ?.model.focusContent(); } private get _nav(): KeyboardNavigationOptions | undefined { @@ -166,6 +322,11 @@ export class AccessibilityService ) { return; } + // Trap Tab within a floating group so focus doesn't leak to the grid + // behind it (the float is non-modal, but its Tab order should be). + if (e.key === 'Tab' && this._trapFloatTab(e)) { + return; + } const keymap = this._keymap; if (matchesBinding(e, keymap.dock)) { this._enterMoveMode(e);