@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.
Files changed (69) 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 +74 -86
  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 +29 -3
  49. package/dist/lib/components/media/MediaCarousel.svelte +19 -0
  50. package/dist/lib/components/media/MediaEmbed.svelte +32 -1
  51. package/dist/lib/components/media/Video.svelte +16 -0
  52. package/dist/lib/components/media/internal/MediaControls.svelte +9 -1
  53. package/dist/lib/components/notifications/NotificationBadge.svelte +1 -1
  54. package/dist/lib/components/notifications/NotificationItem.svelte +19 -28
  55. package/dist/lib/composables/useEscapeKey.svelte.d.ts +4 -0
  56. package/dist/lib/composables/useEscapeKey.svelte.js +15 -0
  57. package/dist/lib/composables/useHlsSource.svelte.js +31 -4
  58. package/dist/lib/composables/useMediaPlayer.svelte.js +38 -9
  59. package/dist/lib/composables/useMediaZoom.svelte.js +32 -0
  60. package/dist/lib/composables/usePersistedForm.svelte.d.ts +6 -2
  61. package/dist/lib/composables/usePersistedForm.svelte.js +7 -7
  62. package/dist/lib/composables/useReducedMotion.svelte.js +6 -1
  63. package/dist/lib/composables/useRovingTabindex.svelte.js +12 -0
  64. package/dist/lib/index.d.ts +1 -1
  65. package/dist/lib/stores/toast.svelte.d.ts +13 -0
  66. package/dist/lib/stores/toast.svelte.js +36 -5
  67. package/dist/lib/utils/media.d.ts +7 -0
  68. package/dist/lib/utils/media.js +18 -3
  69. 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
- if (document.fullscreenElement) {
297
- await document.exitFullscreen();
298
- return;
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
- if (typeof target.requestFullscreen === 'function') {
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
- if (document.pictureInPictureElement) {
315
- await document.exitPictureInPicture();
338
+ try {
339
+ if (document.pictureInPictureElement) {
340
+ await document.exitPictureInPicture();
341
+ }
342
+ else {
343
+ await media.requestPictureInPicture();
344
+ }
316
345
  }
317
- else {
318
- await media.requestPictureInPicture();
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
- * - Duplicate submission prevention with cooldown
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
- /** Cooldown time between submissions in milliseconds (default: 5000ms) */
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
- * - Duplicate submission prevention with cooldown
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 = 5000;
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
- if (now - lastSubmitTime < submitCooldownMs) {
150
- toastStore.warning('Please wait before submitting again');
151
- throw new Error('Submission cooldown active');
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
- let reducedMotion = $state(false);
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
  };
@@ -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
- const duration = toast.duration ?? 5000;
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 */
@@ -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
- const videoId = [...segments].reverse().find((segment) => VIMEO_ID.test(segment));
503
- return videoId ? withStart({ provider: 'vimeo', videoId }, start) : null;
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/${videoId}${query ? `?${query}` : ''}${fragment}`;
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classic-homes/theme-svelte",
3
- "version": "1.25.0",
3
+ "version": "1.26.0",
4
4
  "description": "Svelte components for the Classic theme system",
5
5
  "type": "module",
6
6
  "svelte": "./dist/lib/index.js",