@@ -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 */
111115export 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 {
0 commit comments