@classic-homes/theme-svelte 1.2.3 → 1.4.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 (52) 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/DataPanel.svelte +20 -19
  11. package/dist/lib/components/FileUpload.svelte +221 -4
  12. package/dist/lib/components/FileUpload.svelte.d.ts +13 -1
  13. package/dist/lib/components/media/AspectRatio.svelte +47 -0
  14. package/dist/lib/components/media/AspectRatio.svelte.d.ts +20 -0
  15. package/dist/lib/components/media/Audio.svelte +247 -0
  16. package/dist/lib/components/media/Audio.svelte.d.ts +34 -0
  17. package/dist/lib/components/media/Image.svelte +209 -0
  18. package/dist/lib/components/media/Image.svelte.d.ts +52 -0
  19. package/dist/lib/components/media/Lightbox.svelte +449 -0
  20. package/dist/lib/components/media/Lightbox.svelte.d.ts +33 -0
  21. package/dist/lib/components/media/MediaCarousel.svelte +359 -0
  22. package/dist/lib/components/media/MediaCarousel.svelte.d.ts +30 -0
  23. package/dist/lib/components/media/MediaEmbed.svelte +314 -0
  24. package/dist/lib/components/media/MediaEmbed.svelte.d.ts +99 -0
  25. package/dist/lib/components/media/MediaGrid.svelte +327 -0
  26. package/dist/lib/components/media/MediaGrid.svelte.d.ts +57 -0
  27. package/dist/lib/components/media/Video.svelte +455 -0
  28. package/dist/lib/components/media/Video.svelte.d.ts +81 -0
  29. package/dist/lib/components/media/index.d.ts +14 -0
  30. package/dist/lib/components/media/index.js +14 -0
  31. package/dist/lib/components/media/internal/MediaControls.svelte +283 -0
  32. package/dist/lib/components/media/internal/MediaControls.svelte.d.ts +32 -0
  33. package/dist/lib/components/media/internal/MediaScrubber.svelte +211 -0
  34. package/dist/lib/components/media/internal/MediaScrubber.svelte.d.ts +27 -0
  35. package/dist/lib/components/media/internal/MediaTile.svelte +129 -0
  36. package/dist/lib/components/media/internal/MediaTile.svelte.d.ts +22 -0
  37. package/dist/lib/composables/index.d.ts +3 -0
  38. package/dist/lib/composables/index.js +4 -0
  39. package/dist/lib/composables/useHlsSource.svelte.d.ts +70 -0
  40. package/dist/lib/composables/useHlsSource.svelte.js +119 -0
  41. package/dist/lib/composables/useMediaPlayer.svelte.d.ts +114 -0
  42. package/dist/lib/composables/useMediaPlayer.svelte.js +422 -0
  43. package/dist/lib/composables/useMediaZoom.svelte.d.ts +59 -0
  44. package/dist/lib/composables/useMediaZoom.svelte.js +199 -0
  45. package/dist/lib/index.d.ts +8 -2
  46. package/dist/lib/index.js +14 -1
  47. package/dist/lib/types/components.d.ts +172 -0
  48. package/dist/lib/utils/hls.d.ts +76 -0
  49. package/dist/lib/utils/hls.js +83 -0
  50. package/dist/lib/utils/media.d.ts +379 -0
  51. package/dist/lib/utils/media.js +715 -0
  52. package/package.json +6 -2
@@ -0,0 +1,99 @@
1
+ /**
2
+ * MediaEmbed - Lazy YouTube, Vimeo, Cloudflare Stream, and Matterport embeds
3
+ *
4
+ * Renders a poster facade and only injects the provider's iframe once the
5
+ * viewer clicks, so the page loads no third-party player scripts and sets no
6
+ * tracking cookies on arrival.
7
+ *
8
+ * Features:
9
+ * - Accepts a full watch URL, or an explicit provider + id
10
+ * - YouTube routes through `youtube-nocookie.com` by default
11
+ * - YouTube and Cloudflare Stream posters are derived; Vimeo needs `poster`
12
+ * - Matterport publishes no thumbnail: pass `poster`, or opt into
13
+ * `fetchMetadata` to look one up at runtime
14
+ * - A Matterport space is spatial rather than timed, so its facade offers to
15
+ * open a 3D tour rather than to play something
16
+ * - Keyboard accessible facade with a required accessible `title`
17
+ *
18
+ * For Cloudflare Stream this embeds Cloudflare's own player. To play a
19
+ * Stream video with this design system's controls instead, use `Video`
20
+ * together with `cloudflareStream()`.
21
+ *
22
+ * Note: a derived YouTube poster is still fetched from `i.ytimg.com`, which
23
+ * is a static image host rather than the player. Pass your own `poster` to
24
+ * avoid contacting the provider entirely before playback.
25
+ *
26
+ * @example
27
+ * ```svelte
28
+ * <MediaEmbed url="https://youtu.be/dQw4w9WgXcQ" title="Never Gonna Give You Up" />
29
+ * ```
30
+ *
31
+ * @example Cloudflare Stream
32
+ * ```svelte
33
+ * <MediaEmbed
34
+ * provider="cloudflare"
35
+ * videoId="a1b2c3"
36
+ * customerCode="abc123"
37
+ * title="Product tour"
38
+ * />
39
+ * ```
40
+ */
41
+ import type { MatterportPlayerOptions, MediaAspectRatio, MediaEmbedProvider } from '../../types/components.js';
42
+ interface Props {
43
+ /** Full provider URL. Ignored when `provider` and `videoId` are given. */
44
+ url?: string;
45
+ /** Hosting provider */
46
+ provider?: MediaEmbedProvider;
47
+ /** Provider-specific video identifier */
48
+ videoId?: string;
49
+ /** Matterport space id. An alias for `videoId`, named for what it is. */
50
+ modelId?: string;
51
+ /** Showcase options, for `provider="matterport"` */
52
+ matterport?: MatterportPlayerOptions;
53
+ /**
54
+ * Look up the space's preview image and name from Matterport.
55
+ *
56
+ * Off by default: contacting the provider before the viewer clicks is
57
+ * what the lazy facade exists to avoid. Matterport publishes no derivable
58
+ * thumbnail, so without this, supply `poster` or accept a placeholder.
59
+ */
60
+ fetchMetadata?: boolean;
61
+ /**
62
+ * Accessible name for the embedded player. Required — an iframe with no
63
+ * title is announced only as "frame".
64
+ */
65
+ title: string;
66
+ /**
67
+ * Poster image. Derived automatically for YouTube and Cloudflare Stream;
68
+ * required for Vimeo.
69
+ */
70
+ poster?: string;
71
+ /** Route YouTube through the no-cookie domain (default `true`) */
72
+ privacy?: boolean;
73
+ /** Start offset in seconds */
74
+ start?: number;
75
+ /** Show the provider's related-video suggestions after playback */
76
+ showRelated?: boolean;
77
+ /**
78
+ * Cloudflare Stream customer code, from the Stream dashboard. Required for
79
+ * `provider="cloudflare"` unless `domain` is given or a full `url` is used.
80
+ */
81
+ customerCode?: string;
82
+ /** Custom hostname serving Cloudflare Stream */
83
+ domain?: string;
84
+ /** Accent colour for the Cloudflare player UI, any CSS colour */
85
+ primaryColor?: string;
86
+ /** Letterbox colour for the Cloudflare player, any CSS colour */
87
+ letterboxColor?: string;
88
+ /** BCP-47 language code of the captions track the Cloudflare player enables */
89
+ defaultTextTrack?: string;
90
+ /** Aspect ratio of the embed box */
91
+ ratio?: MediaAspectRatio | number;
92
+ /** Load the iframe immediately instead of showing a facade */
93
+ eager?: boolean;
94
+ /** Additional classes */
95
+ class?: string;
96
+ }
97
+ declare const MediaEmbed: import("svelte").Component<Props, {}, "">;
98
+ type MediaEmbed = ReturnType<typeof MediaEmbed>;
99
+ export default MediaEmbed;
@@ -0,0 +1,327 @@
1
+ <script lang="ts" module>
2
+ const COLUMN_CLASSES = {
3
+ base: {
4
+ 1: 'grid-cols-1',
5
+ 2: 'grid-cols-2',
6
+ 3: 'grid-cols-3',
7
+ 4: 'grid-cols-4',
8
+ 5: 'grid-cols-5',
9
+ 6: 'grid-cols-6',
10
+ } as Record<number, string>,
11
+ sm: {
12
+ 1: 'sm:grid-cols-1',
13
+ 2: 'sm:grid-cols-2',
14
+ 3: 'sm:grid-cols-3',
15
+ 4: 'sm:grid-cols-4',
16
+ 5: 'sm:grid-cols-5',
17
+ 6: 'sm:grid-cols-6',
18
+ } as Record<number, string>,
19
+ md: {
20
+ 1: 'md:grid-cols-1',
21
+ 2: 'md:grid-cols-2',
22
+ 3: 'md:grid-cols-3',
23
+ 4: 'md:grid-cols-4',
24
+ 5: 'md:grid-cols-5',
25
+ 6: 'md:grid-cols-6',
26
+ } as Record<number, string>,
27
+ lg: {
28
+ 1: 'lg:grid-cols-1',
29
+ 2: 'lg:grid-cols-2',
30
+ 3: 'lg:grid-cols-3',
31
+ 4: 'lg:grid-cols-4',
32
+ 5: 'lg:grid-cols-5',
33
+ 6: 'lg:grid-cols-6',
34
+ } as Record<number, string>,
35
+ xl: {
36
+ 1: 'xl:grid-cols-1',
37
+ 2: 'xl:grid-cols-2',
38
+ 3: 'xl:grid-cols-3',
39
+ 4: 'xl:grid-cols-4',
40
+ 5: 'xl:grid-cols-5',
41
+ 6: 'xl:grid-cols-6',
42
+ } as Record<number, string>,
43
+ };
44
+
45
+ const MASONRY_CLASSES = {
46
+ base: {
47
+ 1: 'columns-1',
48
+ 2: 'columns-2',
49
+ 3: 'columns-3',
50
+ 4: 'columns-4',
51
+ 5: 'columns-5',
52
+ 6: 'columns-6',
53
+ } as Record<number, string>,
54
+ sm: {
55
+ 1: 'sm:columns-1',
56
+ 2: 'sm:columns-2',
57
+ 3: 'sm:columns-3',
58
+ 4: 'sm:columns-4',
59
+ 5: 'sm:columns-5',
60
+ 6: 'sm:columns-6',
61
+ } as Record<number, string>,
62
+ md: {
63
+ 1: 'md:columns-1',
64
+ 2: 'md:columns-2',
65
+ 3: 'md:columns-3',
66
+ 4: 'md:columns-4',
67
+ 5: 'md:columns-5',
68
+ 6: 'md:columns-6',
69
+ } as Record<number, string>,
70
+ lg: {
71
+ 1: 'lg:columns-1',
72
+ 2: 'lg:columns-2',
73
+ 3: 'lg:columns-3',
74
+ 4: 'lg:columns-4',
75
+ 5: 'lg:columns-5',
76
+ 6: 'lg:columns-6',
77
+ } as Record<number, string>,
78
+ xl: {
79
+ 1: 'xl:columns-1',
80
+ 2: 'xl:columns-2',
81
+ 3: 'xl:columns-3',
82
+ 4: 'xl:columns-4',
83
+ 5: 'xl:columns-5',
84
+ 6: 'xl:columns-6',
85
+ } as Record<number, string>,
86
+ };
87
+ </script>
88
+
89
+ <script lang="ts">
90
+ /**
91
+ * MediaGrid - Responsive gallery of media thumbnails
92
+ *
93
+ * Features:
94
+ * - Responsive grid, or a masonry layout for mixed-height images
95
+ * - Arrow-key navigation that moves a row at a time, not an item at a time
96
+ * - A single tab stop by default, so a large gallery does not flood tab order
97
+ * - Play affordance and duration badge for video, audio, and embeds
98
+ * - Opens the built-in Lightbox on selection unless `lightbox` is disabled
99
+ *
100
+ * @example
101
+ * ```svelte
102
+ * <MediaGrid items={photos} columns={{ base: 2, md: 3, lg: 4 }} label="Project photos" />
103
+ * ```
104
+ */
105
+ import type { MediaAspectRatio, MediaItem } from '../../types/components.js';
106
+ import { cn } from '../../utils.js';
107
+ import Lightbox from './Lightbox.svelte';
108
+ import MediaTile from './internal/MediaTile.svelte';
109
+
110
+ /** Responsive column counts, keyed by breakpoint. */
111
+ export interface MediaGridColumns {
112
+ base?: number;
113
+ sm?: number;
114
+ md?: number;
115
+ lg?: number;
116
+ xl?: number;
117
+ }
118
+
119
+ interface Props {
120
+ /** Items to display */
121
+ items: MediaItem[];
122
+ /** Accessible name for the gallery list */
123
+ label?: string;
124
+ /**
125
+ * `grid` keeps every tile the same size (best for uniform thumbnails).
126
+ * `masonry` preserves each image's own height via CSS columns — note that
127
+ * CSS columns fill top-to-bottom, so the visual order runs down each
128
+ * column rather than across each row.
129
+ */
130
+ variant?: 'grid' | 'masonry';
131
+ /** Column counts per breakpoint */
132
+ columns?: MediaGridColumns;
133
+ /** Gap between tiles */
134
+ gap?: 'none' | 'sm' | 'md' | 'lg';
135
+ /** Aspect ratio of each tile (ignored in `masonry`) */
136
+ ratio?: MediaAspectRatio | number;
137
+ /** Open the built-in lightbox when a tile is selected */
138
+ lightbox?: boolean;
139
+ /**
140
+ * Keep the gallery to a single tab stop and move between tiles with the
141
+ * arrow keys. Set `false` to make every tile individually tabbable.
142
+ */
143
+ singleTabStop?: boolean;
144
+ /** Called with the selected index, whether or not the lightbox opens */
145
+ onSelect?: (index: number, item: MediaItem) => void;
146
+ /** Additional classes */
147
+ class?: string;
148
+ }
149
+
150
+ let {
151
+ items,
152
+ label = 'Media gallery',
153
+ variant = 'grid',
154
+ columns = { base: 2, md: 3, lg: 4 },
155
+ gap = 'md',
156
+ ratio = 'square',
157
+ lightbox = true,
158
+ singleTabStop = true,
159
+ onSelect,
160
+ class: className,
161
+ }: Props = $props();
162
+
163
+ let listElement = $state<HTMLElement | null>(null);
164
+ let activeIndex = $state(0);
165
+ let lightboxOpen = $state(false);
166
+ let lightboxIndex = $state(0);
167
+
168
+ const gapClass = $derived(
169
+ ({ none: 'gap-0', sm: 'gap-2', md: 'gap-4', lg: 'gap-6' } as const)[gap]
170
+ );
171
+
172
+ // CSS multi-column honours `gap` horizontally only, so vertical rhythm in
173
+ // masonry has to come from a bottom margin on each item.
174
+ const masonryItemGapClass = $derived(
175
+ ({ none: '', sm: 'mb-2', md: 'mb-4', lg: 'mb-6' } as const)[gap]
176
+ );
177
+
178
+ // Column utilities are written out in full rather than interpolated, because
179
+ // Tailwind only ships classes it can find as complete strings in the source.
180
+ const gridColsClass = $derived(
181
+ [
182
+ COLUMN_CLASSES.base[columns.base ?? 2],
183
+ columns.sm ? COLUMN_CLASSES.sm[columns.sm] : '',
184
+ columns.md ? COLUMN_CLASSES.md[columns.md] : '',
185
+ columns.lg ? COLUMN_CLASSES.lg[columns.lg] : '',
186
+ columns.xl ? COLUMN_CLASSES.xl[columns.xl] : '',
187
+ ]
188
+ .filter(Boolean)
189
+ .join(' ')
190
+ );
191
+
192
+ const masonryColsClass = $derived(
193
+ [
194
+ MASONRY_CLASSES.base[columns.base ?? 2],
195
+ columns.sm ? MASONRY_CLASSES.sm[columns.sm] : '',
196
+ columns.md ? MASONRY_CLASSES.md[columns.md] : '',
197
+ columns.lg ? MASONRY_CLASSES.lg[columns.lg] : '',
198
+ columns.xl ? MASONRY_CLASSES.xl[columns.xl] : '',
199
+ ]
200
+ .filter(Boolean)
201
+ .join(' ')
202
+ );
203
+
204
+ function getTiles(): HTMLElement[] {
205
+ if (!listElement) return [];
206
+ return Array.from(listElement.querySelectorAll<HTMLElement>('[data-media-tile]'));
207
+ }
208
+
209
+ /**
210
+ * Number of tiles per visual row, read from layout rather than from the
211
+ * `columns` prop — the prop describes breakpoints, only the DOM knows which
212
+ * one is active. Tiles sharing an `offsetTop` are on the same row.
213
+ */
214
+ function getColumnCount(tiles: HTMLElement[]): number {
215
+ if (tiles.length === 0) return 1;
216
+ const firstTop = tiles[0].offsetTop;
217
+ let count = 0;
218
+ for (const tile of tiles) {
219
+ if (tile.offsetTop !== firstTop) break;
220
+ count += 1;
221
+ }
222
+ return Math.max(count, 1);
223
+ }
224
+
225
+ function focusTile(index: number) {
226
+ const tiles = getTiles();
227
+ if (tiles.length === 0) return;
228
+ const clamped = Math.max(0, Math.min(index, tiles.length - 1));
229
+ activeIndex = clamped;
230
+ tiles[clamped].focus();
231
+ }
232
+
233
+ function handleKeyDown(event: KeyboardEvent) {
234
+ const tiles = getTiles();
235
+ if (tiles.length === 0) return;
236
+
237
+ // Masonry lays out in columns, so a computed "row" would be meaningless;
238
+ // fall back to linear movement there.
239
+ const columnCount = variant === 'masonry' ? 1 : getColumnCount(tiles);
240
+ const current = tiles.findIndex((tile) => tile === document.activeElement);
241
+ const from = current === -1 ? activeIndex : current;
242
+
243
+ let next: number | null = null;
244
+
245
+ switch (event.key) {
246
+ case 'ArrowRight':
247
+ next = from + 1;
248
+ break;
249
+ case 'ArrowLeft':
250
+ next = from - 1;
251
+ break;
252
+ case 'ArrowDown':
253
+ next = from + columnCount;
254
+ break;
255
+ case 'ArrowUp':
256
+ next = from - columnCount;
257
+ break;
258
+ case 'Home':
259
+ next = 0;
260
+ break;
261
+ case 'End':
262
+ next = tiles.length - 1;
263
+ break;
264
+ default:
265
+ return;
266
+ }
267
+
268
+ if (next === null || next < 0 || next >= tiles.length) {
269
+ // Swallow the key at the edges so the page does not scroll unexpectedly
270
+ // mid-navigation, but leave focus where it is.
271
+ if (event.key.startsWith('Arrow')) event.preventDefault();
272
+ return;
273
+ }
274
+
275
+ event.preventDefault();
276
+ focusTile(next);
277
+ }
278
+
279
+ function handleSelect(index: number) {
280
+ activeIndex = index;
281
+ onSelect?.(index, items[index]);
282
+ if (lightbox) {
283
+ lightboxIndex = index;
284
+ lightboxOpen = true;
285
+ }
286
+ }
287
+ </script>
288
+
289
+ {#if items.length === 0}
290
+ <p class={cn('py-8 text-center text-sm text-muted-foreground', className)}>No media to show.</p>
291
+ {:else}
292
+ <!--
293
+ The keydown handler is delegated from the list purely to move focus
294
+ between the tile buttons inside it; the buttons themselves remain the
295
+ interactive elements.
296
+ -->
297
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
298
+ <ul
299
+ bind:this={listElement}
300
+ aria-label={label}
301
+ class={cn(
302
+ variant === 'masonry' ? masonryColsClass : cn('grid', gridColsClass),
303
+ gapClass,
304
+ 'list-none p-0',
305
+ className
306
+ )}
307
+ onkeydown={handleKeyDown}
308
+ >
309
+ {#each items as item, index (item.id)}
310
+ <li class={cn(variant === 'masonry' && cn('break-inside-avoid', masonryItemGapClass))}>
311
+ <MediaTile
312
+ {item}
313
+ {index}
314
+ ratio={variant === 'masonry' ? undefined : ratio}
315
+ onselect={handleSelect}
316
+ class="h-full"
317
+ data-media-tile
318
+ tabindex={!singleTabStop || index === activeIndex ? 0 : -1}
319
+ />
320
+ </li>
321
+ {/each}
322
+ </ul>
323
+
324
+ {#if lightbox}
325
+ <Lightbox {items} bind:open={lightboxOpen} bind:index={lightboxIndex} />
326
+ {/if}
327
+ {/if}
@@ -0,0 +1,57 @@
1
+ /**
2
+ * MediaGrid - Responsive gallery of media thumbnails
3
+ *
4
+ * Features:
5
+ * - Responsive grid, or a masonry layout for mixed-height images
6
+ * - Arrow-key navigation that moves a row at a time, not an item at a time
7
+ * - A single tab stop by default, so a large gallery does not flood tab order
8
+ * - Play affordance and duration badge for video, audio, and embeds
9
+ * - Opens the built-in Lightbox on selection unless `lightbox` is disabled
10
+ *
11
+ * @example
12
+ * ```svelte
13
+ * <MediaGrid items={photos} columns={{ base: 2, md: 3, lg: 4 }} label="Project photos" />
14
+ * ```
15
+ */
16
+ import type { MediaAspectRatio, MediaItem } from '../../types/components.js';
17
+ /** Responsive column counts, keyed by breakpoint. */
18
+ export interface MediaGridColumns {
19
+ base?: number;
20
+ sm?: number;
21
+ md?: number;
22
+ lg?: number;
23
+ xl?: number;
24
+ }
25
+ interface Props {
26
+ /** Items to display */
27
+ items: MediaItem[];
28
+ /** Accessible name for the gallery list */
29
+ label?: string;
30
+ /**
31
+ * `grid` keeps every tile the same size (best for uniform thumbnails).
32
+ * `masonry` preserves each image's own height via CSS columns — note that
33
+ * CSS columns fill top-to-bottom, so the visual order runs down each
34
+ * column rather than across each row.
35
+ */
36
+ variant?: 'grid' | 'masonry';
37
+ /** Column counts per breakpoint */
38
+ columns?: MediaGridColumns;
39
+ /** Gap between tiles */
40
+ gap?: 'none' | 'sm' | 'md' | 'lg';
41
+ /** Aspect ratio of each tile (ignored in `masonry`) */
42
+ ratio?: MediaAspectRatio | number;
43
+ /** Open the built-in lightbox when a tile is selected */
44
+ lightbox?: boolean;
45
+ /**
46
+ * Keep the gallery to a single tab stop and move between tiles with the
47
+ * arrow keys. Set `false` to make every tile individually tabbable.
48
+ */
49
+ singleTabStop?: boolean;
50
+ /** Called with the selected index, whether or not the lightbox opens */
51
+ onSelect?: (index: number, item: MediaItem) => void;
52
+ /** Additional classes */
53
+ class?: string;
54
+ }
55
+ declare const MediaGrid: import("svelte").Component<Props, {}, "">;
56
+ type MediaGrid = ReturnType<typeof MediaGrid>;
57
+ export default MediaGrid;