@classic-homes/theme-svelte 1.41.0 → 1.42.2

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.
@@ -356,6 +356,23 @@
356
356
  }
357
357
  });
358
358
 
359
+ /**
360
+ * Clamp a detached position so the panel (its header above all) stays inside
361
+ * the current viewport. The panel renders at `min(size, 100%)`, so clamp
362
+ * against that visible size: an oversized panel then pins to 0,0.
363
+ * Mirrors the React DataPanel's restore and window-resize clamping.
364
+ */
365
+ function clampDetachedPosition(position: Position, size: Size): Position {
366
+ const vw = layoutViewportWidth();
367
+ const vh = window.innerHeight;
368
+ return constrainPosition(
369
+ position,
370
+ { width: Math.min(size.width, vw), height: Math.min(size.height, vh) },
371
+ vw,
372
+ vh
373
+ );
374
+ }
375
+
359
376
  // Initialize from persistence on mount
360
377
  onMount(() => {
361
378
  // Update viewport dimensions
@@ -370,6 +387,13 @@
370
387
  viewportWidth = layoutViewportWidth();
371
388
  viewportHeight = window.innerHeight;
372
389
  cardExpandedHeight = viewportHeight * resolvedCardConfig.expandedRatio;
390
+ // Keep a detached panel reachable after the window shrinks
391
+ if (mode === 'detached') {
392
+ const clamped = clampDetachedPosition(detachedPosition, detachedSize);
393
+ if (clamped.x !== detachedPosition.x || clamped.y !== detachedPosition.y) {
394
+ detachedPosition = clamped;
395
+ }
396
+ }
373
397
  };
374
398
  window.addEventListener('resize', handleResize);
375
399
 
@@ -402,6 +426,11 @@
402
426
  }
403
427
  }
404
428
 
429
+ // A position restored from storage may have been saved on a larger screen
430
+ if (persistKey) {
431
+ detachedPosition = clampDetachedPosition(detachedPosition, detachedSize);
432
+ }
433
+
405
434
  // Escape-to-close is handled by useEscapeKey; focus trapping by useFocusTrap.
406
435
 
407
436
  return () => {
@@ -15,13 +15,19 @@ function getFocusableElements(container) {
15
15
  const elements = container.querySelectorAll(FOCUSABLE_SELECTOR);
16
16
  return Array.from(elements).filter((el) =>
17
17
  // tabindex="-1" on a natively focusable element (roving-tabindex radios,
18
- // toolbar buttons) takes it out of the Tab order, so it can't be the
19
- // trap's first or last stop.
18
+ // toolbar buttons) takes it out of the Tab order, as do `inert`, `hidden`
19
+ // and a disabled <fieldset> ancestor, so none can be the trap's first or
20
+ // last stop.
20
21
  el.tabIndex >= 0 &&
21
- !el.closest('[inert]') &&
22
+ !el.matches(':disabled') &&
23
+ !el.closest('[inert],[hidden]') &&
22
24
  el.offsetParent !== null &&
23
25
  getComputedStyle(el).visibility !== 'hidden');
24
26
  }
27
+ /** True when `a` comes before `b` in document order. */
28
+ function precedes(a, b) {
29
+ return Boolean(a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING);
30
+ }
25
31
  /**
26
32
  * Creates a focus trap action for use with Svelte's use: directive.
27
33
  *
@@ -53,6 +59,7 @@ export function useFocusTrap(options = {}) {
53
59
  let container = $state(null);
54
60
  let previousActiveElement = null;
55
61
  let trapped = false;
62
+ let addedTabIndexTo = null;
56
63
  function isActive() {
57
64
  const active = currentOptions.active;
58
65
  return typeof active === 'function' ? active() : (active ?? true);
@@ -61,21 +68,39 @@ export function useFocusTrap(options = {}) {
61
68
  if (!trapped || event.key !== 'Tab' || !container)
62
69
  return;
63
70
  const preventScroll = currentOptions.preventScroll ?? false;
71
+ // Focus outside the container (e.g. a portaled nested popover) is not
72
+ // ours to move; the nested overlay manages its own Tab order.
73
+ const current = document.activeElement;
74
+ if (!current || !container.contains(current))
75
+ return;
64
76
  const focusableElements = getFocusableElements(container);
65
- if (focusableElements.length === 0)
77
+ // Nothing tabbable: keep focus on the container instead of letting Tab
78
+ // walk out of the trap.
79
+ if (focusableElements.length === 0) {
80
+ event.preventDefault();
81
+ container.focus({ preventScroll });
66
82
  return;
83
+ }
67
84
  const firstElement = focusableElements[0];
68
85
  const lastElement = focusableElements[focusableElements.length - 1];
86
+ // Focus on the container itself: enter the tab sequence at the matching end.
87
+ if (current === container) {
88
+ event.preventDefault();
89
+ (event.shiftKey ? lastElement : firstElement).focus({ preventScroll });
90
+ return;
91
+ }
69
92
  if (event.shiftKey) {
70
- // Shift + Tab: if on first element, move to last
71
- if (document.activeElement === firstElement) {
93
+ // Shift + Tab from the first stop, or from a non-tabbable element before
94
+ // it (e.g. a clicked tabindex=-1 control), wraps to the last.
95
+ if (current === firstElement || precedes(current, firstElement)) {
72
96
  event.preventDefault();
73
97
  lastElement.focus({ preventScroll });
74
98
  }
75
99
  }
76
100
  else {
77
- // Tab: if on last element, move to first
78
- if (document.activeElement === lastElement) {
101
+ // Tab from the last stop, or from a non-tabbable element after it,
102
+ // wraps to the first.
103
+ if (current === lastElement || precedes(lastElement, current)) {
79
104
  event.preventDefault();
80
105
  firstElement.focus({ preventScroll });
81
106
  }
@@ -87,21 +112,27 @@ export function useFocusTrap(options = {}) {
87
112
  trapped = true;
88
113
  // Save current active element to return focus later
89
114
  previousActiveElement = document.activeElement;
90
- // Focus initial element
115
+ // Focus initial element; an empty trap focuses the container itself,
116
+ // which needs a tabindex for that (removed again on deactivate).
91
117
  const preventScroll = currentOptions.preventScroll ?? false;
92
118
  const focusableElements = getFocusableElements(container);
93
- if (focusableElements.length > 0) {
94
- let elementToFocus = null;
95
- if (currentOptions.initialFocus) {
96
- elementToFocus = container.querySelector(currentOptions.initialFocus);
97
- }
98
- if (!elementToFocus) {
99
- elementToFocus = focusableElements[0];
119
+ let elementToFocus = null;
120
+ if (currentOptions.initialFocus) {
121
+ elementToFocus = container.querySelector(currentOptions.initialFocus);
122
+ }
123
+ if (!elementToFocus) {
124
+ elementToFocus = focusableElements[0] ?? null;
125
+ }
126
+ if (!elementToFocus) {
127
+ if (!container.hasAttribute('tabindex')) {
128
+ container.setAttribute('tabindex', '-1');
129
+ addedTabIndexTo = container;
100
130
  }
101
- requestAnimationFrame(() => {
102
- elementToFocus?.focus({ preventScroll });
103
- });
131
+ elementToFocus = container;
104
132
  }
133
+ requestAnimationFrame(() => {
134
+ elementToFocus?.focus({ preventScroll });
135
+ });
105
136
  document.addEventListener('keydown', handleKeyDown);
106
137
  }
107
138
  function deactivate() {
@@ -109,6 +140,8 @@ export function useFocusTrap(options = {}) {
109
140
  return;
110
141
  trapped = false;
111
142
  document.removeEventListener('keydown', handleKeyDown);
143
+ addedTabIndexTo?.removeAttribute('tabindex');
144
+ addedTabIndexTo = null;
112
145
  // Return focus when trap is deactivated
113
146
  const preventScroll = currentOptions.preventScroll ?? false;
114
147
  const elementToReturn = currentOptions.returnFocusTo ?? previousActiveElement;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classic-homes/theme-svelte",
3
- "version": "1.41.0",
3
+ "version": "1.42.2",
4
4
  "description": "Svelte components for the Classic theme system",
5
5
  "type": "module",
6
6
  "svelte": "./dist/lib/index.js",
@@ -50,7 +50,7 @@
50
50
  "access": "public"
51
51
  },
52
52
  "peerDependencies": {
53
- "svelte": "^5.0.0",
53
+ "svelte": "^5.31.0",
54
54
  "@classic-homes/notifications": "*",
55
55
  "hls.js": "^1.4.0"
56
56
  },