@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,205 @@
1
+ import { aspectRatio } from '@loidolt/theme-tokens';
2
+ export const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
3
+ /** A named frame (`'video'`) or any number or `'4 / 3'` string, as a width-over-height number. */
4
+ export function resolveAspectRatio(ratio) {
5
+ if (typeof ratio === 'number')
6
+ return ratio;
7
+ const value = ratio in aspectRatio ? aspectRatio[ratio] : ratio;
8
+ const [width, height = '1'] = value.split('/').map((part) => part.trim());
9
+ const result = Number(width) / Number(height);
10
+ return Number.isFinite(result) && result > 0 ? result : 16 / 9;
11
+ }
12
+ /** `75` → `1:15`; `3725` → `1:02:05`. */
13
+ export function formatDuration(seconds) {
14
+ if (!Number.isFinite(seconds) || seconds < 0)
15
+ return '0:00';
16
+ const total = Math.floor(seconds);
17
+ const [hours, minutes, secs] = [
18
+ Math.floor(total / 3600),
19
+ Math.floor(total / 60) % 60,
20
+ total % 60,
21
+ ];
22
+ const tail = `${String(secs).padStart(2, '0')}`;
23
+ return hours > 0 ? `${hours}:${String(minutes).padStart(2, '0')}:${tail}` : `${minutes}:${tail}`;
24
+ }
25
+ /** `75` → `1 minute 15 seconds` — for `aria-valuetext`, where `1:15` is read as a ratio. */
26
+ export function formatDurationSpoken(seconds) {
27
+ if (!Number.isFinite(seconds) || seconds < 0)
28
+ return '0 seconds';
29
+ const total = Math.floor(seconds);
30
+ const units = [
31
+ [Math.floor(total / 3600), 'hour'],
32
+ [Math.floor(total / 60) % 60, 'minute'],
33
+ [total % 60, 'second'],
34
+ ];
35
+ const parts = units
36
+ .filter(([amount], index) => amount > 0 || (index === 2 && total === 0))
37
+ .map(([amount, unit]) => `${amount} ${unit}${amount === 1 ? '' : 's'}`);
38
+ return parts.join(' ');
39
+ }
40
+ const EXTENSIONS = {
41
+ image: ['jpg', 'jpeg', 'png', 'gif', 'webp', 'avif', 'svg', 'bmp'],
42
+ video: ['mp4', 'webm', 'ogv', 'mov', 'm4v', 'm3u8'],
43
+ audio: ['mp3', 'wav', 'ogg', 'oga', 'm4a', 'aac', 'flac', 'opus'],
44
+ };
45
+ const MIME = {
46
+ m3u8: 'application/vnd.apple.mpegurl',
47
+ mp4: 'video/mp4',
48
+ m4v: 'video/mp4',
49
+ webm: 'video/webm',
50
+ ogv: 'video/ogg',
51
+ mov: 'video/quicktime',
52
+ mp3: 'audio/mpeg',
53
+ wav: 'audio/wav',
54
+ ogg: 'audio/ogg',
55
+ oga: 'audio/ogg',
56
+ m4a: 'audio/mp4',
57
+ aac: 'audio/aac',
58
+ flac: 'audio/flac',
59
+ opus: 'audio/opus',
60
+ };
61
+ /** The lower-cased extension of a URL's last path segment, ignoring query and hash. */
62
+ export function fileExtension(url) {
63
+ const segment = url.split(/[?#]/)[0].split('/').pop() ?? '';
64
+ const dot = segment.lastIndexOf('.');
65
+ return dot <= 0 || dot === segment.length - 1 ? '' : segment.slice(dot + 1).toLowerCase();
66
+ }
67
+ /** A MIME type for a file extension, when it is a media type this library knows. */
68
+ export function guessMimeType(extension) {
69
+ return MIME[extension.toLowerCase()];
70
+ }
71
+ /** What a URL most likely points at, from its MIME type, embed host or extension. */
72
+ export function inferMediaKind(url, mimeType) {
73
+ const family = mimeType?.split('/')[0];
74
+ if (family === 'image' || family === 'video' || family === 'audio')
75
+ return family;
76
+ if (mimeType && /mpegurl/i.test(mimeType))
77
+ return 'video';
78
+ if (parseEmbedUrl(url))
79
+ return 'embed';
80
+ const extension = fileExtension(url);
81
+ for (const [kind, list] of Object.entries(EXTENSIONS)) {
82
+ if (list.includes(extension))
83
+ return kind;
84
+ }
85
+ return null;
86
+ }
87
+ /** Whether a source is an HLS playlist (`.m3u8` or an HLS MIME type). */
88
+ export function isHlsSource(source) {
89
+ const { src, type } = typeof source === 'string' ? { src: source, type: undefined } : source;
90
+ if (type)
91
+ return /mpegurl/i.test(type);
92
+ return fileExtension(src) === 'm3u8';
93
+ }
94
+ /** Normalises a `src` prop to a list of typed sources. */
95
+ export function toMediaSources(src) {
96
+ if (Array.isArray(src))
97
+ return src;
98
+ return [{ src, type: guessMimeType(fileExtension(src)) }];
99
+ }
100
+ /** A `TimeRanges` object (e.g. `media.buffered`) as plain `[start, end]` pairs. */
101
+ export function timeRangesToArray(ranges) {
102
+ if (!ranges)
103
+ return [];
104
+ return Array.from({ length: ranges.length }, (_, index) => [
105
+ ranges.start(index),
106
+ ranges.end(index),
107
+ ]);
108
+ }
109
+ const YOUTUBE_ID = /^[\w-]{11}$/;
110
+ function parseStart(raw) {
111
+ if (!raw)
112
+ return undefined;
113
+ if (/^\d+$/.test(raw))
114
+ return Number(raw);
115
+ const match = /^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/.exec(raw);
116
+ if (!match || !match.slice(1).some(Boolean))
117
+ return undefined;
118
+ const [, hours = '0', minutes = '0', seconds = '0'] = match;
119
+ return Number(hours) * 3600 + Number(minutes) * 60 + Number(seconds);
120
+ }
121
+ /**
122
+ * Reads a YouTube or Vimeo page, share or embed URL. Returns `null` for anything else, so an
123
+ * arbitrary string can be tested safely.
124
+ */
125
+ export function parseEmbedUrl(url) {
126
+ let parsed;
127
+ try {
128
+ parsed = new URL(url);
129
+ }
130
+ catch {
131
+ return null;
132
+ }
133
+ const host = parsed.hostname.replace(/^(www|m)\./, '');
134
+ const segments = parsed.pathname.split('/').filter(Boolean);
135
+ const fragment = new URLSearchParams(parsed.hash.slice(1));
136
+ const start = parseStart(parsed.searchParams.get('t') ?? parsed.searchParams.get('start') ?? fragment.get('t'));
137
+ const found = (provider, videoId) => videoId ? { provider, videoId, ...(start === undefined ? {} : { start }) } : null;
138
+ if (host === 'youtu.be') {
139
+ return found('youtube', YOUTUBE_ID.test(segments[0] ?? '') ? segments[0] : undefined);
140
+ }
141
+ if (host === 'youtube.com' || host === 'youtube-nocookie.com') {
142
+ const fromQuery = parsed.searchParams.get('v');
143
+ if (fromQuery && YOUTUBE_ID.test(fromQuery))
144
+ return found('youtube', fromQuery);
145
+ if (['embed', 'shorts', 'live', 'v'].includes(segments[0] ?? '')) {
146
+ return found('youtube', YOUTUBE_ID.test(segments[1] ?? '') ? segments[1] : undefined);
147
+ }
148
+ return null;
149
+ }
150
+ if (host === 'vimeo.com' || host === 'player.vimeo.com') {
151
+ return found('vimeo', [...segments].reverse().find((segment) => /^\d+$/.test(segment)));
152
+ }
153
+ return null;
154
+ }
155
+ /** The iframe `src` for a provider's player. */
156
+ export function buildEmbedSrc(provider, videoId, { privacy = true, autoplay = false, start } = {}) {
157
+ const params = new URLSearchParams();
158
+ if (autoplay)
159
+ params.set('autoplay', '1');
160
+ if (provider === 'youtube') {
161
+ // Keep the end screen to this channel's videos rather than the whole site's.
162
+ params.set('rel', '0');
163
+ if (start !== undefined)
164
+ params.set('start', String(Math.floor(start)));
165
+ const host = privacy ? 'www.youtube-nocookie.com' : 'www.youtube.com';
166
+ return `https://${host}/embed/${encodeURIComponent(videoId)}?${params}`;
167
+ }
168
+ if (privacy)
169
+ params.set('dnt', '1');
170
+ const query = params.toString() ? `?${params}` : '';
171
+ // Vimeo reads the start time from the fragment, not the query.
172
+ const hash = start !== undefined ? `#t=${Math.floor(start)}s` : '';
173
+ return `https://player.vimeo.com/video/${encodeURIComponent(videoId)}${query}${hash}`;
174
+ }
175
+ /**
176
+ * YouTube's own still for a video. Loading it contacts Google before the user presses play, so
177
+ * `MediaEmbed` never fetches it by itself — pass it as `poster` only if that is acceptable.
178
+ */
179
+ export function embedThumbnail(provider, videoId) {
180
+ return provider === 'youtube'
181
+ ? `https://i.ytimg.com/vi/${encodeURIComponent(videoId)}/hqdefault.jpg`
182
+ : null;
183
+ }
184
+ /** The preview image for a media item, if it has one. */
185
+ export function mediaThumbnail(item) {
186
+ if (item.thumbnail)
187
+ return item.thumbnail;
188
+ if (item.kind === 'image')
189
+ return item.src;
190
+ if (item.kind === 'video' || item.kind === 'embed')
191
+ return item.poster;
192
+ return item.artwork;
193
+ }
194
+ /** A human name for a media item: its title, alt text or caption. */
195
+ export function mediaLabel(item, index) {
196
+ const name = item.kind === 'image' ? item.alt || item.caption : (item.title ?? item.caption ?? undefined);
197
+ return name || `Item ${index === undefined ? '' : index + 1}`.trim();
198
+ }
199
+ /** A stable key for a media item. */
200
+ export function mediaKey(item, index) {
201
+ if (item.id)
202
+ return item.id;
203
+ const source = item.kind === 'embed' ? item.url : item.src;
204
+ return `${typeof source === 'string' ? source : (source[0]?.src ?? '')}#${index}`;
205
+ }
@@ -0,0 +1,33 @@
1
+ import type { Attachment } from 'svelte/attachments';
2
+ export interface MediaZoomOptions {
3
+ minScale?: number;
4
+ maxScale?: number;
5
+ /** How far one `zoomIn()` / `zoomOut()` moves. */
6
+ step?: number;
7
+ /** Where a double-click or double-tap zooms to. */
8
+ doubleTapScale?: number;
9
+ onScaleChange?: (scale: number) => void;
10
+ }
11
+ export interface MediaZoom {
12
+ /**
13
+ * Put on the frame the image sits in. Handles pinch, trackpad zoom (Ctrl + wheel), double-click
14
+ * and dragging to pan while zoomed.
15
+ */
16
+ readonly attach: Attachment<HTMLElement>;
17
+ readonly scale: number;
18
+ /** Pan offset in pixels. */
19
+ readonly x: number;
20
+ readonly y: number;
21
+ /** A CSS `transform` for the image. */
22
+ readonly transform: string;
23
+ readonly zoomed: boolean;
24
+ zoomIn(): void;
25
+ zoomOut(): void;
26
+ /** Back to 1× and centred. */
27
+ reset(): void;
28
+ /** Between 1× and `doubleTapScale`. */
29
+ toggle(): void;
30
+ destroy(): void;
31
+ }
32
+ /** Zoom and pan state for an image in a frame — the engine behind `Lightbox`'s zoom. */
33
+ export declare function createMediaZoom(options?: MediaZoomOptions): MediaZoom;
@@ -0,0 +1,126 @@
1
+ import { onDestroy } from 'svelte';
2
+ import { clamp } from './media-utils.js';
3
+ /** Zoom and pan state for an image in a frame — the engine behind `Lightbox`'s zoom. */
4
+ export function createMediaZoom(options = {}) {
5
+ const { minScale = 1, maxScale = 4, step = 0.5, doubleTapScale = 2, onScaleChange } = options;
6
+ if (!(minScale > 0 && maxScale >= minScale)) {
7
+ throw new RangeError('createMediaZoom: needs 0 < minScale <= maxScale');
8
+ }
9
+ let scale = $state(minScale);
10
+ let x = $state(0);
11
+ let y = $state(0);
12
+ let frame = null;
13
+ /** Keeps the scaled image covering the frame: no panning past its edges. */
14
+ const bound = () => {
15
+ const width = frame?.clientWidth ?? 0;
16
+ const height = frame?.clientHeight ?? 0;
17
+ const [limitX, limitY] = [((scale - 1) * width) / 2, ((scale - 1) * height) / 2];
18
+ x = clamp(x, -limitX, limitX);
19
+ y = clamp(y, -limitY, limitY);
20
+ };
21
+ const setScale = (next) => {
22
+ const value = clamp(next, minScale, maxScale);
23
+ if (value === scale)
24
+ return;
25
+ scale = value;
26
+ if (scale <= 1)
27
+ [x, y] = [0, 0];
28
+ bound();
29
+ onScaleChange?.(scale);
30
+ };
31
+ let detach;
32
+ const attach = (node) => {
33
+ frame = node;
34
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity -- pointer bookkeeping, never rendered
35
+ const pointers = new Map();
36
+ let pinch = null;
37
+ const distance = () => {
38
+ const [a, b] = [...pointers.values()];
39
+ return Math.hypot(a.x - b.x, a.y - b.y);
40
+ };
41
+ const onPointerDown = (event) => {
42
+ pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
43
+ node.setPointerCapture?.(event.pointerId);
44
+ if (pointers.size === 2)
45
+ pinch = { distance: distance(), scale };
46
+ };
47
+ const onPointerMove = (event) => {
48
+ const previous = pointers.get(event.pointerId);
49
+ if (!previous)
50
+ return;
51
+ pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
52
+ if (pinch && pointers.size === 2) {
53
+ setScale(pinch.scale * (distance() / (pinch.distance || 1)));
54
+ }
55
+ else if (pointers.size === 1 && scale > 1) {
56
+ x += event.clientX - previous.x;
57
+ y += event.clientY - previous.y;
58
+ bound();
59
+ }
60
+ };
61
+ const onPointerUp = (event) => {
62
+ pointers.delete(event.pointerId);
63
+ if (pointers.size < 2)
64
+ pinch = null;
65
+ };
66
+ const onWheel = (event) => {
67
+ // A plain wheel scrolls the page; trackpad pinches arrive as Ctrl + wheel.
68
+ if (!event.ctrlKey)
69
+ return;
70
+ event.preventDefault();
71
+ setScale(scale * Math.exp(-event.deltaY / 100));
72
+ };
73
+ const onDoubleClick = () => setScale(scale > 1 ? minScale : doubleTapScale);
74
+ node.addEventListener('pointerdown', onPointerDown);
75
+ node.addEventListener('pointermove', onPointerMove);
76
+ node.addEventListener('pointerup', onPointerUp);
77
+ node.addEventListener('pointercancel', onPointerUp);
78
+ node.addEventListener('wheel', onWheel, { passive: false });
79
+ node.addEventListener('dblclick', onDoubleClick);
80
+ detach = () => {
81
+ node.removeEventListener('pointerdown', onPointerDown);
82
+ node.removeEventListener('pointermove', onPointerMove);
83
+ node.removeEventListener('pointerup', onPointerUp);
84
+ node.removeEventListener('pointercancel', onPointerUp);
85
+ node.removeEventListener('wheel', onWheel);
86
+ node.removeEventListener('dblclick', onDoubleClick);
87
+ if (frame === node)
88
+ frame = null;
89
+ detach = undefined;
90
+ };
91
+ return () => detach?.();
92
+ };
93
+ const destroy = () => detach?.();
94
+ try {
95
+ onDestroy(destroy);
96
+ }
97
+ catch {
98
+ /* module scope: the caller owns `destroy()` */
99
+ }
100
+ return {
101
+ attach,
102
+ get scale() {
103
+ return scale;
104
+ },
105
+ get x() {
106
+ return x;
107
+ },
108
+ get y() {
109
+ return y;
110
+ },
111
+ get transform() {
112
+ return `translate(${x}px, ${y}px) scale(${scale})`;
113
+ },
114
+ get zoomed() {
115
+ return scale > 1;
116
+ },
117
+ zoomIn: () => setScale(scale + step),
118
+ zoomOut: () => setScale(scale - step),
119
+ reset() {
120
+ [x, y] = [0, 0];
121
+ setScale(minScale);
122
+ },
123
+ toggle: () => setScale(scale > 1 ? minScale : doubleTapScale),
124
+ destroy,
125
+ };
126
+ }
@@ -0,0 +1,55 @@
1
+ /** One sampled point of a drawn stroke, in CSS pixels. `pressure` runs 0–1 (pens report it). */
2
+ export interface SignaturePoint {
3
+ x: number;
4
+ y: number;
5
+ pressure?: number;
6
+ }
7
+ export interface SignatureStroke {
8
+ points: SignaturePoint[];
9
+ /** Line width in CSS pixels. */
10
+ width: number;
11
+ }
12
+ /** What a `SignaturePad` produces. */
13
+ export interface SignatureValue {
14
+ mode: 'draw' | 'type';
15
+ /** Drawn strokes, for re-rendering or server-side checks. */
16
+ strokes?: SignatureStroke[];
17
+ /** The typed name, in `type` mode. */
18
+ typedName?: string;
19
+ /** The font family the typed name was set in. */
20
+ font?: string;
21
+ /** A PNG of the signature. */
22
+ dataUrl: string;
23
+ /** A scalable copy, in `draw` mode. */
24
+ svg?: string;
25
+ width: number;
26
+ height: number;
27
+ }
28
+ export type SignatureError = 'empty' | 'too-short';
29
+ /** Total drawn length in CSS pixels — a scribble-versus-signature heuristic. */
30
+ export declare function strokeLength(strokes: readonly SignatureStroke[]): number;
31
+ /**
32
+ * An SVG path through a stroke's points, smoothed with quadratic curves through the midpoints —
33
+ * the same curve the canvas draws, so the SVG and PNG agree.
34
+ */
35
+ export declare function strokePath(points: readonly SignaturePoint[]): string;
36
+ /** The strokes as a standalone SVG document. */
37
+ export declare function strokesToSvg(strokes: readonly SignatureStroke[], { width, height, color }: {
38
+ width: number;
39
+ height: number;
40
+ color?: string;
41
+ }): string;
42
+ /** Whether a signature is long enough to count, and why not if it is not. */
43
+ export declare function validateSignature(input: {
44
+ mode: 'draw' | 'type';
45
+ strokes?: readonly SignatureStroke[];
46
+ typedName?: string;
47
+ }, { minStrokeLength, minTypedLength, }?: {
48
+ minStrokeLength?: number;
49
+ minTypedLength?: number;
50
+ }): {
51
+ valid: true;
52
+ } | {
53
+ valid: false;
54
+ error: SignatureError;
55
+ };
@@ -0,0 +1,52 @@
1
+ /** Total drawn length in CSS pixels — a scribble-versus-signature heuristic. */
2
+ export function strokeLength(strokes) {
3
+ let total = 0;
4
+ for (const { points } of strokes) {
5
+ for (let index = 1; index < points.length; index++) {
6
+ total += Math.hypot(points[index].x - points[index - 1].x, points[index].y - points[index - 1].y);
7
+ }
8
+ }
9
+ return total;
10
+ }
11
+ const round = (value) => Math.round(value * 100) / 100;
12
+ /**
13
+ * An SVG path through a stroke's points, smoothed with quadratic curves through the midpoints —
14
+ * the same curve the canvas draws, so the SVG and PNG agree.
15
+ */
16
+ export function strokePath(points) {
17
+ if (points.length === 0)
18
+ return '';
19
+ const [first] = points;
20
+ if (points.length === 1)
21
+ return `M${round(first.x)} ${round(first.y)}h0.01`;
22
+ let d = `M${round(first.x)} ${round(first.y)}`;
23
+ for (let index = 1; index < points.length - 1; index++) {
24
+ const [point, next] = [points[index], points[index + 1]];
25
+ d += `Q${round(point.x)} ${round(point.y)} ${round((point.x + next.x) / 2)} ${round((point.y + next.y) / 2)}`;
26
+ }
27
+ const last = points[points.length - 1];
28
+ return `${d}L${round(last.x)} ${round(last.y)}`;
29
+ }
30
+ /** The strokes as a standalone SVG document. */
31
+ export function strokesToSvg(strokes, { width, height, color = '#20231d' }) {
32
+ const paths = strokes
33
+ .filter((stroke) => stroke.points.length)
34
+ .map((stroke) => `<path d="${strokePath(stroke.points)}" stroke-width="${round(stroke.width)}"/>`)
35
+ .join('');
36
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${round(width)} ${round(height)}" width="${round(width)}" height="${round(height)}" fill="none" stroke="${color}" stroke-linecap="round" stroke-linejoin="round">${paths}</svg>`;
37
+ }
38
+ /** Whether a signature is long enough to count, and why not if it is not. */
39
+ export function validateSignature(input, { minStrokeLength = 20, minTypedLength = 2, } = {}) {
40
+ if (input.mode === 'type') {
41
+ const name = input.typedName?.trim() ?? '';
42
+ if (!name)
43
+ return { valid: false, error: 'empty' };
44
+ return name.length < minTypedLength ? { valid: false, error: 'too-short' } : { valid: true };
45
+ }
46
+ const strokes = input.strokes ?? [];
47
+ if (!strokes.some((stroke) => stroke.points.length))
48
+ return { valid: false, error: 'empty' };
49
+ return strokeLength(strokes) < minStrokeLength
50
+ ? { valid: false, error: 'too-short' }
51
+ : { valid: true };
52
+ }
@@ -1,16 +1,30 @@
1
1
  import type { StatusVariant } from './types.js';
2
+ /** A single follow-up the toast offers — "Undo", "View", "Retry". */
3
+ export interface ToastAction {
4
+ label: string;
5
+ onAction: () => void;
6
+ /** Dismiss the toast after the action runs. Defaults to `true`. */
7
+ dismiss?: boolean;
8
+ }
2
9
  export interface ToastOptions {
3
10
  title: string;
4
11
  description?: string;
5
12
  variant?: StatusVariant;
6
13
  /** Milliseconds before auto-dismiss. `0` or `Infinity` keeps the toast until dismissed. */
7
14
  duration?: number;
15
+ /**
16
+ * A button in the toast. Give an actionable toast enough `duration` to reach it (or `0`):
17
+ * WCAG 2.2.1 requires that people who need more time get it.
18
+ */
19
+ action?: ToastAction;
8
20
  }
21
+ /** Everything but the title, for the status shortcuts. */
22
+ export type ToastShortcutOptions = Omit<ToastOptions, 'title' | 'variant'>;
9
23
  export interface ToastRecord extends ToastOptions {
10
24
  id: string;
11
25
  }
12
26
  export interface ToasterOptions {
13
- /** Default `duration` for toasts that do not set one. */
27
+ /** Default `duration` for toasts that do not set one. Defaults to persistent (`0`). */
14
28
  duration?: number;
15
29
  /** Oldest toasts past this count are dropped. */
16
30
  max?: number;
@@ -20,6 +34,16 @@ export interface Toaster {
20
34
  readonly toasts: ToastRecord[];
21
35
  /** Adds a toast and returns its id. */
22
36
  push(toast: ToastOptions): string;
37
+ /**
38
+ * Changes a toast in place — "Uploading…" becoming "Uploaded". A new `duration` restarts its
39
+ * timer. Returns `false` when the toast is already gone.
40
+ */
41
+ update(id: string, patch: Partial<ToastOptions>): boolean;
42
+ success(title: string, options?: ToastShortcutOptions): string;
43
+ info(title: string, options?: ToastShortcutOptions): string;
44
+ warning(title: string, options?: ToastShortcutOptions): string;
45
+ /** Stays until dismissed unless you pass a `duration`: an error should not vanish unread. */
46
+ error(title: string, options?: ToastShortcutOptions): string;
23
47
  dismiss(id: string): void;
24
48
  clear(): void;
25
49
  /** Suspends every auto-dismiss timer — call on pointer enter / focus in. */
@@ -35,7 +59,12 @@ export interface Toaster {
35
59
  *
36
60
  * ```svelte
37
61
  * const toaster = createToaster();
38
- * <ToastViewport onpointerenter={toaster.pause} onpointerleave={toaster.resume}>
62
+ * <ToastViewport
63
+ * onpointerenter={toaster.pause}
64
+ * onpointerleave={toaster.resume}
65
+ * onfocusin={toaster.pause}
66
+ * onfocusout={toaster.resume}
67
+ * >
39
68
  * {#each toaster.toasts as toast (toast.id)}
40
69
  * <Toast {...toast} onDismiss={() => toaster.dismiss(toast.id)} />
41
70
  * {/each}
@@ -5,7 +5,12 @@ import { onDestroy } from 'svelte';
5
5
  *
6
6
  * ```svelte
7
7
  * const toaster = createToaster();
8
- * <ToastViewport onpointerenter={toaster.pause} onpointerleave={toaster.resume}>
8
+ * <ToastViewport
9
+ * onpointerenter={toaster.pause}
10
+ * onpointerleave={toaster.resume}
11
+ * onfocusin={toaster.pause}
12
+ * onfocusout={toaster.resume}
13
+ * >
9
14
  * {#each toaster.toasts as toast (toast.id)}
10
15
  * <Toast {...toast} onDismiss={() => toaster.dismiss(toast.id)} />
11
16
  * {/each}
@@ -13,7 +18,7 @@ import { onDestroy } from 'svelte';
13
18
  * ```
14
19
  */
15
20
  export function createToaster(options = {}) {
16
- const { duration: defaultDuration = 5000, max = 5 } = options;
21
+ const { duration: defaultDuration = 0, max = 5 } = options;
17
22
  if (!Number.isInteger(max) || max < 1) {
18
23
  throw new RangeError('createToaster: `max` must be a positive integer');
19
24
  }
@@ -67,6 +72,24 @@ export function createToaster(options = {}) {
67
72
  }
68
73
  return id;
69
74
  }
75
+ function update(id, patch) {
76
+ const index = toasts.findIndex((toast) => toast.id === id);
77
+ if (index === -1)
78
+ return false;
79
+ if (patch.duration !== undefined) {
80
+ if (!validDuration(patch.duration)) {
81
+ throw new RangeError('createToaster: toast `duration` must be non-negative or Infinity');
82
+ }
83
+ clearTimer(id);
84
+ if (!paused)
85
+ arm(id, patch.duration);
86
+ else
87
+ timers.set(id, { timeout: null, remaining: patch.duration, startedAt: 0 });
88
+ }
89
+ toasts[index] = { ...toasts[index], ...patch, id };
90
+ return true;
91
+ }
92
+ const shortcut = (variant, fallbackDuration) => (title, options = {}) => push({ duration: fallbackDuration, ...options, title, variant });
70
93
  const destroy = () => {
71
94
  for (const timer of timers.values()) {
72
95
  if (timer.timeout !== null)
@@ -87,6 +110,11 @@ export function createToaster(options = {}) {
87
110
  return toasts;
88
111
  },
89
112
  push,
113
+ update,
114
+ success: shortcut('success'),
115
+ info: shortcut('info'),
116
+ warning: shortcut('warning'),
117
+ error: shortcut('error', 0),
90
118
  dismiss,
91
119
  clear() {
92
120
  for (const toast of [...toasts])