From c9d41af6d6b2a78fc06e83eac04f0b26cc62a4f5 Mon Sep 17 00:00:00 2001
From: mathuo <6710312+mathuo@users.noreply.github.com>
Date: Thu, 11 Jun 2026 19:54:15 +0100
Subject: [PATCH 1/5] feat(dockview-core): restore focus to a neighbour on
close (L4)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
First Layer-4 focus-management piece. When removing a panel or group pulls
focus out of the dock, return it to the neighbour the layout just activated
instead of stranding it on
(which would silently break keyboard
operation until the user clicks back in).
The service snapshots whether focus was inside the dock on the 'remove'
mutation (focus still on the closing element), and after the teardown — if it
was inside and has now been lost — focuses the active group's content. Only
acts when focus was actually pulled out, so closing a background tab while
focused elsewhere never yanks focus into the dock. Gated on keyboardNavigation.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../dockview/accessibilityDocking.spec.ts | 78 +++++++++++++++++++
.../src/dockview/accessibilityService.ts | 37 ++++++++-
2 files changed, 113 insertions(+), 2 deletions(-)
diff --git a/packages/dockview-core/src/__tests__/dockview/accessibilityDocking.spec.ts b/packages/dockview-core/src/__tests__/dockview/accessibilityDocking.spec.ts
index 010ae3e14..97540b26b 100644
--- a/packages/dockview-core/src/__tests__/dockview/accessibilityDocking.spec.ts
+++ b/packages/dockview-core/src/__tests__/dockview/accessibilityDocking.spec.ts
@@ -380,3 +380,81 @@ 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();
+ });
+});
diff --git a/packages/dockview-core/src/dockview/accessibilityService.ts b/packages/dockview-core/src/dockview/accessibilityService.ts
index 3d1cdb6d8..2eefcb7bf 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,11 @@ 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 ``.
*
- * Float / popout terminals and cross-window focus management are later phases.
+ * Float / popout `Esc`-restore and cross-window (popout) focus are later phases.
*/
export class AccessibilityService
extends CompositeDisposable
@@ -118,6 +126,7 @@ export class AccessibilityService
{
private _move: MoveState | null = null;
private _preview: IDisposable | undefined;
+ private _focusWasInside = false;
constructor(private readonly host: IAccessibilityHost) {
super();
@@ -135,10 +144,34 @@ export class AccessibilityService
{
dispose: () =>
doc.removeEventListener('keydown', onKeyDown, true),
- }
+ },
+ // 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 get _nav(): KeyboardNavigationOptions | undefined {
const opt = this.host.options.keyboardNavigation;
if (!opt) {
From d7a68e1f017beee93f32f8287fb81a9b4015c0bc Mon Sep 17 00:00:00 2001
From: mathuo <6710312+mathuo@users.noreply.github.com>
Date: Thu, 11 Jun 2026 20:05:58 +0100
Subject: [PATCH 2/5] test(dockview-core): guard focus is preserved across
maximize/restore (L4)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Maximize hides sibling groups via visibility toggling and leaves the maximized
group's DOM in place, so the active panel keeps focus across maximize/restore —
the L4 requirement is already met by construction. Add a regression test so a
future change that re-renders on maximize can't silently drop focus to .
(The niche case of maximizing a *different* group than the focused one can lose
focus in a real browser; deferred — jsdom can't reproduce hidden-element blur,
and a fix risks stealing focus from mouse users.)
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../dockview/accessibilityDocking.spec.ts | 49 +++++++++++++++++++
1 file changed, 49 insertions(+)
diff --git a/packages/dockview-core/src/__tests__/dockview/accessibilityDocking.spec.ts b/packages/dockview-core/src/__tests__/dockview/accessibilityDocking.spec.ts
index 97540b26b..83e97ab28 100644
--- a/packages/dockview-core/src/__tests__/dockview/accessibilityDocking.spec.ts
+++ b/packages/dockview-core/src/__tests__/dockview/accessibilityDocking.spec.ts
@@ -458,3 +458,52 @@ describe('accessibility: focus restore on close', () => {
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);
+ });
+});
From 37ac40893c401fd972f5ff2c5db83764c1473429 Mon Sep 17 00:00:00 2001
From: mathuo <6710312+mathuo@users.noreply.github.com>
Date: Thu, 11 Jun 2026 20:33:13 +0100
Subject: [PATCH 3/5] feat(dockview-core): Esc inside a floating group returns
focus (L4)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
When focus is inside a floating group, Esc returns it to the invoking control —
the last element focused in the main dock before entering the float (tracked via
a focusin observer), falling back to a grid group's content if that element is
gone. Never strands focus in a dismissed float.
Deliberately polite: the handler runs in the BUBBLE phase and bails on
defaultPrevented, so panel content that uses Esc (closing a menu, clearing a
field) keeps priority. Scoped to floats inside this dock and gated on
keyboardNavigation. Float Tab-containment is a separate, later piece.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../dockview/accessibilityDocking.spec.ts | 81 +++++++++++++++++++
.../src/dockview/accessibilityService.ts | 74 ++++++++++++++++-
2 files changed, 154 insertions(+), 1 deletion(-)
diff --git a/packages/dockview-core/src/__tests__/dockview/accessibilityDocking.spec.ts b/packages/dockview-core/src/__tests__/dockview/accessibilityDocking.spec.ts
index 83e97ab28..3aa630514 100644
--- a/packages/dockview-core/src/__tests__/dockview/accessibilityDocking.spec.ts
+++ b/packages/dockview-core/src/__tests__/dockview/accessibilityDocking.spec.ts
@@ -507,3 +507,84 @@ describe('accessibility: focus across maximize', () => {
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
+ });
+});
diff --git a/packages/dockview-core/src/dockview/accessibilityService.ts b/packages/dockview-core/src/dockview/accessibilityService.ts
index 2eefcb7bf..f9967da5c 100644
--- a/packages/dockview-core/src/dockview/accessibilityService.ts
+++ b/packages/dockview-core/src/dockview/accessibilityService.ts
@@ -117,8 +117,11 @@ function matchesBinding(e: KeyboardEvent, binding: string): boolean {
* - **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`).
*
- * Float / popout `Esc`-restore and cross-window (popout) focus are later phases.
+ * Float Tab-containment and cross-window (popout) focus are later phases.
*/
export class AccessibilityService
extends CompositeDisposable
@@ -127,6 +130,7 @@ 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();
@@ -139,12 +143,40 @@ 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
@@ -172,6 +204,46 @@ export class AccessibilityService
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();
+ }
+
+ 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 {
const opt = this.host.options.keyboardNavigation;
if (!opt) {
From 8487de5dabaefa7122473fe9601454e154bb5bbb Mon Sep 17 00:00:00 2001
From: mathuo <6710312+mathuo@users.noreply.github.com>
Date: Thu, 11 Jun 2026 20:43:57 +0100
Subject: [PATCH 4/5] feat(dockview-core): trap Tab within a floating group
(L4)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Tab inside a floating group now wraps within it — at the last tabbable Tab
moves to the first, at the first Shift+Tab moves to the last — so keyboard
focus doesn't leak into the grid behind the (non-modal) float. Tabbables are
the float's naturally-focusable controls plus the active tab's roving anchor;
tabindex="-1" plumbing (content containers, inactive tabs) is excluded. Scoped
to floats in this dock and gated on keyboardNavigation.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../dockview/accessibilityDocking.spec.ts | 93 +++++++++++++++++++
.../src/dockview/accessibilityService.ts | 54 ++++++++++-
2 files changed, 146 insertions(+), 1 deletion(-)
diff --git a/packages/dockview-core/src/__tests__/dockview/accessibilityDocking.spec.ts b/packages/dockview-core/src/__tests__/dockview/accessibilityDocking.spec.ts
index 3aa630514..75c7f68e1 100644
--- a/packages/dockview-core/src/__tests__/dockview/accessibilityDocking.spec.ts
+++ b/packages/dockview-core/src/__tests__/dockview/accessibilityDocking.spec.ts
@@ -588,3 +588,96 @@ describe('accessibility: floating group Esc returns focus', () => {
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('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 f9967da5c..3d0aab2e1 100644
--- a/packages/dockview-core/src/dockview/accessibilityService.ts
+++ b/packages/dockview-core/src/dockview/accessibilityService.ts
@@ -120,8 +120,10 @@ function matchesBinding(e: KeyboardEvent, binding: string): boolean {
* - **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 Tab-containment and cross-window (popout) focus are later phases.
+ * Cross-window (popout) focus is a later phase.
*/
export class AccessibilityService
extends CompositeDisposable
@@ -227,6 +229,51 @@ export class AccessibilityService
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;
+ }
+ const tabbables = this._tabbables(float);
+ if (tabbables.length === 0) {
+ return false;
+ }
+ const first = tabbables[0];
+ const last = tabbables[tabbables.length - 1];
+ const active = float.ownerDocument.activeElement;
+ if (!e.shiftKey && active === last) {
+ e.preventDefault();
+ first.focus();
+ return true;
+ }
+ if (e.shiftKey && active === first) {
+ e.preventDefault();
+ last.focus();
+ return true;
+ }
+ return false;
+ }
+
+ 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 (
@@ -271,6 +318,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);
From d2f4a571538b71c2464650005cec9a83a1ee7a69 Mon Sep 17 00:00:00 2001
From: mathuo <6710312+mathuo@users.noreply.github.com>
Date: Thu, 11 Jun 2026 21:04:20 +0100
Subject: [PATCH 5/5] fix(dockview-core): float Tab-containment must manage
every Tab, not just the boundary
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The boundary-only trap leaked in a real browser: it fired only when focus was
on the exact last/first tabbable, but focus usually sits on the content
container (tabindex="-1"), which matched no tabbable — so the default Tab
escaped to the grid behind the float. (Unit tests missed it by focusing a known
button.)
Now Tab inside a float is always handled: drive the cursor through the float's
tabbables ourselves (wrapping, and snapping to first/last when focus is on
non-tabbable plumbing) and swallow the default so it can never leak out. Adds a
regression test that Tabs from the content container.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../dockview/accessibilityDocking.spec.ts | 14 ++++++++
.../src/dockview/accessibilityService.ts | 32 +++++++++++--------
2 files changed, 32 insertions(+), 14 deletions(-)
diff --git a/packages/dockview-core/src/__tests__/dockview/accessibilityDocking.spec.ts b/packages/dockview-core/src/__tests__/dockview/accessibilityDocking.spec.ts
index 75c7f68e1..5b236a124 100644
--- a/packages/dockview-core/src/__tests__/dockview/accessibilityDocking.spec.ts
+++ b/packages/dockview-core/src/__tests__/dockview/accessibilityDocking.spec.ts
@@ -671,6 +671,20 @@ describe('accessibility: floating group Tab containment', () => {
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();
diff --git a/packages/dockview-core/src/dockview/accessibilityService.ts b/packages/dockview-core/src/dockview/accessibilityService.ts
index 3d0aab2e1..29c737abd 100644
--- a/packages/dockview-core/src/dockview/accessibilityService.ts
+++ b/packages/dockview-core/src/dockview/accessibilityService.ts
@@ -243,24 +243,28 @@ export class AccessibilityService
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 false;
- }
- const first = tabbables[0];
- const last = tabbables[tabbables.length - 1];
- const active = float.ownerDocument.activeElement;
- if (!e.shiftKey && active === last) {
- e.preventDefault();
- first.focus();
return true;
}
- if (e.shiftKey && active === first) {
- e.preventDefault();
- last.focus();
- return true;
- }
- return false;
+ 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[] {