@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
|
@@ -122,6 +122,18 @@ export function useMediaPlayer(getElement, options = {}) {
|
|
|
122
122
|
*/
|
|
123
123
|
const handleReady = () => {
|
|
124
124
|
waiting = false;
|
|
125
|
+
loading = false;
|
|
126
|
+
// A successful (re)load replaces an earlier failure
|
|
127
|
+
if (!media.error)
|
|
128
|
+
error = null;
|
|
129
|
+
};
|
|
130
|
+
/** A new load starts (e.g. "Try again" calling load()): forget the old failure. */
|
|
131
|
+
const handleLoadStart = () => {
|
|
132
|
+
error = null;
|
|
133
|
+
loading = true;
|
|
134
|
+
};
|
|
135
|
+
const handleEmptied = () => {
|
|
136
|
+
error = null;
|
|
125
137
|
};
|
|
126
138
|
const handleError = () => {
|
|
127
139
|
error = media.error;
|
|
@@ -159,6 +171,8 @@ export function useMediaPlayer(getElement, options = {}) {
|
|
|
159
171
|
media.addEventListener('canplaythrough', handleReady);
|
|
160
172
|
media.addEventListener('loadeddata', handleReady);
|
|
161
173
|
media.addEventListener('error', handleError);
|
|
174
|
+
media.addEventListener('loadstart', handleLoadStart);
|
|
175
|
+
media.addEventListener('emptied', handleEmptied);
|
|
162
176
|
media.addEventListener('ratechange', handleRateChange);
|
|
163
177
|
media.addEventListener('seeking', handleSeeking);
|
|
164
178
|
media.addEventListener('seeked', handleSeeking);
|
|
@@ -190,6 +204,8 @@ export function useMediaPlayer(getElement, options = {}) {
|
|
|
190
204
|
media.removeEventListener('canplaythrough', handleReady);
|
|
191
205
|
media.removeEventListener('loadeddata', handleReady);
|
|
192
206
|
media.removeEventListener('error', handleError);
|
|
207
|
+
media.removeEventListener('loadstart', handleLoadStart);
|
|
208
|
+
media.removeEventListener('emptied', handleEmptied);
|
|
193
209
|
media.removeEventListener('ratechange', handleRateChange);
|
|
194
210
|
media.removeEventListener('seeking', handleSeeking);
|
|
195
211
|
media.removeEventListener('seeked', handleSeeking);
|
|
@@ -289,16 +305,24 @@ export function useMediaPlayer(getElement, options = {}) {
|
|
|
289
305
|
return;
|
|
290
306
|
media.playbackRate = rate;
|
|
291
307
|
}
|
|
308
|
+
// Fullscreen and PiP requests reject when the browser refuses them (no user
|
|
309
|
+
// gesture, a permissions policy, an iframe without allowfullscreen). The
|
|
310
|
+
// state simply stays as it was, so the rejection is not worth surfacing.
|
|
292
311
|
async function toggleFullscreen(container) {
|
|
293
312
|
const target = container ?? getElement();
|
|
294
313
|
if (!target)
|
|
295
314
|
return;
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
315
|
+
try {
|
|
316
|
+
if (document.fullscreenElement) {
|
|
317
|
+
await document.exitFullscreen();
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
if (typeof target.requestFullscreen === 'function') {
|
|
321
|
+
await target.requestFullscreen();
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
299
324
|
}
|
|
300
|
-
|
|
301
|
-
await target.requestFullscreen();
|
|
325
|
+
catch {
|
|
302
326
|
return;
|
|
303
327
|
}
|
|
304
328
|
// iOS Safari exposes fullscreen only on the video element itself.
|
|
@@ -311,11 +335,16 @@ export function useMediaPlayer(getElement, options = {}) {
|
|
|
311
335
|
return;
|
|
312
336
|
if (!document.pictureInPictureEnabled)
|
|
313
337
|
return;
|
|
314
|
-
|
|
315
|
-
|
|
338
|
+
try {
|
|
339
|
+
if (document.pictureInPictureElement) {
|
|
340
|
+
await document.exitPictureInPicture();
|
|
341
|
+
}
|
|
342
|
+
else {
|
|
343
|
+
await media.requestPictureInPicture();
|
|
344
|
+
}
|
|
316
345
|
}
|
|
317
|
-
|
|
318
|
-
|
|
346
|
+
catch {
|
|
347
|
+
// See toggleFullscreen
|
|
319
348
|
}
|
|
320
349
|
}
|
|
321
350
|
function setTextTrack(index) {
|
|
@@ -38,6 +38,13 @@ export function useMediaZoom(options = {}) {
|
|
|
38
38
|
let dragOrigin = { x: 0, y: 0, offsetX: 0, offsetY: 0 };
|
|
39
39
|
let pinchStartDistance = 0;
|
|
40
40
|
let pinchStartScale = 1;
|
|
41
|
+
/** Movement (px) beyond which a touch is a drag rather than a tap. */
|
|
42
|
+
const TAP_SLOP = 10;
|
|
43
|
+
/**
|
|
44
|
+
* Touches that may still count as a tap. A touch stops being one once it
|
|
45
|
+
* moves or another finger joins it, so lifting a pinch is never a double-tap.
|
|
46
|
+
*/
|
|
47
|
+
const tapCandidates = new Map();
|
|
41
48
|
function setScale(next) {
|
|
42
49
|
const clamped = clamp(next, minScale, maxScale);
|
|
43
50
|
if (clamped === scale)
|
|
@@ -85,6 +92,16 @@ export function useMediaZoom(options = {}) {
|
|
|
85
92
|
let lastTapTime = 0;
|
|
86
93
|
function handlePointerDown(event) {
|
|
87
94
|
pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
|
95
|
+
tapCandidates.set(event.pointerId, {
|
|
96
|
+
x: event.clientX,
|
|
97
|
+
y: event.clientY,
|
|
98
|
+
tap: pointers.size === 1,
|
|
99
|
+
});
|
|
100
|
+
if (pointers.size > 1) {
|
|
101
|
+
for (const candidate of tapCandidates.values())
|
|
102
|
+
candidate.tap = false;
|
|
103
|
+
lastTapTime = 0;
|
|
104
|
+
}
|
|
88
105
|
if (pointers.size === 2) {
|
|
89
106
|
pinchStartDistance = distanceBetweenPointers();
|
|
90
107
|
pinchStartScale = scale;
|
|
@@ -102,6 +119,11 @@ export function useMediaZoom(options = {}) {
|
|
|
102
119
|
if (!pointers.has(event.pointerId))
|
|
103
120
|
return;
|
|
104
121
|
pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
|
122
|
+
const candidate = tapCandidates.get(event.pointerId);
|
|
123
|
+
if (candidate?.tap &&
|
|
124
|
+
Math.hypot(event.clientX - candidate.x, event.clientY - candidate.y) > TAP_SLOP) {
|
|
125
|
+
candidate.tap = false;
|
|
126
|
+
}
|
|
105
127
|
if (pointers.size === 2 && pinchStartDistance > 0) {
|
|
106
128
|
event.preventDefault();
|
|
107
129
|
const ratio = distanceBetweenPointers() / pinchStartDistance;
|
|
@@ -116,6 +138,9 @@ export function useMediaZoom(options = {}) {
|
|
|
116
138
|
}
|
|
117
139
|
function endPointer(event) {
|
|
118
140
|
pointers.delete(event.pointerId);
|
|
141
|
+
// pointerup keeps its candidate for handlePointerUpTap, which runs next
|
|
142
|
+
if (event.type !== 'pointerup')
|
|
143
|
+
tapCandidates.delete(event.pointerId);
|
|
119
144
|
if (dragPointerId === event.pointerId) {
|
|
120
145
|
dragPointerId = null;
|
|
121
146
|
panning = false;
|
|
@@ -137,8 +162,14 @@ export function useMediaZoom(options = {}) {
|
|
|
137
162
|
}
|
|
138
163
|
/** Touch devices do not fire dblclick, so tap timing is measured directly. */
|
|
139
164
|
function handlePointerUpTap(event) {
|
|
165
|
+
const candidate = tapCandidates.get(event.pointerId);
|
|
166
|
+
tapCandidates.delete(event.pointerId);
|
|
140
167
|
if (event.pointerType !== 'touch')
|
|
141
168
|
return;
|
|
169
|
+
if (!candidate?.tap) {
|
|
170
|
+
lastTapTime = 0;
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
142
173
|
const now = event.timeStamp;
|
|
143
174
|
if (now - lastTapTime < 300) {
|
|
144
175
|
toggle();
|
|
@@ -167,6 +198,7 @@ export function useMediaZoom(options = {}) {
|
|
|
167
198
|
node.removeEventListener('wheel', handleWheel);
|
|
168
199
|
node.removeEventListener('dblclick', handleDoubleClick);
|
|
169
200
|
pointers.clear();
|
|
201
|
+
tapCandidates.clear();
|
|
170
202
|
},
|
|
171
203
|
};
|
|
172
204
|
}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* - Draft restoration on mount
|
|
7
7
|
* - Configurable expiration time
|
|
8
8
|
* - Field exclusion from persistence (e.g., consent fields)
|
|
9
|
-
* -
|
|
9
|
+
* - Optional cooldown that ignores a repeat submit right after a success
|
|
10
10
|
*
|
|
11
11
|
* @example
|
|
12
12
|
* ```svelte
|
|
@@ -56,7 +56,11 @@ export interface UsePersistedFormOptions<T extends z.ZodObject<z.ZodRawShape>> e
|
|
|
56
56
|
onDraftRestored?: () => void;
|
|
57
57
|
/** Debounce time for saving drafts in milliseconds (default: 500ms) */
|
|
58
58
|
debounceMs?: number;
|
|
59
|
-
/**
|
|
59
|
+
/**
|
|
60
|
+
* After a successful submit, ignore further submits for this many
|
|
61
|
+
* milliseconds (a silent no-op: no error, no toast). Default: 0 (off).
|
|
62
|
+
* useForm already ignores a submit while one is in flight.
|
|
63
|
+
*/
|
|
60
64
|
submitCooldownMs?: number;
|
|
61
65
|
}
|
|
62
66
|
export interface UsePersistedFormReturn<T extends z.ZodObject<z.ZodRawShape>> extends UseFormReturn<T> {
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* - Draft restoration on mount
|
|
7
7
|
* - Configurable expiration time
|
|
8
8
|
* - Field exclusion from persistence (e.g., consent fields)
|
|
9
|
-
* -
|
|
9
|
+
* - Optional cooldown that ignores a repeat submit right after a success
|
|
10
10
|
*
|
|
11
11
|
* @example
|
|
12
12
|
* ```svelte
|
|
@@ -44,10 +44,9 @@
|
|
|
44
44
|
* ```
|
|
45
45
|
*/
|
|
46
46
|
import { useForm } from './useForm.svelte.js';
|
|
47
|
-
import { toastStore } from '../stores/toast.svelte.js';
|
|
48
47
|
const DEFAULT_EXPIRATION_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
49
48
|
const DEFAULT_DEBOUNCE_MS = 500;
|
|
50
|
-
const DEFAULT_SUBMIT_COOLDOWN_MS =
|
|
49
|
+
const DEFAULT_SUBMIT_COOLDOWN_MS = 0;
|
|
51
50
|
/**
|
|
52
51
|
* Check if localStorage is available (also checks for browser environment)
|
|
53
52
|
*/
|
|
@@ -146,10 +145,11 @@ export function usePersistedForm(options) {
|
|
|
146
145
|
async function wrappedOnSubmit(data) {
|
|
147
146
|
// Check for duplicate submission
|
|
148
147
|
const now = Date.now();
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
148
|
+
// A repeat within the cooldown is ignored, not an error: throwing made
|
|
149
|
+
// useForm show a global error, call onError and toast for a form that
|
|
150
|
+
// had just been saved.
|
|
151
|
+
if (submitCooldownMs > 0 && now - lastSubmitTime < submitCooldownMs)
|
|
152
|
+
return;
|
|
153
153
|
try {
|
|
154
154
|
await onSubmit(data);
|
|
155
155
|
lastSubmitTime = now;
|
|
@@ -43,7 +43,12 @@
|
|
|
43
43
|
* ```
|
|
44
44
|
*/
|
|
45
45
|
export function useReducedMotion() {
|
|
46
|
-
|
|
46
|
+
// Read the preference up front (not only in the effect): an intro
|
|
47
|
+
// transition starts before effects run and would otherwise see `false`.
|
|
48
|
+
// The server has no preference, and no transition runs there.
|
|
49
|
+
let reducedMotion = $state(typeof window !== 'undefined' && typeof window.matchMedia === 'function'
|
|
50
|
+
? window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
|
51
|
+
: false);
|
|
47
52
|
$effect(() => {
|
|
48
53
|
if (typeof window === 'undefined')
|
|
49
54
|
return;
|
|
@@ -94,12 +94,23 @@ export function useRovingTabindex(options = {}) {
|
|
|
94
94
|
event.stopPropagation();
|
|
95
95
|
}
|
|
96
96
|
}
|
|
97
|
+
// Focus that lands on an item by other means (a click, a programmatic
|
|
98
|
+
// focus()) makes it the active item, so the next arrow key starts from it
|
|
99
|
+
function handleFocusIn(event) {
|
|
100
|
+
const items = getItems();
|
|
101
|
+
const index = items.findIndex((item) => item.contains(event.target));
|
|
102
|
+
if (index === -1 || index === activeIndex)
|
|
103
|
+
return;
|
|
104
|
+
activeIndex = index;
|
|
105
|
+
onIndexChange?.(index);
|
|
106
|
+
}
|
|
97
107
|
/**
|
|
98
108
|
* Svelte action to apply roving tabindex to a container
|
|
99
109
|
*/
|
|
100
110
|
function action(node) {
|
|
101
111
|
container = node;
|
|
102
112
|
node.addEventListener('keydown', handleKeyDown);
|
|
113
|
+
node.addEventListener('focusin', handleFocusIn);
|
|
103
114
|
return {
|
|
104
115
|
update(newOptions) {
|
|
105
116
|
loop = newOptions.loop ?? true;
|
|
@@ -111,6 +122,7 @@ export function useRovingTabindex(options = {}) {
|
|
|
111
122
|
},
|
|
112
123
|
destroy() {
|
|
113
124
|
node.removeEventListener('keydown', handleKeyDown);
|
|
125
|
+
node.removeEventListener('focusin', handleFocusIn);
|
|
114
126
|
container = null;
|
|
115
127
|
},
|
|
116
128
|
};
|
package/dist/lib/index.d.ts
CHANGED
|
@@ -72,7 +72,7 @@ export { Studio, StudioBar, StudioOutline, StudioCanvas, StudioCanvasItem, Studi
|
|
|
72
72
|
export { AspectRatio, Image, Video, Audio, MediaEmbed, MediaGrid, MediaCarousel, Lightbox, } from './components/media/index.js';
|
|
73
73
|
export type { MediaGridColumns } from './components/media/MediaGrid.svelte';
|
|
74
74
|
export type { DropdownMenuItem, DropdownMenuGroup, SelectOption, SelectGroup, Tab, FileMetadata, DataTableColumn, DataTableFilterFn, DataTableFilterOption, RadioOption, ComboboxOption, MultiSelectOption, SignatureData, SignatureStroke, SignaturePoint, SignatureMetadata, SignatureFont, SignatureValidationResult, MediaKind, MediaEmbedProvider, MediaSource, MediaTextTrack, MediaItemBase, ImageMediaItem, VideoMediaItem, AudioMediaItem, EmbedMediaItem, MediaItem, MediaAspectRatio, MediaFit, CloudflareStreamAccount, MatterportPlayerOptions, } from './types/components.js';
|
|
75
|
-
export { createDataTable, type DataTableOptions, type DataTableRow, type DataTableInstance, type ColumnMeta, type BaseColumnMeta, type DataColumnMeta, type FilterableColumnMeta, type SelectionColumnMeta, type RowSelectionState, type ColumnVisibilityState, type ColumnFiltersState, type PaginationState, type DataTableProps, type DataTableCoreProps, type DataTablePaginationProps, type InferRowType, type FormatResult, getAlignClass, formatCellValue, getColumnValue, defaultGetRowId, DataTableToolbar, DataTableSearch, DataTableColumnVisibility, DataTableColumnFilter, DataTableFacetedFilter, DataTablePagination, DataTableVirtual, DataTableEditableCell, } from './components/data-table/index.js';
|
|
75
|
+
export { createDataTable, type DataTableOptions, type DataTableRow, type DataTableInstance, type ColumnMeta, type BaseColumnMeta, type DataColumnMeta, type FilterableColumnMeta, type SelectionColumnMeta, type RowSelectionState, type ColumnVisibilityState, type ColumnFiltersState, type ColumnFilterValue, type PaginationState, type DataTableProps, type DataTableCoreProps, type DataTablePaginationProps, type InferRowType, type FormatResult, getAlignClass, formatCellValue, getColumnValue, defaultGetRowId, DataTableToolbar, DataTableSearch, DataTableColumnVisibility, DataTableColumnFilter, DataTableFacetedFilter, DataTablePagination, DataTableVirtual, DataTableEditableCell, } from './components/data-table/index.js';
|
|
76
76
|
export { default as LogoMain } from './components/LogoMain.svelte';
|
|
77
77
|
export { default as LoadingLogo } from './components/LoadingLogo.svelte';
|
|
78
78
|
export { default as Avatar } from './components/Avatar.svelte';
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* Features:
|
|
5
5
|
* - Auto-dismiss with configurable duration
|
|
6
6
|
* - Manual dismiss support
|
|
7
|
+
* - Pause/resume of the auto-dismiss timer (hover or focus, WCAG 2.2.1)
|
|
7
8
|
* - Proper timeout cleanup to prevent memory leaks
|
|
8
9
|
* - Convenience methods for common toast types
|
|
9
10
|
*/
|
|
@@ -19,12 +20,24 @@ declare class ToastStore {
|
|
|
19
20
|
toasts: Toast[];
|
|
20
21
|
/** Map of toast IDs to their timeout handles for cleanup */
|
|
21
22
|
private timeouts;
|
|
23
|
+
/** Auto-dismiss bookkeeping: when each running timer ends, or how long a paused one has left */
|
|
24
|
+
private deadlines;
|
|
25
|
+
private remaining;
|
|
22
26
|
/**
|
|
23
27
|
* Add a new toast notification
|
|
24
28
|
* @param toast - Toast configuration without id
|
|
25
29
|
* @returns The generated toast id
|
|
26
30
|
*/
|
|
27
31
|
add(toast: ToastInput): string;
|
|
32
|
+
private startTimer;
|
|
33
|
+
/**
|
|
34
|
+
* Hold a toast's auto-dismiss timer, e.g. while it is hovered or focused,
|
|
35
|
+
* so it cannot disappear while being read or used. No-op for a persistent
|
|
36
|
+
* toast or one already paused.
|
|
37
|
+
*/
|
|
38
|
+
pause(id: string): void;
|
|
39
|
+
/** Restart a paused toast's timer with the time it had left (at least 1s). */
|
|
40
|
+
resume(id: string): void;
|
|
28
41
|
/**
|
|
29
42
|
* Remove a toast by id
|
|
30
43
|
* Also clears any pending auto-dismiss timeout
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* Features:
|
|
5
5
|
* - Auto-dismiss with configurable duration
|
|
6
6
|
* - Manual dismiss support
|
|
7
|
+
* - Pause/resume of the auto-dismiss timer (hover or focus, WCAG 2.2.1)
|
|
7
8
|
* - Proper timeout cleanup to prevent memory leaks
|
|
8
9
|
* - Convenience methods for common toast types
|
|
9
10
|
*/
|
|
@@ -23,6 +24,9 @@ class ToastStore {
|
|
|
23
24
|
this.toasts = $state([]);
|
|
24
25
|
/** Map of toast IDs to their timeout handles for cleanup */
|
|
25
26
|
this.timeouts = new Map();
|
|
27
|
+
/** Auto-dismiss bookkeeping: when each running timer ends, or how long a paused one has left */
|
|
28
|
+
this.deadlines = new Map();
|
|
29
|
+
this.remaining = new Map();
|
|
26
30
|
}
|
|
27
31
|
/**
|
|
28
32
|
* Add a new toast notification
|
|
@@ -35,14 +39,37 @@ class ToastStore {
|
|
|
35
39
|
this.toasts = [...this.toasts, newToast];
|
|
36
40
|
// Auto-dismiss unless duration is explicitly 0 (persistent)
|
|
37
41
|
if (toast.duration !== 0) {
|
|
38
|
-
|
|
39
|
-
const timeoutId = setTimeout(() => {
|
|
40
|
-
this.remove(id);
|
|
41
|
-
}, duration);
|
|
42
|
-
this.timeouts.set(id, timeoutId);
|
|
42
|
+
this.startTimer(id, toast.duration ?? 5000);
|
|
43
43
|
}
|
|
44
44
|
return id;
|
|
45
45
|
}
|
|
46
|
+
startTimer(id, ms) {
|
|
47
|
+
this.deadlines.set(id, Date.now() + ms);
|
|
48
|
+
this.timeouts.set(id, setTimeout(() => this.remove(id), ms));
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Hold a toast's auto-dismiss timer, e.g. while it is hovered or focused,
|
|
52
|
+
* so it cannot disappear while being read or used. No-op for a persistent
|
|
53
|
+
* toast or one already paused.
|
|
54
|
+
*/
|
|
55
|
+
pause(id) {
|
|
56
|
+
const timeoutId = this.timeouts.get(id);
|
|
57
|
+
const deadline = this.deadlines.get(id);
|
|
58
|
+
if (timeoutId === undefined || deadline === undefined)
|
|
59
|
+
return;
|
|
60
|
+
clearTimeout(timeoutId);
|
|
61
|
+
this.timeouts.delete(id);
|
|
62
|
+
this.deadlines.delete(id);
|
|
63
|
+
this.remaining.set(id, Math.max(0, deadline - Date.now()));
|
|
64
|
+
}
|
|
65
|
+
/** Restart a paused toast's timer with the time it had left (at least 1s). */
|
|
66
|
+
resume(id) {
|
|
67
|
+
const left = this.remaining.get(id);
|
|
68
|
+
if (left === undefined)
|
|
69
|
+
return;
|
|
70
|
+
this.remaining.delete(id);
|
|
71
|
+
this.startTimer(id, Math.max(left, 1000));
|
|
72
|
+
}
|
|
46
73
|
/**
|
|
47
74
|
* Remove a toast by id
|
|
48
75
|
* Also clears any pending auto-dismiss timeout
|
|
@@ -54,6 +81,8 @@ class ToastStore {
|
|
|
54
81
|
clearTimeout(timeoutId);
|
|
55
82
|
this.timeouts.delete(id);
|
|
56
83
|
}
|
|
84
|
+
this.deadlines.delete(id);
|
|
85
|
+
this.remaining.delete(id);
|
|
57
86
|
this.toasts = this.toasts.filter((t) => t.id !== id);
|
|
58
87
|
}
|
|
59
88
|
/**
|
|
@@ -65,6 +94,8 @@ class ToastStore {
|
|
|
65
94
|
clearTimeout(timeoutId);
|
|
66
95
|
}
|
|
67
96
|
this.timeouts.clear();
|
|
97
|
+
this.deadlines.clear();
|
|
98
|
+
this.remaining.clear();
|
|
68
99
|
this.toasts = [];
|
|
69
100
|
}
|
|
70
101
|
/**
|
|
@@ -263,6 +263,8 @@ export interface ParsedEmbed {
|
|
|
263
263
|
customerCode?: string;
|
|
264
264
|
/** Host, when the URL used a custom Stream domain */
|
|
265
265
|
domain?: string;
|
|
266
|
+
/** Privacy hash of an unlisted Vimeo video (`vimeo.com/<id>/<hash>` or `?h=`) */
|
|
267
|
+
hash?: string;
|
|
266
268
|
}
|
|
267
269
|
/**
|
|
268
270
|
* Parse a YouTube, Vimeo, or Cloudflare Stream URL into a provider and video id.
|
|
@@ -285,6 +287,11 @@ export interface ParsedEmbed {
|
|
|
285
287
|
export declare function parseEmbedUrl(url: string): ParsedEmbed | null;
|
|
286
288
|
/** Options accepted when building an embed iframe URL. */
|
|
287
289
|
export interface EmbedSrcOptions {
|
|
290
|
+
/**
|
|
291
|
+
* Privacy hash of an unlisted Vimeo video. A Vimeo `videoId` of the form
|
|
292
|
+
* `<id>/<hash>` (as in its share URL) is also accepted.
|
|
293
|
+
*/
|
|
294
|
+
hash?: string;
|
|
288
295
|
/** Route YouTube through `youtube-nocookie.com` (default `true`) */
|
|
289
296
|
privacy?: boolean;
|
|
290
297
|
/** Begin playing immediately — set when the user has clicked the facade */
|
package/dist/lib/utils/media.js
CHANGED
|
@@ -437,6 +437,7 @@ export function matterportTour(modelId, options = {}) {
|
|
|
437
437
|
}
|
|
438
438
|
const YOUTUBE_ID = /^[\w-]{11}$/;
|
|
439
439
|
const VIMEO_ID = /^\d+$/;
|
|
440
|
+
const VIMEO_HASH = /^[0-9a-f]+$/i;
|
|
440
441
|
/**
|
|
441
442
|
* Parse a YouTube, Vimeo, or Cloudflare Stream URL into a provider and video id.
|
|
442
443
|
*
|
|
@@ -499,8 +500,18 @@ export function parseEmbedUrl(url) {
|
|
|
499
500
|
}
|
|
500
501
|
if (host === 'vimeo.com' || host === 'player.vimeo.com') {
|
|
501
502
|
// player.vimeo.com/video/<id>, vimeo.com/<id>, vimeo.com/channels/x/<id>
|
|
502
|
-
|
|
503
|
-
|
|
503
|
+
let idIndex = segments.length - 1;
|
|
504
|
+
while (idIndex >= 0 && !VIMEO_ID.test(segments[idIndex]))
|
|
505
|
+
idIndex -= 1;
|
|
506
|
+
if (idIndex === -1)
|
|
507
|
+
return null;
|
|
508
|
+
// An all-digit hash looks like an id: in vimeo.com/<id>/<hash> the id comes first
|
|
509
|
+
if (host === 'vimeo.com' && idIndex === 1 && VIMEO_ID.test(segments[0]))
|
|
510
|
+
idIndex = 0;
|
|
511
|
+
const videoId = segments[idIndex];
|
|
512
|
+
// Unlisted videos only play with their hash: vimeo.com/<id>/<hash> or ?h=<hash>
|
|
513
|
+
const hash = [parsed.searchParams.get('h'), segments[idIndex + 1]].find((candidate) => !!candidate && VIMEO_HASH.test(candidate));
|
|
514
|
+
return withStart({ provider: 'vimeo', videoId, ...(hash ? { hash } : {}) }, start);
|
|
504
515
|
}
|
|
505
516
|
return null;
|
|
506
517
|
}
|
|
@@ -610,6 +621,10 @@ export function buildEmbedSrc(provider, videoId, options = {}) {
|
|
|
610
621
|
return `https://${domain}/embed/${videoId}${query ? `?${query}` : ''}`;
|
|
611
622
|
}
|
|
612
623
|
// Vimeo
|
|
624
|
+
const [vimeoId, idHash] = videoId.split('/');
|
|
625
|
+
const hash = options.hash ?? idHash;
|
|
626
|
+
if (hash)
|
|
627
|
+
params.set('h', hash);
|
|
613
628
|
if (autoplay)
|
|
614
629
|
params.set('autoplay', '1');
|
|
615
630
|
if (privacy)
|
|
@@ -618,7 +633,7 @@ export function buildEmbedSrc(provider, videoId, options = {}) {
|
|
|
618
633
|
// Vimeo reads the start time from the fragment (#t=30s), not the query;
|
|
619
634
|
// URLSearchParams would encode it as `%23t`, which Vimeo ignores.
|
|
620
635
|
const fragment = start !== undefined ? `#t=${Math.floor(start)}s` : '';
|
|
621
|
-
return `https://player.vimeo.com/video/${
|
|
636
|
+
return `https://player.vimeo.com/video/${vimeoId}${query ? `?${query}` : ''}${fragment}`;
|
|
622
637
|
}
|
|
623
638
|
/**
|
|
624
639
|
* Poster image URL for an embedded video, when one can be derived without a
|