@furystack/shades 13.2.2 → 15.0.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.
Files changed (146) hide show
  1. package/CHANGELOG.md +157 -0
  2. package/esm/component-factory.spec.d.ts.map +1 -1
  3. package/esm/component-factory.spec.js +2 -2
  4. package/esm/component-factory.spec.js.map +1 -1
  5. package/esm/components/define-nested-routes.d.ts +27 -0
  6. package/esm/components/define-nested-routes.d.ts.map +1 -0
  7. package/esm/components/define-nested-routes.js +26 -0
  8. package/esm/components/define-nested-routes.js.map +1 -0
  9. package/esm/components/define-nested-routes.spec.d.ts +2 -0
  10. package/esm/components/define-nested-routes.spec.d.ts.map +1 -0
  11. package/esm/components/define-nested-routes.spec.js +81 -0
  12. package/esm/components/define-nested-routes.spec.js.map +1 -0
  13. package/esm/components/index.d.ts +3 -3
  14. package/esm/components/index.d.ts.map +1 -1
  15. package/esm/components/index.js +3 -3
  16. package/esm/components/index.js.map +1 -1
  17. package/esm/components/nested-hooks.d.ts +62 -0
  18. package/esm/components/nested-hooks.d.ts.map +1 -0
  19. package/esm/components/nested-hooks.js +73 -0
  20. package/esm/components/nested-hooks.js.map +1 -0
  21. package/esm/components/nested-hooks.spec.d.ts +2 -0
  22. package/esm/components/nested-hooks.spec.d.ts.map +1 -0
  23. package/esm/components/nested-hooks.spec.js +211 -0
  24. package/esm/components/nested-hooks.spec.js.map +1 -0
  25. package/esm/components/nested-navigate.d.ts +52 -21
  26. package/esm/components/nested-navigate.d.ts.map +1 -1
  27. package/esm/components/nested-navigate.js +32 -17
  28. package/esm/components/nested-navigate.js.map +1 -1
  29. package/esm/components/nested-navigate.spec.js +109 -19
  30. package/esm/components/nested-navigate.spec.js.map +1 -1
  31. package/esm/components/nested-replace.d.ts +36 -0
  32. package/esm/components/nested-replace.d.ts.map +1 -0
  33. package/esm/components/nested-replace.js +38 -0
  34. package/esm/components/nested-replace.js.map +1 -0
  35. package/esm/components/nested-replace.spec.d.ts +2 -0
  36. package/esm/components/nested-replace.spec.d.ts.map +1 -0
  37. package/esm/components/nested-replace.spec.js +59 -0
  38. package/esm/components/nested-replace.spec.js.map +1 -0
  39. package/esm/components/nested-route-link.d.ts +36 -26
  40. package/esm/components/nested-route-link.d.ts.map +1 -1
  41. package/esm/components/nested-route-link.js +18 -20
  42. package/esm/components/nested-route-link.js.map +1 -1
  43. package/esm/components/nested-route-link.spec.js +67 -17
  44. package/esm/components/nested-route-link.spec.js.map +1 -1
  45. package/esm/components/nested-route-types.d.ts +79 -2
  46. package/esm/components/nested-route-types.d.ts.map +1 -1
  47. package/esm/components/nested-router.d.ts +77 -7
  48. package/esm/components/nested-router.d.ts.map +1 -1
  49. package/esm/components/nested-router.js +139 -13
  50. package/esm/components/nested-router.js.map +1 -1
  51. package/esm/components/nested-router.spec.js +283 -52
  52. package/esm/components/nested-router.spec.js.map +1 -1
  53. package/esm/services/location-service.d.ts +39 -42
  54. package/esm/services/location-service.d.ts.map +1 -1
  55. package/esm/services/location-service.js +115 -134
  56. package/esm/services/location-service.js.map +1 -1
  57. package/esm/services/location-service.spec.js +59 -24
  58. package/esm/services/location-service.spec.js.map +1 -1
  59. package/esm/services/route-match-service.d.ts +3 -2
  60. package/esm/services/route-match-service.d.ts.map +1 -1
  61. package/esm/services/route-match-service.js +13 -22
  62. package/esm/services/route-match-service.js.map +1 -1
  63. package/esm/services/route-match-service.spec.js +42 -49
  64. package/esm/services/route-match-service.spec.js.map +1 -1
  65. package/esm/services/route-meta-utils.d.ts +28 -6
  66. package/esm/services/route-meta-utils.d.ts.map +1 -1
  67. package/esm/services/route-meta-utils.js +18 -4
  68. package/esm/services/route-meta-utils.js.map +1 -1
  69. package/esm/services/route-meta-utils.spec.js +60 -5
  70. package/esm/services/route-meta-utils.spec.js.map +1 -1
  71. package/esm/services/screen-service.d.ts +13 -72
  72. package/esm/services/screen-service.d.ts.map +1 -1
  73. package/esm/services/screen-service.js +45 -137
  74. package/esm/services/screen-service.js.map +1 -1
  75. package/esm/services/screen-service.spec.js +19 -18
  76. package/esm/services/screen-service.spec.js.map +1 -1
  77. package/esm/services/spatial-navigation-service.d.ts +17 -39
  78. package/esm/services/spatial-navigation-service.d.ts.map +1 -1
  79. package/esm/services/spatial-navigation-service.js +280 -313
  80. package/esm/services/spatial-navigation-service.js.map +1 -1
  81. package/esm/services/spatial-navigation-service.spec.js +163 -158
  82. package/esm/services/spatial-navigation-service.spec.js.map +1 -1
  83. package/esm/shade.d.ts +1 -1
  84. package/esm/shade.d.ts.map +1 -1
  85. package/esm/shade.js +10 -7
  86. package/esm/shade.js.map +1 -1
  87. package/esm/vnode.d.ts +1 -1
  88. package/esm/vnode.js +1 -1
  89. package/package.json +5 -4
  90. package/src/component-factory.spec.tsx +3 -2
  91. package/src/components/define-nested-routes.spec.ts +91 -0
  92. package/src/components/define-nested-routes.ts +27 -0
  93. package/src/components/index.ts +3 -3
  94. package/src/components/nested-hooks.spec.ts +254 -0
  95. package/src/components/nested-hooks.ts +109 -0
  96. package/src/components/nested-navigate.spec.ts +136 -32
  97. package/src/components/nested-navigate.ts +75 -26
  98. package/src/components/nested-replace.spec.ts +89 -0
  99. package/src/components/nested-replace.ts +46 -0
  100. package/src/components/nested-route-link.spec.tsx +90 -22
  101. package/src/components/nested-route-link.tsx +54 -29
  102. package/src/components/nested-route-types.ts +82 -2
  103. package/src/components/nested-router.spec.tsx +334 -51
  104. package/src/components/nested-router.tsx +184 -24
  105. package/src/services/location-service.spec.ts +68 -25
  106. package/src/services/location-service.tsx +156 -122
  107. package/src/services/route-match-service.spec.ts +41 -28
  108. package/src/services/route-match-service.ts +17 -8
  109. package/src/services/route-meta-utils.spec.ts +78 -10
  110. package/src/services/route-meta-utils.ts +44 -15
  111. package/src/services/screen-service.spec.ts +19 -18
  112. package/src/services/screen-service.ts +65 -110
  113. package/src/services/spatial-navigation-service.spec.ts +165 -160
  114. package/src/services/spatial-navigation-service.ts +299 -287
  115. package/src/shade.ts +11 -8
  116. package/src/vnode.ts +1 -1
  117. package/esm/components/link-to-route.d.ts +0 -10
  118. package/esm/components/link-to-route.d.ts.map +0 -1
  119. package/esm/components/link-to-route.js +0 -16
  120. package/esm/components/link-to-route.js.map +0 -1
  121. package/esm/components/link-to-route.spec.d.ts +0 -2
  122. package/esm/components/link-to-route.spec.d.ts.map +0 -1
  123. package/esm/components/link-to-route.spec.js +0 -30
  124. package/esm/components/link-to-route.spec.js.map +0 -1
  125. package/esm/components/route-link.d.ts +0 -10
  126. package/esm/components/route-link.d.ts.map +0 -1
  127. package/esm/components/route-link.js +0 -25
  128. package/esm/components/route-link.js.map +0 -1
  129. package/esm/components/route-link.spec.d.ts +0 -2
  130. package/esm/components/route-link.spec.d.ts.map +0 -1
  131. package/esm/components/route-link.spec.js +0 -34
  132. package/esm/components/route-link.spec.js.map +0 -1
  133. package/esm/components/router.d.ts +0 -44
  134. package/esm/components/router.d.ts.map +0 -1
  135. package/esm/components/router.js +0 -66
  136. package/esm/components/router.js.map +0 -1
  137. package/esm/components/router.spec.d.ts +0 -2
  138. package/esm/components/router.spec.d.ts.map +0 -1
  139. package/esm/components/router.spec.js +0 -161
  140. package/esm/components/router.spec.js.map +0 -1
  141. package/src/components/link-to-route.spec.tsx +0 -45
  142. package/src/components/link-to-route.tsx +0 -26
  143. package/src/components/route-link.spec.tsx +0 -44
  144. package/src/components/route-link.tsx +0 -29
  145. package/src/components/router.spec.tsx +0 -217
  146. package/src/components/router.tsx +0 -103
@@ -1,4 +1,5 @@
1
- import { Injectable, type Injector } from '@furystack/inject'
1
+ import type { Injector, Token } from '@furystack/inject'
2
+ import { defineService } from '@furystack/inject'
2
3
  import { ObservableValue } from '@furystack/utils'
3
4
 
4
5
  /**
@@ -232,6 +233,16 @@ const shouldPassthroughArrowKeys = (element: Element, key: string): boolean => {
232
233
  return false
233
234
  }
234
235
 
236
+ /**
237
+ * Configuration token for {@link SpatialNavigationService}. Override via
238
+ * {@link configureSpatialNavigation} before the service is first resolved.
239
+ */
240
+ export const SpatialNavigationSettings: Token<SpatialNavigationOptions, 'singleton'> = defineService({
241
+ name: '@furystack/shades/SpatialNavigationSettings',
242
+ lifetime: 'singleton',
243
+ factory: () => ({}),
244
+ })
245
+
235
246
  /**
236
247
  * Service for D-pad / arrow-key spatial navigation across interactive elements.
237
248
  *
@@ -242,356 +253,357 @@ const shouldPassthroughArrowKeys = (element: Element, key: string): boolean => {
242
253
  * @example
243
254
  * ```typescript
244
255
  * // Opt in to spatial navigation
245
- * const spatialNav = injector.getInstance(SpatialNavigationService)
256
+ * const spatialNav = injector.get(SpatialNavigationService)
246
257
  *
247
258
  * // Disable during video playback
248
259
  * spatialNav.enabled.setValue(false)
249
- *
250
- * // Re-enable
251
- * spatialNav.enabled.setValue(true)
252
260
  * ```
253
261
  */
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>>()
262
+ export interface SpatialNavigationService {
263
+ readonly enabled: ObservableValue<boolean>
264
+ readonly activeSection: ObservableValue<string | null>
265
+ /** Push a focus trap onto the stack. Nesting is supported — only the topmost trap is enforced. */
266
+ pushFocusTrap(sectionName: string): void
267
+ /** Remove a focus trap from the stack. */
268
+ popFocusTrap(sectionName: string, previousSection?: string | null): void
269
+ /** Programmatically move focus in a direction */
270
+ moveFocus(direction: SpatialDirection): void
271
+ /** Programmatically activate (click) the currently focused element */
272
+ activateFocused(): void
273
+ }
264
274
 
265
- private readonly focusTrapStack: string[] = []
275
+ export const SpatialNavigationService: Token<SpatialNavigationService, 'singleton'> = defineService({
276
+ name: '@furystack/shades/SpatialNavigationService',
277
+ lifetime: 'singleton',
278
+ factory: ({ inject, onDispose }) => {
279
+ const options = inject(SpatialNavigationSettings)
266
280
 
267
- private readonly focusableSelector: string
268
- private readonly crossSectionNavigation: boolean
269
- private readonly backspaceGoesBack: boolean
270
- private readonly escapeGoesToParentSection: boolean
281
+ const enabled = new ObservableValue<boolean>(options.initiallyEnabled ?? true)
282
+ const activeSection = new ObservableValue<string | null>(null)
283
+ const focusMemory = new Map<string, WeakRef<Element>>()
284
+ const focusTrapStack: string[] = []
271
285
 
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
286
+ const focusableSelector = options.focusableSelector ?? DEFAULT_FOCUSABLE_SELECTOR
287
+ const crossSectionNavigation = options.crossSectionNavigation ?? true
288
+ const backspaceGoesBack = options.backspaceGoesBack ?? false
289
+ const escapeGoesToParentSection = options.escapeGoesToParentSection ?? false
278
290
 
279
- window.addEventListener('keydown', this.handleKeyDown)
280
- }
291
+ const getActiveTrap = (): string | null => focusTrapStack[focusTrapStack.length - 1] ?? null
281
292
 
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)
293
+ const pushFocusTrap = (sectionName: string): void => {
294
+ focusTrapStack.push(sectionName)
295
+ activeSection.setValue(sectionName)
308
296
  }
309
- const top = this.focusTrapStack[this.focusTrapStack.length - 1]
310
- this.activeSection.setValue(top ?? previousSection ?? null)
311
- }
312
297
 
313
- private get activeTrap(): string | null {
314
- return this.focusTrapStack[this.focusTrapStack.length - 1] ?? null
315
- }
298
+ const popFocusTrap = (sectionName: string, previousSection?: string | null): void => {
299
+ const idx = focusTrapStack.lastIndexOf(sectionName)
300
+ if (idx !== -1) {
301
+ focusTrapStack.splice(idx, 1)
302
+ }
303
+ const top = focusTrapStack[focusTrapStack.length - 1]
304
+ activeSection.setValue(top ?? previousSection ?? null)
305
+ }
316
306
 
317
- /** Programmatically move focus in a direction */
318
- public moveFocus(direction: SpatialDirection): void {
319
- const activeElement = document.activeElement as HTMLElement | null
307
+ const findContainingSection = (element: Element): Element | null => element.closest('[data-nav-section]')
308
+
309
+ const isVisibleInScrollContainers = (el: Element, rect: DOMRect): boolean => {
310
+ const centerX = rect.left + rect.width / 2
311
+ const centerY = rect.top + rect.height / 2
312
+ const hasOverflow = (val: string) => val !== '' && val !== 'visible'
313
+ let ancestor = el.parentElement
314
+ while (ancestor) {
315
+ const style = getComputedStyle(ancestor)
316
+ if (hasOverflow(style.overflow) || hasOverflow(style.overflowX) || hasOverflow(style.overflowY)) {
317
+ const containerRect = ancestor.getBoundingClientRect()
318
+ if (
319
+ centerX < containerRect.left ||
320
+ centerX > containerRect.right ||
321
+ centerY < containerRect.top ||
322
+ centerY > containerRect.bottom
323
+ ) {
324
+ return false
325
+ }
326
+ }
327
+ ancestor = ancestor.parentElement
328
+ }
329
+ return true
330
+ }
320
331
 
321
- if (!activeElement || activeElement === document.body) {
322
- this.focusFirstElement()
323
- return
332
+ const getFocusableCandidates = (
333
+ root: Element | Document,
334
+ exclude: Element,
335
+ candidateOptions?: { skipScrollVisibility?: boolean },
336
+ ): Element[] => {
337
+ return Array.from(root.querySelectorAll(focusableSelector)).filter((el) => {
338
+ if (el === exclude) return false
339
+ if (!el.hasAttribute('data-spatial-nav-target') && el.closest('[data-spatial-nav-target]')) return false
340
+ const rect = el.getBoundingClientRect()
341
+ if (rect.width <= 0 || rect.height <= 0) return false
342
+ if (candidateOptions?.skipScrollVisibility) return true
343
+ return isVisibleInScrollContainers(el, rect)
344
+ })
324
345
  }
325
346
 
326
- const currentSection = this.findContainingSection(activeElement)
327
- const currentSectionName = currentSection?.getAttribute('data-nav-section') ?? null
328
- this.activeSection.setValue(currentSectionName)
347
+ const findNearestInDirection = (
348
+ currentRect: DOMRect,
349
+ candidates: Element[],
350
+ direction: SpatialDirection,
351
+ ): HTMLElement | null => {
352
+ const currentCenter = getElementCenter(currentRect)
353
+ let nearest: HTMLElement | null = null
354
+ let nearestDistance = Infinity
355
+
356
+ for (const candidate of candidates) {
357
+ const candidateRect = candidate.getBoundingClientRect()
358
+ if (!isInDirection(currentRect, candidateRect, direction)) continue
359
+
360
+ const candidateCenter = getElementCenter(candidateRect)
361
+ const distance = spatialDistance(currentCenter, candidateCenter, direction)
362
+ if (distance < nearestDistance) {
363
+ nearestDistance = distance
364
+ nearest = candidate as HTMLElement
365
+ }
366
+ }
329
367
 
330
- const searchRoot = currentSection ?? document
331
- const candidates = this.getFocusableCandidates(searchRoot, activeElement)
368
+ return nearest
369
+ }
332
370
 
333
- const currentRect = activeElement.getBoundingClientRect()
334
- let target = this.findNearestInDirection(currentRect, candidates, direction)
371
+ const storeFocusMemory = (sectionName: string | null, element: Element): void => {
372
+ if (sectionName) {
373
+ focusMemory.set(sectionName, new WeakRef(element))
374
+ }
375
+ }
335
376
 
336
- if (!target) {
337
- const relaxedCandidates = this.getFocusableCandidates(searchRoot, activeElement, {
338
- skipScrollVisibility: true,
377
+ const navigateCrossSection = (
378
+ activeElement: Element,
379
+ currentSection: Element,
380
+ direction: SpatialDirection,
381
+ ): void => {
382
+ const currentSectionName = currentSection.getAttribute('data-nav-section')
383
+
384
+ const allFocusable = Array.from(document.querySelectorAll(focusableSelector)).filter((el) => {
385
+ if (el === activeElement) return false
386
+ if (currentSection.contains(el)) return false
387
+ if (!el.hasAttribute('data-spatial-nav-target') && el.closest('[data-spatial-nav-target]')) return false
388
+ const rect = el.getBoundingClientRect()
389
+ return rect.width > 0 && rect.height > 0 && isVisibleInScrollContainers(el, rect)
339
390
  })
340
- target = this.findNearestInDirection(currentRect, relaxedCandidates, direction)
341
- }
342
391
 
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
- }
392
+ const currentRect = activeElement.getBoundingClientRect()
393
+ const nearest = findNearestInDirection(currentRect, allFocusable, direction)
351
394
 
352
- if (this.crossSectionNavigation && currentSection && !this.activeTrap) {
353
- this.navigateCrossSection(activeElement, currentSection, direction)
354
- }
355
- }
395
+ if (nearest) {
396
+ storeFocusMemory(currentSectionName, activeElement)
356
397
 
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
- }
398
+ const targetSection = findContainingSection(nearest)
399
+ const targetSectionName = targetSection?.getAttribute('data-nav-section') ?? null
364
400
 
365
- private handleKeyDown = (ev: KeyboardEvent): void => {
366
- if (!this.enabled.getValue()) return
367
- if (ev.defaultPrevented) return
401
+ const remembered = targetSectionName ? focusMemory.get(targetSectionName)?.deref() : null
402
+ if (
403
+ remembered &&
404
+ remembered !== activeElement &&
405
+ targetSection?.contains(remembered) &&
406
+ remembered.matches(focusableSelector)
407
+ ) {
408
+ const rememberedRect = remembered.getBoundingClientRect()
409
+ if (
410
+ rememberedRect.width > 0 &&
411
+ rememberedRect.height > 0 &&
412
+ isVisibleInScrollContainers(remembered, rememberedRect)
413
+ ) {
414
+ ;(remembered as HTMLElement).focus()
415
+ remembered.scrollIntoView({ block: 'nearest', inline: 'nearest' })
416
+ activeSection.setValue(targetSectionName)
417
+ return
418
+ }
419
+ }
368
420
 
369
- const { activeElement } = document
370
- if (activeElement && isInsidePassthrough(activeElement)) return
371
- if (activeElement && shouldPassthroughArrowKeys(activeElement, ev.key)) {
372
- return
421
+ nearest.focus()
422
+ nearest.scrollIntoView({ block: 'nearest', inline: 'nearest' })
423
+ activeSection.setValue(targetSectionName)
424
+ }
373
425
  }
374
426
 
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
427
+ const focusFirstElement = (): void => {
428
+ const trap = getActiveTrap()
429
+ if (trap) {
430
+ const trapSection = document.querySelector(`[data-nav-section="${escapeCssString(trap)}"]`)
431
+ if (trapSection) {
432
+ const firstFocusable = trapSection.querySelector(focusableSelector)
433
+ if (firstFocusable) {
434
+ ;(firstFocusable as HTMLElement).focus()
435
+ activeSection.setValue(trap)
436
+ return
437
+ }
408
438
  }
409
- if (this.escapeGoesToParentSection) {
410
- this.moveToParentSection()
411
- }
412
- break
413
- default:
414
- break
415
- }
416
- }
439
+ }
417
440
 
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)
441
+ const sections = document.querySelectorAll('[data-nav-section]')
442
+ if (sections.length > 0) {
443
+ const firstFocusable = sections[0].querySelector(focusableSelector)
424
444
  if (firstFocusable) {
425
445
  ;(firstFocusable as HTMLElement).focus()
426
- this.activeSection.setValue(trap)
446
+ activeSection.setValue(sections[0].getAttribute('data-nav-section'))
427
447
  return
428
448
  }
429
449
  }
430
- }
431
450
 
432
- const sections = document.querySelectorAll('[data-nav-section]')
433
- if (sections.length > 0) {
434
- const firstFocusable = sections[0].querySelector(this.focusableSelector)
451
+ const firstFocusable = document.querySelector(focusableSelector)
435
452
  if (firstFocusable) {
436
453
  ;(firstFocusable as HTMLElement).focus()
437
- this.activeSection.setValue(sections[0].getAttribute('data-nav-section'))
438
- return
439
454
  }
440
455
  }
441
456
 
442
- const firstFocusable = document.querySelector(this.focusableSelector)
443
- if (firstFocusable) {
444
- ;(firstFocusable as HTMLElement).focus()
445
- }
446
- }
457
+ const moveFocus = (direction: SpatialDirection): void => {
458
+ const activeElement = document.activeElement as HTMLElement | null
447
459
 
448
- private findContainingSection(element: Element): Element | null {
449
- return element.closest('[data-nav-section]')
450
- }
460
+ if (!activeElement || activeElement === document.body) {
461
+ focusFirstElement()
462
+ return
463
+ }
451
464
 
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
- }
465
+ const currentSection = findContainingSection(activeElement)
466
+ const currentSectionName = currentSection?.getAttribute('data-nav-section') ?? null
467
+ activeSection.setValue(currentSectionName)
468
+
469
+ const searchRoot = currentSection ?? document
470
+ const candidates = getFocusableCandidates(searchRoot, activeElement)
471
+
472
+ const currentRect = activeElement.getBoundingClientRect()
473
+ let target = findNearestInDirection(currentRect, candidates, direction)
474
+
475
+ if (!target) {
476
+ const relaxedCandidates = getFocusableCandidates(searchRoot, activeElement, {
477
+ skipScrollVisibility: true,
478
+ })
479
+ target = findNearestInDirection(currentRect, relaxedCandidates, direction)
469
480
  }
470
- ancestor = ancestor.parentElement
471
- }
472
- return true
473
- }
474
481
 
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
- }
482
+ if (target) {
483
+ storeFocusMemory(currentSectionName, activeElement)
484
+ target.focus()
485
+ target.scrollIntoView({ block: 'nearest', inline: 'nearest' })
486
+ const targetSection = findContainingSection(target)
487
+ activeSection.setValue(targetSection?.getAttribute('data-nav-section') ?? null)
488
+ return
489
+ }
489
490
 
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
491
+ if (crossSectionNavigation && currentSection && !getActiveTrap()) {
492
+ navigateCrossSection(activeElement, currentSection, direction)
508
493
  }
509
494
  }
510
495
 
511
- return nearest
512
- }
496
+ const activateFocused = (): void => {
497
+ const { activeElement } = document
498
+ if (activeElement && activeElement !== document.body) {
499
+ ;(activeElement as HTMLElement).click()
500
+ }
501
+ }
513
502
 
514
- private navigateCrossSection(activeElement: Element, currentSection: Element, direction: SpatialDirection): void {
515
- const currentSectionName = currentSection.getAttribute('data-nav-section')
503
+ const moveToParentSection = (): void => {
504
+ const { activeElement } = document
505
+ if (!activeElement || activeElement === document.body) return
516
506
 
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
- })
507
+ const currentSection = findContainingSection(activeElement)
508
+ if (!currentSection) return
524
509
 
525
- const currentRect = activeElement.getBoundingClientRect()
526
- const nearest = this.findNearestInDirection(currentRect, allFocusable, direction)
510
+ const parentSection = currentSection.parentElement?.closest('[data-nav-section]')
511
+ if (!parentSection) return
527
512
 
528
- if (nearest) {
529
- this.storeFocusMemory(currentSectionName, activeElement)
513
+ const firstFocusable = parentSection.querySelector(focusableSelector)
514
+ if (firstFocusable) {
515
+ ;(firstFocusable as HTMLElement).focus()
516
+ activeSection.setValue(parentSection.getAttribute('data-nav-section'))
517
+ }
518
+ }
530
519
 
531
- const targetSection = this.findContainingSection(nearest)
532
- const targetSectionName = targetSection?.getAttribute('data-nav-section') ?? null
520
+ const handleKeyDown = (ev: KeyboardEvent): void => {
521
+ if (!enabled.getValue()) return
522
+ if (ev.defaultPrevented) return
533
523
 
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
- }
524
+ const { activeElement } = document
525
+ if (activeElement && isInsidePassthrough(activeElement)) return
526
+ if (activeElement && shouldPassthroughArrowKeys(activeElement, ev.key)) {
527
+ return
552
528
  }
553
529
 
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))
530
+ switch (ev.key) {
531
+ case 'ArrowUp':
532
+ ev.preventDefault()
533
+ moveFocus('up')
534
+ break
535
+ case 'ArrowDown':
536
+ ev.preventDefault()
537
+ moveFocus('down')
538
+ break
539
+ case 'ArrowLeft':
540
+ ev.preventDefault()
541
+ moveFocus('left')
542
+ break
543
+ case 'ArrowRight':
544
+ ev.preventDefault()
545
+ moveFocus('right')
546
+ break
547
+ case 'Enter':
548
+ if (activeElement && isTextInput(activeElement)) break
549
+ ev.preventDefault()
550
+ activateFocused()
551
+ break
552
+ case 'Backspace':
553
+ if (backspaceGoesBack && !(activeElement && isTextInput(activeElement))) {
554
+ ev.preventDefault()
555
+ history.back()
556
+ }
557
+ break
558
+ case 'Escape':
559
+ if (activeElement && activeElement !== document.body && shouldEscapeBlurElement(activeElement)) {
560
+ ev.preventDefault()
561
+ ;(activeElement as HTMLElement).blur()
562
+ break
563
+ }
564
+ if (escapeGoesToParentSection) {
565
+ moveToParentSection()
566
+ }
567
+ break
568
+ default:
569
+ break
570
+ }
563
571
  }
564
- }
565
-
566
- private moveToParentSection(): void {
567
- const { activeElement } = document
568
- if (!activeElement || activeElement === document.body) return
569
572
 
570
- const currentSection = this.findContainingSection(activeElement)
571
- if (!currentSection) return
573
+ window.addEventListener('keydown', handleKeyDown)
572
574
 
573
- const parentSection = currentSection.parentElement?.closest('[data-nav-section]')
574
- if (!parentSection) return
575
+ onDispose(() => {
576
+ window.removeEventListener('keydown', handleKeyDown)
577
+ // eslint-disable-next-line furystack/prefer-using-wrapper -- Disposal is deferred to the injector's onDispose hook.
578
+ enabled[Symbol.dispose]()
579
+ // eslint-disable-next-line furystack/prefer-using-wrapper -- Disposal is deferred to the injector's onDispose hook.
580
+ activeSection[Symbol.dispose]()
581
+ focusMemory.clear()
582
+ focusTrapStack.length = 0
583
+ })
575
584
 
576
- const firstFocusable = parentSection.querySelector(this.focusableSelector)
577
- if (firstFocusable) {
578
- ;(firstFocusable as HTMLElement).focus()
579
- this.activeSection.setValue(parentSection.getAttribute('data-nav-section'))
585
+ return {
586
+ enabled,
587
+ activeSection,
588
+ pushFocusTrap,
589
+ popFocusTrap,
590
+ moveFocus,
591
+ activateFocused,
580
592
  }
581
- }
582
- }
593
+ },
594
+ })
583
595
 
584
596
  /**
585
597
  * 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
598
+ * Rebinds {@link SpatialNavigationSettings} and invalidates the cached
599
+ * {@link SpatialNavigationService}. Must be called **before** the service is
600
+ * first resolved — calling afterwards drops the cached instance without
601
+ * disposing it (listeners leak until the injector is disposed).
602
+ * @param injector The root injector.
603
+ * @param options Configuration options for spatial navigation.
589
604
  */
590
605
  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)
606
+ injector.bind(SpatialNavigationSettings, () => options)
607
+ injector.invalidate(SpatialNavigationSettings)
608
+ injector.invalidate(SpatialNavigationService)
597
609
  }