Skip to content

Commit 055c07c

Browse files
authored
Merge pull request #1320 from mathuo/feat/keyboard-directional-focus
feat(dockview-core): spatial keyboard group focus + focus-logic refactor
2 parents af21b54 + 5b565a8 commit 055c07c

4 files changed

Lines changed: 257 additions & 71 deletions

File tree

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

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,3 +270,113 @@ describe('accessibility: group focus navigation', () => {
270270
expect(dockview.activeGroup?.id).toBe(before);
271271
});
272272
});
273+
274+
/**
275+
* Spatial group focus — Ctrl+Shift+Arrow focuses the group geometrically in
276+
* that direction. jsdom has no layout, so a clean 2x2 grid is mocked via
277+
* getBoundingClientRect.
278+
*/
279+
describe('accessibility: spatial group focus', () => {
280+
let container: HTMLElement;
281+
let dockview: DockviewComponent;
282+
283+
const make = (): void => {
284+
container = document.createElement('div');
285+
document.body.appendChild(container);
286+
dockview = new DockviewComponent(container, {
287+
createComponent: () => new TestPanel(),
288+
keyboardNavigation: true,
289+
});
290+
dockview.layout(200, 200);
291+
};
292+
293+
const groupOf = (panelId: string) =>
294+
dockview.groups.find((g) => g.panels.some((p) => p.id === panelId))!;
295+
296+
const setRect = (panelId: string, left: number, top: number): void => {
297+
groupOf(panelId).element.getBoundingClientRect = () =>
298+
({
299+
left,
300+
top,
301+
width: 100,
302+
height: 100,
303+
right: left + 100,
304+
bottom: top + 100,
305+
x: left,
306+
y: top,
307+
toJSON: () => ({}),
308+
}) as DOMRect;
309+
};
310+
311+
const grid2x2 = (): void => {
312+
dockview.addPanel({ id: 'tl', component: 'default' });
313+
dockview.addPanel({
314+
id: 'tr',
315+
component: 'default',
316+
position: { referencePanel: 'tl', direction: 'right' },
317+
});
318+
dockview.addPanel({
319+
id: 'bl',
320+
component: 'default',
321+
position: { referencePanel: 'tl', direction: 'below' },
322+
});
323+
dockview.addPanel({
324+
id: 'br',
325+
component: 'default',
326+
position: { referencePanel: 'tr', direction: 'below' },
327+
});
328+
// pin a clean 2x2 geometry regardless of jsdom's (absent) layout
329+
setRect('tl', 0, 0);
330+
setRect('tr', 100, 0);
331+
setRect('bl', 0, 100);
332+
setRect('br', 100, 100);
333+
};
334+
335+
const dir = (key: string): void => {
336+
fireEvent.keyDown(dockview.element, {
337+
key,
338+
ctrlKey: true,
339+
shiftKey: true,
340+
});
341+
};
342+
343+
afterEach(() => {
344+
dockview.dispose();
345+
container.remove();
346+
});
347+
348+
test('Ctrl+Shift+Right / Down focus the neighbouring group', () => {
349+
make();
350+
grid2x2();
351+
expect(dockview.groups.length).toBe(4);
352+
groupOf('tl').api.setActive();
353+
expect(dockview.activeGroup).toBe(groupOf('tl'));
354+
355+
dir('ArrowRight');
356+
expect(dockview.activeGroup).toBe(groupOf('tr'));
357+
358+
groupOf('tl').api.setActive();
359+
dir('ArrowDown');
360+
expect(dockview.activeGroup).toBe(groupOf('bl'));
361+
});
362+
363+
test('picks the dominant-axis neighbour, not a diagonal one', () => {
364+
make();
365+
grid2x2();
366+
groupOf('tl').api.setActive();
367+
368+
// 'right' from top-left must land on top-right, never bottom-right
369+
dir('ArrowRight');
370+
expect(dockview.activeGroup).toBe(groupOf('tr'));
371+
expect(dockview.activeGroup).not.toBe(groupOf('br'));
372+
});
373+
374+
test('does nothing when there is no group in that direction', () => {
375+
make();
376+
grid2x2();
377+
groupOf('tl').api.setActive();
378+
379+
dir('ArrowLeft'); // nothing to the left of top-left
380+
expect(dockview.activeGroup).toBe(groupOf('tl'));
381+
});
382+
});

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

Lines changed: 118 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -26,17 +26,17 @@ export interface IAccessibilityHost {
2626
readonly rootElement: HTMLElement;
2727
readonly options: DockviewComponentOptions;
2828
readonly groups: DockviewGroupPanel[];
29+
readonly activeGroup: DockviewGroupPanel | undefined;
2930
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;
34-
/** Move focus to the next group (wraps round). */
35-
focusNextGroup(): void;
36-
/** Move focus to the previous group (wraps round). */
37-
focusPreviousGroup(): void;
38-
/** Return DOM focus to the active group's content (keeps it inside the dock). */
39-
focusActiveContent(): void;
31+
/**
32+
* The next / previous group in gridview (spatial) order, wrapping round —
33+
* the one piece of navigation that needs the grid internals. All other
34+
* focus logic lives in the service, using the public group API.
35+
*/
36+
adjacentGroup(
37+
group: DockviewGroupPanel,
38+
reverse: boolean
39+
): DockviewGroupPanel | undefined;
4040
showDropPreview(group: DockviewGroupPanel, position: Position): IDisposable;
4141
announce(message: string): void;
4242
dockPanel(
@@ -70,6 +70,10 @@ const DEFAULT_KEYMAP: DockviewKeybindings = {
7070
prevTab: 'ctrl+[',
7171
focusNextGroup: 'f6',
7272
focusPrevGroup: 'shift+f6',
73+
focusGroupLeft: 'ctrl+shift+arrowleft',
74+
focusGroupRight: 'ctrl+shift+arrowright',
75+
focusGroupUp: 'ctrl+shift+arrowup',
76+
focusGroupDown: 'ctrl+shift+arrowdown',
7377
dock: 'ctrl+m',
7478
};
7579

@@ -96,7 +100,8 @@ function matchesBinding(e: KeyboardEvent, binding: string): boolean {
96100
* `keyboardNavigation`, with a rebindable {@link DockviewKeybindings} keymap.
97101
*
98102
* - **Switch tab** (`Ctrl+]` / `Ctrl+[`) — cycle the focused group's tabs.
99-
* - **Focus group** (`F6` / `Shift+F6`) — move focus between groups.
103+
* - **Focus group** (`F6` / `Shift+F6` sequential, `Ctrl+Shift+Arrows`
104+
* spatial) — move focus between groups.
100105
* - **Keyboard docking** (`Ctrl+M`) — arms a two-phase move of the active
101106
* panel with a live drop preview + screen-reader narration:
102107
* 1. PICK TARGET — arrows cycle the groups (incl. the panel's own, so a tab
@@ -105,8 +110,7 @@ function matchesBinding(e: KeyboardEvent, binding: string): boolean {
105110
* centre (tab-into); `Enter` commits, `Escape` steps back.
106111
* `Escape` from the target phase cancels.
107112
*
108-
* Spatial (directional) group focus, float / popout terminals and cross-window
109-
* focus management are later phases.
113+
* Float / popout terminals and cross-window focus management are later phases.
110114
*/
111115
export class AccessibilityService
112116
extends CompositeDisposable
@@ -167,17 +171,112 @@ export class AccessibilityService
167171
this._enterMoveMode(e);
168172
} else if (matchesBinding(e, keymap.nextTab)) {
169173
this._consume(e);
170-
this.host.focusNextPanel();
174+
this._switchTab(false);
171175
} else if (matchesBinding(e, keymap.prevTab)) {
172176
this._consume(e);
173-
this.host.focusPreviousPanel();
177+
this._switchTab(true);
174178
} else if (matchesBinding(e, keymap.focusNextGroup)) {
175179
this._consume(e);
176-
this.host.focusNextGroup();
180+
this._cycleGroup(false);
177181
} else if (matchesBinding(e, keymap.focusPrevGroup)) {
178182
this._consume(e);
179-
this.host.focusPreviousGroup();
183+
this._cycleGroup(true);
184+
} else if (matchesBinding(e, keymap.focusGroupLeft)) {
185+
this._consume(e);
186+
this._focusGroupInDirection('left');
187+
} else if (matchesBinding(e, keymap.focusGroupRight)) {
188+
this._consume(e);
189+
this._focusGroupInDirection('right');
190+
} else if (matchesBinding(e, keymap.focusGroupUp)) {
191+
this._consume(e);
192+
this._focusGroupInDirection('up');
193+
} else if (matchesBinding(e, keymap.focusGroupDown)) {
194+
this._consume(e);
195+
this._focusGroupInDirection('down');
196+
}
197+
}
198+
199+
// --- navigation (uses the public group API + the adjacentGroup primitive) ---
200+
201+
private _switchTab(reverse: boolean): void {
202+
const group = this.host.activeGroup;
203+
if (!group) {
204+
return;
205+
}
206+
if (reverse) {
207+
group.model.moveToPrevious();
208+
} else {
209+
group.model.moveToNext();
210+
}
211+
// Keep DOM focus inside the dock: switching hides the previously
212+
// focused content, which would otherwise drop focus to <body> and
213+
// leave the keymap unable to see the next key.
214+
group.model.focusContent();
215+
}
216+
217+
private _cycleGroup(reverse: boolean): void {
218+
const current = this.host.activeGroup;
219+
const target = current
220+
? this.host.adjacentGroup(current, reverse)
221+
: this.host.groups[0];
222+
this._focusGroup(target);
223+
}
224+
225+
private _focusGroupInDirection(
226+
direction: 'left' | 'right' | 'up' | 'down'
227+
): void {
228+
const current = this.host.activeGroup;
229+
if (!current || current.api.location.type !== 'grid') {
230+
return;
231+
}
232+
const from = current.element.getBoundingClientRect();
233+
const fromX = from.left + from.width / 2;
234+
const fromY = from.top + from.height / 2;
235+
236+
let best: DockviewGroupPanel | undefined;
237+
let bestDistance = Number.POSITIVE_INFINITY;
238+
for (const group of this.host.groups) {
239+
if (group === current || group.api.location.type !== 'grid') {
240+
continue;
241+
}
242+
const rect = group.element.getBoundingClientRect();
243+
const dx = rect.left + rect.width / 2 - fromX;
244+
const dy = rect.top + rect.height / 2 - fromY;
245+
// require the candidate to sit predominantly in the asked-for
246+
// direction (dominant axis), so 'left' ignores a group that's
247+
// mostly above/below.
248+
const inDirection =
249+
direction === 'left'
250+
? dx < 0 && Math.abs(dx) >= Math.abs(dy)
251+
: direction === 'right'
252+
? dx > 0 && Math.abs(dx) >= Math.abs(dy)
253+
: direction === 'up'
254+
? dy < 0 && Math.abs(dy) >= Math.abs(dx)
255+
: dy > 0 && Math.abs(dy) >= Math.abs(dx);
256+
if (!inDirection) {
257+
continue;
258+
}
259+
const distance = dx * dx + dy * dy;
260+
if (distance < bestDistance) {
261+
bestDistance = distance;
262+
best = group;
263+
}
264+
}
265+
266+
this._focusGroup(best);
267+
}
268+
269+
private _focusGroup(target: DockviewGroupPanel | undefined): void {
270+
if (!target) {
271+
return;
180272
}
273+
target.api.setActive();
274+
target.model.focusContent();
275+
}
276+
277+
/** Return DOM focus to the active group's content, keeping it in the dock. */
278+
private _restoreFocus(): void {
279+
this.host.activeGroup?.model.focusContent();
181280
}
182281

183282
private _enterMoveMode(e: KeyboardEvent): void {
@@ -213,7 +312,7 @@ export class AccessibilityService
213312
} else {
214313
this._exit();
215314
this.host.announce('Move cancelled.');
216-
this.host.focusActiveContent();
315+
this._restoreFocus();
217316
}
218317
return;
219318
}
@@ -298,7 +397,7 @@ export class AccessibilityService
298397
}
299398
// The move re-renders the grid; pull focus back into the dock so the
300399
// keymap keeps working without a click.
301-
this.host.focusActiveContent();
400+
this._restoreFocus();
302401
}
303402

304403
private _describe(position: Position): string {

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

Lines changed: 19 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -706,60 +706,28 @@ export class DockviewComponent
706706
return this._shellManager?.element ?? this.element;
707707
}
708708

709-
focusNextPanel(): void {
710-
const group = this.activeGroup;
711-
if (!group) {
712-
return;
713-
}
714-
group.model.moveToNext();
715-
// Keep DOM focus inside the dock: switching hides the previously
716-
// focused content, which would otherwise drop focus to <body> and
717-
// leave the keymap unable to see the next key.
718-
group.model.focusContent();
719-
}
720-
721-
focusPreviousPanel(): void {
722-
const group = this.activeGroup;
723-
if (!group) {
724-
return;
725-
}
726-
group.model.moveToPrevious();
727-
group.model.focusContent();
728-
}
729-
730-
focusNextGroup(): void {
731-
this._focusAdjacentGroup(false);
732-
}
733-
734-
focusPreviousGroup(): void {
735-
this._focusAdjacentGroup(true);
736-
}
737-
738-
/** Land DOM focus on the active group's content, keeping it inside the dock. */
739-
focusActiveContent(): void {
740-
this.activeGroup?.model.focusContent();
741-
}
742-
743-
private _focusAdjacentGroup(reverse: boolean): void {
744-
const current = this.activeGroup;
709+
/**
710+
* The next / previous group in gridview (spatial) order, wrapping round.
711+
* The keyboard accessibility module's focus navigation is built on this
712+
* primitive — the only piece that needs the grid internals; the rest of
713+
* the navigation logic lives in the AccessibilityService.
714+
*/
715+
adjacentGroup(
716+
group: DockviewGroupPanel,
717+
reverse: boolean
718+
): DockviewGroupPanel | undefined {
745719
// gridview traversal only covers grid groups; a floating/popout group
746720
// isn't in the grid, so there's no adjacent grid group to step to.
747-
if (current && current.api.location.type !== 'grid') {
748-
return;
749-
}
750-
const location = current ? getGridLocation(current.element) : undefined;
751-
const target = current
752-
? <DockviewGroupPanel | undefined>(
753-
(reverse
754-
? this.gridview.previous(location!)
755-
: this.gridview.next(location!)
756-
)?.view
757-
)
758-
: this.groups[0];
759-
if (target) {
760-
this.doSetGroupAndPanelActive(target);
761-
target.model.focusContent();
721+
if (group.api.location.type !== 'grid') {
722+
return undefined;
762723
}
724+
const location = getGridLocation(group.element);
725+
return <DockviewGroupPanel | undefined>(
726+
(reverse
727+
? this.gridview.previous(location)
728+
: this.gridview.next(location)
729+
)?.view
730+
);
763731
}
764732

765733
showDropPreview(

0 commit comments

Comments
 (0)