@furystack/shades 14.0.0 → 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.
- package/CHANGELOG.md +13 -0
- package/esm/component-factory.spec.d.ts.map +1 -1
- package/esm/component-factory.spec.js +2 -2
- package/esm/component-factory.spec.js.map +1 -1
- package/esm/components/nested-hooks.js +2 -2
- package/esm/components/nested-hooks.js.map +1 -1
- package/esm/components/nested-hooks.spec.js +14 -14
- package/esm/components/nested-hooks.spec.js.map +1 -1
- package/esm/components/nested-navigate.js +1 -1
- package/esm/components/nested-navigate.js.map +1 -1
- package/esm/components/nested-navigate.spec.js +16 -16
- package/esm/components/nested-navigate.spec.js.map +1 -1
- package/esm/components/nested-replace.js +1 -1
- package/esm/components/nested-replace.js.map +1 -1
- package/esm/components/nested-replace.spec.js +6 -6
- package/esm/components/nested-replace.spec.js.map +1 -1
- package/esm/components/nested-route-link.js +1 -1
- package/esm/components/nested-route-link.js.map +1 -1
- package/esm/components/nested-route-link.spec.js +8 -8
- package/esm/components/nested-route-link.spec.js.map +1 -1
- package/esm/components/nested-router.js +3 -3
- package/esm/components/nested-router.js.map +1 -1
- package/esm/components/nested-router.spec.js +24 -24
- package/esm/components/nested-router.spec.js.map +1 -1
- package/esm/services/location-service.d.ts +39 -42
- package/esm/services/location-service.d.ts.map +1 -1
- package/esm/services/location-service.js +115 -134
- package/esm/services/location-service.js.map +1 -1
- package/esm/services/location-service.spec.js +32 -30
- package/esm/services/location-service.spec.js.map +1 -1
- package/esm/services/route-match-service.d.ts +3 -2
- package/esm/services/route-match-service.d.ts.map +1 -1
- package/esm/services/route-match-service.js +13 -22
- package/esm/services/route-match-service.js.map +1 -1
- package/esm/services/route-match-service.spec.js +40 -51
- package/esm/services/route-match-service.spec.js.map +1 -1
- package/esm/services/route-meta-utils.spec.js +3 -3
- package/esm/services/route-meta-utils.spec.js.map +1 -1
- package/esm/services/screen-service.d.ts +13 -72
- package/esm/services/screen-service.d.ts.map +1 -1
- package/esm/services/screen-service.js +45 -137
- package/esm/services/screen-service.js.map +1 -1
- package/esm/services/screen-service.spec.js +19 -18
- package/esm/services/screen-service.spec.js.map +1 -1
- package/esm/services/spatial-navigation-service.d.ts +17 -39
- package/esm/services/spatial-navigation-service.d.ts.map +1 -1
- package/esm/services/spatial-navigation-service.js +280 -313
- package/esm/services/spatial-navigation-service.js.map +1 -1
- package/esm/services/spatial-navigation-service.spec.js +163 -158
- package/esm/services/spatial-navigation-service.spec.js.map +1 -1
- package/esm/shade.d.ts +1 -1
- package/esm/shade.d.ts.map +1 -1
- package/esm/shade.js +10 -7
- package/esm/shade.js.map +1 -1
- package/package.json +5 -4
- package/src/component-factory.spec.tsx +3 -2
- package/src/components/nested-hooks.spec.ts +14 -14
- package/src/components/nested-hooks.ts +2 -2
- package/src/components/nested-navigate.spec.ts +17 -16
- package/src/components/nested-navigate.ts +1 -1
- package/src/components/nested-replace.spec.ts +7 -6
- package/src/components/nested-replace.ts +1 -1
- package/src/components/nested-route-link.spec.tsx +8 -8
- package/src/components/nested-route-link.tsx +1 -1
- package/src/components/nested-router.spec.tsx +24 -24
- package/src/components/nested-router.tsx +3 -3
- package/src/services/location-service.spec.ts +35 -31
- package/src/services/location-service.tsx +156 -122
- package/src/services/route-match-service.spec.ts +41 -32
- package/src/services/route-match-service.ts +17 -8
- package/src/services/route-meta-utils.spec.ts +3 -3
- package/src/services/screen-service.spec.ts +19 -18
- package/src/services/screen-service.ts +65 -110
- package/src/services/spatial-navigation-service.spec.ts +165 -160
- package/src/services/spatial-navigation-service.ts +299 -287
- package/src/shade.ts +11 -8
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
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.
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
/**
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
280
|
-
}
|
|
291
|
+
const getActiveTrap = (): string | null => focusTrapStack[focusTrapStack.length - 1] ?? null
|
|
281
292
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
|
|
314
|
-
|
|
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
|
-
|
|
318
|
-
|
|
319
|
-
const
|
|
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
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
|
327
|
-
|
|
328
|
-
|
|
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
|
-
|
|
331
|
-
|
|
368
|
+
return nearest
|
|
369
|
+
}
|
|
332
370
|
|
|
333
|
-
const
|
|
334
|
-
|
|
371
|
+
const storeFocusMemory = (sectionName: string | null, element: Element): void => {
|
|
372
|
+
if (sectionName) {
|
|
373
|
+
focusMemory.set(sectionName, new WeakRef(element))
|
|
374
|
+
}
|
|
375
|
+
}
|
|
335
376
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
|
|
344
|
-
|
|
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
|
-
|
|
353
|
-
|
|
354
|
-
}
|
|
355
|
-
}
|
|
395
|
+
if (nearest) {
|
|
396
|
+
storeFocusMemory(currentSectionName, activeElement)
|
|
356
397
|
|
|
357
|
-
|
|
358
|
-
|
|
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
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
421
|
+
nearest.focus()
|
|
422
|
+
nearest.scrollIntoView({ block: 'nearest', inline: 'nearest' })
|
|
423
|
+
activeSection.setValue(targetSectionName)
|
|
424
|
+
}
|
|
373
425
|
}
|
|
374
426
|
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
-
|
|
410
|
-
this.moveToParentSection()
|
|
411
|
-
}
|
|
412
|
-
break
|
|
413
|
-
default:
|
|
414
|
-
break
|
|
415
|
-
}
|
|
416
|
-
}
|
|
439
|
+
}
|
|
417
440
|
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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
|
-
|
|
446
|
+
activeSection.setValue(sections[0].getAttribute('data-nav-section'))
|
|
427
447
|
return
|
|
428
448
|
}
|
|
429
449
|
}
|
|
430
|
-
}
|
|
431
450
|
|
|
432
|
-
|
|
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
|
|
443
|
-
|
|
444
|
-
;(firstFocusable as HTMLElement).focus()
|
|
445
|
-
}
|
|
446
|
-
}
|
|
457
|
+
const moveFocus = (direction: SpatialDirection): void => {
|
|
458
|
+
const activeElement = document.activeElement as HTMLElement | null
|
|
447
459
|
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
460
|
+
if (!activeElement || activeElement === document.body) {
|
|
461
|
+
focusFirstElement()
|
|
462
|
+
return
|
|
463
|
+
}
|
|
451
464
|
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
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
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
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
|
-
|
|
491
|
-
|
|
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
|
-
|
|
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
|
-
|
|
515
|
-
|
|
503
|
+
const moveToParentSection = (): void => {
|
|
504
|
+
const { activeElement } = document
|
|
505
|
+
if (!activeElement || activeElement === document.body) return
|
|
516
506
|
|
|
517
|
-
|
|
518
|
-
if (
|
|
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
|
-
|
|
526
|
-
|
|
510
|
+
const parentSection = currentSection.parentElement?.closest('[data-nav-section]')
|
|
511
|
+
if (!parentSection) return
|
|
527
512
|
|
|
528
|
-
|
|
529
|
-
|
|
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
|
-
|
|
532
|
-
|
|
520
|
+
const handleKeyDown = (ev: KeyboardEvent): void => {
|
|
521
|
+
if (!enabled.getValue()) return
|
|
522
|
+
if (ev.defaultPrevented) return
|
|
533
523
|
|
|
534
|
-
const
|
|
535
|
-
if (
|
|
536
|
-
|
|
537
|
-
|
|
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
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
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
|
-
|
|
571
|
-
if (!currentSection) return
|
|
573
|
+
window.addEventListener('keydown', handleKeyDown)
|
|
572
574
|
|
|
573
|
-
|
|
574
|
-
|
|
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
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
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
|
-
*
|
|
587
|
-
* @
|
|
588
|
-
*
|
|
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
|
-
|
|
592
|
-
|
|
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
|
}
|