@classic-homes/theme-svelte 1.2.3 → 1.3.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 (51) hide show
  1. package/README.md +43 -0
  2. package/dist/__tests__/a11y/Media.a11y.test.js +145 -0
  3. package/dist/__tests__/media-components.test.js +206 -0
  4. package/dist/__tests__/media-hls.test.js +273 -0
  5. package/dist/__tests__/media-matterport.test.js +296 -0
  6. package/dist/__tests__/media-players.test.js +244 -0
  7. package/dist/__tests__/media-scrubber.test.js +44 -0
  8. package/dist/__tests__/media-utils.test.js +427 -0
  9. package/dist/__tests__/setup.js +50 -0
  10. package/dist/lib/components/FileUpload.svelte +221 -4
  11. package/dist/lib/components/FileUpload.svelte.d.ts +13 -1
  12. package/dist/lib/components/media/AspectRatio.svelte +47 -0
  13. package/dist/lib/components/media/AspectRatio.svelte.d.ts +20 -0
  14. package/dist/lib/components/media/Audio.svelte +247 -0
  15. package/dist/lib/components/media/Audio.svelte.d.ts +34 -0
  16. package/dist/lib/components/media/Image.svelte +209 -0
  17. package/dist/lib/components/media/Image.svelte.d.ts +52 -0
  18. package/dist/lib/components/media/Lightbox.svelte +449 -0
  19. package/dist/lib/components/media/Lightbox.svelte.d.ts +33 -0
  20. package/dist/lib/components/media/MediaCarousel.svelte +359 -0
  21. package/dist/lib/components/media/MediaCarousel.svelte.d.ts +30 -0
  22. package/dist/lib/components/media/MediaEmbed.svelte +314 -0
  23. package/dist/lib/components/media/MediaEmbed.svelte.d.ts +99 -0
  24. package/dist/lib/components/media/MediaGrid.svelte +327 -0
  25. package/dist/lib/components/media/MediaGrid.svelte.d.ts +57 -0
  26. package/dist/lib/components/media/Video.svelte +455 -0
  27. package/dist/lib/components/media/Video.svelte.d.ts +81 -0
  28. package/dist/lib/components/media/index.d.ts +14 -0
  29. package/dist/lib/components/media/index.js +14 -0
  30. package/dist/lib/components/media/internal/MediaControls.svelte +283 -0
  31. package/dist/lib/components/media/internal/MediaControls.svelte.d.ts +32 -0
  32. package/dist/lib/components/media/internal/MediaScrubber.svelte +211 -0
  33. package/dist/lib/components/media/internal/MediaScrubber.svelte.d.ts +27 -0
  34. package/dist/lib/components/media/internal/MediaTile.svelte +129 -0
  35. package/dist/lib/components/media/internal/MediaTile.svelte.d.ts +22 -0
  36. package/dist/lib/composables/index.d.ts +3 -0
  37. package/dist/lib/composables/index.js +4 -0
  38. package/dist/lib/composables/useHlsSource.svelte.d.ts +70 -0
  39. package/dist/lib/composables/useHlsSource.svelte.js +119 -0
  40. package/dist/lib/composables/useMediaPlayer.svelte.d.ts +114 -0
  41. package/dist/lib/composables/useMediaPlayer.svelte.js +422 -0
  42. package/dist/lib/composables/useMediaZoom.svelte.d.ts +59 -0
  43. package/dist/lib/composables/useMediaZoom.svelte.js +199 -0
  44. package/dist/lib/index.d.ts +8 -2
  45. package/dist/lib/index.js +14 -1
  46. package/dist/lib/types/components.d.ts +172 -0
  47. package/dist/lib/utils/hls.d.ts +76 -0
  48. package/dist/lib/utils/hls.js +83 -0
  49. package/dist/lib/utils/media.d.ts +379 -0
  50. package/dist/lib/utils/media.js +715 -0
  51. package/package.json +6 -2
@@ -0,0 +1,449 @@
1
+ <script lang="ts">
2
+ /**
3
+ * Lightbox - Full-screen media viewer
4
+ *
5
+ * Built directly on the Bits UI dialog primitive rather than the `Dialog`
6
+ * component, because a lightbox needs an edge-to-edge surface with no card
7
+ * chrome. Focus trapping, Escape handling, scroll locking, and `aria-modal`
8
+ * all come from the primitive.
9
+ *
10
+ * Features:
11
+ * - Renders images, video, audio, and third-party embeds from one list
12
+ * - Arrow/Home/End navigation, plus `+` `-` `0` zoom on images
13
+ * - Pinch, wheel, double-tap zoom and drag panning on images
14
+ * - Swipe navigation on touch while the image is not zoomed
15
+ * - Preloads the neighbouring images so navigation feels instant
16
+ * - Unmounts the outgoing player on navigation, so audio never keeps playing
17
+ *
18
+ * @example
19
+ * ```svelte
20
+ * <Lightbox items={photos} bind:open bind:index />
21
+ * ```
22
+ */
23
+ import { Dialog as DialogPrimitive } from 'bits-ui';
24
+ import { untrack, type Snippet } from 'svelte';
25
+ import { useMediaZoom } from '../../composables/useMediaZoom.svelte.js';
26
+ import { useReducedMotion } from '../../composables/useReducedMotion.svelte.js';
27
+ import type { MediaItem } from '../../types/components.js';
28
+ import { cn } from '../../utils.js';
29
+ import { getMediaLabel } from '../../utils/media.js';
30
+ import Icon from '../Icon.svelte';
31
+ import Audio from './Audio.svelte';
32
+ import MediaEmbed from './MediaEmbed.svelte';
33
+ import Video from './Video.svelte';
34
+ import MediaTile from './internal/MediaTile.svelte';
35
+
36
+ /** Horizontal distance that counts as a swipe rather than a tap */
37
+ const SWIPE_THRESHOLD = 60;
38
+
39
+ interface Props {
40
+ /** Items to page through */
41
+ items: MediaItem[];
42
+ /** Whether the lightbox is open (bindable) */
43
+ open?: boolean;
44
+ /** Index of the visible item (bindable) */
45
+ index?: number;
46
+ /** Called when the open state changes */
47
+ onOpenChange?: (open: boolean) => void;
48
+ /** Called when the visible item changes */
49
+ onIndexChange?: (index: number) => void;
50
+ /** Wrap from the last item back to the first */
51
+ loop?: boolean;
52
+ /** Show the thumbnail strip along the bottom */
53
+ showThumbnails?: boolean;
54
+ /** Show the caption beneath the media */
55
+ showCaption?: boolean;
56
+ /** Show the "n of m" counter */
57
+ showCounter?: boolean;
58
+ /** Allow zooming and panning images */
59
+ zoomable?: boolean;
60
+ /** Show a download link for items that expose a URL */
61
+ downloadable?: boolean;
62
+ /** Optional trigger rendered inline; omit when controlling `open` yourself */
63
+ trigger?: Snippet;
64
+ /** Additional classes for the dialog surface */
65
+ class?: string;
66
+ }
67
+
68
+ let {
69
+ items,
70
+ open = $bindable(false),
71
+ index = $bindable(0),
72
+ onOpenChange,
73
+ onIndexChange,
74
+ loop = true,
75
+ showThumbnails = false,
76
+ showCaption = true,
77
+ showCounter = true,
78
+ zoomable = true,
79
+ downloadable = false,
80
+ trigger,
81
+ class: className,
82
+ }: Props = $props();
83
+
84
+ let swipeStartX = 0;
85
+ let swipeStartY = 0;
86
+ let swipeActive = false;
87
+
88
+ const reducedMotion = useReducedMotion();
89
+ const zoom = useMediaZoom();
90
+
91
+ const activeItem = $derived(items[index]);
92
+ const hasMultiple = $derived(items.length > 1);
93
+ const canGoPrevious = $derived(loop || index > 0);
94
+ const canGoNext = $derived(loop || index < items.length - 1);
95
+ const positionLabel = $derived(`${index + 1} of ${items.length}`);
96
+
97
+ const dialogTitle = $derived(activeItem ? getMediaLabel(activeItem, index) : 'Media viewer');
98
+
99
+ const downloadUrl = $derived.by(() => {
100
+ if (!downloadable || !activeItem) return null;
101
+ if (activeItem.kind === 'embed') return null;
102
+ return activeItem.downloadUrl ?? (typeof activeItem.src === 'string' ? activeItem.src : null);
103
+ });
104
+
105
+ const chromeButtonClass =
106
+ 'inline-flex h-11 w-11 items-center justify-center rounded-full bg-white/10 text-white outline-none backdrop-blur-sm hover:bg-white/20 focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-transparent disabled:pointer-events-none disabled:opacity-40';
107
+
108
+ function handleOpenChange(next: boolean) {
109
+ open = next;
110
+ onOpenChange?.(next);
111
+ }
112
+
113
+ function goTo(next: number) {
114
+ if (items.length === 0) return;
115
+
116
+ let target = next;
117
+ if (target < 0) target = loop ? items.length - 1 : 0;
118
+ if (target >= items.length) target = loop ? 0 : items.length - 1;
119
+ if (target === index) return;
120
+
121
+ index = target;
122
+ onIndexChange?.(target);
123
+ }
124
+
125
+ // Zoom is per-item: carrying a magnified view onto the next photo would be
126
+ // disorienting, and the offsets would be meaningless at a new size.
127
+ //
128
+ // `reset()` reads the zoom state internally, so it must run untracked —
129
+ // otherwise this effect subscribes to the scale it just wrote and undoes
130
+ // every zoom the moment it happens.
131
+ $effect(() => {
132
+ void index;
133
+ untrack(() => zoom.reset());
134
+ });
135
+
136
+ /**
137
+ * Warm the neighbouring images so paging does not flash a blank frame.
138
+ * `document.createElement('img')` rather than `new Image()`, because this
139
+ * package exports a component named `Image` that shadows the global.
140
+ */
141
+ $effect(() => {
142
+ if (!open) return;
143
+
144
+ for (const offset of [-1, 1]) {
145
+ const neighbour = items[index + offset];
146
+ if (neighbour?.kind !== 'image') continue;
147
+ const preloader = document.createElement('img');
148
+ preloader.decoding = 'async';
149
+ if (neighbour.srcset) preloader.srcset = neighbour.srcset;
150
+ preloader.src = neighbour.src;
151
+ }
152
+ });
153
+
154
+ function handleKeyDown(event: KeyboardEvent) {
155
+ if (event.metaKey || event.ctrlKey || event.altKey) return;
156
+
157
+ switch (event.key) {
158
+ case 'ArrowLeft':
159
+ // While zoomed, arrows pan the image instead of changing items.
160
+ if (zoom.zoomed) {
161
+ event.preventDefault();
162
+ zoom.panBy(40, 0);
163
+ } else if (hasMultiple) {
164
+ event.preventDefault();
165
+ goTo(index - 1);
166
+ }
167
+ break;
168
+ case 'ArrowRight':
169
+ if (zoom.zoomed) {
170
+ event.preventDefault();
171
+ zoom.panBy(-40, 0);
172
+ } else if (hasMultiple) {
173
+ event.preventDefault();
174
+ goTo(index + 1);
175
+ }
176
+ break;
177
+ case 'ArrowUp':
178
+ if (zoom.zoomed) {
179
+ event.preventDefault();
180
+ zoom.panBy(0, 40);
181
+ }
182
+ break;
183
+ case 'ArrowDown':
184
+ if (zoom.zoomed) {
185
+ event.preventDefault();
186
+ zoom.panBy(0, -40);
187
+ }
188
+ break;
189
+ case 'Home':
190
+ event.preventDefault();
191
+ goTo(0);
192
+ break;
193
+ case 'End':
194
+ event.preventDefault();
195
+ goTo(items.length - 1);
196
+ break;
197
+ case '+':
198
+ case '=':
199
+ if (!zoomable) return;
200
+ event.preventDefault();
201
+ zoom.zoomIn();
202
+ break;
203
+ case '-':
204
+ case '_':
205
+ if (!zoomable) return;
206
+ event.preventDefault();
207
+ zoom.zoomOut();
208
+ break;
209
+ case '0':
210
+ if (!zoomable) return;
211
+ event.preventDefault();
212
+ zoom.reset();
213
+ break;
214
+ }
215
+ }
216
+
217
+ function handleSwipeStart(event: PointerEvent) {
218
+ if (zoom.zoomed || !hasMultiple) return;
219
+ swipeActive = true;
220
+ swipeStartX = event.clientX;
221
+ swipeStartY = event.clientY;
222
+ }
223
+
224
+ function handleSwipeEnd(event: PointerEvent) {
225
+ if (!swipeActive) return;
226
+ swipeActive = false;
227
+
228
+ const dx = event.clientX - swipeStartX;
229
+ const dy = event.clientY - swipeStartY;
230
+ // Ignore mostly-vertical drags so a scroll gesture never flips the item.
231
+ if (Math.abs(dx) < SWIPE_THRESHOLD || Math.abs(dx) < Math.abs(dy)) return;
232
+
233
+ goTo(dx > 0 ? index - 1 : index + 1);
234
+ }
235
+ </script>
236
+
237
+ <DialogPrimitive.Root bind:open onOpenChange={handleOpenChange}>
238
+ {#if trigger}
239
+ <DialogPrimitive.Trigger>
240
+ {#snippet child({ props })}
241
+ <span {...props}>
242
+ {@render trigger()}
243
+ </span>
244
+ {/snippet}
245
+ </DialogPrimitive.Trigger>
246
+ {/if}
247
+
248
+ <DialogPrimitive.Portal>
249
+ <DialogPrimitive.Overlay
250
+ class={cn('fixed inset-0 z-60 bg-black/95', !reducedMotion.value && 'animate-fade-in')}
251
+ />
252
+
253
+ <DialogPrimitive.Content
254
+ class={cn(
255
+ 'fixed inset-0 z-60 flex flex-col text-white outline-none',
256
+ !reducedMotion.value && 'animate-fade-in',
257
+ className
258
+ )}
259
+ onkeydown={handleKeyDown}
260
+ >
261
+ <!-- The dialog needs an accessible name; the visible counter is not enough. -->
262
+ <DialogPrimitive.Title class="sr-only">{dialogTitle}</DialogPrimitive.Title>
263
+ {#if activeItem?.caption}
264
+ <DialogPrimitive.Description class="sr-only">
265
+ {activeItem.caption}
266
+ </DialogPrimitive.Description>
267
+ {/if}
268
+
269
+ <!-- Top chrome -->
270
+ <div class="relative z-10 flex shrink-0 items-center gap-2 p-3">
271
+ {#if showCounter && hasMultiple}
272
+ <span class="rounded-full bg-white/10 px-3 py-1 text-sm tabular-nums" aria-hidden="true">
273
+ {positionLabel}
274
+ </span>
275
+ {/if}
276
+
277
+ <div class="min-w-0 flex-1">
278
+ {#if activeItem?.title}
279
+ <p class="truncate px-1 text-sm font-medium">{activeItem.title}</p>
280
+ {/if}
281
+ </div>
282
+
283
+ {#if zoomable && activeItem?.kind === 'image'}
284
+ <button
285
+ type="button"
286
+ class={chromeButtonClass}
287
+ onclick={() => zoom.zoomOut()}
288
+ disabled={!zoom.zoomed}
289
+ aria-label="Zoom out"
290
+ >
291
+ <Icon name="minimize" size={18} />
292
+ </button>
293
+ <button
294
+ type="button"
295
+ class={chromeButtonClass}
296
+ onclick={() => zoom.zoomIn()}
297
+ aria-label="Zoom in"
298
+ >
299
+ <Icon name="maximize" size={18} />
300
+ </button>
301
+ {/if}
302
+
303
+ {#if downloadUrl}
304
+ <a
305
+ href={downloadUrl}
306
+ download
307
+ class={chromeButtonClass}
308
+ aria-label="Download {dialogTitle}"
309
+ >
310
+ <Icon name="download" size={18} />
311
+ </a>
312
+ {/if}
313
+
314
+ <DialogPrimitive.Close class={chromeButtonClass} aria-label="Close viewer">
315
+ <Icon name="close" size={20} />
316
+ </DialogPrimitive.Close>
317
+ </div>
318
+
319
+ <!-- Media stage.
320
+ The pan surface is a sibling of the chrome buttons above and the
321
+ navigation buttons below, never their ancestor — a pan surface that
322
+ wrapped them would swallow their clicks. -->
323
+ <div class="relative min-h-0 flex-1">
324
+ {#if !activeItem}
325
+ <div class="flex h-full items-center justify-center text-white/70">
326
+ <p class="text-sm">No media to show.</p>
327
+ </div>
328
+ {:else}
329
+ {#key activeItem.id}
330
+ <div class="flex h-full w-full items-center justify-center p-4">
331
+ {#if activeItem.kind === 'image'}
332
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
333
+ <div
334
+ class={cn(
335
+ 'flex h-full w-full items-center justify-center overflow-hidden',
336
+ zoom.zoomed && (zoom.panning ? 'cursor-grabbing' : 'cursor-grab')
337
+ )}
338
+ use:zoom.action
339
+ onpointerdown={handleSwipeStart}
340
+ onpointerup={handleSwipeEnd}
341
+ onpointercancel={() => (swipeActive = false)}
342
+ >
343
+ <img
344
+ src={activeItem.src}
345
+ srcset={activeItem.srcset}
346
+ sizes={activeItem.sizes}
347
+ alt={activeItem.alt}
348
+ draggable="false"
349
+ class={cn(
350
+ 'max-h-full max-w-full select-none object-contain',
351
+ !zoom.panning && !reducedMotion.value && 'transition-transform duration-200'
352
+ )}
353
+ style="transform: {zoom.transform}"
354
+ />
355
+ </div>
356
+ {:else if activeItem.kind === 'video'}
357
+ <Video
358
+ src={activeItem.src}
359
+ poster={activeItem.poster}
360
+ tracks={activeItem.tracks}
361
+ label={dialogTitle}
362
+ ratio={null}
363
+ class="max-h-full w-auto max-w-full"
364
+ />
365
+ {:else if activeItem.kind === 'audio'}
366
+ <Audio
367
+ src={activeItem.src}
368
+ title={activeItem.title}
369
+ artist={activeItem.artist}
370
+ artwork={activeItem.artwork}
371
+ tracks={activeItem.tracks}
372
+ class="w-full max-w-xl"
373
+ />
374
+ {:else}
375
+ <MediaEmbed
376
+ provider={activeItem.provider}
377
+ videoId={activeItem.videoId}
378
+ poster={activeItem.poster}
379
+ start={activeItem.start}
380
+ customerCode={activeItem.customerCode}
381
+ domain={activeItem.domain}
382
+ matterport={activeItem.matterport}
383
+ title={dialogTitle}
384
+ class="w-full max-w-5xl"
385
+ />
386
+ {/if}
387
+ </div>
388
+ {/key}
389
+ {/if}
390
+
391
+ {#if hasMultiple}
392
+ <button
393
+ type="button"
394
+ class={cn(chromeButtonClass, 'absolute left-3 top-1/2 z-10 -translate-y-1/2')}
395
+ onclick={() => goTo(index - 1)}
396
+ disabled={!canGoPrevious}
397
+ aria-label="Previous item"
398
+ >
399
+ <Icon name="chevron-left" size={22} />
400
+ </button>
401
+
402
+ <button
403
+ type="button"
404
+ class={cn(chromeButtonClass, 'absolute right-3 top-1/2 z-10 -translate-y-1/2')}
405
+ onclick={() => goTo(index + 1)}
406
+ disabled={!canGoNext}
407
+ aria-label="Next item"
408
+ >
409
+ <Icon name="chevron-right" size={22} />
410
+ </button>
411
+ {/if}
412
+ </div>
413
+
414
+ <!-- Bottom chrome -->
415
+ <div class="relative z-10 shrink-0 space-y-3 p-3">
416
+ {#if showCaption && activeItem?.caption}
417
+ <p class="mx-auto max-w-3xl text-center text-sm text-white/80">
418
+ {activeItem.caption}
419
+ </p>
420
+ {/if}
421
+
422
+ {#if showThumbnails && hasMultiple}
423
+ <!--
424
+ `p-1` leaves room for the 4px selection and focus rings, which
425
+ paint outside each tile's border box. Without it they are clipped:
426
+ setting `overflow-x` makes `overflow-y` compute to `auto` as well,
427
+ so the strip crops anything drawn beyond its edges.
428
+ -->
429
+ <ul
430
+ class="mx-auto flex max-w-full list-none gap-2 overflow-x-auto p-1"
431
+ aria-label="Media thumbnails"
432
+ >
433
+ {#each items as item, thumbIndex (item.id)}
434
+ <li class="w-16 shrink-0">
435
+ <MediaTile
436
+ {item}
437
+ index={thumbIndex}
438
+ ratio="square"
439
+ selected={thumbIndex === index}
440
+ onselect={(i) => goTo(i)}
441
+ />
442
+ </li>
443
+ {/each}
444
+ </ul>
445
+ {/if}
446
+ </div>
447
+ </DialogPrimitive.Content>
448
+ </DialogPrimitive.Portal>
449
+ </DialogPrimitive.Root>
@@ -0,0 +1,33 @@
1
+ import { type Snippet } from 'svelte';
2
+ import type { MediaItem } from '../../types/components.js';
3
+ interface Props {
4
+ /** Items to page through */
5
+ items: MediaItem[];
6
+ /** Whether the lightbox is open (bindable) */
7
+ open?: boolean;
8
+ /** Index of the visible item (bindable) */
9
+ index?: number;
10
+ /** Called when the open state changes */
11
+ onOpenChange?: (open: boolean) => void;
12
+ /** Called when the visible item changes */
13
+ onIndexChange?: (index: number) => void;
14
+ /** Wrap from the last item back to the first */
15
+ loop?: boolean;
16
+ /** Show the thumbnail strip along the bottom */
17
+ showThumbnails?: boolean;
18
+ /** Show the caption beneath the media */
19
+ showCaption?: boolean;
20
+ /** Show the "n of m" counter */
21
+ showCounter?: boolean;
22
+ /** Allow zooming and panning images */
23
+ zoomable?: boolean;
24
+ /** Show a download link for items that expose a URL */
25
+ downloadable?: boolean;
26
+ /** Optional trigger rendered inline; omit when controlling `open` yourself */
27
+ trigger?: Snippet;
28
+ /** Additional classes for the dialog surface */
29
+ class?: string;
30
+ }
31
+ declare const Lightbox: import("svelte").Component<Props, {}, "open" | "index">;
32
+ type Lightbox = ReturnType<typeof Lightbox>;
33
+ export default Lightbox;