@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.
- package/README.md +43 -0
- package/dist/__tests__/a11y/Media.a11y.test.js +145 -0
- package/dist/__tests__/media-components.test.js +206 -0
- package/dist/__tests__/media-hls.test.js +273 -0
- package/dist/__tests__/media-matterport.test.js +296 -0
- package/dist/__tests__/media-players.test.js +244 -0
- package/dist/__tests__/media-scrubber.test.js +44 -0
- package/dist/__tests__/media-utils.test.js +427 -0
- package/dist/__tests__/setup.js +50 -0
- package/dist/lib/components/DataPanel.svelte +20 -19
- package/dist/lib/components/FileUpload.svelte +221 -4
- package/dist/lib/components/FileUpload.svelte.d.ts +13 -1
- package/dist/lib/components/media/AspectRatio.svelte +47 -0
- package/dist/lib/components/media/AspectRatio.svelte.d.ts +20 -0
- package/dist/lib/components/media/Audio.svelte +247 -0
- package/dist/lib/components/media/Audio.svelte.d.ts +34 -0
- package/dist/lib/components/media/Image.svelte +209 -0
- package/dist/lib/components/media/Image.svelte.d.ts +52 -0
- package/dist/lib/components/media/Lightbox.svelte +449 -0
- package/dist/lib/components/media/Lightbox.svelte.d.ts +33 -0
- package/dist/lib/components/media/MediaCarousel.svelte +359 -0
- package/dist/lib/components/media/MediaCarousel.svelte.d.ts +30 -0
- package/dist/lib/components/media/MediaEmbed.svelte +314 -0
- package/dist/lib/components/media/MediaEmbed.svelte.d.ts +99 -0
- package/dist/lib/components/media/MediaGrid.svelte +327 -0
- package/dist/lib/components/media/MediaGrid.svelte.d.ts +57 -0
- package/dist/lib/components/media/Video.svelte +455 -0
- package/dist/lib/components/media/Video.svelte.d.ts +81 -0
- package/dist/lib/components/media/index.d.ts +14 -0
- package/dist/lib/components/media/index.js +14 -0
- package/dist/lib/components/media/internal/MediaControls.svelte +283 -0
- package/dist/lib/components/media/internal/MediaControls.svelte.d.ts +32 -0
- package/dist/lib/components/media/internal/MediaScrubber.svelte +211 -0
- package/dist/lib/components/media/internal/MediaScrubber.svelte.d.ts +27 -0
- package/dist/lib/components/media/internal/MediaTile.svelte +129 -0
- package/dist/lib/components/media/internal/MediaTile.svelte.d.ts +22 -0
- package/dist/lib/composables/index.d.ts +3 -0
- package/dist/lib/composables/index.js +4 -0
- package/dist/lib/composables/useHlsSource.svelte.d.ts +70 -0
- package/dist/lib/composables/useHlsSource.svelte.js +119 -0
- package/dist/lib/composables/useMediaPlayer.svelte.d.ts +114 -0
- package/dist/lib/composables/useMediaPlayer.svelte.js +422 -0
- package/dist/lib/composables/useMediaZoom.svelte.d.ts +59 -0
- package/dist/lib/composables/useMediaZoom.svelte.js +199 -0
- package/dist/lib/index.d.ts +8 -2
- package/dist/lib/index.js +14 -1
- package/dist/lib/types/components.d.ts +172 -0
- package/dist/lib/utils/hls.d.ts +76 -0
- package/dist/lib/utils/hls.js +83 -0
- package/dist/lib/utils/media.d.ts +379 -0
- package/dist/lib/utils/media.js +715 -0
- 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;
|