@classic-homes/theme-svelte 1.25.1 → 1.27.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 (70) hide show
  1. package/dist/lib/components/AlertDialog.svelte +14 -6
  2. package/dist/lib/components/AlertDialog.svelte.d.ts +8 -2
  3. package/dist/lib/components/Badge.svelte +6 -2
  4. package/dist/lib/components/CodeField.svelte +24 -8
  5. package/dist/lib/components/CodeField.svelte.d.ts +8 -0
  6. package/dist/lib/components/Combobox.svelte +25 -111
  7. package/dist/lib/components/DataPanel.svelte +31 -24
  8. package/dist/lib/components/DataPanelHeader.svelte +66 -26
  9. package/dist/lib/components/DataTable.svelte +303 -138
  10. package/dist/lib/components/DataTable.svelte.d.ts +37 -13
  11. package/dist/lib/components/DateTimePicker.svelte +159 -34
  12. package/dist/lib/components/Dialog.svelte +14 -6
  13. package/dist/lib/components/Dialog.svelte.d.ts +8 -2
  14. package/dist/lib/components/FileUpload.svelte +23 -6
  15. package/dist/lib/components/FileUpload.svelte.d.ts +7 -2
  16. package/dist/lib/components/MultiSelect.svelte +28 -155
  17. package/dist/lib/components/NumberInput.svelte +28 -110
  18. package/dist/lib/components/OTPInput.svelte +73 -85
  19. package/dist/lib/components/Select.svelte +24 -29
  20. package/dist/lib/components/Signature.svelte +80 -48
  21. package/dist/lib/components/Signature.svelte.d.ts +6 -1
  22. package/dist/lib/components/SkipLink.svelte +7 -2
  23. package/dist/lib/components/SkipLink.svelte.d.ts +2 -0
  24. package/dist/lib/components/Slider.svelte +4 -4
  25. package/dist/lib/components/Toast.svelte +11 -1
  26. package/dist/lib/components/ToastContainer.svelte +38 -24
  27. package/dist/lib/components/Tooltip.svelte +19 -6
  28. package/dist/lib/components/Tooltip.svelte.d.ts +8 -2
  29. package/dist/lib/components/data-table/DataTableColumnFilter.svelte +3 -1
  30. package/dist/lib/components/data-table/DataTableEditableCell.svelte +25 -3
  31. package/dist/lib/components/data-table/DataTableFacetedFilter.svelte +50 -40
  32. package/dist/lib/components/data-table/DataTableFacetedFilter.svelte.d.ts +0 -10
  33. package/dist/lib/components/data-table/DataTableVirtual.svelte +109 -57
  34. package/dist/lib/components/data-table/DataTableVirtual.svelte.d.ts +2 -0
  35. package/dist/lib/components/data-table/createDataTable.svelte.d.ts +26 -4
  36. package/dist/lib/components/data-table/createDataTable.svelte.js +97 -40
  37. package/dist/lib/components/data-table/index.d.ts +1 -1
  38. package/dist/lib/components/data-table/types.d.ts +14 -4
  39. package/dist/lib/components/data-table/types.js +5 -1
  40. package/dist/lib/components/internal/TriggerChild.svelte +60 -0
  41. package/dist/lib/components/internal/TriggerChild.svelte.d.ts +32 -0
  42. package/dist/lib/components/layout/Sidebar.svelte +33 -3
  43. package/dist/lib/components/layout/Sidebar.svelte.d.ts +1 -1
  44. package/dist/lib/components/layout/sidebar/SidebarFlyout.svelte +6 -1
  45. package/dist/lib/components/layout/sidebar/SidebarNavItem.svelte +6 -1
  46. package/dist/lib/components/layout/sidebar/SidebarSection.svelte +6 -1
  47. package/dist/lib/components/media/Audio.svelte +16 -0
  48. package/dist/lib/components/media/Lightbox.svelte +44 -9
  49. package/dist/lib/components/media/Lightbox.svelte.d.ts +9 -2
  50. package/dist/lib/components/media/MediaCarousel.svelte +19 -0
  51. package/dist/lib/components/media/MediaEmbed.svelte +32 -1
  52. package/dist/lib/components/media/Video.svelte +16 -0
  53. package/dist/lib/components/media/internal/MediaControls.svelte +9 -1
  54. package/dist/lib/components/notifications/NotificationBadge.svelte +1 -1
  55. package/dist/lib/components/notifications/NotificationItem.svelte +19 -28
  56. package/dist/lib/composables/useEscapeKey.svelte.d.ts +4 -0
  57. package/dist/lib/composables/useEscapeKey.svelte.js +15 -0
  58. package/dist/lib/composables/useHlsSource.svelte.js +31 -4
  59. package/dist/lib/composables/useMediaPlayer.svelte.js +38 -9
  60. package/dist/lib/composables/useMediaZoom.svelte.js +32 -0
  61. package/dist/lib/composables/usePersistedForm.svelte.d.ts +6 -2
  62. package/dist/lib/composables/usePersistedForm.svelte.js +7 -7
  63. package/dist/lib/composables/useReducedMotion.svelte.js +6 -1
  64. package/dist/lib/composables/useRovingTabindex.svelte.js +12 -0
  65. package/dist/lib/index.d.ts +1 -1
  66. package/dist/lib/stores/toast.svelte.d.ts +13 -0
  67. package/dist/lib/stores/toast.svelte.js +36 -5
  68. package/dist/lib/utils/media.d.ts +7 -0
  69. package/dist/lib/utils/media.js +18 -3
  70. package/package.json +1 -1
@@ -0,0 +1,32 @@
1
+ /** What a trigger snippet receives: spread `props` on the focusable element */
2
+ export type TriggerSnippetProps = {
3
+ props: Record<string, unknown>;
4
+ };
5
+ import type { Snippet } from 'svelte';
6
+ interface Props {
7
+ /** The props bits-ui hands to its `child` snippet */
8
+ props: Record<string, unknown>;
9
+ /** The consumer's trigger snippet */
10
+ trigger: Snippet<[TriggerSnippetProps]>;
11
+ /** Class for the fallback wrapper span */
12
+ fallbackClass?: string;
13
+ /** Called once when the snippet ignored the props */
14
+ onfallback?: () => void;
15
+ }
16
+ /**
17
+ * Renders a consumer's trigger snippet inside a bits-ui `child` snippet.
18
+ *
19
+ * The snippet receives the trigger props (`{ props }`) and should spread them
20
+ * on its own focusable element (`<Button {...props}>`), so that element is the
21
+ * real trigger: it carries aria-expanded/aria-describedby and gets focus back
22
+ * when a dialog closes.
23
+ *
24
+ * Snippets written before the props were passed ignore them. Those are
25
+ * detected after mount (no element inside carries the trigger id) and the
26
+ * props move to a wrapping span, the old behaviour. `onfallback` tells the
27
+ * parent so it can change the trigger id, which makes bits-ui look the
28
+ * trigger node up again.
29
+ */
30
+ declare const TriggerChild: import("svelte").Component<Props, {}, "">;
31
+ type TriggerChild = ReturnType<typeof TriggerChild>;
32
+ export default TriggerChild;
@@ -12,7 +12,7 @@
12
12
  * - Optional stronger border styling
13
13
  * - Quick links section at bottom
14
14
  */
15
- import type { Snippet } from 'svelte';
15
+ import { untrack, type Snippet } from 'svelte';
16
16
  import type { NavSection, NavItem, QuickLink } from '../../types/layout.js';
17
17
  import { cn } from '../../utils.js';
18
18
  import { filterNavItems } from '../../utils/nav-access.js';
@@ -388,6 +388,34 @@
388
388
  // Focus management for mobile sidebar
389
389
  let mobileCloseButton = $state<HTMLButtonElement | null>(null);
390
390
 
391
+ // On the open edge remember what had focus (normally the header menu
392
+ // button); on the closed edge send focus back there. Every close path (the
393
+ // close button, the scrim, a nav click, a swipe, Escape) makes the drawer
394
+ // `inert` while it may hold focus, which would drop focus to <body>. Runs
395
+ // before the DOM update, so focus moves before `inert` is applied.
396
+ let drawerReturnFocus: HTMLElement | null = null;
397
+ let wasDrawerOpen = false;
398
+ $effect.pre(() => {
399
+ const drawerOpen = isMobile && mobileOpen;
400
+ untrack(() => {
401
+ const active = document.activeElement as HTMLElement | null;
402
+ if (drawerOpen && !wasDrawerOpen) {
403
+ drawerReturnFocus =
404
+ active && active !== document.body && !asideElement?.contains(active) ? active : null;
405
+ } else if (!drawerOpen && wasDrawerOpen) {
406
+ // Leave focus alone if the host already moved it somewhere else
407
+ if (!active || active === document.body || asideElement?.contains(active)) {
408
+ const target = drawerReturnFocus?.isConnected
409
+ ? drawerReturnFocus
410
+ : document.querySelector<HTMLElement>('[data-sidebar-trigger]');
411
+ target?.focus();
412
+ }
413
+ drawerReturnFocus = null;
414
+ }
415
+ wasDrawerOpen = drawerOpen;
416
+ });
417
+ });
418
+
391
419
  $effect(() => {
392
420
  if (isMobile && mobileOpen && mobileCloseButton) {
393
421
  // Focus the close button when mobile sidebar opens
@@ -445,14 +473,14 @@
445
473
  let asideElement = $state<HTMLElement | null>(null);
446
474
 
447
475
  // Focus trap for the open mobile drawer: Tab cycles within the drawer,
448
- // Escape closes it and returns focus to the header menu button.
476
+ // Escape closes it (focus then returns to the header menu button).
449
477
  function handleDrawerKeydown(event: KeyboardEvent) {
450
478
  if (!isMobile || !mobileOpen) return;
451
479
 
452
480
  if (event.key === 'Escape') {
453
481
  event.preventDefault();
482
+ // Focus returns to the trigger when the host closes the drawer
454
483
  onClose?.();
455
- document.querySelector<HTMLElement>('[data-sidebar-trigger]')?.focus();
456
484
  return;
457
485
  }
458
486
 
@@ -538,6 +566,8 @@
538
566
  return styles.join('; ');
539
567
  })()}
540
568
  aria-label="Main navigation"
569
+ role={isMobile && mobileOpen ? 'dialog' : undefined}
570
+ aria-modal={isMobile && mobileOpen ? 'true' : undefined}
541
571
  bind:this={asideElement}
542
572
  inert={isMobile && !mobileOpen}
543
573
  onkeydown={handleDrawerKeydown}
@@ -11,7 +11,7 @@
11
11
  * - Optional stronger border styling
12
12
  * - Quick links section at bottom
13
13
  */
14
- import type { Snippet } from 'svelte';
14
+ import { type Snippet } from 'svelte';
15
15
  import type { NavSection, NavItem, QuickLink } from '../../types/layout.js';
16
16
  interface Props {
17
17
  /** Navigation sections */
@@ -5,6 +5,7 @@
5
5
  * Features entrance animation and keyboard navigation
6
6
  */
7
7
  import { fly } from 'svelte/transition';
8
+ import { useReducedMotion } from '../../../composables/useReducedMotion.svelte.js';
8
9
  import type { NavItem } from '../../../types/layout.js';
9
10
  import { cn } from '../../../utils.js';
10
11
 
@@ -35,6 +36,10 @@
35
36
  onMouseLeave,
36
37
  }: Props = $props();
37
38
 
39
+ // Svelte transitions run as Web Animations, which the CSS reduced-motion
40
+ // override does not reach, so gate the duration here
41
+ const reducedMotion = useReducedMotion();
42
+
38
43
  let flyoutElement = $state<HTMLElement | null>(null);
39
44
 
40
45
  // Get all focusable elements within the flyout
@@ -171,7 +176,7 @@
171
176
  onmouseenter={onMouseEnter}
172
177
  onmouseleave={onMouseLeave}
173
178
  onkeydown={handleKeydown}
174
- transition:fly={{ x: -10, duration: 150 }}
179
+ transition:fly={{ x: reducedMotion.value ? 0 : -10, duration: reducedMotion.value ? 0 : 150 }}
175
180
  >
176
181
  <div class="p-2">
177
182
  <!-- Item name as header - always clickable unless disabled -->
@@ -9,6 +9,7 @@
9
9
  * - Disabled and loading states
10
10
  */
11
11
  import { slide } from 'svelte/transition';
12
+ import { useReducedMotion } from '../../../composables/useReducedMotion.svelte.js';
12
13
  import type { Snippet } from 'svelte';
13
14
  import type { NavItem } from '../../../types/layout.js';
14
15
  import { cn } from '../../../utils.js';
@@ -67,6 +68,10 @@
67
68
  nested = false,
68
69
  }: Props = $props();
69
70
 
71
+ // Svelte transitions run as Web Animations, which the CSS reduced-motion
72
+ // override does not reach, so gate the duration here
73
+ const reducedMotion = useReducedMotion();
74
+
70
75
  // Highlight matching text in item names
71
76
  function highlightText(
72
77
  text: string,
@@ -387,7 +392,7 @@
387
392
  !nested && 'ml-3 rounded-md p-1',
388
393
  !nested && (isLight ? 'bg-muted/50' : 'bg-sidebar-accent/30')
389
394
  )}
390
- transition:slide={{ duration: sidebarStore.animate ? 200 : 0 }}
395
+ transition:slide={{ duration: sidebarStore.animate && !reducedMotion.value ? 200 : 0 }}
391
396
  >
392
397
  <ul class={cn('space-y-1', nested && 'ml-3 border-l border-outline/40 pl-1')}>
393
398
  {#each item.children ?? [] as child (child.id)}
@@ -5,6 +5,7 @@
5
5
  * Features animated collapse/expand transitions
6
6
  */
7
7
  import { slide } from 'svelte/transition';
8
+ import { useReducedMotion } from '../../../composables/useReducedMotion.svelte.js';
8
9
  import type { Snippet } from 'svelte';
9
10
  import type { NavSection, NavItem } from '../../../types/layout.js';
10
11
  import { cn } from '../../../utils.js';
@@ -59,6 +60,10 @@
59
60
  siblingSectionIds = [],
60
61
  }: Props = $props();
61
62
 
63
+ // Svelte transitions run as Web Animations, which the CSS reduced-motion
64
+ // override does not reach, so gate the duration here
65
+ const reducedMotion = useReducedMotion();
66
+
62
67
  // Items here that can expand: the siblings an expanding item closes under autoCollapse
63
68
  const expandableItemIds = $derived(
64
69
  autoCollapse ? section.items.filter((i) => i.children?.length).map((i) => i.id) : []
@@ -153,7 +158,7 @@
153
158
  'mx-2 overflow-hidden rounded-md p-1',
154
159
  isLight ? 'bg-muted/50' : 'bg-sidebar-accent/30'
155
160
  )}
156
- transition:slide={{ duration: sidebarStore.animate ? 200 : 0 }}
161
+ transition:slide={{ duration: sidebarStore.animate && !reducedMotion.value ? 200 : 0 }}
157
162
  >
158
163
  <ul class="space-y-1" id={`sidebar-section-${section.id}`}>
159
164
  {#each section.items as item}
@@ -89,11 +89,27 @@
89
89
  );
90
90
  }
91
91
 
92
+ /**
93
+ * Space on a control inside the player (Mute, Speed, CC...) must activate
94
+ * that control, not toggle playback.
95
+ */
96
+ function isInnerControl(event: KeyboardEvent): boolean {
97
+ const target = event.target;
98
+ if (!(target instanceof Element) || target === event.currentTarget) return false;
99
+ return !!target.closest(
100
+ 'button, a[href], summary, [role="button"], [role="checkbox"], [role="radio"], [role="switch"], [role="menuitem"], [role="tab"]'
101
+ );
102
+ }
103
+
92
104
  function handleKeyDown(event: KeyboardEvent) {
93
105
  if (isTextEntry(event.target) || event.metaKey || event.ctrlKey || event.altKey) return;
94
106
 
95
107
  switch (event.key) {
96
108
  case ' ':
109
+ if (isInnerControl(event)) return;
110
+ event.preventDefault();
111
+ player.toggle();
112
+ break;
97
113
  case 'k':
98
114
  case 'K':
99
115
  event.preventDefault();
@@ -32,6 +32,7 @@
32
32
  import MediaEmbed from './MediaEmbed.svelte';
33
33
  import Video from './Video.svelte';
34
34
  import MediaTile from './internal/MediaTile.svelte';
35
+ import TriggerChild, { type TriggerSnippetProps } from '../internal/TriggerChild.svelte';
35
36
 
36
37
  /** Horizontal distance that counts as a swipe rather than a tap */
37
38
  const SWIPE_THRESHOLD = 60;
@@ -59,8 +60,14 @@
59
60
  zoomable?: boolean;
60
61
  /** Show a download link for items that expose a URL */
61
62
  downloadable?: boolean;
62
- /** Optional trigger rendered inline; omit when controlling `open` yourself */
63
- trigger?: Snippet;
63
+ /**
64
+ * Optional trigger element; omit when controlling `open` yourself. It
65
+ * receives `{ props }`: spread them on your own button
66
+ * (`{#snippet trigger({ props })}<Button {...props}>Open</Button>{/snippet}`)
67
+ * so that button is the trigger and gets focus back on close. A snippet
68
+ * that ignores them still works, wrapped in a span trigger as before.
69
+ */
70
+ trigger?: Snippet<[TriggerSnippetProps]>;
64
71
  /** Additional classes for the dialog surface */
65
72
  class?: string;
66
73
  }
@@ -81,9 +88,17 @@
81
88
  class: className,
82
89
  }: Props = $props();
83
90
 
91
+ const uid = $props.id();
92
+ // Changes once if the trigger snippet ignores its props (see TriggerChild)
93
+ let triggerId = $state(`${uid}-trigger`);
94
+
84
95
  let swipeStartX = 0;
85
96
  let swipeStartY = 0;
86
97
  let swipeActive = false;
98
+ /** The one pointer a swipe follows; a second finger means a pinch. */
99
+ let swipePointerId: number | null = null;
100
+ let swipeStartScale = 1;
101
+ const downPointers = new Set<number>();
87
102
 
88
103
  const reducedMotion = useReducedMotion();
89
104
  const zoom = useMediaZoom();
@@ -218,16 +233,37 @@
218
233
  }
219
234
  }
220
235
 
236
+ function cancelSwipe() {
237
+ swipeActive = false;
238
+ swipePointerId = null;
239
+ }
240
+
221
241
  function handleSwipeStart(event: PointerEvent) {
242
+ downPointers.add(event.pointerId);
243
+ // A second pointer turns the gesture into a pinch, never a swipe
244
+ if (downPointers.size > 1) {
245
+ cancelSwipe();
246
+ return;
247
+ }
222
248
  if (panning || !hasMultiple) return;
223
249
  swipeActive = true;
250
+ swipePointerId = event.pointerId;
224
251
  swipeStartX = event.clientX;
225
252
  swipeStartY = event.clientY;
253
+ swipeStartScale = zoom.scale;
254
+ }
255
+
256
+ function handlePointerGone(event: PointerEvent) {
257
+ downPointers.delete(event.pointerId);
258
+ if (event.pointerId === swipePointerId) cancelSwipe();
226
259
  }
227
260
 
228
261
  function handleSwipeEnd(event: PointerEvent) {
229
- if (!swipeActive) return;
230
- swipeActive = false;
262
+ downPointers.delete(event.pointerId);
263
+ if (!swipeActive || event.pointerId !== swipePointerId) return;
264
+ cancelSwipe();
265
+ // The gesture zoomed (pinch or double-tap): it was not a swipe
266
+ if (zoom.scale !== swipeStartScale) return;
231
267
 
232
268
  const dx = event.clientX - swipeStartX;
233
269
  const dy = event.clientY - swipeStartY;
@@ -240,11 +276,9 @@
240
276
 
241
277
  <DialogPrimitive.Root bind:open onOpenChange={handleOpenChange}>
242
278
  {#if trigger}
243
- <DialogPrimitive.Trigger>
279
+ <DialogPrimitive.Trigger id={triggerId}>
244
280
  {#snippet child({ props })}
245
- <span {...props}>
246
- {@render trigger()}
247
- </span>
281
+ <TriggerChild {props} {trigger} onfallback={() => (triggerId = `${uid}-trigger-wrap`)} />
248
282
  {/snippet}
249
283
  </DialogPrimitive.Trigger>
250
284
  {/if}
@@ -342,7 +376,8 @@
342
376
  use:zoom.action
343
377
  onpointerdown={handleSwipeStart}
344
378
  onpointerup={handleSwipeEnd}
345
- onpointercancel={() => (swipeActive = false)}
379
+ onpointercancel={handlePointerGone}
380
+ onpointerleave={handlePointerGone}
346
381
  >
347
382
  <img
348
383
  src={activeItem.src}
@@ -1,5 +1,6 @@
1
1
  import { type Snippet } from 'svelte';
2
2
  import type { MediaItem } from '../../types/components.js';
3
+ import { type TriggerSnippetProps } from '../internal/TriggerChild.svelte';
3
4
  interface Props {
4
5
  /** Items to page through */
5
6
  items: MediaItem[];
@@ -23,8 +24,14 @@ interface Props {
23
24
  zoomable?: boolean;
24
25
  /** Show a download link for items that expose a URL */
25
26
  downloadable?: boolean;
26
- /** Optional trigger rendered inline; omit when controlling `open` yourself */
27
- trigger?: Snippet;
27
+ /**
28
+ * Optional trigger element; omit when controlling `open` yourself. It
29
+ * receives `{ props }`: spread them on your own button
30
+ * (`{#snippet trigger({ props })}<Button {...props}>Open</Button>{/snippet}`)
31
+ * so that button is the trigger and gets focus back on close. A snippet
32
+ * that ignores them still works, wrapped in a span trigger as before.
33
+ */
34
+ trigger?: Snippet<[TriggerSnippetProps]>;
28
35
  /** Additional classes for the dialog surface */
29
36
  class?: string;
30
37
  }
@@ -18,6 +18,7 @@
18
18
  * <MediaCarousel items={photos} label="Project gallery" showThumbnails />
19
19
  * ```
20
20
  */
21
+ import { untrack } from 'svelte';
21
22
  import { useReducedMotion } from '../../composables/useReducedMotion.svelte.js';
22
23
  import type { MediaAspectRatio, MediaItem } from '../../types/components.js';
23
24
  import { cn } from '../../utils.js';
@@ -78,6 +79,8 @@
78
79
  let interacting = $state(false);
79
80
  let scrollSyncTimer: ReturnType<typeof setTimeout> | undefined;
80
81
  let autoplayTimer: ReturnType<typeof setInterval> | undefined;
82
+ /** The index the track was last scrolled to (or scrolled itself to); -1 before mount. */
83
+ let syncedIndex = -1;
81
84
 
82
85
  const reducedMotion = useReducedMotion();
83
86
 
@@ -98,10 +101,25 @@
98
101
  if (target === index) return;
99
102
 
100
103
  index = target;
104
+ syncedIndex = target;
101
105
  onIndexChange?.(target);
102
106
  scrollToSlide(target, smooth);
103
107
  }
104
108
 
109
+ // An initial or parent-set `index` must move the track too, or the visible
110
+ // slide is not the active one (it stays inert and hidden from AT). Only
111
+ // `index` and the track are tracked; the scroll itself runs untracked.
112
+ $effect(() => {
113
+ const target = index;
114
+ if (!trackElement) return;
115
+ untrack(() => {
116
+ if (target === syncedIndex) return;
117
+ // Jump on mount; animate later changes
118
+ scrollToSlide(target, syncedIndex !== -1);
119
+ syncedIndex = target;
120
+ });
121
+ });
122
+
105
123
  function scrollToSlide(target: number, smooth: boolean) {
106
124
  if (!trackElement) return;
107
125
  const slide = trackElement.children[target] as HTMLElement | undefined;
@@ -138,6 +156,7 @@
138
156
  }
139
157
 
140
158
  if (closest !== index) {
159
+ syncedIndex = closest;
141
160
  index = closest;
142
161
  onIndexChange?.(closest);
143
162
  }
@@ -44,6 +44,7 @@
44
44
  MediaAspectRatio,
45
45
  MediaEmbedProvider,
46
46
  } from '../../types/components.js';
47
+ import { tick, untrack } from 'svelte';
47
48
  import { cn } from '../../utils.js';
48
49
  import {
49
50
  buildEmbedSrc,
@@ -169,6 +170,33 @@
169
170
  /** Matterport's own name for the space, used when no `title` was supplied. */
170
171
  const facadeTitle = $derived(title || fetchedName || '');
171
172
 
173
+ // A new video gets its own facade: carrying `activated` over would load and
174
+ // autoplay it without a click, and the fetched poster belongs to the old one.
175
+ let source: string | null = null;
176
+ $effect(() => {
177
+ const next = `${resolvedProvider}:${resolvedVideoId}`;
178
+ untrack(() => {
179
+ if (source !== null && source !== next) {
180
+ activated = false;
181
+ fetchedPoster = null;
182
+ fetchedName = null;
183
+ }
184
+ source = next;
185
+ });
186
+ });
187
+
188
+ let iframeElement = $state<HTMLIFrameElement | null>(null);
189
+
190
+ // Activating the facade from the keyboard removes the focused button; move
191
+ // focus to the player that replaced it rather than dropping it on <body>.
192
+ async function activate(event: MouseEvent) {
193
+ const hadFocus = document.activeElement === event.currentTarget;
194
+ activated = true;
195
+ if (!hadFocus) return;
196
+ await tick();
197
+ iframeElement?.focus();
198
+ }
199
+
172
200
  // Only reaches the network when the caller opted in, and never when a poster
173
201
  // was supplied — there would be nothing to gain.
174
202
  $effect(() => {
@@ -193,6 +221,8 @@
193
221
  // satisfies the browser's user-gesture requirement.
194
222
  autoplay: activated,
195
223
  start: resolvedStart,
224
+ // Unlisted Vimeo hash from `url`, unless `videoId` names another video
225
+ hash: parsed?.videoId === resolvedVideoId ? parsed?.hash : undefined,
196
226
  showRelated,
197
227
  primaryColor,
198
228
  letterboxColor,
@@ -250,6 +280,7 @@
250
280
  </div>
251
281
  {:else if showIframe}
252
282
  <iframe
283
+ bind:this={iframeElement}
253
284
  src={iframeSrc}
254
285
  {title}
255
286
  class="h-full w-full border-0"
@@ -264,7 +295,7 @@
264
295
  <button
265
296
  type="button"
266
297
  class="group/facade relative h-full w-full overflow-hidden bg-black outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
267
- onclick={() => (activated = true)}
298
+ onclick={activate}
268
299
  aria-label={isMatterport
269
300
  ? `Open ${facadeTitle} as a 3D tour on ${providerLabel}`
270
301
  : `Play ${facadeTitle} on ${providerLabel}`}
@@ -225,6 +225,18 @@
225
225
  );
226
226
  }
227
227
 
228
+ /**
229
+ * Space on a control inside the player (Mute, Speed, CC...) must activate
230
+ * that control, not toggle playback.
231
+ */
232
+ function isInnerControl(event: KeyboardEvent): boolean {
233
+ const target = event.target;
234
+ if (!(target instanceof Element) || target === event.currentTarget) return false;
235
+ return !!target.closest(
236
+ 'button, a[href], summary, [role="button"], [role="checkbox"], [role="radio"], [role="switch"], [role="menuitem"], [role="tab"]'
237
+ );
238
+ }
239
+
228
240
  function handleKeyDown(event: KeyboardEvent) {
229
241
  if (isTextEntry(event.target) || event.metaKey || event.ctrlKey || event.altKey) return;
230
242
 
@@ -232,6 +244,10 @@
232
244
 
233
245
  switch (event.key) {
234
246
  case ' ':
247
+ if (isInnerControl(event)) return;
248
+ event.preventDefault();
249
+ togglePlayback();
250
+ break;
235
251
  case 'k':
236
252
  case 'K':
237
253
  event.preventDefault();
@@ -81,6 +81,14 @@
81
81
 
82
82
  const hasCaptions = $derived(player.textTracks.length > 0);
83
83
  const captionsOn = $derived(player.activeTextTrack !== null);
84
+ // Firefox (and video elements that opt out) have no picture-in-picture API;
85
+ // a button there would do nothing. `element` is null during SSR.
86
+ const pipSupported = $derived(
87
+ !!player.element &&
88
+ player.element instanceof HTMLVideoElement &&
89
+ document.pictureInPictureEnabled === true &&
90
+ !player.element.disablePictureInPicture
91
+ );
84
92
  const volumeValue = $derived(player.muted ? 0 : Math.round(player.volume * 100));
85
93
 
86
94
  const volumeIcon = $derived(
@@ -258,7 +266,7 @@
258
266
  </button>
259
267
  {/if}
260
268
 
261
- {#if showPictureInPicture}
269
+ {#if showPictureInPicture && pipSupported}
262
270
  <button
263
271
  type="button"
264
272
  class={buttonClass}
@@ -54,7 +54,7 @@
54
54
  {#if showBadge}
55
55
  <span
56
56
  class={cn(
57
- 'absolute -top-1 -right-1 flex items-center justify-center rounded-full bg-destructive text-white font-medium px-1',
57
+ 'absolute -top-1 -right-1 flex items-center justify-center rounded-full bg-destructive text-destructive-foreground font-medium px-1',
58
58
  sizeClasses[size]
59
59
  )}
60
60
  aria-label={`${count} unread notifications`}
@@ -60,19 +60,11 @@
60
60
  function handleCheckboxChange(checked: boolean) {
61
61
  onToggleSelect?.(notification.id, checked);
62
62
  }
63
-
64
- function handleKeyDown(e: KeyboardEvent) {
65
- // Only keys pressed on the row itself; Enter on the nested action link
66
- // must follow the link, not activate the row.
67
- if (e.target !== e.currentTarget) return;
68
- if (e.key === 'Enter' || e.key === ' ') {
69
- e.preventDefault();
70
- onClick?.(notification);
71
- }
72
- }
73
63
  </script>
74
64
 
75
- <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
65
+ <!-- When clickable, the title is the button and its ::after stretches over
66
+ the whole card, so the card has one tab stop and no interactive element
67
+ is nested inside another. The checkbox and action link sit above it. -->
76
68
  <div
77
69
  class={cn(
78
70
  'group relative flex gap-3 rounded-lg border p-4',
@@ -82,18 +74,9 @@
82
74
  isSelected && 'ring-2 ring-primary ring-offset-2',
83
75
  className
84
76
  )}
85
- role={onClick ? 'button' : undefined}
86
- tabindex={onClick ? 0 : undefined}
87
- onclick={onClick ? handleClick : undefined}
88
- onkeydown={onClick ? handleKeyDown : undefined}
89
77
  >
90
78
  {#if selectable}
91
- <div
92
- class="flex items-start pt-0.5"
93
- role="presentation"
94
- onclick={(e) => e.stopPropagation()}
95
- onkeydown={(e) => e.stopPropagation()}
96
- >
79
+ <div class="relative z-10 flex items-start pt-0.5">
97
80
  <Checkbox
98
81
  checked={isSelected}
99
82
  onCheckedChange={handleCheckboxChange}
@@ -118,7 +101,19 @@
118
101
  <div class="min-w-0 flex-1">
119
102
  <div class="flex items-center gap-2 flex-wrap">
120
103
  <h4 class={cn('text-sm font-medium truncate', !notification.read && 'font-semibold')}>
121
- {notification.title}
104
+ {#if onClick}
105
+ <button
106
+ type="button"
107
+ class="block max-w-full truncate text-left font-[inherit] focus-visible:outline-none after:absolute after:inset-0 after:rounded-lg after:content-[''] focus-visible:after:ring-2 focus-visible:after:ring-ring"
108
+ onclick={handleClick}
109
+ >
110
+ {notification.title}
111
+ {#if !notification.read}<span class="sr-only">(unread)</span>{/if}
112
+ </button>
113
+ {:else}
114
+ {notification.title}
115
+ {#if !notification.read}<span class="sr-only">(unread)</span>{/if}
116
+ {/if}
122
117
  </h4>
123
118
  <Badge variant={typeBadgeVariant} size="sm">
124
119
  {#snippet children()}
@@ -142,18 +137,14 @@
142
137
  {relativeTime}
143
138
  </span>
144
139
  {#if !notification.read}
145
- <span class="h-2 w-2 rounded-full bg-info" aria-label="Unread"></span>
140
+ <span class="h-2 w-2 rounded-full bg-info" aria-hidden="true"></span>
146
141
  {/if}
147
142
  </div>
148
143
  </div>
149
144
 
150
145
  {#if actionHref && notification.actionLabel}
151
146
  <div class="mt-2">
152
- <a
153
- href={actionHref}
154
- class="text-sm font-medium text-primary hover:underline"
155
- onclick={(e) => e.stopPropagation()}
156
- >
147
+ <a href={actionHref} class="relative z-10 text-sm font-medium text-primary hover:underline">
157
148
  {notification.actionLabel}
158
149
  </a>
159
150
  </div>
@@ -27,6 +27,10 @@ export interface UseEscapeKeyOptions {
27
27
  * Registers a global keydown listener for the Escape key.
28
28
  * Useful for closing modals, dialogs, dropdowns, etc.
29
29
  *
30
+ * Escape that something else already handled is ignored: an event whose
31
+ * default was prevented, or one pressed while a bits-ui layer (an open
32
+ * Select, Popover, menu or dialog) is open, which closes that layer instead.
33
+ *
30
34
  * @see https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/
31
35
  *
32
36
  * @example
@@ -1,9 +1,22 @@
1
+ /**
2
+ * Whether a bits-ui escape layer (an open Select, Popover, Dialog, menu...)
3
+ * is active. bits-ui registers those in a global map; the topmost one owns
4
+ * Escape, so a page-level handler must leave the key to it.
5
+ */
6
+ function hasOpenEscapeLayer() {
7
+ const layers = globalThis.bitsEscapeLayers;
8
+ return !!layers && layers.size > 0;
9
+ }
1
10
  /**
2
11
  * useEscapeKey - Composable for handling Escape key press
3
12
  *
4
13
  * Registers a global keydown listener for the Escape key.
5
14
  * Useful for closing modals, dialogs, dropdowns, etc.
6
15
  *
16
+ * Escape that something else already handled is ignored: an event whose
17
+ * default was prevented, or one pressed while a bits-ui layer (an open
18
+ * Select, Popover, menu or dialog) is open, which closes that layer instead.
19
+ *
7
20
  * @see https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/
8
21
  *
9
22
  * @example
@@ -34,6 +47,8 @@ export function useEscapeKey(callback, options) {
34
47
  return;
35
48
  function handleKeyDown(event) {
36
49
  if (event.key === 'Escape') {
50
+ if (event.defaultPrevented || hasOpenEscapeLayer())
51
+ return;
37
52
  if (preventDefault) {
38
53
  event.preventDefault();
39
54
  }