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);