@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.
@@ -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
+ }