@loidolt/theme-svelte 0.4.0 → 0.6.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 (127) hide show
  1. package/README.md +11 -0
  2. package/dist/announcer.svelte.d.ts +36 -0
  3. package/dist/announcer.svelte.js +73 -0
  4. package/dist/attachments.d.ts +83 -0
  5. package/dist/attachments.js +211 -0
  6. package/dist/components/ActiveFilterChips.svelte +80 -0
  7. package/dist/components/ActiveFilterChips.svelte.d.ts +41 -0
  8. package/dist/components/AlertDialog.svelte +4 -4
  9. package/dist/components/AppShell.svelte +9 -2
  10. package/dist/components/AppShell.svelte.d.ts +3 -1
  11. package/dist/components/AspectRatio.svelte +32 -0
  12. package/dist/components/AspectRatio.svelte.d.ts +13 -0
  13. package/dist/components/AudioPlayer.svelte +94 -0
  14. package/dist/components/AudioPlayer.svelte.d.ts +24 -0
  15. package/dist/components/Avatar.svelte +85 -0
  16. package/dist/components/Avatar.svelte.d.ts +31 -0
  17. package/dist/components/Badge.svelte +28 -2
  18. package/dist/components/Badge.svelte.d.ts +13 -1
  19. package/dist/components/Combobox.svelte +180 -0
  20. package/dist/components/Combobox.svelte.d.ts +77 -0
  21. package/dist/components/DataTable.svelte +228 -0
  22. package/dist/components/DataTable.svelte.d.ts +62 -0
  23. package/dist/components/DataTableColumnVisibility.svelte +46 -0
  24. package/dist/components/DataTableColumnVisibility.svelte.d.ts +31 -0
  25. package/dist/components/DataTableFacetedFilter.svelte +87 -0
  26. package/dist/components/DataTableFacetedFilter.svelte.d.ts +40 -0
  27. package/dist/components/DataTablePagination.svelte +68 -0
  28. package/dist/components/DataTablePagination.svelte.d.ts +42 -0
  29. package/dist/components/DataTableSearch.svelte +67 -0
  30. package/dist/components/DataTableSearch.svelte.d.ts +37 -0
  31. package/dist/components/Dialog.svelte +35 -14
  32. package/dist/components/Dialog.svelte.d.ts +7 -0
  33. package/dist/components/Drawer.svelte +3 -3
  34. package/dist/components/DropdownMenu.svelte +95 -28
  35. package/dist/components/DropdownMenu.svelte.d.ts +12 -3
  36. package/dist/components/Field.svelte +2 -3
  37. package/dist/components/Fieldset.svelte +2 -3
  38. package/dist/components/Filmstrip.svelte +10 -3
  39. package/dist/components/FilterPanel.svelte +62 -0
  40. package/dist/components/FilterPanel.svelte.d.ts +22 -0
  41. package/dist/components/Lightbox.svelte +239 -0
  42. package/dist/components/Lightbox.svelte.d.ts +40 -0
  43. package/dist/components/LiveRegion.svelte +48 -0
  44. package/dist/components/LiveRegion.svelte.d.ts +20 -0
  45. package/dist/components/MediaCarousel.svelte +177 -0
  46. package/dist/components/MediaCarousel.svelte.d.ts +32 -0
  47. package/dist/components/MediaEmbed.svelte +116 -0
  48. package/dist/components/MediaEmbed.svelte.d.ts +34 -0
  49. package/dist/components/MediaGrid.svelte +91 -0
  50. package/dist/components/MediaGrid.svelte.d.ts +24 -0
  51. package/dist/components/MultiSelect.svelte +232 -0
  52. package/dist/components/MultiSelect.svelte.d.ts +67 -0
  53. package/dist/components/NumberField.svelte +53 -25
  54. package/dist/components/NumberField.svelte.d.ts +47 -18
  55. package/dist/components/OTPInput.svelte +94 -0
  56. package/dist/components/OTPInput.svelte.d.ts +26 -0
  57. package/dist/components/PasswordInput.svelte +93 -0
  58. package/dist/components/PasswordInput.svelte.d.ts +24 -0
  59. package/dist/components/Progress.svelte +57 -14
  60. package/dist/components/Progress.svelte.d.ts +13 -0
  61. package/dist/components/RadioGroup.svelte +19 -5
  62. package/dist/components/RadioGroup.svelte.d.ts +3 -2
  63. package/dist/components/Select.svelte +15 -5
  64. package/dist/components/Select.svelte.d.ts +3 -2
  65. package/dist/components/SignaturePad.svelte +317 -0
  66. package/dist/components/SignaturePad.svelte.d.ts +48 -0
  67. package/dist/components/Skeleton.svelte +40 -9
  68. package/dist/components/Skeleton.svelte.d.ts +8 -0
  69. package/dist/components/SkipLink.svelte +48 -0
  70. package/dist/components/SkipLink.svelte.d.ts +14 -0
  71. package/dist/components/Slider.svelte +148 -0
  72. package/dist/components/Slider.svelte.d.ts +55 -0
  73. package/dist/components/Spinner.svelte +11 -2
  74. package/dist/components/Spinner.svelte.d.ts +3 -0
  75. package/dist/components/Tabs.svelte.d.ts +1 -1
  76. package/dist/components/Thumbnail.svelte +52 -6
  77. package/dist/components/Thumbnail.svelte.d.ts +14 -2
  78. package/dist/components/Toast.svelte +24 -0
  79. package/dist/components/Toast.svelte.d.ts +10 -0
  80. package/dist/components/ToastViewport.svelte +10 -1
  81. package/dist/components/ToastViewport.svelte.d.ts +2 -0
  82. package/dist/components/Toolbar.svelte +49 -0
  83. package/dist/components/Toolbar.svelte.d.ts +23 -0
  84. package/dist/components/Tooltip.svelte +9 -1
  85. package/dist/components/Tooltip.svelte.d.ts +2 -0
  86. package/dist/components/VideoPlayer.svelte +136 -0
  87. package/dist/components/VideoPlayer.svelte.d.ts +38 -0
  88. package/dist/data-table-utils.d.ts +62 -0
  89. package/dist/data-table-utils.js +91 -0
  90. package/dist/data-table.svelte.d.ts +132 -0
  91. package/dist/data-table.svelte.js +292 -0
  92. package/dist/hls.d.ts +35 -0
  93. package/dist/hls.js +37 -0
  94. package/dist/hls.svelte.d.ts +22 -0
  95. package/dist/hls.svelte.js +66 -0
  96. package/dist/index.d.ts +47 -4
  97. package/dist/index.js +35 -2
  98. package/dist/internal/EditableCell.svelte +58 -0
  99. package/dist/internal/EditableCell.svelte.d.ts +8 -0
  100. package/dist/internal/ListboxEntries.svelte +49 -0
  101. package/dist/internal/ListboxEntries.svelte.d.ts +35 -0
  102. package/dist/internal/media/MediaControls.svelte +163 -0
  103. package/dist/internal/media/MediaControls.svelte.d.ts +12 -0
  104. package/dist/internal/media/MediaStage.svelte +49 -0
  105. package/dist/internal/media/MediaStage.svelte.d.ts +11 -0
  106. package/dist/internal/media/MediaTile.svelte +36 -0
  107. package/dist/internal/media/MediaTile.svelte.d.ts +11 -0
  108. package/dist/internal/media/keys.d.ts +7 -0
  109. package/dist/internal/media/keys.js +51 -0
  110. package/dist/internal/media/labels.d.ts +19 -0
  111. package/dist/internal/media/labels.js +15 -0
  112. package/dist/internal/options.d.ts +9 -0
  113. package/dist/internal/options.js +20 -0
  114. package/dist/media-player.svelte.d.ts +61 -0
  115. package/dist/media-player.svelte.js +246 -0
  116. package/dist/media-utils.d.ts +112 -0
  117. package/dist/media-utils.js +205 -0
  118. package/dist/media-zoom.svelte.d.ts +33 -0
  119. package/dist/media-zoom.svelte.js +126 -0
  120. package/dist/signature.d.ts +55 -0
  121. package/dist/signature.js +52 -0
  122. package/dist/toaster.svelte.d.ts +31 -2
  123. package/dist/toaster.svelte.js +30 -2
  124. package/dist/types.d.ts +62 -3
  125. package/dist/utils.d.ts +9 -0
  126. package/dist/utils.js +11 -0
  127. package/package.json +10 -4
@@ -0,0 +1,239 @@
1
+ <script lang="ts">
2
+ import { Dialog as DialogPrimitive } from 'bits-ui';
3
+ import type { Snippet } from 'svelte';
4
+ import MediaStage from '../internal/media/MediaStage.svelte';
5
+ import { createMediaZoom } from '../media-zoom.svelte.js';
6
+ import { mediaKey, mediaLabel, mediaThumbnail, type MediaItem } from '../media-utils.js';
7
+ import type { TriggerChildProps } from '../types.js';
8
+ import { cx } from '../utils.js';
9
+ import Filmstrip from './Filmstrip.svelte';
10
+ import LiveRegion from './LiveRegion.svelte';
11
+ import Thumbnail from './Thumbnail.svelte';
12
+
13
+ interface Props {
14
+ items: MediaItem[];
15
+ open?: boolean;
16
+ /** The item showing, 0-based. */
17
+ index?: number;
18
+ /** Accessible name of the dialog. */
19
+ title?: string;
20
+ /** Wrap from the last item to the first. */
21
+ loop?: boolean;
22
+ showThumbnails?: boolean;
23
+ showCaption?: boolean;
24
+ showCounter?: boolean;
25
+ /** Let images be zoomed and panned: pinch, Ctrl + wheel, double-click, or + and −. */
26
+ zoomable?: boolean;
27
+ /** Offer a download link for images that set `download`. */
28
+ downloadable?: boolean;
29
+ /** Opens the lightbox. Leave it out to drive `open` yourself, as `MediaGrid` does. */
30
+ trigger?: Snippet;
31
+ triggerChild?: Snippet<[TriggerChildProps]>;
32
+ triggerClass?: string;
33
+ closeLabel?: string;
34
+ previousLabel?: string;
35
+ nextLabel?: string;
36
+ zoomInLabel?: string;
37
+ zoomOutLabel?: string;
38
+ downloadLabel?: string;
39
+ thumbnailsLabel?: string;
40
+ counterText?: (position: number, count: number) => string;
41
+ onOpenChange?: (open: boolean) => void;
42
+ onIndexChange?: (index: number) => void;
43
+ /** Lands on the dialog content. */
44
+ class?: string;
45
+ ref?: HTMLElement | null;
46
+ }
47
+
48
+ let {
49
+ items,
50
+ open = $bindable(false),
51
+ index = $bindable(0),
52
+ title = 'Media viewer',
53
+ loop = true,
54
+ showThumbnails = false,
55
+ showCaption = true,
56
+ showCounter = true,
57
+ zoomable = true,
58
+ downloadable = false,
59
+ trigger,
60
+ triggerChild,
61
+ triggerClass = 'ldt-button',
62
+ closeLabel = 'Close viewer',
63
+ previousLabel = 'Previous',
64
+ nextLabel = 'Next',
65
+ zoomInLabel = 'Zoom in',
66
+ zoomOutLabel = 'Zoom out',
67
+ downloadLabel = 'Download',
68
+ thumbnailsLabel = 'All items',
69
+ counterText = (position, count) => `${position} of ${count}`,
70
+ onOpenChange,
71
+ onIndexChange,
72
+ class: className,
73
+ ref = $bindable(null),
74
+ }: Props = $props();
75
+
76
+ const zoom = createMediaZoom();
77
+ const count = $derived(items.length);
78
+ const current = $derived(items[Math.min(Math.max(0, index), Math.max(0, count - 1))]);
79
+ const ids = $derived(items.map((item, position) => mediaKey(item, position)));
80
+ const canPrevious = $derived(loop ? count > 1 : index > 0);
81
+ const canNext = $derived(loop ? count > 1 : index < count - 1);
82
+ const isImage = $derived(current?.kind === 'image');
83
+
84
+ function go(next: number) {
85
+ if (count === 0) return;
86
+ const target = loop ? (next + count) % count : Math.min(Math.max(0, next), count - 1);
87
+ if (target === index) return;
88
+ zoom.reset();
89
+ index = target;
90
+ onIndexChange?.(target);
91
+ }
92
+
93
+ function handleKeydown(event: KeyboardEvent) {
94
+ // Keys aimed at a control inside the stage (a player's range, a select) stay with it.
95
+ if ((event.target as HTMLElement).matches('input, select, textarea')) return;
96
+ const handled = (() => {
97
+ if (event.key === 'ArrowRight') go(index + 1);
98
+ else if (event.key === 'ArrowLeft') go(index - 1);
99
+ else if (event.key === 'Home') go(0);
100
+ else if (event.key === 'End') go(count - 1);
101
+ else if (zoomable && isImage && (event.key === '+' || event.key === '=')) zoom.zoomIn();
102
+ else if (zoomable && isImage && event.key === '-') zoom.zoomOut();
103
+ else if (zoomable && isImage && event.key === '0') zoom.reset();
104
+ else return false;
105
+ return true;
106
+ })();
107
+ if (handled) event.preventDefault();
108
+ }
109
+
110
+ // Swipe to navigate when the image is not zoomed (a zoomed image pans instead).
111
+ let swipeStart: number | null = null;
112
+ const onPointerDown = (event: PointerEvent) => {
113
+ swipeStart = zoom.zoomed ? null : event.clientX;
114
+ };
115
+ const onPointerUp = (event: PointerEvent) => {
116
+ if (swipeStart === null) return;
117
+ const delta = event.clientX - swipeStart;
118
+ swipeStart = null;
119
+ if (Math.abs(delta) > 50) go(delta < 0 ? index + 1 : index - 1);
120
+ };
121
+
122
+ // Warm the neighbours, so moving on shows the next image at once.
123
+ $effect(() => {
124
+ if (!open || count < 2) return;
125
+ for (const offset of [-1, 1]) {
126
+ const neighbour = items[(index + offset + count) % count];
127
+ if (neighbour?.kind === 'image') new Image().src = neighbour.src;
128
+ }
129
+ });
130
+
131
+ function handleOpenChange(next: boolean) {
132
+ if (!next) zoom.reset();
133
+ onOpenChange?.(next);
134
+ }
135
+ </script>
136
+
137
+ <DialogPrimitive.Root bind:open onOpenChange={handleOpenChange}>
138
+ {#if triggerChild}<DialogPrimitive.Trigger
139
+ >{#snippet child(childProps)}{@render triggerChild(
140
+ childProps
141
+ )}{/snippet}</DialogPrimitive.Trigger
142
+ >{:else if trigger}<DialogPrimitive.Trigger class={triggerClass}
143
+ >{@render trigger()}</DialogPrimitive.Trigger
144
+ >{/if}
145
+ <DialogPrimitive.Portal>
146
+ <DialogPrimitive.Overlay class="ldt-overlay ldt-lightbox__overlay" />
147
+ <DialogPrimitive.Content
148
+ bind:ref
149
+ class={cx('ldt-lightbox', className)}
150
+ onkeydown={handleKeydown}
151
+ >
152
+ <DialogPrimitive.Title class="ldt-sr-only">{title}</DialogPrimitive.Title>
153
+ <div class="ldt-lightbox__bar">
154
+ {#if showCounter && count}<span class="ldt-lightbox__counter" aria-hidden="true"
155
+ >{counterText(index + 1, count)}</span
156
+ >{/if}
157
+ <div class="ldt-lightbox__tools">
158
+ {#if zoomable && isImage}
159
+ <button
160
+ type="button"
161
+ class="ldt-lightbox__control"
162
+ aria-label={zoomOutLabel}
163
+ disabled={zoom.scale <= 1}
164
+ onclick={zoom.zoomOut}>−</button
165
+ >
166
+ <button
167
+ type="button"
168
+ class="ldt-lightbox__control"
169
+ aria-label={zoomInLabel}
170
+ onclick={zoom.zoomIn}>+</button
171
+ >
172
+ {/if}
173
+ {#if downloadable && current?.kind === 'image' && current.download}<a
174
+ class="ldt-lightbox__control ldt-lightbox__download"
175
+ href={current.download}
176
+ download>{downloadLabel}</a
177
+ >{/if}
178
+ <DialogPrimitive.Close class="ldt-lightbox__control" aria-label={closeLabel}
179
+ >×</DialogPrimitive.Close
180
+ >
181
+ </div>
182
+ </div>
183
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
184
+ <div
185
+ class="ldt-lightbox__stage"
186
+ data-zoomed={zoom.zoomed ? '' : undefined}
187
+ onpointerdown={onPointerDown}
188
+ onpointerup={onPointerUp}
189
+ >
190
+ {#if current}
191
+ {#key ids[index]}<MediaStage item={current} zoom={zoomable ? zoom : undefined} />{/key}
192
+ {/if}
193
+ {#if canPrevious}<button
194
+ type="button"
195
+ class="ldt-lightbox__control ldt-lightbox__nav ldt-lightbox__nav--previous"
196
+ aria-label={previousLabel}
197
+ onclick={() => go(index - 1)}
198
+ ><span aria-hidden="true" class="ldt-lightbox__chevron"></span></button
199
+ >{/if}
200
+ {#if canNext}<button
201
+ type="button"
202
+ class="ldt-lightbox__control ldt-lightbox__nav ldt-lightbox__nav--next"
203
+ aria-label={nextLabel}
204
+ onclick={() => go(index + 1)}
205
+ ><span aria-hidden="true" class="ldt-lightbox__chevron"></span></button
206
+ >{/if}
207
+ </div>
208
+ {#if showCaption && current?.caption}<p class="ldt-lightbox__caption">
209
+ {current.caption}
210
+ </p>{/if}
211
+ {#if showThumbnails && count > 1}
212
+ <Filmstrip
213
+ class="ldt-lightbox__strip"
214
+ orientation="horizontal"
215
+ label={thumbnailsLabel}
216
+ items={ids.map((id) => ({ id }))}
217
+ selectedId={ids[index]}
218
+ onSelect={(id) => go(ids.indexOf(id))}
219
+ >
220
+ {#snippet item(entry)}
221
+ {@const position = ids.indexOf(entry.id)}
222
+ <Thumbnail
223
+ src={mediaThumbnail(items[position])}
224
+ alt=""
225
+ ratio="square"
226
+ fit="cover"
227
+ /><span class="ldt-sr-only">{mediaLabel(items[position], position)}</span>
228
+ {/snippet}
229
+ </Filmstrip>
230
+ {/if}
231
+ <!-- The visible counter is hidden from assistive tech; this announces each move once. -->
232
+ <LiveRegion
233
+ message={count && current
234
+ ? `${mediaLabel(current, index)}, ${counterText(index + 1, count)}`
235
+ : ''}
236
+ />
237
+ </DialogPrimitive.Content>
238
+ </DialogPrimitive.Portal>
239
+ </DialogPrimitive.Root>
@@ -0,0 +1,40 @@
1
+ import type { Snippet } from 'svelte';
2
+ import { type MediaItem } from '../media-utils.js';
3
+ import type { TriggerChildProps } from '../types.js';
4
+ interface Props {
5
+ items: MediaItem[];
6
+ open?: boolean;
7
+ /** The item showing, 0-based. */
8
+ index?: number;
9
+ /** Accessible name of the dialog. */
10
+ title?: string;
11
+ /** Wrap from the last item to the first. */
12
+ loop?: boolean;
13
+ showThumbnails?: boolean;
14
+ showCaption?: boolean;
15
+ showCounter?: boolean;
16
+ /** Let images be zoomed and panned: pinch, Ctrl + wheel, double-click, or + and −. */
17
+ zoomable?: boolean;
18
+ /** Offer a download link for images that set `download`. */
19
+ downloadable?: boolean;
20
+ /** Opens the lightbox. Leave it out to drive `open` yourself, as `MediaGrid` does. */
21
+ trigger?: Snippet;
22
+ triggerChild?: Snippet<[TriggerChildProps]>;
23
+ triggerClass?: string;
24
+ closeLabel?: string;
25
+ previousLabel?: string;
26
+ nextLabel?: string;
27
+ zoomInLabel?: string;
28
+ zoomOutLabel?: string;
29
+ downloadLabel?: string;
30
+ thumbnailsLabel?: string;
31
+ counterText?: (position: number, count: number) => string;
32
+ onOpenChange?: (open: boolean) => void;
33
+ onIndexChange?: (index: number) => void;
34
+ /** Lands on the dialog content. */
35
+ class?: string;
36
+ ref?: HTMLElement | null;
37
+ }
38
+ declare const Lightbox: import("svelte").Component<Props, {}, "ref" | "open" | "index">;
39
+ type Lightbox = ReturnType<typeof Lightbox>;
40
+ export default Lightbox;
@@ -0,0 +1,48 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import type { HTMLAttributes } from 'svelte/elements';
4
+ import type { Politeness } from '../announcer.svelte.js';
5
+ import { cx } from '../utils.js';
6
+
7
+ interface Props extends HTMLAttributes<HTMLDivElement> {
8
+ /** Text to announce. Changing it is what triggers speech. */
9
+ message?: string;
10
+ /** `'polite'` waits for a pause (`role="status"`); `'assertive'` interrupts (`role="alert"`). */
11
+ politeness?: Politeness;
12
+ /** Read the whole region on every change, not only the changed part. */
13
+ atomic?: boolean;
14
+ /** Keep the region off-screen. Set `false` to also show the message visually. */
15
+ visuallyHidden?: boolean;
16
+ /** Rendered after `message`, for richer content. */
17
+ children?: Snippet;
18
+ class?: string;
19
+ ref?: HTMLDivElement | null;
20
+ }
21
+
22
+ let {
23
+ message = '',
24
+ politeness = 'polite',
25
+ atomic = true,
26
+ visuallyHidden = true,
27
+ children,
28
+ class: className,
29
+ ref = $bindable(null),
30
+ ...rest
31
+ }: Props = $props();
32
+ </script>
33
+
34
+ <!--
35
+ Must already be in the document before its text changes: a region mounted together with its
36
+ message is silent in most screen readers. Render it once, unconditionally, and change the
37
+ message — `createAnnouncer()` does that bookkeeping.
38
+ -->
39
+ <div
40
+ bind:this={ref}
41
+ class={cx('ldt-live-region', visuallyHidden && 'ldt-sr-only', className)}
42
+ role={politeness === 'assertive' ? 'alert' : 'status'}
43
+ aria-live={politeness}
44
+ aria-atomic={atomic}
45
+ {...rest}
46
+ >
47
+ {message}{#if children}{@render children()}{/if}
48
+ </div>
@@ -0,0 +1,20 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { HTMLAttributes } from 'svelte/elements';
3
+ import type { Politeness } from '../announcer.svelte.js';
4
+ interface Props extends HTMLAttributes<HTMLDivElement> {
5
+ /** Text to announce. Changing it is what triggers speech. */
6
+ message?: string;
7
+ /** `'polite'` waits for a pause (`role="status"`); `'assertive'` interrupts (`role="alert"`). */
8
+ politeness?: Politeness;
9
+ /** Read the whole region on every change, not only the changed part. */
10
+ atomic?: boolean;
11
+ /** Keep the region off-screen. Set `false` to also show the message visually. */
12
+ visuallyHidden?: boolean;
13
+ /** Rendered after `message`, for richer content. */
14
+ children?: Snippet;
15
+ class?: string;
16
+ ref?: HTMLDivElement | null;
17
+ }
18
+ declare const LiveRegion: import("svelte").Component<Props, {}, "ref">;
19
+ type LiveRegion = ReturnType<typeof LiveRegion>;
20
+ export default LiveRegion;
@@ -0,0 +1,177 @@
1
+ <script lang="ts">
2
+ import type { HTMLAttributes } from 'svelte/elements';
3
+ import MediaStage from '../internal/media/MediaStage.svelte';
4
+ import { createMediaQuery } from '../media.svelte.js';
5
+ import {
6
+ mediaKey,
7
+ mediaLabel,
8
+ mediaThumbnail,
9
+ resolveAspectRatio,
10
+ type AspectRatioName,
11
+ type MediaItem,
12
+ } from '../media-utils.js';
13
+ import { cx } from '../utils.js';
14
+ import Filmstrip from './Filmstrip.svelte';
15
+ import Thumbnail from './Thumbnail.svelte';
16
+
17
+ interface Props extends Omit<HTMLAttributes<HTMLElement>, 'children'> {
18
+ items: MediaItem[];
19
+ /** Accessible name of the carousel. */
20
+ label: string;
21
+ /** The slide showing, 0-based. */
22
+ index?: number;
23
+ loop?: boolean;
24
+ ratio?: AspectRatioName | number | string;
25
+ showThumbnails?: boolean;
26
+ showCaption?: boolean;
27
+ /**
28
+ * Advance on a timer. It pauses while the pointer or focus is inside, offers a visible
29
+ * pause button (WCAG 2.2.2), and starts paused for people who prefer reduced motion.
30
+ */
31
+ autoplay?: boolean;
32
+ /** Milliseconds per slide when autoplaying. */
33
+ interval?: number;
34
+ previousLabel?: string;
35
+ nextLabel?: string;
36
+ pauseLabel?: string;
37
+ playLabel?: string;
38
+ thumbnailsLabel?: string;
39
+ slideLabel?: (position: number, count: number) => string;
40
+ onIndexChange?: (index: number) => void;
41
+ class?: string;
42
+ ref?: HTMLElement | null;
43
+ }
44
+
45
+ let {
46
+ items,
47
+ label,
48
+ index = $bindable(0),
49
+ loop = true,
50
+ ratio = 'video',
51
+ showThumbnails = false,
52
+ showCaption = true,
53
+ autoplay = false,
54
+ interval = 5000,
55
+ previousLabel = 'Previous slide',
56
+ nextLabel = 'Next slide',
57
+ pauseLabel = 'Pause slideshow',
58
+ playLabel = 'Start slideshow',
59
+ thumbnailsLabel = 'Slides',
60
+ slideLabel = (position, count) => `${position} of ${count}`,
61
+ onIndexChange,
62
+ class: className,
63
+ ref = $bindable(null),
64
+ ...rest
65
+ }: Props = $props();
66
+
67
+ const reducedMotion = createMediaQuery('(prefers-reduced-motion: reduce)');
68
+ const count = $derived(items.length);
69
+ const ids = $derived(items.map((item, position) => mediaKey(item, position)));
70
+ const canPrevious = $derived(loop ? count > 1 : index > 0);
71
+ const canNext = $derived(loop ? count > 1 : index < count - 1);
72
+
73
+ // `rotating` is the user's choice; `held` covers hover and focus, which pause without changing it.
74
+ let rotating = $derived(autoplay && !reducedMotion.matches);
75
+ let hovered = $state(false);
76
+ let focused = $state(false);
77
+ const running = $derived(rotating && !hovered && !focused && count > 1);
78
+
79
+ function go(next: number) {
80
+ if (count === 0) return;
81
+ const target = loop ? (next + count) % count : Math.min(Math.max(0, next), count - 1);
82
+ if (target === index) return;
83
+ index = target;
84
+ onIndexChange?.(target);
85
+ }
86
+
87
+ $effect(() => {
88
+ if (!running) return;
89
+ const timer = setInterval(() => go(index + 1), interval);
90
+ return () => clearInterval(timer);
91
+ });
92
+ </script>
93
+
94
+ <section
95
+ bind:this={ref}
96
+ class={cx('ldt-media-carousel', className)}
97
+ aria-roledescription="carousel"
98
+ aria-label={label}
99
+ onpointerenter={() => (hovered = true)}
100
+ onpointerleave={() => (hovered = false)}
101
+ onfocusin={() => (focused = true)}
102
+ onfocusout={(event) => {
103
+ if (!event.currentTarget.contains(event.relatedTarget as Node | null)) focused = false;
104
+ }}
105
+ {...rest}
106
+ >
107
+ <div class="ldt-media-carousel__controls">
108
+ {#if autoplay}<button
109
+ type="button"
110
+ class="ldt-media-carousel__control"
111
+ aria-label={rotating ? pauseLabel : playLabel}
112
+ onclick={() => (rotating = !rotating)}
113
+ ><svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true" focusable="false"
114
+ >{#if rotating}<path d="M4 3h3v10H4zM9 3h3v10H9z" fill="currentColor" />{:else}<path
115
+ d="M4 2.5v11l9-5.5z"
116
+ fill="currentColor"
117
+ />{/if}</svg
118
+ ></button
119
+ >{/if}
120
+ <button
121
+ type="button"
122
+ class="ldt-media-carousel__control"
123
+ aria-label={previousLabel}
124
+ disabled={!canPrevious}
125
+ onclick={() => go(index - 1)}
126
+ ><span class="ldt-media-carousel__chevron" aria-hidden="true"></span></button
127
+ >
128
+ <button
129
+ type="button"
130
+ class="ldt-media-carousel__control ldt-media-carousel__control--next"
131
+ aria-label={nextLabel}
132
+ disabled={!canNext}
133
+ onclick={() => go(index + 1)}
134
+ ><span class="ldt-media-carousel__chevron" aria-hidden="true"></span></button
135
+ >
136
+ </div>
137
+ <!--
138
+ Polite while the user drives it; silent while it rotates on its own, so a screen reader is
139
+ not interrupted every few seconds.
140
+ -->
141
+ <div
142
+ class="ldt-media-carousel__viewport"
143
+ style:--ldt-aspect-ratio={resolveAspectRatio(ratio)}
144
+ aria-live={running ? 'off' : 'polite'}
145
+ >
146
+ {#each items as item, position (ids[position])}
147
+ <div
148
+ class="ldt-media-carousel__slide"
149
+ role="group"
150
+ aria-roledescription="slide"
151
+ aria-label={`${slideLabel(position + 1, count)}: ${mediaLabel(item, position)}`}
152
+ hidden={position !== index}
153
+ >
154
+ {#if position === index}<MediaStage {item} fit="cover" />{/if}
155
+ {#if showCaption && item.caption}<p class="ldt-media-carousel__caption">
156
+ {item.caption}
157
+ </p>{/if}
158
+ </div>
159
+ {/each}
160
+ </div>
161
+ {#if showThumbnails && count > 1}
162
+ <Filmstrip
163
+ orientation="horizontal"
164
+ label={thumbnailsLabel}
165
+ items={ids.map((id) => ({ id }))}
166
+ selectedId={ids[index]}
167
+ onSelect={(id) => go(ids.indexOf(id))}
168
+ >
169
+ {#snippet item(entry)}
170
+ {@const position = ids.indexOf(entry.id)}
171
+ <Thumbnail src={mediaThumbnail(items[position])} alt="" ratio="square" fit="cover" /><span
172
+ class="ldt-sr-only">{mediaLabel(items[position], position)}</span
173
+ >
174
+ {/snippet}
175
+ </Filmstrip>
176
+ {/if}
177
+ </section>
@@ -0,0 +1,32 @@
1
+ import type { HTMLAttributes } from 'svelte/elements';
2
+ import { type AspectRatioName, type MediaItem } from '../media-utils.js';
3
+ interface Props extends Omit<HTMLAttributes<HTMLElement>, 'children'> {
4
+ items: MediaItem[];
5
+ /** Accessible name of the carousel. */
6
+ label: string;
7
+ /** The slide showing, 0-based. */
8
+ index?: number;
9
+ loop?: boolean;
10
+ ratio?: AspectRatioName | number | string;
11
+ showThumbnails?: boolean;
12
+ showCaption?: boolean;
13
+ /**
14
+ * Advance on a timer. It pauses while the pointer or focus is inside, offers a visible
15
+ * pause button (WCAG 2.2.2), and starts paused for people who prefer reduced motion.
16
+ */
17
+ autoplay?: boolean;
18
+ /** Milliseconds per slide when autoplaying. */
19
+ interval?: number;
20
+ previousLabel?: string;
21
+ nextLabel?: string;
22
+ pauseLabel?: string;
23
+ playLabel?: string;
24
+ thumbnailsLabel?: string;
25
+ slideLabel?: (position: number, count: number) => string;
26
+ onIndexChange?: (index: number) => void;
27
+ class?: string;
28
+ ref?: HTMLElement | null;
29
+ }
30
+ declare const MediaCarousel: import("svelte").Component<Props, {}, "ref" | "index">;
31
+ type MediaCarousel = ReturnType<typeof MediaCarousel>;
32
+ export default MediaCarousel;
@@ -0,0 +1,116 @@
1
+ <script lang="ts">
2
+ import type { HTMLAttributes } from 'svelte/elements';
3
+ import {
4
+ buildEmbedSrc,
5
+ parseEmbedUrl,
6
+ resolveAspectRatio,
7
+ type AspectRatioName,
8
+ type EmbedProvider,
9
+ } from '../media-utils.js';
10
+ import { cx } from '../utils.js';
11
+
12
+ interface Props extends Omit<HTMLAttributes<HTMLDivElement>, 'children' | 'title'> {
13
+ /** A YouTube or Vimeo page, share or embed URL. */
14
+ url?: string;
15
+ /** Or name the video directly. */
16
+ provider?: EmbedProvider;
17
+ videoId?: string;
18
+ /** Required: names the iframe and the play button. */
19
+ title: string;
20
+ /**
21
+ * Still shown before the player loads. None is fetched from the provider by default — that
22
+ * request alone would tell them someone opened the page.
23
+ */
24
+ poster?: string;
25
+ /** No-tracking hosts: `youtube-nocookie.com`, Vimeo's `dnt=1`. */
26
+ privacy?: boolean;
27
+ /** Start offset in seconds. Read from the URL when it carries one. */
28
+ start?: number;
29
+ /**
30
+ * Load the provider's player straight away. By default a local facade stands in until the
31
+ * user presses play, so no third-party request happens before they choose to watch.
32
+ */
33
+ eager?: boolean;
34
+ ratio?: AspectRatioName | number | string;
35
+ playLabel?: (title: string) => string;
36
+ /** Shown when the URL is not a recognised YouTube or Vimeo link. */
37
+ unsupportedText?: string;
38
+ class?: string;
39
+ ref?: HTMLDivElement | null;
40
+ }
41
+
42
+ let {
43
+ url,
44
+ provider,
45
+ videoId,
46
+ title,
47
+ poster,
48
+ privacy = true,
49
+ start,
50
+ eager = false,
51
+ ratio = 'video',
52
+ playLabel = (name) => `Play ${name}`,
53
+ unsupportedText = 'This video link is not supported.',
54
+ class: className,
55
+ ref = $bindable(null),
56
+ ...rest
57
+ }: Props = $props();
58
+
59
+ const parsed = $derived(
60
+ provider && videoId ? { provider, videoId, start: undefined } : url ? parseEmbedUrl(url) : null
61
+ );
62
+ let activated = $state(false);
63
+ const loaded = $derived(eager || activated);
64
+ const src = $derived(
65
+ parsed &&
66
+ buildEmbedSrc(parsed.provider, parsed.videoId, {
67
+ privacy,
68
+ autoplay: activated,
69
+ start: start ?? parsed.start,
70
+ })
71
+ );
72
+ let frame = $state<HTMLIFrameElement | null>(null);
73
+
74
+ function activate() {
75
+ activated = true;
76
+ // Keep keyboard users where the button was: on the player that replaced it.
77
+ queueMicrotask(() => frame?.focus());
78
+ }
79
+ </script>
80
+
81
+ <div
82
+ bind:this={ref}
83
+ class={cx('ldt-media-embed', className)}
84
+ style:--ldt-aspect-ratio={resolveAspectRatio(ratio)}
85
+ {...rest}
86
+ >
87
+ {#if !parsed}
88
+ <p class="ldt-media-embed__unsupported">{unsupportedText}</p>
89
+ {:else if loaded}
90
+ <iframe
91
+ bind:this={frame}
92
+ class="ldt-media-embed__frame"
93
+ {src}
94
+ {title}
95
+ loading="lazy"
96
+ allow="autoplay; encrypted-media; fullscreen; picture-in-picture; clipboard-write"
97
+ allowfullscreen
98
+ referrerpolicy="strict-origin-when-cross-origin"
99
+ ></iframe>
100
+ {:else}
101
+ <button
102
+ type="button"
103
+ class="ldt-media-embed__facade"
104
+ style:background-image={poster ? `url("${poster}")` : undefined}
105
+ aria-label={playLabel(title)}
106
+ onclick={activate}
107
+ >
108
+ <span class="ldt-media-embed__play" aria-hidden="true">
109
+ <svg viewBox="0 0 16 16" width="22" height="22" focusable="false"
110
+ ><path d="M4 2.5v11l9-5.5z" fill="currentColor" /></svg
111
+ >
112
+ </span>
113
+ <span class="ldt-media-embed__title" aria-hidden="true">{title}</span>
114
+ </button>
115
+ {/if}
116
+ </div>