@loidolt/theme-svelte 0.5.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 (123) 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/AspectRatio.svelte +32 -0
  9. package/dist/components/AspectRatio.svelte.d.ts +13 -0
  10. package/dist/components/AudioPlayer.svelte +94 -0
  11. package/dist/components/AudioPlayer.svelte.d.ts +24 -0
  12. package/dist/components/Avatar.svelte +85 -0
  13. package/dist/components/Avatar.svelte.d.ts +31 -0
  14. package/dist/components/Badge.svelte +28 -2
  15. package/dist/components/Badge.svelte.d.ts +13 -1
  16. package/dist/components/Combobox.svelte +180 -0
  17. package/dist/components/Combobox.svelte.d.ts +77 -0
  18. package/dist/components/DataTable.svelte +228 -0
  19. package/dist/components/DataTable.svelte.d.ts +62 -0
  20. package/dist/components/DataTableColumnVisibility.svelte +46 -0
  21. package/dist/components/DataTableColumnVisibility.svelte.d.ts +31 -0
  22. package/dist/components/DataTableFacetedFilter.svelte +87 -0
  23. package/dist/components/DataTableFacetedFilter.svelte.d.ts +40 -0
  24. package/dist/components/DataTablePagination.svelte +68 -0
  25. package/dist/components/DataTablePagination.svelte.d.ts +42 -0
  26. package/dist/components/DataTableSearch.svelte +67 -0
  27. package/dist/components/DataTableSearch.svelte.d.ts +37 -0
  28. package/dist/components/Dialog.svelte +32 -11
  29. package/dist/components/Dialog.svelte.d.ts +7 -0
  30. package/dist/components/DropdownMenu.svelte +95 -28
  31. package/dist/components/DropdownMenu.svelte.d.ts +12 -3
  32. package/dist/components/Field.svelte +2 -3
  33. package/dist/components/Fieldset.svelte +2 -3
  34. package/dist/components/Filmstrip.svelte +10 -3
  35. package/dist/components/FilterPanel.svelte +62 -0
  36. package/dist/components/FilterPanel.svelte.d.ts +22 -0
  37. package/dist/components/Lightbox.svelte +239 -0
  38. package/dist/components/Lightbox.svelte.d.ts +40 -0
  39. package/dist/components/LiveRegion.svelte +48 -0
  40. package/dist/components/LiveRegion.svelte.d.ts +20 -0
  41. package/dist/components/MediaCarousel.svelte +177 -0
  42. package/dist/components/MediaCarousel.svelte.d.ts +32 -0
  43. package/dist/components/MediaEmbed.svelte +116 -0
  44. package/dist/components/MediaEmbed.svelte.d.ts +34 -0
  45. package/dist/components/MediaGrid.svelte +91 -0
  46. package/dist/components/MediaGrid.svelte.d.ts +24 -0
  47. package/dist/components/MultiSelect.svelte +232 -0
  48. package/dist/components/MultiSelect.svelte.d.ts +67 -0
  49. package/dist/components/NumberField.svelte +53 -25
  50. package/dist/components/NumberField.svelte.d.ts +47 -18
  51. package/dist/components/OTPInput.svelte +94 -0
  52. package/dist/components/OTPInput.svelte.d.ts +26 -0
  53. package/dist/components/PasswordInput.svelte +93 -0
  54. package/dist/components/PasswordInput.svelte.d.ts +24 -0
  55. package/dist/components/Progress.svelte +57 -14
  56. package/dist/components/Progress.svelte.d.ts +13 -0
  57. package/dist/components/RadioGroup.svelte +19 -5
  58. package/dist/components/RadioGroup.svelte.d.ts +3 -2
  59. package/dist/components/Select.svelte +15 -5
  60. package/dist/components/Select.svelte.d.ts +3 -2
  61. package/dist/components/SignaturePad.svelte +317 -0
  62. package/dist/components/SignaturePad.svelte.d.ts +48 -0
  63. package/dist/components/Skeleton.svelte +40 -9
  64. package/dist/components/Skeleton.svelte.d.ts +8 -0
  65. package/dist/components/SkipLink.svelte +48 -0
  66. package/dist/components/SkipLink.svelte.d.ts +14 -0
  67. package/dist/components/Slider.svelte +148 -0
  68. package/dist/components/Slider.svelte.d.ts +55 -0
  69. package/dist/components/Spinner.svelte +11 -2
  70. package/dist/components/Spinner.svelte.d.ts +3 -0
  71. package/dist/components/Tabs.svelte.d.ts +1 -1
  72. package/dist/components/Thumbnail.svelte +52 -6
  73. package/dist/components/Thumbnail.svelte.d.ts +14 -2
  74. package/dist/components/Toast.svelte +24 -0
  75. package/dist/components/Toast.svelte.d.ts +10 -0
  76. package/dist/components/ToastViewport.svelte +9 -1
  77. package/dist/components/ToastViewport.svelte.d.ts +2 -0
  78. package/dist/components/Toolbar.svelte +49 -0
  79. package/dist/components/Toolbar.svelte.d.ts +23 -0
  80. package/dist/components/Tooltip.svelte +9 -1
  81. package/dist/components/Tooltip.svelte.d.ts +2 -0
  82. package/dist/components/VideoPlayer.svelte +136 -0
  83. package/dist/components/VideoPlayer.svelte.d.ts +38 -0
  84. package/dist/data-table-utils.d.ts +62 -0
  85. package/dist/data-table-utils.js +91 -0
  86. package/dist/data-table.svelte.d.ts +132 -0
  87. package/dist/data-table.svelte.js +292 -0
  88. package/dist/hls.d.ts +35 -0
  89. package/dist/hls.js +37 -0
  90. package/dist/hls.svelte.d.ts +22 -0
  91. package/dist/hls.svelte.js +66 -0
  92. package/dist/index.d.ts +47 -4
  93. package/dist/index.js +35 -2
  94. package/dist/internal/EditableCell.svelte +58 -0
  95. package/dist/internal/EditableCell.svelte.d.ts +8 -0
  96. package/dist/internal/ListboxEntries.svelte +49 -0
  97. package/dist/internal/ListboxEntries.svelte.d.ts +35 -0
  98. package/dist/internal/media/MediaControls.svelte +163 -0
  99. package/dist/internal/media/MediaControls.svelte.d.ts +12 -0
  100. package/dist/internal/media/MediaStage.svelte +49 -0
  101. package/dist/internal/media/MediaStage.svelte.d.ts +11 -0
  102. package/dist/internal/media/MediaTile.svelte +36 -0
  103. package/dist/internal/media/MediaTile.svelte.d.ts +11 -0
  104. package/dist/internal/media/keys.d.ts +7 -0
  105. package/dist/internal/media/keys.js +51 -0
  106. package/dist/internal/media/labels.d.ts +19 -0
  107. package/dist/internal/media/labels.js +15 -0
  108. package/dist/internal/options.d.ts +9 -0
  109. package/dist/internal/options.js +20 -0
  110. package/dist/media-player.svelte.d.ts +61 -0
  111. package/dist/media-player.svelte.js +246 -0
  112. package/dist/media-utils.d.ts +112 -0
  113. package/dist/media-utils.js +205 -0
  114. package/dist/media-zoom.svelte.d.ts +33 -0
  115. package/dist/media-zoom.svelte.js +126 -0
  116. package/dist/signature.d.ts +55 -0
  117. package/dist/signature.js +52 -0
  118. package/dist/toaster.svelte.d.ts +24 -0
  119. package/dist/toaster.svelte.js +23 -0
  120. package/dist/types.d.ts +62 -3
  121. package/dist/utils.d.ts +9 -0
  122. package/dist/utils.js +11 -0
  123. 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,11 +1,25 @@
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
  }
@@ -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. */
@@ -72,6 +72,24 @@ export function createToaster(options = {}) {
72
72
  }
73
73
  return id;
74
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 });
75
93
  const destroy = () => {
76
94
  for (const timer of timers.values()) {
77
95
  if (timer.timeout !== null)
@@ -92,6 +110,11 @@ export function createToaster(options = {}) {
92
110
  return toasts;
93
111
  },
94
112
  push,
113
+ update,
114
+ success: shortcut('success'),
115
+ info: shortcut('info'),
116
+ warning: shortcut('warning'),
117
+ error: shortcut('error', 0),
95
118
  dismiss,
96
119
  clear() {
97
120
  for (const toast of [...toasts])
package/dist/types.d.ts CHANGED
@@ -5,9 +5,10 @@ export type ActionVariant = 'default' | 'primary' | 'quiet' | 'danger' | 'ghost'
5
5
  export type StatusVariant = 'info' | 'success' | 'warning' | 'error';
6
6
  /** Badge spans both sets: neutral, brand accent, or a status. */
7
7
  export type BadgeVariant = 'default' | 'accent' | StatusVariant;
8
- /** Shared control scale. Dialogs extend it with `xl`. */
8
+ /** Shared control scale. Dialogs extend it with `xl` and `full`. */
9
9
  export type ControlSize = 'sm' | 'md' | 'lg';
10
- export type DialogSize = ControlSize | 'xl';
10
+ /** `full` takes the viewport less a gutter — for editors and previews that need the room. */
11
+ export type DialogSize = ControlSize | 'xl' | 'full';
11
12
  /**
12
13
  * Badges default to `inline`: a compact chip sized to the text it annotates, for table cells and
13
14
  * running copy. The `ControlSize` steps match the button scale so a badge standing in a row of
@@ -28,6 +29,17 @@ export interface Option<T extends string = string> {
28
29
  label: string;
29
30
  disabled?: boolean;
30
31
  }
32
+ /** A labelled run of options: an `<optgroup>` in `Select`, a group heading in list popups. */
33
+ export interface OptionGroup<T extends string = string> {
34
+ label: string;
35
+ options: Option<T>[];
36
+ /** Disables every option in the group. */
37
+ disabled?: boolean;
38
+ }
39
+ /** An option with a secondary line, for choices that need a sentence of explanation. */
40
+ export interface ChoiceOption<T extends string = string> extends Option<T> {
41
+ description?: string;
42
+ }
31
43
  /** `Option` is the single vocabulary for choice lists; `NavItem` is kept as a readable alias. */
32
44
  export type NavItem<T extends string = string> = Option<T>;
33
45
  /** One step in a breadcrumb trail. The final crumb is the current page and takes no `href`. */
@@ -43,14 +55,61 @@ export interface Disclosure<T extends string = string> {
43
55
  }
44
56
  /** A `DropdownMenu` row. Renders as a link when `href` is set. */
45
57
  export interface MenuItem<T extends string = string> extends Option<T> {
58
+ type?: 'item';
46
59
  href?: string;
47
- /** Trailing hint, e.g. a keyboard shortcut. */
60
+ /** Trailing hint. Defaults to `shortcut` when that is set. */
48
61
  hint?: string;
62
+ /**
63
+ * The keyboard shortcut this action also has elsewhere in the app, in `aria-keyshortcuts`
64
+ * syntax (`'Control+D'`). Announced with the item and shown as its hint. The menu does not
65
+ * bind the key — your app does.
66
+ */
67
+ shortcut?: string;
68
+ /** Styles the row as destructive. Pair it with a confirmation for anything irreversible. */
69
+ destructive?: boolean;
49
70
  /** Leading visual. */
50
71
  icon?: Snippet;
51
72
  /** Draw a separator above this item. */
52
73
  separatorBefore?: boolean;
53
74
  }
75
+ /** Rows under an optional heading, announced as one group. */
76
+ export interface MenuGroup<T extends string = string> {
77
+ type: 'group';
78
+ label?: string;
79
+ items: MenuEntry<T>[];
80
+ separatorBefore?: boolean;
81
+ }
82
+ /** A toggle that stays in the menu, e.g. "Show grid". Reported through `onCheckedChange`. */
83
+ export interface MenuCheckboxItem<T extends string = string> {
84
+ type: 'checkbox';
85
+ value: T;
86
+ label: string;
87
+ checked: boolean;
88
+ disabled?: boolean;
89
+ hint?: string;
90
+ separatorBefore?: boolean;
91
+ }
92
+ /** A one-of-many choice inside a menu, e.g. a sort order. Reported through `onValueChange`. */
93
+ export interface MenuRadioGroup<T extends string = string> {
94
+ type: 'radio';
95
+ /** Identifies the group in `onValueChange`. */
96
+ name: string;
97
+ label?: string;
98
+ value: T | '';
99
+ options: Option<T>[];
100
+ separatorBefore?: boolean;
101
+ }
102
+ /** A nested menu that opens beside its row. */
103
+ export interface MenuSub<T extends string = string> {
104
+ type: 'sub';
105
+ label: string;
106
+ icon?: Snippet;
107
+ disabled?: boolean;
108
+ items: MenuEntry<T>[];
109
+ separatorBefore?: boolean;
110
+ }
111
+ /** Anything `DropdownMenu`'s `items` can hold. A plain `MenuItem` needs no `type`. */
112
+ export type MenuEntry<T extends string = string> = MenuItem<T> | MenuGroup<T> | MenuCheckboxItem<T> | MenuRadioGroup<T> | MenuSub<T>;
54
113
  /**
55
114
  * Payload of Bits UI's `child` snippet: spread `props` onto your own element to take over
56
115
  * rendering of a trigger without nesting an extra button inside it.
package/dist/utils.d.ts CHANGED
@@ -3,3 +3,12 @@
3
3
  * Accepts the `class` prop of any component in this package.
4
4
  */
5
5
  export declare function cx(...values: Array<string | false | null | undefined>): string;
6
+ /**
7
+ * Joins the ids of the elements that describe a control into one `aria-describedby` value, or
8
+ * `undefined` when there are none — an empty attribute is still an attribute.
9
+ *
10
+ * ```svelte
11
+ * <input aria-describedby={describedBy(hint && hintId, error && errorId)} />
12
+ * ```
13
+ */
14
+ export declare function describedBy(...ids: Array<string | false | null | undefined>): string | undefined;
package/dist/utils.js CHANGED
@@ -5,3 +5,14 @@
5
5
  export function cx(...values) {
6
6
  return values.filter(Boolean).join(' ');
7
7
  }
8
+ /**
9
+ * Joins the ids of the elements that describe a control into one `aria-describedby` value, or
10
+ * `undefined` when there are none — an empty attribute is still an attribute.
11
+ *
12
+ * ```svelte
13
+ * <input aria-describedby={describedBy(hint && hintId, error && errorId)} />
14
+ * ```
15
+ */
16
+ export function describedBy(...ids) {
17
+ return ids.filter(Boolean).join(' ') || undefined;
18
+ }