@furystack/shades 14.0.0 → 15.0.1

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 (125) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/esm/compile-route.d.ts +5 -0
  3. package/esm/compile-route.d.ts.map +1 -1
  4. package/esm/compile-route.js +5 -0
  5. package/esm/compile-route.js.map +1 -1
  6. package/esm/component-factory.spec.d.ts.map +1 -1
  7. package/esm/component-factory.spec.js +2 -2
  8. package/esm/component-factory.spec.js.map +1 -1
  9. package/esm/components/nested-hooks.js +2 -2
  10. package/esm/components/nested-hooks.js.map +1 -1
  11. package/esm/components/nested-hooks.spec.js +14 -14
  12. package/esm/components/nested-hooks.spec.js.map +1 -1
  13. package/esm/components/nested-navigate.js +1 -1
  14. package/esm/components/nested-navigate.js.map +1 -1
  15. package/esm/components/nested-navigate.spec.js +16 -16
  16. package/esm/components/nested-navigate.spec.js.map +1 -1
  17. package/esm/components/nested-replace.js +1 -1
  18. package/esm/components/nested-replace.js.map +1 -1
  19. package/esm/components/nested-replace.spec.js +6 -6
  20. package/esm/components/nested-replace.spec.js.map +1 -1
  21. package/esm/components/nested-route-link.js +1 -1
  22. package/esm/components/nested-route-link.js.map +1 -1
  23. package/esm/components/nested-route-link.spec.js +8 -8
  24. package/esm/components/nested-route-link.spec.js.map +1 -1
  25. package/esm/components/nested-router.js +3 -3
  26. package/esm/components/nested-router.js.map +1 -1
  27. package/esm/components/nested-router.spec.js +24 -24
  28. package/esm/components/nested-router.spec.js.map +1 -1
  29. package/esm/css-generator.d.ts +9 -26
  30. package/esm/css-generator.d.ts.map +1 -1
  31. package/esm/css-generator.js +9 -26
  32. package/esm/css-generator.js.map +1 -1
  33. package/esm/css-generator.spec.js.map +1 -1
  34. package/esm/models/css-object.d.ts +3 -5
  35. package/esm/models/css-object.d.ts.map +1 -1
  36. package/esm/models/render-options.d.ts +16 -20
  37. package/esm/models/render-options.d.ts.map +1 -1
  38. package/esm/models/shade-component.d.ts +6 -4
  39. package/esm/models/shade-component.d.ts.map +1 -1
  40. package/esm/models/shade-component.js +3 -3
  41. package/esm/models/shade-component.js.map +1 -1
  42. package/esm/services/location-service.d.ts +39 -42
  43. package/esm/services/location-service.d.ts.map +1 -1
  44. package/esm/services/location-service.js +115 -134
  45. package/esm/services/location-service.js.map +1 -1
  46. package/esm/services/location-service.spec.js +32 -30
  47. package/esm/services/location-service.spec.js.map +1 -1
  48. package/esm/services/route-match-service.d.ts +3 -2
  49. package/esm/services/route-match-service.d.ts.map +1 -1
  50. package/esm/services/route-match-service.js +13 -22
  51. package/esm/services/route-match-service.js.map +1 -1
  52. package/esm/services/route-match-service.spec.js +40 -51
  53. package/esm/services/route-match-service.spec.js.map +1 -1
  54. package/esm/services/route-meta-utils.js.map +1 -1
  55. package/esm/services/route-meta-utils.spec.js +3 -3
  56. package/esm/services/route-meta-utils.spec.js.map +1 -1
  57. package/esm/services/screen-service.d.ts +13 -72
  58. package/esm/services/screen-service.d.ts.map +1 -1
  59. package/esm/services/screen-service.js +45 -137
  60. package/esm/services/screen-service.js.map +1 -1
  61. package/esm/services/screen-service.spec.js +19 -18
  62. package/esm/services/screen-service.spec.js.map +1 -1
  63. package/esm/services/spatial-navigation-service.d.ts +17 -39
  64. package/esm/services/spatial-navigation-service.d.ts.map +1 -1
  65. package/esm/services/spatial-navigation-service.js +280 -313
  66. package/esm/services/spatial-navigation-service.js.map +1 -1
  67. package/esm/services/spatial-navigation-service.spec.js +163 -158
  68. package/esm/services/spatial-navigation-service.spec.js.map +1 -1
  69. package/esm/shade-component.d.ts +14 -17
  70. package/esm/shade-component.d.ts.map +1 -1
  71. package/esm/shade-component.js +14 -17
  72. package/esm/shade-component.js.map +1 -1
  73. package/esm/shade.d.ts +18 -15
  74. package/esm/shade.d.ts.map +1 -1
  75. package/esm/shade.js +22 -32
  76. package/esm/shade.js.map +1 -1
  77. package/esm/style-manager.d.ts +8 -22
  78. package/esm/style-manager.d.ts.map +1 -1
  79. package/esm/style-manager.js +8 -22
  80. package/esm/style-manager.js.map +1 -1
  81. package/esm/styled-shade.d.ts +4 -7
  82. package/esm/styled-shade.d.ts.map +1 -1
  83. package/esm/styled-shade.js +4 -4
  84. package/esm/styled-shade.js.map +1 -1
  85. package/esm/view-transition.d.ts +10 -22
  86. package/esm/view-transition.d.ts.map +1 -1
  87. package/esm/view-transition.js +10 -22
  88. package/esm/view-transition.js.map +1 -1
  89. package/esm/vnode.js.map +1 -1
  90. package/esm/vnode.spec.js.map +1 -1
  91. package/package.json +9 -8
  92. package/src/compile-route.ts +5 -0
  93. package/src/component-factory.spec.tsx +3 -2
  94. package/src/components/nested-hooks.spec.ts +14 -14
  95. package/src/components/nested-hooks.ts +2 -2
  96. package/src/components/nested-navigate.spec.ts +17 -16
  97. package/src/components/nested-navigate.ts +1 -1
  98. package/src/components/nested-replace.spec.ts +7 -6
  99. package/src/components/nested-replace.ts +1 -1
  100. package/src/components/nested-route-link.spec.tsx +8 -8
  101. package/src/components/nested-route-link.tsx +1 -1
  102. package/src/components/nested-router.spec.tsx +24 -24
  103. package/src/components/nested-router.tsx +5 -5
  104. package/src/css-generator.spec.ts +1 -1
  105. package/src/css-generator.ts +9 -26
  106. package/src/models/css-object.ts +3 -5
  107. package/src/models/render-options.ts +17 -20
  108. package/src/models/shade-component.ts +6 -4
  109. package/src/services/location-service.spec.ts +35 -31
  110. package/src/services/location-service.tsx +156 -122
  111. package/src/services/route-match-service.spec.ts +41 -32
  112. package/src/services/route-match-service.ts +17 -8
  113. package/src/services/route-meta-utils.spec.ts +3 -3
  114. package/src/services/route-meta-utils.ts +1 -1
  115. package/src/services/screen-service.spec.ts +21 -20
  116. package/src/services/screen-service.ts +65 -110
  117. package/src/services/spatial-navigation-service.spec.ts +165 -160
  118. package/src/services/spatial-navigation-service.ts +299 -287
  119. package/src/shade-component.ts +14 -17
  120. package/src/shade.ts +31 -41
  121. package/src/style-manager.ts +9 -22
  122. package/src/styled-shade.ts +5 -8
  123. package/src/view-transition.ts +10 -22
  124. package/src/vnode.spec.ts +3 -3
  125. package/src/vnode.ts +2 -2
@@ -1,13 +1,4 @@
1
- var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
- var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
- if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
- else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
- return c > 3 && r && Object.defineProperty(target, key, r), r;
6
- };
7
- var __metadata = (this && this.__metadata) || function (k, v) {
8
- if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
9
- };
10
- import { Injectable } from '@furystack/inject';
1
+ import { defineService } from '@furystack/inject';
11
2
  import { ObservableValue } from '@furystack/utils';
12
3
  const DEFAULT_FOCUSABLE_SELECTOR = [
13
4
  '[tabindex]:not([tabindex="-1"])',
@@ -193,331 +184,307 @@ const shouldPassthroughArrowKeys = (element, key) => {
193
184
  return false;
194
185
  };
195
186
  /**
196
- * Service for D-pad / arrow-key spatial navigation across interactive elements.
197
- *
198
- * Intercepts arrow key events and moves focus spatially based on element geometry.
199
- * Supports section boundaries via `data-nav-section` attributes and optional
200
- * cross-section navigation.
201
- *
202
- * @example
203
- * ```typescript
204
- * // Opt in to spatial navigation
205
- * const spatialNav = injector.getInstance(SpatialNavigationService)
206
- *
207
- * // Disable during video playback
208
- * spatialNav.enabled.setValue(false)
209
- *
210
- * // Re-enable
211
- * spatialNav.enabled.setValue(true)
212
- * ```
187
+ * Configuration token for {@link SpatialNavigationService}. Override via
188
+ * {@link configureSpatialNavigation} before the service is first resolved.
213
189
  */
214
- let SpatialNavigationService = class SpatialNavigationService {
215
- /** Toggle spatial navigation on/off at runtime */
216
- enabled;
217
- /** The currently active section name (from data-nav-section), or null if none */
218
- activeSection = new ObservableValue(null);
219
- /** Remembered last-focused element per section for focus restoration */
220
- focusMemory = new Map();
221
- focusTrapStack = [];
222
- focusableSelector;
223
- crossSectionNavigation;
224
- backspaceGoesBack;
225
- escapeGoesToParentSection;
226
- constructor(options = {}) {
227
- this.enabled = new ObservableValue(options.initiallyEnabled ?? true);
228
- this.focusableSelector = options.focusableSelector ?? DEFAULT_FOCUSABLE_SELECTOR;
229
- this.crossSectionNavigation = options.crossSectionNavigation ?? true;
230
- this.backspaceGoesBack = options.backspaceGoesBack ?? false;
231
- this.escapeGoesToParentSection = options.escapeGoesToParentSection ?? false;
232
- window.addEventListener('keydown', this.handleKeyDown);
233
- }
234
- [Symbol.dispose]() {
235
- window.removeEventListener('keydown', this.handleKeyDown);
236
- this.enabled[Symbol.dispose]();
237
- this.activeSection[Symbol.dispose]();
238
- this.focusMemory.clear();
239
- this.focusTrapStack.length = 0;
240
- }
241
- /**
242
- * Push a focus trap onto the stack. While the trap is active, cross-section
243
- * navigation is blocked and `activeSection` is locked to `sectionName`.
244
- * Supports nesting — only the topmost trap is enforced.
245
- */
246
- pushFocusTrap(sectionName) {
247
- this.focusTrapStack.push(sectionName);
248
- this.activeSection.setValue(sectionName);
249
- }
250
- /**
251
- * Remove a focus trap from the stack. If other traps remain, the topmost
252
- * one becomes active. Otherwise `activeSection` reverts to `previousSection`.
253
- */
254
- popFocusTrap(sectionName, previousSection) {
255
- const idx = this.focusTrapStack.lastIndexOf(sectionName);
256
- if (idx !== -1) {
257
- this.focusTrapStack.splice(idx, 1);
258
- }
259
- const top = this.focusTrapStack[this.focusTrapStack.length - 1];
260
- this.activeSection.setValue(top ?? previousSection ?? null);
261
- }
262
- get activeTrap() {
263
- return this.focusTrapStack[this.focusTrapStack.length - 1] ?? null;
264
- }
265
- /** Programmatically move focus in a direction */
266
- moveFocus(direction) {
267
- const activeElement = document.activeElement;
268
- if (!activeElement || activeElement === document.body) {
269
- this.focusFirstElement();
270
- return;
271
- }
272
- const currentSection = this.findContainingSection(activeElement);
273
- const currentSectionName = currentSection?.getAttribute('data-nav-section') ?? null;
274
- this.activeSection.setValue(currentSectionName);
275
- const searchRoot = currentSection ?? document;
276
- const candidates = this.getFocusableCandidates(searchRoot, activeElement);
277
- const currentRect = activeElement.getBoundingClientRect();
278
- let target = this.findNearestInDirection(currentRect, candidates, direction);
279
- if (!target) {
280
- const relaxedCandidates = this.getFocusableCandidates(searchRoot, activeElement, {
281
- skipScrollVisibility: true,
190
+ export const SpatialNavigationSettings = defineService({
191
+ name: '@furystack/shades/SpatialNavigationSettings',
192
+ lifetime: 'singleton',
193
+ factory: () => ({}),
194
+ });
195
+ export const SpatialNavigationService = defineService({
196
+ name: '@furystack/shades/SpatialNavigationService',
197
+ lifetime: 'singleton',
198
+ factory: ({ inject, onDispose }) => {
199
+ const options = inject(SpatialNavigationSettings);
200
+ const enabled = new ObservableValue(options.initiallyEnabled ?? true);
201
+ const activeSection = new ObservableValue(null);
202
+ const focusMemory = new Map();
203
+ const focusTrapStack = [];
204
+ const focusableSelector = options.focusableSelector ?? DEFAULT_FOCUSABLE_SELECTOR;
205
+ const crossSectionNavigation = options.crossSectionNavigation ?? true;
206
+ const backspaceGoesBack = options.backspaceGoesBack ?? false;
207
+ const escapeGoesToParentSection = options.escapeGoesToParentSection ?? false;
208
+ const getActiveTrap = () => focusTrapStack[focusTrapStack.length - 1] ?? null;
209
+ const pushFocusTrap = (sectionName) => {
210
+ focusTrapStack.push(sectionName);
211
+ activeSection.setValue(sectionName);
212
+ };
213
+ const popFocusTrap = (sectionName, previousSection) => {
214
+ const idx = focusTrapStack.lastIndexOf(sectionName);
215
+ if (idx !== -1) {
216
+ focusTrapStack.splice(idx, 1);
217
+ }
218
+ const top = focusTrapStack[focusTrapStack.length - 1];
219
+ activeSection.setValue(top ?? previousSection ?? null);
220
+ };
221
+ const findContainingSection = (element) => element.closest('[data-nav-section]');
222
+ const isVisibleInScrollContainers = (el, rect) => {
223
+ const centerX = rect.left + rect.width / 2;
224
+ const centerY = rect.top + rect.height / 2;
225
+ const hasOverflow = (val) => val !== '' && val !== 'visible';
226
+ let ancestor = el.parentElement;
227
+ while (ancestor) {
228
+ const style = getComputedStyle(ancestor);
229
+ if (hasOverflow(style.overflow) || hasOverflow(style.overflowX) || hasOverflow(style.overflowY)) {
230
+ const containerRect = ancestor.getBoundingClientRect();
231
+ if (centerX < containerRect.left ||
232
+ centerX > containerRect.right ||
233
+ centerY < containerRect.top ||
234
+ centerY > containerRect.bottom) {
235
+ return false;
236
+ }
237
+ }
238
+ ancestor = ancestor.parentElement;
239
+ }
240
+ return true;
241
+ };
242
+ const getFocusableCandidates = (root, exclude, candidateOptions) => {
243
+ return Array.from(root.querySelectorAll(focusableSelector)).filter((el) => {
244
+ if (el === exclude)
245
+ return false;
246
+ if (!el.hasAttribute('data-spatial-nav-target') && el.closest('[data-spatial-nav-target]'))
247
+ return false;
248
+ const rect = el.getBoundingClientRect();
249
+ if (rect.width <= 0 || rect.height <= 0)
250
+ return false;
251
+ if (candidateOptions?.skipScrollVisibility)
252
+ return true;
253
+ return isVisibleInScrollContainers(el, rect);
282
254
  });
283
- target = this.findNearestInDirection(currentRect, relaxedCandidates, direction);
284
- }
285
- if (target) {
286
- this.storeFocusMemory(currentSectionName, activeElement);
287
- target.focus();
288
- target.scrollIntoView({ block: 'nearest', inline: 'nearest' });
289
- const targetSection = this.findContainingSection(target);
290
- this.activeSection.setValue(targetSection?.getAttribute('data-nav-section') ?? null);
291
- return;
292
- }
293
- if (this.crossSectionNavigation && currentSection && !this.activeTrap) {
294
- this.navigateCrossSection(activeElement, currentSection, direction);
295
- }
296
- }
297
- /** Programmatically activate (click) the currently focused element */
298
- activateFocused() {
299
- const { activeElement } = document;
300
- if (activeElement && activeElement !== document.body) {
301
- ;
302
- activeElement.click();
303
- }
304
- }
305
- handleKeyDown = (ev) => {
306
- if (!this.enabled.getValue())
307
- return;
308
- if (ev.defaultPrevented)
309
- return;
310
- const { activeElement } = document;
311
- if (activeElement && isInsidePassthrough(activeElement))
312
- return;
313
- if (activeElement && shouldPassthroughArrowKeys(activeElement, ev.key)) {
314
- return;
315
- }
316
- switch (ev.key) {
317
- case 'ArrowUp':
318
- ev.preventDefault();
319
- this.moveFocus('up');
320
- break;
321
- case 'ArrowDown':
322
- ev.preventDefault();
323
- this.moveFocus('down');
324
- break;
325
- case 'ArrowLeft':
326
- ev.preventDefault();
327
- this.moveFocus('left');
328
- break;
329
- case 'ArrowRight':
330
- ev.preventDefault();
331
- this.moveFocus('right');
332
- break;
333
- case 'Enter':
334
- if (activeElement && isTextInput(activeElement))
335
- break;
336
- ev.preventDefault();
337
- this.activateFocused();
338
- break;
339
- case 'Backspace':
340
- if (this.backspaceGoesBack && !(activeElement && isTextInput(activeElement))) {
341
- ev.preventDefault();
342
- history.back();
255
+ };
256
+ const findNearestInDirection = (currentRect, candidates, direction) => {
257
+ const currentCenter = getElementCenter(currentRect);
258
+ let nearest = null;
259
+ let nearestDistance = Infinity;
260
+ for (const candidate of candidates) {
261
+ const candidateRect = candidate.getBoundingClientRect();
262
+ if (!isInDirection(currentRect, candidateRect, direction))
263
+ continue;
264
+ const candidateCenter = getElementCenter(candidateRect);
265
+ const distance = spatialDistance(currentCenter, candidateCenter, direction);
266
+ if (distance < nearestDistance) {
267
+ nearestDistance = distance;
268
+ nearest = candidate;
343
269
  }
344
- break;
345
- case 'Escape':
346
- if (activeElement && activeElement !== document.body && shouldEscapeBlurElement(activeElement)) {
347
- ev.preventDefault();
348
- activeElement.blur();
349
- break;
270
+ }
271
+ return nearest;
272
+ };
273
+ const storeFocusMemory = (sectionName, element) => {
274
+ if (sectionName) {
275
+ focusMemory.set(sectionName, new WeakRef(element));
276
+ }
277
+ };
278
+ const navigateCrossSection = (activeElement, currentSection, direction) => {
279
+ const currentSectionName = currentSection.getAttribute('data-nav-section');
280
+ const allFocusable = Array.from(document.querySelectorAll(focusableSelector)).filter((el) => {
281
+ if (el === activeElement)
282
+ return false;
283
+ if (currentSection.contains(el))
284
+ return false;
285
+ if (!el.hasAttribute('data-spatial-nav-target') && el.closest('[data-spatial-nav-target]'))
286
+ return false;
287
+ const rect = el.getBoundingClientRect();
288
+ return rect.width > 0 && rect.height > 0 && isVisibleInScrollContainers(el, rect);
289
+ });
290
+ const currentRect = activeElement.getBoundingClientRect();
291
+ const nearest = findNearestInDirection(currentRect, allFocusable, direction);
292
+ if (nearest) {
293
+ storeFocusMemory(currentSectionName, activeElement);
294
+ const targetSection = findContainingSection(nearest);
295
+ const targetSectionName = targetSection?.getAttribute('data-nav-section') ?? null;
296
+ const remembered = targetSectionName ? focusMemory.get(targetSectionName)?.deref() : null;
297
+ if (remembered &&
298
+ remembered !== activeElement &&
299
+ targetSection?.contains(remembered) &&
300
+ remembered.matches(focusableSelector)) {
301
+ const rememberedRect = remembered.getBoundingClientRect();
302
+ if (rememberedRect.width > 0 &&
303
+ rememberedRect.height > 0 &&
304
+ isVisibleInScrollContainers(remembered, rememberedRect)) {
305
+ ;
306
+ remembered.focus();
307
+ remembered.scrollIntoView({ block: 'nearest', inline: 'nearest' });
308
+ activeSection.setValue(targetSectionName);
309
+ return;
310
+ }
350
311
  }
351
- if (this.escapeGoesToParentSection) {
352
- this.moveToParentSection();
312
+ nearest.focus();
313
+ nearest.scrollIntoView({ block: 'nearest', inline: 'nearest' });
314
+ activeSection.setValue(targetSectionName);
315
+ }
316
+ };
317
+ const focusFirstElement = () => {
318
+ const trap = getActiveTrap();
319
+ if (trap) {
320
+ const trapSection = document.querySelector(`[data-nav-section="${escapeCssString(trap)}"]`);
321
+ if (trapSection) {
322
+ const firstFocusable = trapSection.querySelector(focusableSelector);
323
+ if (firstFocusable) {
324
+ ;
325
+ firstFocusable.focus();
326
+ activeSection.setValue(trap);
327
+ return;
328
+ }
353
329
  }
354
- break;
355
- default:
356
- break;
357
- }
358
- };
359
- focusFirstElement() {
360
- const trap = this.activeTrap;
361
- if (trap) {
362
- const trapSection = document.querySelector(`[data-nav-section="${escapeCssString(trap)}"]`);
363
- if (trapSection) {
364
- const firstFocusable = trapSection.querySelector(this.focusableSelector);
330
+ }
331
+ const sections = document.querySelectorAll('[data-nav-section]');
332
+ if (sections.length > 0) {
333
+ const firstFocusable = sections[0].querySelector(focusableSelector);
365
334
  if (firstFocusable) {
366
335
  ;
367
336
  firstFocusable.focus();
368
- this.activeSection.setValue(trap);
337
+ activeSection.setValue(sections[0].getAttribute('data-nav-section'));
369
338
  return;
370
339
  }
371
340
  }
372
- }
373
- const sections = document.querySelectorAll('[data-nav-section]');
374
- if (sections.length > 0) {
375
- const firstFocusable = sections[0].querySelector(this.focusableSelector);
341
+ const firstFocusable = document.querySelector(focusableSelector);
376
342
  if (firstFocusable) {
377
343
  ;
378
344
  firstFocusable.focus();
379
- this.activeSection.setValue(sections[0].getAttribute('data-nav-section'));
345
+ }
346
+ };
347
+ const moveFocus = (direction) => {
348
+ const activeElement = document.activeElement;
349
+ if (!activeElement || activeElement === document.body) {
350
+ focusFirstElement();
380
351
  return;
381
352
  }
382
- }
383
- const firstFocusable = document.querySelector(this.focusableSelector);
384
- if (firstFocusable) {
385
- ;
386
- firstFocusable.focus();
387
- }
388
- }
389
- findContainingSection(element) {
390
- return element.closest('[data-nav-section]');
391
- }
392
- isVisibleInScrollContainers(el, rect) {
393
- const centerX = rect.left + rect.width / 2;
394
- const centerY = rect.top + rect.height / 2;
395
- const hasOverflow = (val) => val !== '' && val !== 'visible';
396
- let ancestor = el.parentElement;
397
- while (ancestor) {
398
- const style = getComputedStyle(ancestor);
399
- if (hasOverflow(style.overflow) || hasOverflow(style.overflowX) || hasOverflow(style.overflowY)) {
400
- const containerRect = ancestor.getBoundingClientRect();
401
- if (centerX < containerRect.left ||
402
- centerX > containerRect.right ||
403
- centerY < containerRect.top ||
404
- centerY > containerRect.bottom) {
405
- return false;
406
- }
353
+ const currentSection = findContainingSection(activeElement);
354
+ const currentSectionName = currentSection?.getAttribute('data-nav-section') ?? null;
355
+ activeSection.setValue(currentSectionName);
356
+ const searchRoot = currentSection ?? document;
357
+ const candidates = getFocusableCandidates(searchRoot, activeElement);
358
+ const currentRect = activeElement.getBoundingClientRect();
359
+ let target = findNearestInDirection(currentRect, candidates, direction);
360
+ if (!target) {
361
+ const relaxedCandidates = getFocusableCandidates(searchRoot, activeElement, {
362
+ skipScrollVisibility: true,
363
+ });
364
+ target = findNearestInDirection(currentRect, relaxedCandidates, direction);
407
365
  }
408
- ancestor = ancestor.parentElement;
409
- }
410
- return true;
411
- }
412
- getFocusableCandidates(root, exclude, options) {
413
- return Array.from(root.querySelectorAll(this.focusableSelector)).filter((el) => {
414
- if (el === exclude)
415
- return false;
416
- if (!el.hasAttribute('data-spatial-nav-target') && el.closest('[data-spatial-nav-target]'))
417
- return false;
418
- const rect = el.getBoundingClientRect();
419
- if (rect.width <= 0 || rect.height <= 0)
420
- return false;
421
- if (options?.skipScrollVisibility)
422
- return true;
423
- return this.isVisibleInScrollContainers(el, rect);
424
- });
425
- }
426
- findNearestInDirection(currentRect, candidates, direction) {
427
- const currentCenter = getElementCenter(currentRect);
428
- let nearest = null;
429
- let nearestDistance = Infinity;
430
- for (const candidate of candidates) {
431
- const candidateRect = candidate.getBoundingClientRect();
432
- if (!isInDirection(currentRect, candidateRect, direction))
433
- continue;
434
- const candidateCenter = getElementCenter(candidateRect);
435
- const distance = spatialDistance(currentCenter, candidateCenter, direction);
436
- if (distance < nearestDistance) {
437
- nearestDistance = distance;
438
- nearest = candidate;
366
+ if (target) {
367
+ storeFocusMemory(currentSectionName, activeElement);
368
+ target.focus();
369
+ target.scrollIntoView({ block: 'nearest', inline: 'nearest' });
370
+ const targetSection = findContainingSection(target);
371
+ activeSection.setValue(targetSection?.getAttribute('data-nav-section') ?? null);
372
+ return;
439
373
  }
440
- }
441
- return nearest;
442
- }
443
- navigateCrossSection(activeElement, currentSection, direction) {
444
- const currentSectionName = currentSection.getAttribute('data-nav-section');
445
- const allFocusable = Array.from(document.querySelectorAll(this.focusableSelector)).filter((el) => {
446
- if (el === activeElement)
447
- return false;
448
- if (currentSection.contains(el))
449
- return false;
450
- if (!el.hasAttribute('data-spatial-nav-target') && el.closest('[data-spatial-nav-target]'))
451
- return false;
452
- const rect = el.getBoundingClientRect();
453
- return rect.width > 0 && rect.height > 0 && this.isVisibleInScrollContainers(el, rect);
454
- });
455
- const currentRect = activeElement.getBoundingClientRect();
456
- const nearest = this.findNearestInDirection(currentRect, allFocusable, direction);
457
- if (nearest) {
458
- this.storeFocusMemory(currentSectionName, activeElement);
459
- const targetSection = this.findContainingSection(nearest);
460
- const targetSectionName = targetSection?.getAttribute('data-nav-section') ?? null;
461
- const remembered = targetSectionName ? this.focusMemory.get(targetSectionName)?.deref() : null;
462
- if (remembered &&
463
- remembered !== activeElement &&
464
- targetSection?.contains(remembered) &&
465
- remembered.matches(this.focusableSelector)) {
466
- const rememberedRect = remembered.getBoundingClientRect();
467
- if (rememberedRect.width > 0 &&
468
- rememberedRect.height > 0 &&
469
- this.isVisibleInScrollContainers(remembered, rememberedRect)) {
470
- ;
471
- remembered.focus();
472
- remembered.scrollIntoView({ block: 'nearest', inline: 'nearest' });
473
- this.activeSection.setValue(targetSectionName);
474
- return;
475
- }
374
+ if (crossSectionNavigation && currentSection && !getActiveTrap()) {
375
+ navigateCrossSection(activeElement, currentSection, direction);
476
376
  }
477
- nearest.focus();
478
- nearest.scrollIntoView({ block: 'nearest', inline: 'nearest' });
479
- this.activeSection.setValue(targetSectionName);
480
- }
481
- }
482
- storeFocusMemory(sectionName, element) {
483
- if (sectionName) {
484
- this.focusMemory.set(sectionName, new WeakRef(element));
485
- }
486
- }
487
- moveToParentSection() {
488
- const { activeElement } = document;
489
- if (!activeElement || activeElement === document.body)
490
- return;
491
- const currentSection = this.findContainingSection(activeElement);
492
- if (!currentSection)
493
- return;
494
- const parentSection = currentSection.parentElement?.closest('[data-nav-section]');
495
- if (!parentSection)
496
- return;
497
- const firstFocusable = parentSection.querySelector(this.focusableSelector);
498
- if (firstFocusable) {
499
- ;
500
- firstFocusable.focus();
501
- this.activeSection.setValue(parentSection.getAttribute('data-nav-section'));
502
- }
503
- }
504
- };
505
- SpatialNavigationService = __decorate([
506
- Injectable({ lifetime: 'singleton' }),
507
- __metadata("design:paramtypes", [Object])
508
- ], SpatialNavigationService);
509
- export { SpatialNavigationService };
377
+ };
378
+ const activateFocused = () => {
379
+ const { activeElement } = document;
380
+ if (activeElement && activeElement !== document.body) {
381
+ ;
382
+ activeElement.click();
383
+ }
384
+ };
385
+ const moveToParentSection = () => {
386
+ const { activeElement } = document;
387
+ if (!activeElement || activeElement === document.body)
388
+ return;
389
+ const currentSection = findContainingSection(activeElement);
390
+ if (!currentSection)
391
+ return;
392
+ const parentSection = currentSection.parentElement?.closest('[data-nav-section]');
393
+ if (!parentSection)
394
+ return;
395
+ const firstFocusable = parentSection.querySelector(focusableSelector);
396
+ if (firstFocusable) {
397
+ ;
398
+ firstFocusable.focus();
399
+ activeSection.setValue(parentSection.getAttribute('data-nav-section'));
400
+ }
401
+ };
402
+ const handleKeyDown = (ev) => {
403
+ if (!enabled.getValue())
404
+ return;
405
+ if (ev.defaultPrevented)
406
+ return;
407
+ const { activeElement } = document;
408
+ if (activeElement && isInsidePassthrough(activeElement))
409
+ return;
410
+ if (activeElement && shouldPassthroughArrowKeys(activeElement, ev.key)) {
411
+ return;
412
+ }
413
+ switch (ev.key) {
414
+ case 'ArrowUp':
415
+ ev.preventDefault();
416
+ moveFocus('up');
417
+ break;
418
+ case 'ArrowDown':
419
+ ev.preventDefault();
420
+ moveFocus('down');
421
+ break;
422
+ case 'ArrowLeft':
423
+ ev.preventDefault();
424
+ moveFocus('left');
425
+ break;
426
+ case 'ArrowRight':
427
+ ev.preventDefault();
428
+ moveFocus('right');
429
+ break;
430
+ case 'Enter':
431
+ if (activeElement && isTextInput(activeElement))
432
+ break;
433
+ ev.preventDefault();
434
+ activateFocused();
435
+ break;
436
+ case 'Backspace':
437
+ if (backspaceGoesBack && !(activeElement && isTextInput(activeElement))) {
438
+ ev.preventDefault();
439
+ history.back();
440
+ }
441
+ break;
442
+ case 'Escape':
443
+ if (activeElement && activeElement !== document.body && shouldEscapeBlurElement(activeElement)) {
444
+ ev.preventDefault();
445
+ activeElement.blur();
446
+ break;
447
+ }
448
+ if (escapeGoesToParentSection) {
449
+ moveToParentSection();
450
+ }
451
+ break;
452
+ default:
453
+ break;
454
+ }
455
+ };
456
+ window.addEventListener('keydown', handleKeyDown);
457
+ onDispose(() => {
458
+ window.removeEventListener('keydown', handleKeyDown);
459
+ // eslint-disable-next-line furystack/prefer-using-wrapper -- Disposal is deferred to the injector's onDispose hook.
460
+ enabled[Symbol.dispose]();
461
+ // eslint-disable-next-line furystack/prefer-using-wrapper -- Disposal is deferred to the injector's onDispose hook.
462
+ activeSection[Symbol.dispose]();
463
+ focusMemory.clear();
464
+ focusTrapStack.length = 0;
465
+ });
466
+ return {
467
+ enabled,
468
+ activeSection,
469
+ pushFocusTrap,
470
+ popFocusTrap,
471
+ moveFocus,
472
+ activateFocused,
473
+ };
474
+ },
475
+ });
510
476
  /**
511
477
  * Configures spatial navigation options before the service is first instantiated.
512
- * Must be called **before** `SpatialNavigationService` is first resolved from the injector.
513
- * @param injector The root injector
514
- * @param options Configuration options for spatial navigation
478
+ * Rebinds {@link SpatialNavigationSettings} and invalidates the cached
479
+ * {@link SpatialNavigationService}. Must be called **before** the service is
480
+ * first resolved — calling afterwards drops the cached instance without
481
+ * disposing it (listeners leak until the injector is disposed).
482
+ * @param injector The root injector.
483
+ * @param options Configuration options for spatial navigation.
515
484
  */
516
485
  export const configureSpatialNavigation = (injector, options) => {
517
- if (injector.cachedSingletons.has(SpatialNavigationService)) {
518
- throw new Error('configureSpatialNavigation must be called before the SpatialNavigationService is instantiated');
519
- }
520
- const service = new SpatialNavigationService(options);
521
- injector.setExplicitInstance(service, SpatialNavigationService);
486
+ injector.bind(SpatialNavigationSettings, () => options);
487
+ injector.invalidate(SpatialNavigationSettings);
488
+ injector.invalidate(SpatialNavigationService);
522
489
  };
523
490
  //# sourceMappingURL=spatial-navigation-service.js.map