@classic-homes/theme-svelte 1.2.2 → 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.
- 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 +8 -0
- package/dist/lib/components/DataPanel.svelte.d.ts +6 -0
- package/dist/lib/components/DataPanelTab.svelte +38 -8
- package/dist/lib/components/DataPanelTab.svelte.d.ts +7 -0
- 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,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;
|