@classic-homes/theme-svelte 1.25.0 → 1.26.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/dist/lib/components/AlertDialog.svelte +14 -6
- package/dist/lib/components/AlertDialog.svelte.d.ts +8 -2
- package/dist/lib/components/Badge.svelte +6 -2
- package/dist/lib/components/CodeField.svelte +24 -8
- package/dist/lib/components/CodeField.svelte.d.ts +8 -0
- package/dist/lib/components/Combobox.svelte +25 -111
- package/dist/lib/components/DataPanel.svelte +31 -24
- package/dist/lib/components/DataPanelHeader.svelte +66 -26
- package/dist/lib/components/DataTable.svelte +303 -138
- package/dist/lib/components/DataTable.svelte.d.ts +37 -13
- package/dist/lib/components/DateTimePicker.svelte +159 -34
- package/dist/lib/components/Dialog.svelte +14 -6
- package/dist/lib/components/Dialog.svelte.d.ts +8 -2
- package/dist/lib/components/FileUpload.svelte +23 -6
- package/dist/lib/components/FileUpload.svelte.d.ts +7 -2
- package/dist/lib/components/MultiSelect.svelte +28 -155
- package/dist/lib/components/NumberInput.svelte +28 -110
- package/dist/lib/components/OTPInput.svelte +74 -86
- package/dist/lib/components/Select.svelte +24 -29
- package/dist/lib/components/Signature.svelte +80 -48
- package/dist/lib/components/Signature.svelte.d.ts +6 -1
- package/dist/lib/components/SkipLink.svelte +7 -2
- package/dist/lib/components/SkipLink.svelte.d.ts +2 -0
- package/dist/lib/components/Slider.svelte +4 -4
- package/dist/lib/components/Toast.svelte +11 -1
- package/dist/lib/components/ToastContainer.svelte +38 -24
- package/dist/lib/components/Tooltip.svelte +19 -6
- package/dist/lib/components/Tooltip.svelte.d.ts +8 -2
- package/dist/lib/components/data-table/DataTableColumnFilter.svelte +3 -1
- package/dist/lib/components/data-table/DataTableEditableCell.svelte +25 -3
- package/dist/lib/components/data-table/DataTableFacetedFilter.svelte +50 -40
- package/dist/lib/components/data-table/DataTableFacetedFilter.svelte.d.ts +0 -10
- package/dist/lib/components/data-table/DataTableVirtual.svelte +109 -57
- package/dist/lib/components/data-table/DataTableVirtual.svelte.d.ts +2 -0
- package/dist/lib/components/data-table/createDataTable.svelte.d.ts +26 -4
- package/dist/lib/components/data-table/createDataTable.svelte.js +97 -40
- package/dist/lib/components/data-table/index.d.ts +1 -1
- package/dist/lib/components/data-table/types.d.ts +14 -4
- package/dist/lib/components/data-table/types.js +5 -1
- package/dist/lib/components/internal/TriggerChild.svelte +60 -0
- package/dist/lib/components/internal/TriggerChild.svelte.d.ts +32 -0
- package/dist/lib/components/layout/Sidebar.svelte +33 -3
- package/dist/lib/components/layout/Sidebar.svelte.d.ts +1 -1
- package/dist/lib/components/layout/sidebar/SidebarFlyout.svelte +6 -1
- package/dist/lib/components/layout/sidebar/SidebarNavItem.svelte +6 -1
- package/dist/lib/components/layout/sidebar/SidebarSection.svelte +6 -1
- package/dist/lib/components/media/Audio.svelte +16 -0
- package/dist/lib/components/media/Lightbox.svelte +29 -3
- package/dist/lib/components/media/MediaCarousel.svelte +19 -0
- package/dist/lib/components/media/MediaEmbed.svelte +32 -1
- package/dist/lib/components/media/Video.svelte +16 -0
- package/dist/lib/components/media/internal/MediaControls.svelte +9 -1
- package/dist/lib/components/notifications/NotificationBadge.svelte +1 -1
- package/dist/lib/components/notifications/NotificationItem.svelte +19 -28
- package/dist/lib/composables/useEscapeKey.svelte.d.ts +4 -0
- package/dist/lib/composables/useEscapeKey.svelte.js +15 -0
- package/dist/lib/composables/useHlsSource.svelte.js +31 -4
- package/dist/lib/composables/useMediaPlayer.svelte.js +38 -9
- package/dist/lib/composables/useMediaZoom.svelte.js +32 -0
- package/dist/lib/composables/usePersistedForm.svelte.d.ts +6 -2
- package/dist/lib/composables/usePersistedForm.svelte.js +7 -7
- package/dist/lib/composables/useReducedMotion.svelte.js +6 -1
- package/dist/lib/composables/useRovingTabindex.svelte.js +12 -0
- package/dist/lib/index.d.ts +1 -1
- package/dist/lib/stores/toast.svelte.d.ts +13 -0
- package/dist/lib/stores/toast.svelte.js +36 -5
- package/dist/lib/utils/media.d.ts +7 -0
- package/dist/lib/utils/media.js +18 -3
- 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
|
|
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
|
|
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
|
|
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();
|
|
@@ -84,6 +84,10 @@
|
|
|
84
84
|
let swipeStartX = 0;
|
|
85
85
|
let swipeStartY = 0;
|
|
86
86
|
let swipeActive = false;
|
|
87
|
+
/** The one pointer a swipe follows; a second finger means a pinch. */
|
|
88
|
+
let swipePointerId: number | null = null;
|
|
89
|
+
let swipeStartScale = 1;
|
|
90
|
+
const downPointers = new Set<number>();
|
|
87
91
|
|
|
88
92
|
const reducedMotion = useReducedMotion();
|
|
89
93
|
const zoom = useMediaZoom();
|
|
@@ -218,16 +222,37 @@
|
|
|
218
222
|
}
|
|
219
223
|
}
|
|
220
224
|
|
|
225
|
+
function cancelSwipe() {
|
|
226
|
+
swipeActive = false;
|
|
227
|
+
swipePointerId = null;
|
|
228
|
+
}
|
|
229
|
+
|
|
221
230
|
function handleSwipeStart(event: PointerEvent) {
|
|
231
|
+
downPointers.add(event.pointerId);
|
|
232
|
+
// A second pointer turns the gesture into a pinch, never a swipe
|
|
233
|
+
if (downPointers.size > 1) {
|
|
234
|
+
cancelSwipe();
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
222
237
|
if (panning || !hasMultiple) return;
|
|
223
238
|
swipeActive = true;
|
|
239
|
+
swipePointerId = event.pointerId;
|
|
224
240
|
swipeStartX = event.clientX;
|
|
225
241
|
swipeStartY = event.clientY;
|
|
242
|
+
swipeStartScale = zoom.scale;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function handlePointerGone(event: PointerEvent) {
|
|
246
|
+
downPointers.delete(event.pointerId);
|
|
247
|
+
if (event.pointerId === swipePointerId) cancelSwipe();
|
|
226
248
|
}
|
|
227
249
|
|
|
228
250
|
function handleSwipeEnd(event: PointerEvent) {
|
|
229
|
-
|
|
230
|
-
swipeActive
|
|
251
|
+
downPointers.delete(event.pointerId);
|
|
252
|
+
if (!swipeActive || event.pointerId !== swipePointerId) return;
|
|
253
|
+
cancelSwipe();
|
|
254
|
+
// The gesture zoomed (pinch or double-tap): it was not a swipe
|
|
255
|
+
if (zoom.scale !== swipeStartScale) return;
|
|
231
256
|
|
|
232
257
|
const dx = event.clientX - swipeStartX;
|
|
233
258
|
const dy = event.clientY - swipeStartY;
|
|
@@ -342,7 +367,8 @@
|
|
|
342
367
|
use:zoom.action
|
|
343
368
|
onpointerdown={handleSwipeStart}
|
|
344
369
|
onpointerup={handleSwipeEnd}
|
|
345
|
-
onpointercancel={
|
|
370
|
+
onpointercancel={handlePointerGone}
|
|
371
|
+
onpointerleave={handlePointerGone}
|
|
346
372
|
>
|
|
347
373
|
<img
|
|
348
374
|
src={activeItem.src}
|
|
@@ -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={
|
|
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-
|
|
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
|
-
<!--
|
|
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
|
-
{
|
|
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-
|
|
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
|
}
|
|
@@ -24,6 +24,18 @@
|
|
|
24
24
|
* ```
|
|
25
25
|
*/
|
|
26
26
|
import { canPlayHlsNatively, loadHls, } from '../utils/hls.js';
|
|
27
|
+
/** Recovery attempts per error type before a fatal error is treated as terminal. */
|
|
28
|
+
const MAX_RECOVERY_ATTEMPTS = 3;
|
|
29
|
+
/**
|
|
30
|
+
* Fatal network errors that retrying cannot fix: the playlist itself is
|
|
31
|
+
* missing (404), unreachable, or not a playlist.
|
|
32
|
+
*/
|
|
33
|
+
const TERMINAL_DETAILS = new Set([
|
|
34
|
+
'manifestLoadError',
|
|
35
|
+
'manifestLoadTimeOut',
|
|
36
|
+
'manifestParsingError',
|
|
37
|
+
'manifestIncompatibleCodecsError',
|
|
38
|
+
]);
|
|
27
39
|
export function useHlsSource(getElement, options) {
|
|
28
40
|
let status = $state('idle');
|
|
29
41
|
let error = $state(null);
|
|
@@ -67,16 +79,31 @@ export function useHlsSource(getElement, options) {
|
|
|
67
79
|
return;
|
|
68
80
|
}
|
|
69
81
|
instance = new Hls(config);
|
|
82
|
+
let networkRetries = 0;
|
|
83
|
+
let mediaRecoveries = 0;
|
|
84
|
+
// A fragment that loads means playback recovered; a long stream gets a
|
|
85
|
+
// fresh allowance for its next unrelated hiccup.
|
|
86
|
+
if (Hls.Events.FRAG_LOADED) {
|
|
87
|
+
instance.on(Hls.Events.FRAG_LOADED, () => {
|
|
88
|
+
networkRetries = 0;
|
|
89
|
+
mediaRecoveries = 0;
|
|
90
|
+
});
|
|
91
|
+
}
|
|
70
92
|
instance.on(Hls.Events.ERROR, (_event, data) => {
|
|
71
93
|
if (!data?.fatal)
|
|
72
94
|
return;
|
|
73
|
-
// hls.js can recover from network and media errors
|
|
74
|
-
//
|
|
75
|
-
|
|
95
|
+
// hls.js can recover from network and media errors, but only so
|
|
96
|
+
// often: an unbounded retry of a 404 would never report 'error'.
|
|
97
|
+
const terminal = data.details !== undefined && TERMINAL_DETAILS.has(data.details);
|
|
98
|
+
if (data.type === Hls.ErrorTypes.NETWORK_ERROR &&
|
|
99
|
+
!terminal &&
|
|
100
|
+
networkRetries < MAX_RECOVERY_ATTEMPTS) {
|
|
101
|
+
networkRetries += 1;
|
|
76
102
|
instance?.startLoad();
|
|
77
103
|
return;
|
|
78
104
|
}
|
|
79
|
-
if (data.type === Hls.ErrorTypes.MEDIA_ERROR) {
|
|
105
|
+
if (data.type === Hls.ErrorTypes.MEDIA_ERROR && mediaRecoveries < MAX_RECOVERY_ATTEMPTS) {
|
|
106
|
+
mediaRecoveries += 1;
|
|
80
107
|
instance?.recoverMediaError();
|
|
81
108
|
return;
|
|
82
109
|
}
|