Skip to content

Commit 65b3966

Browse files
mathuoclaude
andcommitted
feat(dockview-core): rebindable keymap + keyboard tab switching
Generalise the keyboard-docking option into `keyboardNavigation` (still opt-in), backed by a rebindable `DockviewKeybindings` keymap, and add within-group tab switching: - `Ctrl+]` / `Ctrl+[` cycle the focused group's tabs (wrapping round) via the group model's moveToNext/moveToPrevious. - `Ctrl+M` docking is now just another keymap entry (`dock`). Pass `keyboardNavigation: { keymap: { ... } }` to rebind any action — the defaults deliberately avoid Cmd-based and browser-reserved combinations so they survive on macOS. Bindings are matched modifier-exact against KeyboardEvent.key. Renames the unreleased `keyboardDocking` option to `keyboardNavigation`. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e8defa4 commit 65b3966

5 files changed

Lines changed: 197 additions & 30 deletions

File tree

__generated__/dockview-core-exports.txt

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/dockview-core/src/__tests__/dockview/accessibilityDocking.spec.ts

Lines changed: 81 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,14 @@ describe('accessibility: keyboard docking', () => {
2323
let container: HTMLElement;
2424
let dockview: DockviewComponent;
2525

26-
const make = (keyboardDocking: boolean): void => {
26+
const make = (
27+
keyboardNavigation: boolean | { keymap?: Record<string, string> }
28+
): void => {
2729
container = document.createElement('div');
2830
document.body.appendChild(container);
2931
dockview = new DockviewComponent(container, {
3032
createComponent: () => new TestPanel(),
31-
keyboardDocking,
33+
keyboardNavigation,
3234
});
3335
dockview.layout(1000, 1000);
3436
};
@@ -96,7 +98,7 @@ describe('accessibility: keyboard docking', () => {
9698
expect(dockview.groups.length).toBe(2);
9799
});
98100

99-
test('does nothing when keyboardDocking is off (default)', () => {
101+
test('does nothing when keyboardNavigation is off (default)', () => {
100102
make(false);
101103
twoGroups();
102104

@@ -105,3 +107,79 @@ describe('accessibility: keyboard docking', () => {
105107
expect(dockview.groups.length).toBe(2);
106108
});
107109
});
110+
111+
/**
112+
* Switch tabs within the focused group by keyboard — Ctrl+] / Ctrl+[ cycle
113+
* the active group's panels (wrapping round), driven by the rebindable keymap.
114+
*/
115+
describe('accessibility: tab switching', () => {
116+
let container: HTMLElement;
117+
let dockview: DockviewComponent;
118+
119+
const make = (
120+
keyboardNavigation: boolean | { keymap?: Record<string, string> }
121+
): void => {
122+
container = document.createElement('div');
123+
document.body.appendChild(container);
124+
dockview = new DockviewComponent(container, {
125+
createComponent: () => new TestPanel(),
126+
keyboardNavigation,
127+
});
128+
dockview.layout(1000, 1000);
129+
};
130+
131+
const threeTabs = (): void => {
132+
dockview.addPanel({ id: 'p1', component: 'default', title: 'P1' });
133+
dockview.addPanel({ id: 'p2', component: 'default', title: 'P2' });
134+
dockview.addPanel({ id: 'p3', component: 'default', title: 'P3' });
135+
};
136+
137+
afterEach(() => {
138+
dockview.dispose();
139+
container.remove();
140+
});
141+
142+
test('Ctrl+] advances to the next tab and wraps round', () => {
143+
make(true);
144+
threeTabs(); // one group, p3 active
145+
expect(dockview.activePanel?.id).toBe('p3');
146+
147+
fireEvent.keyDown(dockview.element, { key: ']', ctrlKey: true });
148+
expect(dockview.activePanel?.id).toBe('p1'); // wrapped past the end
149+
150+
fireEvent.keyDown(dockview.element, { key: ']', ctrlKey: true });
151+
expect(dockview.activePanel?.id).toBe('p2');
152+
});
153+
154+
test('Ctrl+[ steps back to the previous tab and wraps round', () => {
155+
make(true);
156+
threeTabs(); // p3 active
157+
158+
fireEvent.keyDown(dockview.element, { key: '[', ctrlKey: true });
159+
expect(dockview.activePanel?.id).toBe('p2');
160+
161+
fireEvent.keyDown(dockview.element, { key: '[', ctrlKey: true });
162+
fireEvent.keyDown(dockview.element, { key: '[', ctrlKey: true });
163+
expect(dockview.activePanel?.id).toBe('p3'); // wrapped past the start
164+
});
165+
166+
test('a rebound keymap is honoured (and the default no longer fires)', () => {
167+
make({ keymap: { nextTab: 'alt+n' } });
168+
threeTabs(); // p3 active
169+
170+
// default binding is overridden, so Ctrl+] does nothing now
171+
fireEvent.keyDown(dockview.element, { key: ']', ctrlKey: true });
172+
expect(dockview.activePanel?.id).toBe('p3');
173+
174+
fireEvent.keyDown(dockview.element, { key: 'n', altKey: true });
175+
expect(dockview.activePanel?.id).toBe('p1');
176+
});
177+
178+
test('does nothing when keyboardNavigation is off', () => {
179+
make(false);
180+
threeTabs(); // p3 active
181+
182+
fireEvent.keyDown(dockview.element, { key: ']', ctrlKey: true });
183+
expect(dockview.activePanel?.id).toBe('p3');
184+
});
185+
});

packages/dockview-core/src/dockview/accessibilityService.ts

Lines changed: 72 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@ import { CompositeDisposable, IDisposable } from '../lifecycle';
22
import { Position } from '../dnd/droptarget';
33
import { DockviewGroupPanel } from './dockviewGroupPanel';
44
import { IDockviewPanel } from './dockviewPanel';
5-
import { DockviewComponentOptions } from './options';
5+
import {
6+
DockviewComponentOptions,
7+
DockviewKeybindings,
8+
KeyboardNavigationOptions,
9+
} from './options';
610
import { defineModule } from './modules';
711
import { AdvancedDnDModule } from './advancedDnDService';
812
import { LiveRegionModule } from './liveRegionService';
@@ -23,6 +27,10 @@ export interface IAccessibilityHost {
2327
readonly options: DockviewComponentOptions;
2428
readonly groups: DockviewGroupPanel[];
2529
readonly activePanel: IDockviewPanel | undefined;
30+
/** Activate the next tab in the focused group (wraps round). */
31+
focusNextPanel(): void;
32+
/** Activate the previous tab in the focused group (wraps round). */
33+
focusPreviousPanel(): void;
2634
showDropPreview(group: DockviewGroupPanel, position: Position): IDisposable;
2735
announce(message: string): void;
2836
dockPanel(
@@ -51,18 +59,45 @@ const EDGE_FROM_KEY: Record<string, Position> = {
5159
ArrowDown: 'bottom',
5260
};
5361

62+
const DEFAULT_KEYMAP: DockviewKeybindings = {
63+
nextTab: 'ctrl+]',
64+
prevTab: 'ctrl+[',
65+
dock: 'ctrl+m',
66+
};
67+
68+
/**
69+
* Does `e` match a binding string like `'ctrl+]'` / `'shift+f6'`? Modifiers
70+
* are matched exactly (a binding without `shift` will not fire while Shift is
71+
* held), and the final part is compared to `KeyboardEvent.key`, lower-cased.
72+
*/
73+
function matchesBinding(e: KeyboardEvent, binding: string): boolean {
74+
const parts = binding.toLowerCase().split('+');
75+
const key = parts[parts.length - 1];
76+
const mods = parts.slice(0, -1);
77+
return (
78+
e.ctrlKey === mods.includes('ctrl') &&
79+
e.shiftKey === mods.includes('shift') &&
80+
e.altKey === mods.includes('alt') &&
81+
e.metaKey === (mods.includes('meta') || mods.includes('cmd')) &&
82+
e.key.toLowerCase() === key
83+
);
84+
}
85+
5486
/**
55-
* Pro accessibility module — operate the dock without a mouse. Keyboard
56-
* docking: `Ctrl+M` on the active panel arms a two-phase move with a live drop
57-
* preview + screen-reader narration:
58-
* 1. PICK TARGET — arrows cycle the groups (incl. the panel's own, so a tab
59-
* can be split out); `Enter` selects one.
60-
* 2. PICK EDGE — arrows choose a split edge (left/right/top/bottom) or the
61-
* centre (tab-into); `Enter` commits, `Escape` steps back.
62-
* `Escape` from the target phase cancels. Opt-in via `keyboardDocking`.
87+
* Pro accessibility module — operate the dock without a mouse. Opt-in via
88+
* `keyboardNavigation`, with a rebindable {@link DockviewKeybindings} keymap.
6389
*
64-
* Float / popout terminals, spatial F6 navigation and cross-window focus
65-
* management are later phases.
90+
* - **Switch tab** (`Ctrl+]` / `Ctrl+[`) — cycle the focused group's tabs.
91+
* - **Keyboard docking** (`Ctrl+M`) — arms a two-phase move of the active
92+
* panel with a live drop preview + screen-reader narration:
93+
* 1. PICK TARGET — arrows cycle the groups (incl. the panel's own, so a tab
94+
* can be split out); `Enter` selects one.
95+
* 2. PICK EDGE — arrows choose a split edge (left/right/top/bottom) or the
96+
* centre (tab-into); `Enter` commits, `Escape` steps back.
97+
* `Escape` from the target phase cancels.
98+
*
99+
* Inter-group focus navigation (F6 / spatial), float / popout terminals and
100+
* cross-window focus management are later phases.
66101
*/
67102
export class AccessibilityService
68103
extends CompositeDisposable
@@ -91,24 +126,42 @@ export class AccessibilityService
91126
);
92127
}
93128

129+
private get _nav(): KeyboardNavigationOptions | undefined {
130+
const opt = this.host.options.keyboardNavigation;
131+
if (!opt) {
132+
return undefined;
133+
}
134+
return opt === true ? {} : opt;
135+
}
136+
137+
private get _keymap(): DockviewKeybindings {
138+
return { ...DEFAULT_KEYMAP, ...this._nav?.keymap };
139+
}
140+
94141
private _onKeyDown(e: KeyboardEvent): void {
95-
if (!this.host.options.keyboardDocking) {
142+
if (!this._nav) {
96143
return;
97144
}
98145
if (this._move) {
99146
this._onMoveKey(e);
100147
return;
101148
}
102-
// Ctrl+M only — NOT Cmd+M (macOS minimise-window, OS-intercepted).
103-
// Scope to events originating inside *this* dockview.
149+
// Only act on events originating inside *this* dockview.
104150
if (
105-
e.ctrlKey &&
106-
!e.metaKey &&
107-
(e.key === 'm' || e.key === 'M') &&
108-
e.target instanceof Node &&
109-
this.host.rootElement.contains(e.target)
151+
!(e.target instanceof Node) ||
152+
!this.host.rootElement.contains(e.target)
110153
) {
154+
return;
155+
}
156+
const keymap = this._keymap;
157+
if (matchesBinding(e, keymap.dock)) {
111158
this._enterMoveMode(e);
159+
} else if (matchesBinding(e, keymap.nextTab)) {
160+
this._consume(e);
161+
this.host.focusNextPanel();
162+
} else if (matchesBinding(e, keymap.prevTab)) {
163+
this._consume(e);
164+
this.host.focusPreviousPanel();
112165
}
113166
}
114167

packages/dockview-core/src/dockview/dockviewComponent.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -706,6 +706,14 @@ export class DockviewComponent
706706
return this._shellManager?.element ?? this.element;
707707
}
708708

709+
focusNextPanel(): void {
710+
this.activeGroup?.model.moveToNext();
711+
}
712+
713+
focusPreviousPanel(): void {
714+
this.activeGroup?.model.moveToPrevious();
715+
}
716+
709717
showDropPreview(
710718
group: DockviewGroupPanel,
711719
position: Position

packages/dockview-core/src/dockview/options.ts

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,27 @@ export interface LiveRegionEvent {
7171
panel: IDockviewPanel;
7272
}
7373

74+
/**
75+
* Key bindings for {@link DockviewComponentOptions.keyboardNavigation}. Each
76+
* value is a string of `+`-separated parts, modifiers first, e.g. `'ctrl+]'`,
77+
* `'shift+f6'`, `'ctrl+m'`. Recognised modifiers: `ctrl`, `shift`, `alt`,
78+
* `meta` (alias `cmd`). The final part is the `KeyboardEvent.key` to match,
79+
* case-insensitively (`'m'`, `']'`, `'f6'`, `'arrowleft'`).
80+
*/
81+
export interface DockviewKeybindings {
82+
/** Switch to the next tab in the focused group. Default `ctrl+]`. */
83+
nextTab: string;
84+
/** Switch to the previous tab in the focused group. Default `ctrl+[`. */
85+
prevTab: string;
86+
/** Arm keyboard docking of the active panel. Default `ctrl+m`. */
87+
dock: string;
88+
}
89+
90+
export interface KeyboardNavigationOptions {
91+
/** Override individual {@link DockviewKeybindings}; unset keys keep their defaults. */
92+
keymap?: Partial<DockviewKeybindings>;
93+
}
94+
7495
export interface GetTabContextMenuItemsParams {
7596
panel: IDockviewPanel;
7697
group: DockviewGroupPanel;
@@ -256,15 +277,20 @@ export interface DockviewOptions {
256277
*/
257278
getAnnouncement?: (event: LiveRegionEvent) => string | null | undefined;
258279
/**
259-
* Enable keyboard docking — move the active panel without a mouse:
260-
* `Ctrl`+`M` enters move mode, arrows cycle the target group (with a live
261-
* drop preview + screen-reader narration), `Enter` docks it, `Escape`
262-
* cancels. Off by default (opt-in while the feature matures).
280+
* Operate the dock with the keyboard. `true` enables the default bindings;
281+
* pass an object to override individual ones via `keymap`. Off by default
282+
* (opt-in while the feature matures). Enables:
283+
*
284+
* - **Switch tab** within the focused group — `Ctrl`+`]` / `Ctrl`+`[`.
285+
* - **Dock the active panel** without a mouse — `Ctrl`+`M` arms a two-phase
286+
* move (arrows cycle the target group with a live drop preview +
287+
* screen-reader narration, `Enter` docks, `Escape` cancels).
263288
*
264-
* (`Cmd`+`M` is intentionally not used — it's the macOS minimise-window
265-
* shortcut. A rebindable keymap is a planned follow-up.)
289+
* Defaults avoid `Cmd`-based and browser-reserved combinations (e.g.
290+
* `Cmd`+`M` is the macOS minimise-window shortcut); use {@link keymap} to
291+
* rebind for your platform.
266292
*/
267-
keyboardDocking?: boolean;
293+
keyboardNavigation?: boolean | KeyboardNavigationOptions;
268294
/**
269295
* Replace the built-in tab group color palette with a user-defined list.
270296
*
@@ -349,7 +375,7 @@ export const PROPERTY_KEYS_DOCKVIEW: (keyof DockviewOptions)[] = (() => {
349375
dropOverlayModel: undefined,
350376
announcements: undefined,
351377
getAnnouncement: undefined,
352-
keyboardDocking: undefined,
378+
keyboardNavigation: undefined,
353379
tabGroupColors: undefined,
354380
tabGroupAccent: undefined,
355381
};

0 commit comments

Comments
 (0)