@furystack/shades 13.0.0 → 13.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +52 -0
- package/esm/models/render-options.d.ts +5 -2
- package/esm/models/render-options.d.ts.map +1 -1
- package/esm/services/index.d.ts +1 -0
- package/esm/services/index.d.ts.map +1 -1
- package/esm/services/index.js +1 -0
- package/esm/services/index.js.map +1 -1
- package/esm/services/spatial-navigation-service.d.ts +88 -0
- package/esm/services/spatial-navigation-service.d.ts.map +1 -0
- package/esm/services/spatial-navigation-service.js +523 -0
- package/esm/services/spatial-navigation-service.js.map +1 -0
- package/esm/services/spatial-navigation-service.spec.d.ts +2 -0
- package/esm/services/spatial-navigation-service.spec.d.ts.map +1 -0
- package/esm/services/spatial-navigation-service.spec.js +1133 -0
- package/esm/services/spatial-navigation-service.spec.js.map +1 -0
- package/package.json +2 -2
- package/src/models/render-options.ts +5 -2
- package/src/services/index.ts +1 -0
- package/src/services/spatial-navigation-service.spec.ts +1396 -0
- package/src/services/spatial-navigation-service.ts +597 -0
|
@@ -0,0 +1,597 @@
|
|
|
1
|
+
import { Injectable, type Injector } from '@furystack/inject'
|
|
2
|
+
import { ObservableValue } from '@furystack/utils'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Direction for spatial navigation movement.
|
|
6
|
+
*/
|
|
7
|
+
export type SpatialDirection = 'up' | 'down' | 'left' | 'right'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Configuration options for the SpatialNavigationService.
|
|
11
|
+
*/
|
|
12
|
+
export type SpatialNavigationOptions = {
|
|
13
|
+
/** Whether spatial navigation is enabled on startup. Default: true */
|
|
14
|
+
initiallyEnabled?: boolean
|
|
15
|
+
/** Whether to allow cross-section navigation. Default: true */
|
|
16
|
+
crossSectionNavigation?: boolean
|
|
17
|
+
/** Custom focusable selector override */
|
|
18
|
+
focusableSelector?: string
|
|
19
|
+
/** Whether Backspace triggers history.back(). Default: false */
|
|
20
|
+
backspaceGoesBack?: boolean
|
|
21
|
+
/** Whether Escape moves focus to parent section. Default: false */
|
|
22
|
+
escapeGoesToParentSection?: boolean
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const DEFAULT_FOCUSABLE_SELECTOR = [
|
|
26
|
+
'[tabindex]:not([tabindex="-1"])',
|
|
27
|
+
'[data-spatial-nav-target]',
|
|
28
|
+
'a[href]',
|
|
29
|
+
'button:not([disabled])',
|
|
30
|
+
'input:not([disabled])',
|
|
31
|
+
'select:not([disabled])',
|
|
32
|
+
'textarea:not([disabled])',
|
|
33
|
+
].join(', ')
|
|
34
|
+
|
|
35
|
+
const INPUT_PASSTHROUGH_TAGS = new Set(['TEXTAREA', 'SELECT'])
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Input types treated as "text-like" for Enter suppression and Escape blur.
|
|
39
|
+
* `number` is included here even though it also appears in
|
|
40
|
+
* INPUT_VERTICAL_ONLY_PASSTHROUGH_TYPES — the overlap is intentional:
|
|
41
|
+
* isTextInput gates Enter suppression and Escape-blur behavior, while
|
|
42
|
+
* the vertical-only set gates which arrow keys pass through.
|
|
43
|
+
*/
|
|
44
|
+
const INPUT_PASSTHROUGH_TYPES = new Set([
|
|
45
|
+
'text',
|
|
46
|
+
'password',
|
|
47
|
+
'email',
|
|
48
|
+
'number',
|
|
49
|
+
'search',
|
|
50
|
+
'tel',
|
|
51
|
+
'url',
|
|
52
|
+
'date',
|
|
53
|
+
'datetime-local',
|
|
54
|
+
'month',
|
|
55
|
+
'time',
|
|
56
|
+
'week',
|
|
57
|
+
])
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Input types that always pass through all arrow keys because they use
|
|
61
|
+
* arrows for built-in value manipulation (radio group cycling).
|
|
62
|
+
*/
|
|
63
|
+
const INPUT_FULL_ARROW_PASSTHROUGH_TYPES = new Set(['radio'])
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Input types where only Up/Down arrows should pass through when
|
|
67
|
+
* selectionStart is unavailable (e.g. number inputs use Up/Down
|
|
68
|
+
* for increment/decrement but Left/Right have no useful behavior).
|
|
69
|
+
*/
|
|
70
|
+
const INPUT_VERTICAL_ONLY_PASSTHROUGH_TYPES = new Set(['number'])
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Input types where only Left/Right arrows should pass through
|
|
74
|
+
* (e.g. horizontal range sliders use Left/Right to adjust value
|
|
75
|
+
* but Up/Down can be used for spatial navigation).
|
|
76
|
+
*/
|
|
77
|
+
const INPUT_HORIZONTAL_ONLY_PASSTHROUGH_TYPES = new Set(['range'])
|
|
78
|
+
|
|
79
|
+
const getElementCenter = (rect: DOMRect) => ({
|
|
80
|
+
x: rect.left + rect.width / 2,
|
|
81
|
+
y: rect.top + rect.height / 2,
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
const PERPENDICULAR_WEIGHT = 3
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Weighted distance that penalizes perpendicular displacement.
|
|
88
|
+
* For horizontal navigation (left/right), vertical offset is weighted 3x.
|
|
89
|
+
* For vertical navigation (up/down), horizontal offset is weighted 3x.
|
|
90
|
+
* This ensures elements aligned on the movement axis are strongly preferred.
|
|
91
|
+
*/
|
|
92
|
+
const spatialDistance = (
|
|
93
|
+
a: { x: number; y: number },
|
|
94
|
+
b: { x: number; y: number },
|
|
95
|
+
direction: SpatialDirection,
|
|
96
|
+
): number => {
|
|
97
|
+
const dx = Math.abs(a.x - b.x)
|
|
98
|
+
const dy = Math.abs(a.y - b.y)
|
|
99
|
+
const isHorizontal = direction === 'left' || direction === 'right'
|
|
100
|
+
const primary = isHorizontal ? dx : dy
|
|
101
|
+
const perpendicular = isHorizontal ? dy : dx
|
|
102
|
+
return Math.sqrt(primary ** 2 + (perpendicular * PERPENDICULAR_WEIGHT) ** 2)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const isInDirection = (current: DOMRect, candidate: DOMRect, direction: SpatialDirection): boolean => {
|
|
106
|
+
const currentCenter = getElementCenter(current)
|
|
107
|
+
const candidateCenter = getElementCenter(candidate)
|
|
108
|
+
|
|
109
|
+
switch (direction) {
|
|
110
|
+
case 'right':
|
|
111
|
+
return candidateCenter.x > currentCenter.x
|
|
112
|
+
case 'left':
|
|
113
|
+
return candidateCenter.x < currentCenter.x
|
|
114
|
+
case 'down':
|
|
115
|
+
return candidateCenter.y > currentCenter.y
|
|
116
|
+
case 'up':
|
|
117
|
+
return candidateCenter.y < currentCenter.y
|
|
118
|
+
default:
|
|
119
|
+
return false
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Walks up the DOM checking the `contentEditable` property rather than
|
|
125
|
+
* using `.closest('[contenteditable="true"]')` — the attribute selector
|
|
126
|
+
* is unreliable in jsdom when contentEditable is set via the IDL property.
|
|
127
|
+
*/
|
|
128
|
+
const isInsideContentEditable = (element: Element): boolean => {
|
|
129
|
+
let current: Element | null = element
|
|
130
|
+
while (current) {
|
|
131
|
+
if ((current as HTMLElement).contentEditable === 'true') return true
|
|
132
|
+
current = current.parentElement
|
|
133
|
+
}
|
|
134
|
+
return false
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const isTextInput = (element: Element): boolean => {
|
|
138
|
+
if (INPUT_PASSTHROUGH_TAGS.has(element.tagName)) {
|
|
139
|
+
return true
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (element.tagName === 'INPUT') {
|
|
143
|
+
const type = (element as HTMLInputElement).type?.toLowerCase() || 'text'
|
|
144
|
+
return INPUT_PASSTHROUGH_TYPES.has(type)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (isInsideContentEditable(element)) {
|
|
148
|
+
return true
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return false
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const isInsidePassthrough = (element: Element): boolean => {
|
|
155
|
+
return !!element.closest('[data-spatial-nav-passthrough]')
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const escapeCssString = (value: string): string =>
|
|
159
|
+
typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(value) : value.replace(/[^\w-]/g, (ch) => `\\${ch}`)
|
|
160
|
+
|
|
161
|
+
const ARROW_KEYS = new Set(['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'])
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Returns true when the focused element is an input-like control that
|
|
165
|
+
* passes through arrow keys internally, meaning the user cannot escape
|
|
166
|
+
* it via arrow keys alone (e.g. range, radio, date, time, textarea).
|
|
167
|
+
* In these cases Escape should blur the element to resume spatial navigation.
|
|
168
|
+
*/
|
|
169
|
+
const shouldEscapeBlurElement = (element: Element): boolean => {
|
|
170
|
+
if (isTextInput(element)) return true
|
|
171
|
+
if (element.tagName === 'INPUT') {
|
|
172
|
+
const type = (element as HTMLInputElement).type?.toLowerCase() || 'text'
|
|
173
|
+
if (INPUT_FULL_ARROW_PASSTHROUGH_TYPES.has(type)) return true
|
|
174
|
+
if (INPUT_HORIZONTAL_ONLY_PASSTHROUGH_TYPES.has(type)) return true
|
|
175
|
+
}
|
|
176
|
+
return false
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Determines whether an arrow key should be passed through to an input element.
|
|
181
|
+
* Returns false (allowing spatial nav to take over) when the key press would
|
|
182
|
+
* have no useful effect within the field:
|
|
183
|
+
* - For text-like inputs: at cursor boundaries (start/end of text)
|
|
184
|
+
* - For radio inputs: always pass through (built-in arrow key behavior)
|
|
185
|
+
* - For range inputs: pass through Left/Right (slider adjustment), intercept Up/Down
|
|
186
|
+
* - For number inputs: pass through Up/Down (increment/decrement), intercept Left/Right
|
|
187
|
+
* - For date/time inputs: always pass through (internal segment navigation)
|
|
188
|
+
*/
|
|
189
|
+
const shouldPassthroughArrowKeys = (element: Element, key: string): boolean => {
|
|
190
|
+
if (!ARROW_KEYS.has(key)) return false
|
|
191
|
+
|
|
192
|
+
if (element.tagName === 'INPUT') {
|
|
193
|
+
const type = (element as HTMLInputElement).type?.toLowerCase() || 'text'
|
|
194
|
+
if (INPUT_FULL_ARROW_PASSTHROUGH_TYPES.has(type)) return true
|
|
195
|
+
if (INPUT_HORIZONTAL_ONLY_PASSTHROUGH_TYPES.has(type)) {
|
|
196
|
+
return key === 'ArrowLeft' || key === 'ArrowRight'
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (!isTextInput(element)) return false
|
|
201
|
+
|
|
202
|
+
// Textareas are multi-line editing areas; arrow keys always serve
|
|
203
|
+
// editing purposes. Users exit via Tab or Escape.
|
|
204
|
+
if (element.tagName === 'TEXTAREA') return true
|
|
205
|
+
|
|
206
|
+
const el = element as HTMLInputElement | HTMLTextAreaElement
|
|
207
|
+
|
|
208
|
+
if (typeof el.selectionStart !== 'number' || typeof el.selectionEnd !== 'number') {
|
|
209
|
+
if (element.tagName === 'INPUT') {
|
|
210
|
+
const type = (element as HTMLInputElement).type?.toLowerCase() || 'text'
|
|
211
|
+
if (INPUT_VERTICAL_ONLY_PASSTHROUGH_TYPES.has(type)) {
|
|
212
|
+
return key === 'ArrowUp' || key === 'ArrowDown'
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return true
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const hasSelection = el.selectionStart !== el.selectionEnd
|
|
219
|
+
if (hasSelection) return true
|
|
220
|
+
|
|
221
|
+
const cursor = el.selectionStart
|
|
222
|
+
const length = el.value?.length ?? 0
|
|
223
|
+
|
|
224
|
+
if (key === 'ArrowUp' || key === 'ArrowLeft') {
|
|
225
|
+
return cursor > 0
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (key === 'ArrowDown' || key === 'ArrowRight') {
|
|
229
|
+
return cursor < length
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return false
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Service for D-pad / arrow-key spatial navigation across interactive elements.
|
|
237
|
+
*
|
|
238
|
+
* Intercepts arrow key events and moves focus spatially based on element geometry.
|
|
239
|
+
* Supports section boundaries via `data-nav-section` attributes and optional
|
|
240
|
+
* cross-section navigation.
|
|
241
|
+
*
|
|
242
|
+
* @example
|
|
243
|
+
* ```typescript
|
|
244
|
+
* // Opt in to spatial navigation
|
|
245
|
+
* const spatialNav = injector.getInstance(SpatialNavigationService)
|
|
246
|
+
*
|
|
247
|
+
* // Disable during video playback
|
|
248
|
+
* spatialNav.enabled.setValue(false)
|
|
249
|
+
*
|
|
250
|
+
* // Re-enable
|
|
251
|
+
* spatialNav.enabled.setValue(true)
|
|
252
|
+
* ```
|
|
253
|
+
*/
|
|
254
|
+
@Injectable({ lifetime: 'singleton' })
|
|
255
|
+
export class SpatialNavigationService implements Disposable {
|
|
256
|
+
/** Toggle spatial navigation on/off at runtime */
|
|
257
|
+
public readonly enabled: ObservableValue<boolean>
|
|
258
|
+
|
|
259
|
+
/** The currently active section name (from data-nav-section), or null if none */
|
|
260
|
+
public readonly activeSection = new ObservableValue<string | null>(null)
|
|
261
|
+
|
|
262
|
+
/** Remembered last-focused element per section for focus restoration */
|
|
263
|
+
private readonly focusMemory = new Map<string, WeakRef<Element>>()
|
|
264
|
+
|
|
265
|
+
private readonly focusTrapStack: string[] = []
|
|
266
|
+
|
|
267
|
+
private readonly focusableSelector: string
|
|
268
|
+
private readonly crossSectionNavigation: boolean
|
|
269
|
+
private readonly backspaceGoesBack: boolean
|
|
270
|
+
private readonly escapeGoesToParentSection: boolean
|
|
271
|
+
|
|
272
|
+
constructor(options: SpatialNavigationOptions = {}) {
|
|
273
|
+
this.enabled = new ObservableValue<boolean>(options.initiallyEnabled ?? true)
|
|
274
|
+
this.focusableSelector = options.focusableSelector ?? DEFAULT_FOCUSABLE_SELECTOR
|
|
275
|
+
this.crossSectionNavigation = options.crossSectionNavigation ?? true
|
|
276
|
+
this.backspaceGoesBack = options.backspaceGoesBack ?? false
|
|
277
|
+
this.escapeGoesToParentSection = options.escapeGoesToParentSection ?? false
|
|
278
|
+
|
|
279
|
+
window.addEventListener('keydown', this.handleKeyDown)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
public [Symbol.dispose](): void {
|
|
283
|
+
window.removeEventListener('keydown', this.handleKeyDown)
|
|
284
|
+
this.enabled[Symbol.dispose]()
|
|
285
|
+
this.activeSection[Symbol.dispose]()
|
|
286
|
+
this.focusMemory.clear()
|
|
287
|
+
this.focusTrapStack.length = 0
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Push a focus trap onto the stack. While the trap is active, cross-section
|
|
292
|
+
* navigation is blocked and `activeSection` is locked to `sectionName`.
|
|
293
|
+
* Supports nesting — only the topmost trap is enforced.
|
|
294
|
+
*/
|
|
295
|
+
public pushFocusTrap(sectionName: string): void {
|
|
296
|
+
this.focusTrapStack.push(sectionName)
|
|
297
|
+
this.activeSection.setValue(sectionName)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Remove a focus trap from the stack. If other traps remain, the topmost
|
|
302
|
+
* one becomes active. Otherwise `activeSection` reverts to `previousSection`.
|
|
303
|
+
*/
|
|
304
|
+
public popFocusTrap(sectionName: string, previousSection?: string | null): void {
|
|
305
|
+
const idx = this.focusTrapStack.lastIndexOf(sectionName)
|
|
306
|
+
if (idx !== -1) {
|
|
307
|
+
this.focusTrapStack.splice(idx, 1)
|
|
308
|
+
}
|
|
309
|
+
const top = this.focusTrapStack[this.focusTrapStack.length - 1]
|
|
310
|
+
this.activeSection.setValue(top ?? previousSection ?? null)
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
private get activeTrap(): string | null {
|
|
314
|
+
return this.focusTrapStack[this.focusTrapStack.length - 1] ?? null
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Programmatically move focus in a direction */
|
|
318
|
+
public moveFocus(direction: SpatialDirection): void {
|
|
319
|
+
const activeElement = document.activeElement as HTMLElement | null
|
|
320
|
+
|
|
321
|
+
if (!activeElement || activeElement === document.body) {
|
|
322
|
+
this.focusFirstElement()
|
|
323
|
+
return
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const currentSection = this.findContainingSection(activeElement)
|
|
327
|
+
const currentSectionName = currentSection?.getAttribute('data-nav-section') ?? null
|
|
328
|
+
this.activeSection.setValue(currentSectionName)
|
|
329
|
+
|
|
330
|
+
const searchRoot = currentSection ?? document
|
|
331
|
+
const candidates = this.getFocusableCandidates(searchRoot, activeElement)
|
|
332
|
+
|
|
333
|
+
const currentRect = activeElement.getBoundingClientRect()
|
|
334
|
+
let target = this.findNearestInDirection(currentRect, candidates, direction)
|
|
335
|
+
|
|
336
|
+
if (!target) {
|
|
337
|
+
const relaxedCandidates = this.getFocusableCandidates(searchRoot, activeElement, {
|
|
338
|
+
skipScrollVisibility: true,
|
|
339
|
+
})
|
|
340
|
+
target = this.findNearestInDirection(currentRect, relaxedCandidates, direction)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (target) {
|
|
344
|
+
this.storeFocusMemory(currentSectionName, activeElement)
|
|
345
|
+
target.focus()
|
|
346
|
+
target.scrollIntoView({ block: 'nearest', inline: 'nearest' })
|
|
347
|
+
const targetSection = this.findContainingSection(target)
|
|
348
|
+
this.activeSection.setValue(targetSection?.getAttribute('data-nav-section') ?? null)
|
|
349
|
+
return
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (this.crossSectionNavigation && currentSection && !this.activeTrap) {
|
|
353
|
+
this.navigateCrossSection(activeElement, currentSection, direction)
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Programmatically activate (click) the currently focused element */
|
|
358
|
+
public activateFocused(): void {
|
|
359
|
+
const { activeElement } = document
|
|
360
|
+
if (activeElement && activeElement !== document.body) {
|
|
361
|
+
;(activeElement as HTMLElement).click()
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
private handleKeyDown = (ev: KeyboardEvent): void => {
|
|
366
|
+
if (!this.enabled.getValue()) return
|
|
367
|
+
if (ev.defaultPrevented) return
|
|
368
|
+
|
|
369
|
+
const { activeElement } = document
|
|
370
|
+
if (activeElement && isInsidePassthrough(activeElement)) return
|
|
371
|
+
if (activeElement && shouldPassthroughArrowKeys(activeElement, ev.key)) {
|
|
372
|
+
return
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
switch (ev.key) {
|
|
376
|
+
case 'ArrowUp':
|
|
377
|
+
ev.preventDefault()
|
|
378
|
+
this.moveFocus('up')
|
|
379
|
+
break
|
|
380
|
+
case 'ArrowDown':
|
|
381
|
+
ev.preventDefault()
|
|
382
|
+
this.moveFocus('down')
|
|
383
|
+
break
|
|
384
|
+
case 'ArrowLeft':
|
|
385
|
+
ev.preventDefault()
|
|
386
|
+
this.moveFocus('left')
|
|
387
|
+
break
|
|
388
|
+
case 'ArrowRight':
|
|
389
|
+
ev.preventDefault()
|
|
390
|
+
this.moveFocus('right')
|
|
391
|
+
break
|
|
392
|
+
case 'Enter':
|
|
393
|
+
if (activeElement && isTextInput(activeElement)) break
|
|
394
|
+
ev.preventDefault()
|
|
395
|
+
this.activateFocused()
|
|
396
|
+
break
|
|
397
|
+
case 'Backspace':
|
|
398
|
+
if (this.backspaceGoesBack && !(activeElement && isTextInput(activeElement))) {
|
|
399
|
+
ev.preventDefault()
|
|
400
|
+
history.back()
|
|
401
|
+
}
|
|
402
|
+
break
|
|
403
|
+
case 'Escape':
|
|
404
|
+
if (activeElement && activeElement !== document.body && shouldEscapeBlurElement(activeElement)) {
|
|
405
|
+
ev.preventDefault()
|
|
406
|
+
;(activeElement as HTMLElement).blur()
|
|
407
|
+
break
|
|
408
|
+
}
|
|
409
|
+
if (this.escapeGoesToParentSection) {
|
|
410
|
+
this.moveToParentSection()
|
|
411
|
+
}
|
|
412
|
+
break
|
|
413
|
+
default:
|
|
414
|
+
break
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
private focusFirstElement(): void {
|
|
419
|
+
const trap = this.activeTrap
|
|
420
|
+
if (trap) {
|
|
421
|
+
const trapSection = document.querySelector(`[data-nav-section="${escapeCssString(trap)}"]`)
|
|
422
|
+
if (trapSection) {
|
|
423
|
+
const firstFocusable = trapSection.querySelector(this.focusableSelector)
|
|
424
|
+
if (firstFocusable) {
|
|
425
|
+
;(firstFocusable as HTMLElement).focus()
|
|
426
|
+
this.activeSection.setValue(trap)
|
|
427
|
+
return
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const sections = document.querySelectorAll('[data-nav-section]')
|
|
433
|
+
if (sections.length > 0) {
|
|
434
|
+
const firstFocusable = sections[0].querySelector(this.focusableSelector)
|
|
435
|
+
if (firstFocusable) {
|
|
436
|
+
;(firstFocusable as HTMLElement).focus()
|
|
437
|
+
this.activeSection.setValue(sections[0].getAttribute('data-nav-section'))
|
|
438
|
+
return
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const firstFocusable = document.querySelector(this.focusableSelector)
|
|
443
|
+
if (firstFocusable) {
|
|
444
|
+
;(firstFocusable as HTMLElement).focus()
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
private findContainingSection(element: Element): Element | null {
|
|
449
|
+
return element.closest('[data-nav-section]')
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
private isVisibleInScrollContainers(el: Element, rect: DOMRect): boolean {
|
|
453
|
+
const centerX = rect.left + rect.width / 2
|
|
454
|
+
const centerY = rect.top + rect.height / 2
|
|
455
|
+
const hasOverflow = (val: string) => val !== '' && val !== 'visible'
|
|
456
|
+
let ancestor = el.parentElement
|
|
457
|
+
while (ancestor) {
|
|
458
|
+
const style = getComputedStyle(ancestor)
|
|
459
|
+
if (hasOverflow(style.overflow) || hasOverflow(style.overflowX) || hasOverflow(style.overflowY)) {
|
|
460
|
+
const containerRect = ancestor.getBoundingClientRect()
|
|
461
|
+
if (
|
|
462
|
+
centerX < containerRect.left ||
|
|
463
|
+
centerX > containerRect.right ||
|
|
464
|
+
centerY < containerRect.top ||
|
|
465
|
+
centerY > containerRect.bottom
|
|
466
|
+
) {
|
|
467
|
+
return false
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
ancestor = ancestor.parentElement
|
|
471
|
+
}
|
|
472
|
+
return true
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
private getFocusableCandidates(
|
|
476
|
+
root: Element | Document,
|
|
477
|
+
exclude: Element,
|
|
478
|
+
options?: { skipScrollVisibility?: boolean },
|
|
479
|
+
): Element[] {
|
|
480
|
+
return Array.from(root.querySelectorAll(this.focusableSelector)).filter((el) => {
|
|
481
|
+
if (el === exclude) return false
|
|
482
|
+
if (!el.hasAttribute('data-spatial-nav-target') && el.closest('[data-spatial-nav-target]')) return false
|
|
483
|
+
const rect = el.getBoundingClientRect()
|
|
484
|
+
if (rect.width <= 0 || rect.height <= 0) return false
|
|
485
|
+
if (options?.skipScrollVisibility) return true
|
|
486
|
+
return this.isVisibleInScrollContainers(el, rect)
|
|
487
|
+
})
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
private findNearestInDirection(
|
|
491
|
+
currentRect: DOMRect,
|
|
492
|
+
candidates: Element[],
|
|
493
|
+
direction: SpatialDirection,
|
|
494
|
+
): HTMLElement | null {
|
|
495
|
+
const currentCenter = getElementCenter(currentRect)
|
|
496
|
+
let nearest: HTMLElement | null = null
|
|
497
|
+
let nearestDistance = Infinity
|
|
498
|
+
|
|
499
|
+
for (const candidate of candidates) {
|
|
500
|
+
const candidateRect = candidate.getBoundingClientRect()
|
|
501
|
+
if (!isInDirection(currentRect, candidateRect, direction)) continue
|
|
502
|
+
|
|
503
|
+
const candidateCenter = getElementCenter(candidateRect)
|
|
504
|
+
const distance = spatialDistance(currentCenter, candidateCenter, direction)
|
|
505
|
+
if (distance < nearestDistance) {
|
|
506
|
+
nearestDistance = distance
|
|
507
|
+
nearest = candidate as HTMLElement
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
return nearest
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
private navigateCrossSection(activeElement: Element, currentSection: Element, direction: SpatialDirection): void {
|
|
515
|
+
const currentSectionName = currentSection.getAttribute('data-nav-section')
|
|
516
|
+
|
|
517
|
+
const allFocusable = Array.from(document.querySelectorAll(this.focusableSelector)).filter((el) => {
|
|
518
|
+
if (el === activeElement) return false
|
|
519
|
+
if (currentSection.contains(el)) return false
|
|
520
|
+
if (!el.hasAttribute('data-spatial-nav-target') && el.closest('[data-spatial-nav-target]')) return false
|
|
521
|
+
const rect = el.getBoundingClientRect()
|
|
522
|
+
return rect.width > 0 && rect.height > 0 && this.isVisibleInScrollContainers(el, rect)
|
|
523
|
+
})
|
|
524
|
+
|
|
525
|
+
const currentRect = activeElement.getBoundingClientRect()
|
|
526
|
+
const nearest = this.findNearestInDirection(currentRect, allFocusable, direction)
|
|
527
|
+
|
|
528
|
+
if (nearest) {
|
|
529
|
+
this.storeFocusMemory(currentSectionName, activeElement)
|
|
530
|
+
|
|
531
|
+
const targetSection = this.findContainingSection(nearest)
|
|
532
|
+
const targetSectionName = targetSection?.getAttribute('data-nav-section') ?? null
|
|
533
|
+
|
|
534
|
+
const remembered = targetSectionName ? this.focusMemory.get(targetSectionName)?.deref() : null
|
|
535
|
+
if (
|
|
536
|
+
remembered &&
|
|
537
|
+
remembered !== activeElement &&
|
|
538
|
+
targetSection?.contains(remembered) &&
|
|
539
|
+
remembered.matches(this.focusableSelector)
|
|
540
|
+
) {
|
|
541
|
+
const rememberedRect = remembered.getBoundingClientRect()
|
|
542
|
+
if (
|
|
543
|
+
rememberedRect.width > 0 &&
|
|
544
|
+
rememberedRect.height > 0 &&
|
|
545
|
+
this.isVisibleInScrollContainers(remembered, rememberedRect)
|
|
546
|
+
) {
|
|
547
|
+
;(remembered as HTMLElement).focus()
|
|
548
|
+
remembered.scrollIntoView({ block: 'nearest', inline: 'nearest' })
|
|
549
|
+
this.activeSection.setValue(targetSectionName)
|
|
550
|
+
return
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
nearest.focus()
|
|
555
|
+
nearest.scrollIntoView({ block: 'nearest', inline: 'nearest' })
|
|
556
|
+
this.activeSection.setValue(targetSectionName)
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
private storeFocusMemory(sectionName: string | null, element: Element): void {
|
|
561
|
+
if (sectionName) {
|
|
562
|
+
this.focusMemory.set(sectionName, new WeakRef(element))
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
private moveToParentSection(): void {
|
|
567
|
+
const { activeElement } = document
|
|
568
|
+
if (!activeElement || activeElement === document.body) return
|
|
569
|
+
|
|
570
|
+
const currentSection = this.findContainingSection(activeElement)
|
|
571
|
+
if (!currentSection) return
|
|
572
|
+
|
|
573
|
+
const parentSection = currentSection.parentElement?.closest('[data-nav-section]')
|
|
574
|
+
if (!parentSection) return
|
|
575
|
+
|
|
576
|
+
const firstFocusable = parentSection.querySelector(this.focusableSelector)
|
|
577
|
+
if (firstFocusable) {
|
|
578
|
+
;(firstFocusable as HTMLElement).focus()
|
|
579
|
+
this.activeSection.setValue(parentSection.getAttribute('data-nav-section'))
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* Configures spatial navigation options before the service is first instantiated.
|
|
586
|
+
* Must be called **before** `SpatialNavigationService` is first resolved from the injector.
|
|
587
|
+
* @param injector The root injector
|
|
588
|
+
* @param options Configuration options for spatial navigation
|
|
589
|
+
*/
|
|
590
|
+
export const configureSpatialNavigation = (injector: Injector, options: SpatialNavigationOptions): void => {
|
|
591
|
+
if (injector.cachedSingletons.has(SpatialNavigationService)) {
|
|
592
|
+
throw new Error('configureSpatialNavigation must be called before the SpatialNavigationService is instantiated')
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
const service = new SpatialNavigationService(options)
|
|
596
|
+
injector.setExplicitInstance(service, SpatialNavigationService)
|
|
597
|
+
}
|