@classic-homes/theme-svelte 1.25.1 → 1.27.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/lib/components/AlertDialog.svelte +14 -6
- package/dist/lib/components/AlertDialog.svelte.d.ts +8 -2
- package/dist/lib/components/Badge.svelte +6 -2
- package/dist/lib/components/CodeField.svelte +24 -8
- package/dist/lib/components/CodeField.svelte.d.ts +8 -0
- package/dist/lib/components/Combobox.svelte +25 -111
- package/dist/lib/components/DataPanel.svelte +31 -24
- package/dist/lib/components/DataPanelHeader.svelte +66 -26
- package/dist/lib/components/DataTable.svelte +303 -138
- package/dist/lib/components/DataTable.svelte.d.ts +37 -13
- package/dist/lib/components/DateTimePicker.svelte +159 -34
- package/dist/lib/components/Dialog.svelte +14 -6
- package/dist/lib/components/Dialog.svelte.d.ts +8 -2
- package/dist/lib/components/FileUpload.svelte +23 -6
- package/dist/lib/components/FileUpload.svelte.d.ts +7 -2
- package/dist/lib/components/MultiSelect.svelte +28 -155
- package/dist/lib/components/NumberInput.svelte +28 -110
- package/dist/lib/components/OTPInput.svelte +73 -85
- package/dist/lib/components/Select.svelte +24 -29
- package/dist/lib/components/Signature.svelte +80 -48
- package/dist/lib/components/Signature.svelte.d.ts +6 -1
- package/dist/lib/components/SkipLink.svelte +7 -2
- package/dist/lib/components/SkipLink.svelte.d.ts +2 -0
- package/dist/lib/components/Slider.svelte +4 -4
- package/dist/lib/components/Toast.svelte +11 -1
- package/dist/lib/components/ToastContainer.svelte +38 -24
- package/dist/lib/components/Tooltip.svelte +19 -6
- package/dist/lib/components/Tooltip.svelte.d.ts +8 -2
- package/dist/lib/components/data-table/DataTableColumnFilter.svelte +3 -1
- package/dist/lib/components/data-table/DataTableEditableCell.svelte +25 -3
- package/dist/lib/components/data-table/DataTableFacetedFilter.svelte +50 -40
- package/dist/lib/components/data-table/DataTableFacetedFilter.svelte.d.ts +0 -10
- package/dist/lib/components/data-table/DataTableVirtual.svelte +109 -57
- package/dist/lib/components/data-table/DataTableVirtual.svelte.d.ts +2 -0
- package/dist/lib/components/data-table/createDataTable.svelte.d.ts +26 -4
- package/dist/lib/components/data-table/createDataTable.svelte.js +97 -40
- package/dist/lib/components/data-table/index.d.ts +1 -1
- package/dist/lib/components/data-table/types.d.ts +14 -4
- package/dist/lib/components/data-table/types.js +5 -1
- package/dist/lib/components/internal/TriggerChild.svelte +60 -0
- package/dist/lib/components/internal/TriggerChild.svelte.d.ts +32 -0
- package/dist/lib/components/layout/Sidebar.svelte +33 -3
- package/dist/lib/components/layout/Sidebar.svelte.d.ts +1 -1
- package/dist/lib/components/layout/sidebar/SidebarFlyout.svelte +6 -1
- package/dist/lib/components/layout/sidebar/SidebarNavItem.svelte +6 -1
- package/dist/lib/components/layout/sidebar/SidebarSection.svelte +6 -1
- package/dist/lib/components/media/Audio.svelte +16 -0
- package/dist/lib/components/media/Lightbox.svelte +44 -9
- package/dist/lib/components/media/Lightbox.svelte.d.ts +9 -2
- package/dist/lib/components/media/MediaCarousel.svelte +19 -0
- package/dist/lib/components/media/MediaEmbed.svelte +32 -1
- package/dist/lib/components/media/Video.svelte +16 -0
- package/dist/lib/components/media/internal/MediaControls.svelte +9 -1
- package/dist/lib/components/notifications/NotificationBadge.svelte +1 -1
- package/dist/lib/components/notifications/NotificationItem.svelte +19 -28
- package/dist/lib/composables/useEscapeKey.svelte.d.ts +4 -0
- package/dist/lib/composables/useEscapeKey.svelte.js +15 -0
- package/dist/lib/composables/useHlsSource.svelte.js +31 -4
- package/dist/lib/composables/useMediaPlayer.svelte.js +38 -9
- package/dist/lib/composables/useMediaZoom.svelte.js +32 -0
- package/dist/lib/composables/usePersistedForm.svelte.d.ts +6 -2
- package/dist/lib/composables/usePersistedForm.svelte.js +7 -7
- package/dist/lib/composables/useReducedMotion.svelte.js +6 -1
- package/dist/lib/composables/useRovingTabindex.svelte.js +12 -0
- package/dist/lib/index.d.ts +1 -1
- package/dist/lib/stores/toast.svelte.d.ts +13 -0
- package/dist/lib/stores/toast.svelte.js +36 -5
- package/dist/lib/utils/media.d.ts +7 -0
- package/dist/lib/utils/media.js +18 -3
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { cn } from '../utils.js';
|
|
3
|
-
import {
|
|
3
|
+
import { computeDescribedBy } from '../utils/form.js';
|
|
4
4
|
import Label from './Label.svelte';
|
|
5
5
|
|
|
6
6
|
interface Props {
|
|
@@ -57,9 +57,9 @@
|
|
|
57
57
|
class: className,
|
|
58
58
|
}: Props = $props();
|
|
59
59
|
|
|
60
|
-
// Generate unique IDs for accessibility
|
|
61
|
-
const
|
|
62
|
-
const componentId = $derived(id || `number-input-${
|
|
60
|
+
// Generate unique IDs for accessibility ($props.id() is SSR-stable)
|
|
61
|
+
const uid = $props.id();
|
|
62
|
+
const componentId = $derived(id || `number-input-${uid}`);
|
|
63
63
|
const hintId = $derived(`${componentId}-hint`);
|
|
64
64
|
const errorId = $derived(`${componentId}-error`);
|
|
65
65
|
|
|
@@ -148,115 +148,21 @@
|
|
|
148
148
|
);
|
|
149
149
|
|
|
150
150
|
const displayValue = $derived(value !== null ? String(value) : '');
|
|
151
|
-
|
|
152
|
-
// Determine if we need a container wrapper
|
|
153
|
-
const hasWrapper = $derived(!!label || !!hint || !!error);
|
|
154
151
|
</script>
|
|
155
152
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
</Label>
|
|
166
|
-
{/if}
|
|
167
|
-
|
|
168
|
-
<div class={cn('relative flex items-center', className)}>
|
|
169
|
-
{#if showStepper}
|
|
170
|
-
<button
|
|
171
|
-
type="button"
|
|
172
|
-
onclick={decrement}
|
|
173
|
-
disabled={disabled || !canDecrement}
|
|
174
|
-
class={cn(
|
|
175
|
-
'flex h-10 w-10 items-center justify-center rounded-l-md border border-r-0 border-input bg-background text-foreground text-sm font-medium hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
|
176
|
-
error && 'border-destructive'
|
|
177
|
-
)}
|
|
178
|
-
aria-label="Decrease value"
|
|
179
|
-
>
|
|
180
|
-
<svg
|
|
181
|
-
xmlns="http://www.w3.org/2000/svg"
|
|
182
|
-
width="16"
|
|
183
|
-
height="16"
|
|
184
|
-
viewBox="0 0 24 24"
|
|
185
|
-
fill="none"
|
|
186
|
-
stroke="currentColor"
|
|
187
|
-
stroke-width="2"
|
|
188
|
-
stroke-linecap="round"
|
|
189
|
-
stroke-linejoin="round"
|
|
190
|
-
>
|
|
191
|
-
<path d="M5 12h14" />
|
|
192
|
-
</svg>
|
|
193
|
-
</button>
|
|
194
|
-
{/if}
|
|
195
|
-
|
|
196
|
-
<input
|
|
197
|
-
type="text"
|
|
198
|
-
inputmode="decimal"
|
|
199
|
-
value={displayValue}
|
|
200
|
-
{placeholder}
|
|
201
|
-
{disabled}
|
|
202
|
-
{readonly}
|
|
203
|
-
{required}
|
|
204
|
-
{name}
|
|
205
|
-
id={componentId}
|
|
206
|
-
class={cn(
|
|
207
|
-
'flex h-10 w-full bg-background px-3 py-2 text-base sm:text-sm text-center ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
|
208
|
-
showStepper ? 'border-y border-input' : 'border border-input rounded-md',
|
|
209
|
-
error && 'border-destructive focus-visible:ring-destructive'
|
|
210
|
-
)}
|
|
211
|
-
oninput={handleInput}
|
|
212
|
-
onblur={handleBlur}
|
|
213
|
-
onkeydown={handleKeyDown}
|
|
214
|
-
aria-invalid={error ? 'true' : undefined}
|
|
215
|
-
aria-describedby={describedBy}
|
|
216
|
-
/>
|
|
217
|
-
|
|
218
|
-
{#if showStepper}
|
|
219
|
-
<button
|
|
220
|
-
type="button"
|
|
221
|
-
onclick={increment}
|
|
222
|
-
disabled={disabled || !canIncrement}
|
|
223
|
-
class={cn(
|
|
224
|
-
'flex h-10 w-10 items-center justify-center rounded-r-md border border-l-0 border-input bg-background text-foreground text-sm font-medium hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
|
225
|
-
error && 'border-destructive'
|
|
226
|
-
)}
|
|
227
|
-
aria-label="Increase value"
|
|
228
|
-
>
|
|
229
|
-
<svg
|
|
230
|
-
xmlns="http://www.w3.org/2000/svg"
|
|
231
|
-
width="16"
|
|
232
|
-
height="16"
|
|
233
|
-
viewBox="0 0 24 24"
|
|
234
|
-
fill="none"
|
|
235
|
-
stroke="currentColor"
|
|
236
|
-
stroke-width="2"
|
|
237
|
-
stroke-linecap="round"
|
|
238
|
-
stroke-linejoin="round"
|
|
239
|
-
>
|
|
240
|
-
<path d="M5 12h14" />
|
|
241
|
-
<path d="M12 5v14" />
|
|
242
|
-
</svg>
|
|
243
|
-
</button>
|
|
153
|
+
<!-- One structure whether or not there is a label, hint or error, so the
|
|
154
|
+
focused control is never remounted when an error appears. -->
|
|
155
|
+
<div class="space-y-2">
|
|
156
|
+
{#if label}
|
|
157
|
+
<Label for={componentId} {disabled}>
|
|
158
|
+
{label}
|
|
159
|
+
{#if required}
|
|
160
|
+
<span class="text-destructive ml-0.5" aria-hidden="true">*</span>
|
|
161
|
+
<span class="sr-only">(required)</span>
|
|
244
162
|
{/if}
|
|
245
|
-
</
|
|
246
|
-
|
|
247
|
-
{#if hint && !error}
|
|
248
|
-
<p id={hintId} class="text-sm text-muted-foreground">
|
|
249
|
-
{hint}
|
|
250
|
-
</p>
|
|
251
|
-
{/if}
|
|
163
|
+
</Label>
|
|
164
|
+
{/if}
|
|
252
165
|
|
|
253
|
-
{#if error}
|
|
254
|
-
<p id={errorId} class="text-sm text-destructive" role="alert" aria-live="polite">
|
|
255
|
-
{error}
|
|
256
|
-
</p>
|
|
257
|
-
{/if}
|
|
258
|
-
</div>
|
|
259
|
-
{:else}
|
|
260
166
|
<div class={cn('relative flex items-center', className)}>
|
|
261
167
|
{#if showStepper}
|
|
262
168
|
<button
|
|
@@ -335,4 +241,16 @@
|
|
|
335
241
|
</button>
|
|
336
242
|
{/if}
|
|
337
243
|
</div>
|
|
338
|
-
|
|
244
|
+
|
|
245
|
+
{#if hint && !error}
|
|
246
|
+
<p id={hintId} class="text-sm text-muted-foreground">
|
|
247
|
+
{hint}
|
|
248
|
+
</p>
|
|
249
|
+
{/if}
|
|
250
|
+
|
|
251
|
+
{#if error}
|
|
252
|
+
<p id={errorId} class="text-sm text-destructive" role="alert" aria-live="polite">
|
|
253
|
+
{error}
|
|
254
|
+
</p>
|
|
255
|
+
{/if}
|
|
256
|
+
</div>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { cn } from '../utils.js';
|
|
3
|
-
import {
|
|
3
|
+
import { untrack } from 'svelte';
|
|
4
|
+
import { computeDescribedBy } from '../utils/form.js';
|
|
4
5
|
import Label from './Label.svelte';
|
|
5
6
|
import LiveRegion from './LiveRegion.svelte';
|
|
6
7
|
|
|
@@ -49,9 +50,9 @@
|
|
|
49
50
|
class: className,
|
|
50
51
|
}: Props = $props();
|
|
51
52
|
|
|
52
|
-
// Generate unique IDs for accessibility
|
|
53
|
-
const
|
|
54
|
-
const componentId = $derived(id || `otp-${
|
|
53
|
+
// Generate unique IDs for accessibility ($props.id() is SSR-stable)
|
|
54
|
+
const uid = $props.id();
|
|
55
|
+
const componentId = $derived(id || `otp-${uid}`);
|
|
55
56
|
const hintId = $derived(`${componentId}-hint`);
|
|
56
57
|
const errorId = $derived(`${componentId}-error`);
|
|
57
58
|
|
|
@@ -60,14 +61,32 @@
|
|
|
60
61
|
|
|
61
62
|
let inputs: HTMLInputElement[] = $state([]);
|
|
62
63
|
|
|
63
|
-
//
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
64
|
+
// One entry per cell. The cells, not `value`, are the source of truth for
|
|
65
|
+
// what is shown, so clearing a middle cell leaves a gap instead of shifting
|
|
66
|
+
// the later digits left, and a digit typed into a later cell stays there.
|
|
67
|
+
// `value` is the filled cells joined in order.
|
|
68
|
+
let digits = $state<string[]>(untrack(() => toCells(value, length)));
|
|
69
|
+
|
|
70
|
+
function toCells(str: string, len: number): string[] {
|
|
71
|
+
const arr = str.split('').slice(0, len);
|
|
72
|
+
while (arr.length < len) arr.push('');
|
|
70
73
|
return arr;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function compact(cells: string[]): string {
|
|
77
|
+
return cells.join('').slice(0, length);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Re-seed the cells when `value` or `length` changes from outside (a reset,
|
|
81
|
+
// a programmatic fill). Our own writes keep compact(digits) === value, so
|
|
82
|
+
// they leave the cells, gaps included, alone.
|
|
83
|
+
$effect(() => {
|
|
84
|
+
const next = value;
|
|
85
|
+
const len = length;
|
|
86
|
+
untrack(() => {
|
|
87
|
+
if (digits.length === len && compact(digits) === next) return;
|
|
88
|
+
digits = toCells(next, len);
|
|
89
|
+
});
|
|
71
90
|
});
|
|
72
91
|
|
|
73
92
|
// Pattern for input validation
|
|
@@ -88,11 +107,13 @@
|
|
|
88
107
|
let announcement = $state('');
|
|
89
108
|
|
|
90
109
|
function updateValue(newDigits: string[]) {
|
|
91
|
-
|
|
92
|
-
const newValue = newDigits
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
110
|
+
digits = newDigits;
|
|
111
|
+
const newValue = compact(newDigits);
|
|
112
|
+
// The DOM `value` of a cell isn't reset when its digit doesn't change
|
|
113
|
+
// (e.g. a rejected key), so write every cell back explicitly.
|
|
114
|
+
newDigits.forEach((d, i) => {
|
|
115
|
+
if (inputs[i] && inputs[i].value !== d) inputs[i].value = d;
|
|
116
|
+
});
|
|
96
117
|
value = newValue;
|
|
97
118
|
onValueChange?.(newValue);
|
|
98
119
|
|
|
@@ -110,7 +131,15 @@
|
|
|
110
131
|
const target = e.target as HTMLInputElement;
|
|
111
132
|
const inputValue = target.value;
|
|
112
133
|
|
|
113
|
-
if (!inputValue)
|
|
134
|
+
if (!inputValue) {
|
|
135
|
+
// The browser cleared the cell (e.g. cut, or IME); mirror it
|
|
136
|
+
if (digits[index]) {
|
|
137
|
+
const newDigits = [...digits];
|
|
138
|
+
newDigits[index] = '';
|
|
139
|
+
updateValue(newDigits);
|
|
140
|
+
}
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
114
143
|
|
|
115
144
|
// Handle paste of multiple characters
|
|
116
145
|
if (inputValue.length > 1) {
|
|
@@ -206,78 +235,25 @@
|
|
|
206
235
|
const target = e.target as HTMLInputElement;
|
|
207
236
|
target.select();
|
|
208
237
|
}
|
|
209
|
-
|
|
210
|
-
// Determine if we need a container wrapper
|
|
211
|
-
const hasWrapper = $derived(!!label || !!hint || !!error);
|
|
212
238
|
</script>
|
|
213
239
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
class={cn('flex gap-1.5 sm:gap-2', className)}
|
|
228
|
-
role="group"
|
|
229
|
-
aria-label={label || 'One-time password input'}
|
|
230
|
-
aria-describedby={describedBy}
|
|
231
|
-
>
|
|
232
|
-
{#each { length } as _, index}
|
|
233
|
-
<input
|
|
234
|
-
bind:this={inputs[index]}
|
|
235
|
-
type="text"
|
|
236
|
-
inputmode={inputMode}
|
|
237
|
-
maxlength="1"
|
|
238
|
-
{pattern}
|
|
239
|
-
{disabled}
|
|
240
|
-
value={digits[index] || ''}
|
|
241
|
-
id={`${componentId}-${index}`}
|
|
242
|
-
oninput={(e) => handleInput(index, e)}
|
|
243
|
-
onkeydown={(e) => handleKeyDown(index, e)}
|
|
244
|
-
onpaste={handlePaste}
|
|
245
|
-
onfocus={handleFocus}
|
|
246
|
-
class={cn(
|
|
247
|
-
'h-10 w-10 text-base sm:h-12 sm:w-12 sm:text-lg text-center font-semibold rounded-md border border-input bg-background text-foreground ring-offset-background transition-colors',
|
|
248
|
-
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
|
249
|
-
'disabled:cursor-not-allowed disabled:opacity-50',
|
|
250
|
-
error && 'border-destructive focus:ring-destructive'
|
|
251
|
-
)}
|
|
252
|
-
aria-label={`Digit ${index + 1} of ${length}`}
|
|
253
|
-
aria-invalid={error ? 'true' : undefined}
|
|
254
|
-
autocomplete="one-time-code"
|
|
255
|
-
/>
|
|
256
|
-
{/each}
|
|
257
|
-
</div>
|
|
258
|
-
|
|
259
|
-
<!-- Hidden input for form submission -->
|
|
260
|
-
{#if name}
|
|
261
|
-
<input type="hidden" {name} {value} />
|
|
262
|
-
{/if}
|
|
263
|
-
|
|
264
|
-
{#if hint && !error}
|
|
265
|
-
<p id={hintId} class="text-sm text-muted-foreground">
|
|
266
|
-
{hint}
|
|
267
|
-
</p>
|
|
268
|
-
{/if}
|
|
269
|
-
|
|
270
|
-
{#if error}
|
|
271
|
-
<p id={errorId} class="text-sm text-destructive" role="alert" aria-live="polite">
|
|
272
|
-
{error}
|
|
273
|
-
</p>
|
|
274
|
-
{/if}
|
|
275
|
-
</div>
|
|
276
|
-
{:else}
|
|
240
|
+
<!-- One structure regardless of label/hint/error, so a cell holding focus is
|
|
241
|
+
never remounted when an error appears. -->
|
|
242
|
+
<div class="space-y-2">
|
|
243
|
+
{#if label}
|
|
244
|
+
<Label for={`${componentId}-0`} {disabled}>
|
|
245
|
+
{label}
|
|
246
|
+
{#if required}
|
|
247
|
+
<span class="text-destructive ml-0.5" aria-hidden="true">*</span>
|
|
248
|
+
<span class="sr-only">(required)</span>
|
|
249
|
+
{/if}
|
|
250
|
+
</Label>
|
|
251
|
+
{/if}
|
|
252
|
+
|
|
277
253
|
<div
|
|
278
254
|
class={cn('flex gap-1.5 sm:gap-2', className)}
|
|
279
255
|
role="group"
|
|
280
|
-
aria-label=
|
|
256
|
+
aria-label={label || 'One-time password input'}
|
|
281
257
|
aria-describedby={describedBy}
|
|
282
258
|
>
|
|
283
259
|
{#each { length } as _, index}
|
|
@@ -311,7 +287,19 @@
|
|
|
311
287
|
{#if name}
|
|
312
288
|
<input type="hidden" {name} {value} />
|
|
313
289
|
{/if}
|
|
314
|
-
|
|
290
|
+
|
|
291
|
+
{#if hint && !error}
|
|
292
|
+
<p id={hintId} class="text-sm text-muted-foreground">
|
|
293
|
+
{hint}
|
|
294
|
+
</p>
|
|
295
|
+
{/if}
|
|
296
|
+
|
|
297
|
+
{#if error}
|
|
298
|
+
<p id={errorId} class="text-sm text-destructive" role="alert" aria-live="polite">
|
|
299
|
+
{error}
|
|
300
|
+
</p>
|
|
301
|
+
{/if}
|
|
302
|
+
</div>
|
|
315
303
|
|
|
316
304
|
<!-- Live region for screen reader announcements -->
|
|
317
305
|
<LiveRegion message={announcement} politeness="polite" />
|
|
@@ -137,9 +137,6 @@
|
|
|
137
137
|
onValueChange?.(newValue);
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
-
// Determine if we need a container wrapper
|
|
141
|
-
const hasWrapper = $derived(!!label || !!hint || !!error);
|
|
142
|
-
|
|
143
140
|
// Common trigger classes
|
|
144
141
|
const triggerClasses = $derived(
|
|
145
142
|
cn(
|
|
@@ -270,32 +267,30 @@
|
|
|
270
267
|
</SelectPrimitive.Root>
|
|
271
268
|
{/snippet}
|
|
272
269
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
270
|
+
<!-- One structure whether or not there is a label, hint or error, so the
|
|
271
|
+
focused control is never remounted when an error appears. -->
|
|
272
|
+
<div class="space-y-2">
|
|
273
|
+
{#if label}
|
|
274
|
+
<Label for={componentId} {disabled}>
|
|
275
|
+
{label}
|
|
276
|
+
{#if required}
|
|
277
|
+
<span class="text-destructive ml-0.5" aria-hidden="true">*</span>
|
|
278
|
+
<span class="sr-only">(required)</span>
|
|
279
|
+
{/if}
|
|
280
|
+
</Label>
|
|
281
|
+
{/if}
|
|
284
282
|
|
|
285
|
-
|
|
283
|
+
{@render selectRoot(fallbackAriaLabel)}
|
|
286
284
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
285
|
+
{#if hint && !error}
|
|
286
|
+
<p id={hintId} class="text-sm text-muted-foreground">
|
|
287
|
+
{hint}
|
|
288
|
+
</p>
|
|
289
|
+
{/if}
|
|
292
290
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
{:else}
|
|
300
|
-
{@render selectRoot(fallbackAriaLabel)}
|
|
301
|
-
{/if}
|
|
291
|
+
{#if error}
|
|
292
|
+
<p id={errorId} class="text-sm text-destructive" role="alert" aria-live="polite">
|
|
293
|
+
{error}
|
|
294
|
+
</p>
|
|
295
|
+
{/if}
|
|
296
|
+
</div>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { untrack } from 'svelte';
|
|
3
|
+
import { RadioGroup as RadioGroupPrimitive } from 'bits-ui';
|
|
3
4
|
import { cn } from '../utils.js';
|
|
4
5
|
import { tv, type VariantProps } from 'tailwind-variants';
|
|
5
6
|
import type {
|
|
@@ -28,7 +29,7 @@
|
|
|
28
29
|
labelText: 'text-sm font-medium leading-none',
|
|
29
30
|
modeToggle: 'inline-flex items-center rounded-lg bg-muted p-1',
|
|
30
31
|
modeButton:
|
|
31
|
-
'inline-flex items-center justify-center rounded-md px-3 py-1.5 text-sm font-medium',
|
|
32
|
+
'inline-flex items-center justify-center rounded-md px-3 py-1.5 text-sm font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
|
32
33
|
modeButtonActive: 'bg-background text-foreground shadow-sm',
|
|
33
34
|
modeButtonInactive: 'text-muted-foreground hover:text-foreground',
|
|
34
35
|
// A white pad, like the exported image, so the default black ink stays
|
|
@@ -46,7 +47,8 @@
|
|
|
46
47
|
'h-2 w-20 cursor-pointer appearance-none rounded-full bg-muted accent-primary',
|
|
47
48
|
typeInput: 'w-full',
|
|
48
49
|
fontPicker: 'flex flex-wrap gap-2',
|
|
49
|
-
fontButton:
|
|
50
|
+
fontButton:
|
|
51
|
+
'rounded-lg border-2 px-3 py-2 text-lg hover:border-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
|
50
52
|
fontButtonSelected: 'border-primary bg-primary/5',
|
|
51
53
|
fontButtonUnselected: 'border-input',
|
|
52
54
|
consent: 'flex items-start gap-2',
|
|
@@ -119,7 +121,12 @@
|
|
|
119
121
|
minStrokeLength?: number;
|
|
120
122
|
/** Minimum characters for typed signature */
|
|
121
123
|
minTypedLength?: number;
|
|
122
|
-
/**
|
|
124
|
+
/**
|
|
125
|
+
* Extra validation, run on each new signature value once the built-in
|
|
126
|
+
* checks (strokes / typed length) pass. Return `{ valid: false, error }`
|
|
127
|
+
* to reject it: the error shows under the pad like the built-in ones and
|
|
128
|
+
* onComplete does not fire. onValueChange still receives the value.
|
|
129
|
+
*/
|
|
123
130
|
validate?: (data: SignatureData) => SignatureValidationResult;
|
|
124
131
|
|
|
125
132
|
/** Whether to show consent checkbox */
|
|
@@ -443,17 +450,26 @@
|
|
|
443
450
|
}
|
|
444
451
|
|
|
445
452
|
// Load Google Font
|
|
453
|
+
// Families already requested, so a font is fetched once however many times
|
|
454
|
+
// loadFont runs before its first load resolves (plain Set: not reactive)
|
|
455
|
+
const fontsRequested = new Set<string>();
|
|
456
|
+
|
|
446
457
|
async function loadFont(font: SignatureFont): Promise<void> {
|
|
447
|
-
if (fontsLoaded.has(font.family)) return;
|
|
458
|
+
if (fontsLoaded.has(font.family) || fontsRequested.has(font.family)) return;
|
|
459
|
+
fontsRequested.add(font.family);
|
|
448
460
|
if (!font.googleFont) {
|
|
449
461
|
fontsLoaded = new Set([...fontsLoaded, font.family]);
|
|
450
462
|
return;
|
|
451
463
|
}
|
|
452
464
|
|
|
453
|
-
const
|
|
454
|
-
|
|
455
|
-
link.
|
|
456
|
-
|
|
465
|
+
const href = `https://fonts.googleapis.com/css2?family=${font.family.replace(/\s+/g, '+')}&display=swap`;
|
|
466
|
+
// Another Signature on the page may have added it already
|
|
467
|
+
if (![...document.head.querySelectorAll('link')].some((l) => l.href === href)) {
|
|
468
|
+
const link = document.createElement('link');
|
|
469
|
+
link.href = href;
|
|
470
|
+
link.rel = 'stylesheet';
|
|
471
|
+
document.head.appendChild(link);
|
|
472
|
+
}
|
|
457
473
|
|
|
458
474
|
// Wait for font to load
|
|
459
475
|
await document.fonts.load(`48px "${font.family}"`);
|
|
@@ -725,10 +741,22 @@
|
|
|
725
741
|
return;
|
|
726
742
|
}
|
|
727
743
|
|
|
744
|
+
// Built-in checks first (an initial typedName never went through an
|
|
745
|
+
// input handler), then the consumer's check on the generated value
|
|
746
|
+
runValidation();
|
|
728
747
|
const data = await generateSignatureData();
|
|
729
748
|
value = data;
|
|
730
749
|
onValueChange?.(data);
|
|
731
750
|
|
|
751
|
+
if (customValidate && validationResult.valid) {
|
|
752
|
+
const result = customValidate(data);
|
|
753
|
+
if (!result.valid) {
|
|
754
|
+
validationResult = { valid: false, error: result.error };
|
|
755
|
+
completeFired = false;
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
732
760
|
// Only fire onComplete once per valid signature
|
|
733
761
|
if (isValid && hasSignature && !completeFired) {
|
|
734
762
|
completeFired = true;
|
|
@@ -804,8 +832,11 @@
|
|
|
804
832
|
|
|
805
833
|
// Effects
|
|
806
834
|
$effect(() => {
|
|
807
|
-
// Load all fonts on mount
|
|
808
|
-
|
|
835
|
+
// Load all fonts on mount (and when the list changes). untrack: loadFont
|
|
836
|
+
// reads and writes fontsLoaded, which would otherwise re-run this effect
|
|
837
|
+
// once per loaded font and request every font again.
|
|
838
|
+
const list = fonts;
|
|
839
|
+
untrack(() => list.forEach((font) => loadFont(font)));
|
|
809
840
|
});
|
|
810
841
|
|
|
811
842
|
$effect(() => {
|
|
@@ -880,34 +911,26 @@
|
|
|
880
911
|
{/if}
|
|
881
912
|
|
|
882
913
|
{#if showModeToggle}
|
|
883
|
-
<
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
mode === 'type' ? styles.modeButtonActive() : styles.modeButtonInactive()
|
|
904
|
-
)}
|
|
905
|
-
onclick={() => setMode('type')}
|
|
906
|
-
{disabled}
|
|
907
|
-
>
|
|
908
|
-
Type
|
|
909
|
-
</button>
|
|
910
|
-
</div>
|
|
914
|
+
<RadioGroupPrimitive.Root
|
|
915
|
+
value={mode}
|
|
916
|
+
onValueChange={(v) => setMode(v as 'draw' | 'type')}
|
|
917
|
+
{disabled}
|
|
918
|
+
orientation="horizontal"
|
|
919
|
+
aria-label="Signature input method"
|
|
920
|
+
class={styles.modeToggle()}
|
|
921
|
+
>
|
|
922
|
+
{#each [{ value: 'draw', label: 'Draw' }, { value: 'type', label: 'Type' }] as option (option.value)}
|
|
923
|
+
<RadioGroupPrimitive.Item
|
|
924
|
+
value={option.value}
|
|
925
|
+
class={cn(
|
|
926
|
+
styles.modeButton(),
|
|
927
|
+
mode === option.value ? styles.modeButtonActive() : styles.modeButtonInactive()
|
|
928
|
+
)}
|
|
929
|
+
>
|
|
930
|
+
{option.label}
|
|
931
|
+
</RadioGroupPrimitive.Item>
|
|
932
|
+
{/each}
|
|
933
|
+
</RadioGroupPrimitive.Root>
|
|
911
934
|
{/if}
|
|
912
935
|
</div>
|
|
913
936
|
|
|
@@ -1009,12 +1032,23 @@
|
|
|
1009
1032
|
aria-invalid={error ? 'true' : undefined}
|
|
1010
1033
|
/>
|
|
1011
1034
|
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1035
|
+
<!-- Single choice: a radio group (arrow keys move and select). Each
|
|
1036
|
+
option is named by its font, not the preview text it shows. -->
|
|
1037
|
+
<RadioGroupPrimitive.Root
|
|
1038
|
+
value={selectedFont}
|
|
1039
|
+
onValueChange={(family) => {
|
|
1040
|
+
const font = fonts.find((f) => f.family === family);
|
|
1041
|
+
if (font && family !== selectedFont) selectFont(font);
|
|
1042
|
+
}}
|
|
1043
|
+
{disabled}
|
|
1044
|
+
orientation="horizontal"
|
|
1045
|
+
aria-label="Signature font"
|
|
1046
|
+
class={styles.fontPicker()}
|
|
1047
|
+
>
|
|
1048
|
+
{#each fonts as font (font.family)}
|
|
1049
|
+
<RadioGroupPrimitive.Item
|
|
1050
|
+
value={font.family}
|
|
1051
|
+
aria-label={font.label}
|
|
1018
1052
|
class={cn(
|
|
1019
1053
|
styles.fontButton(),
|
|
1020
1054
|
selectedFont === font.family
|
|
@@ -1022,13 +1056,11 @@
|
|
|
1022
1056
|
: styles.fontButtonUnselected()
|
|
1023
1057
|
)}
|
|
1024
1058
|
style="font-family: '{font.family}', cursive;"
|
|
1025
|
-
onclick={() => selectFont(font)}
|
|
1026
|
-
{disabled}
|
|
1027
1059
|
>
|
|
1028
1060
|
{typedName || 'Preview'}
|
|
1029
|
-
</
|
|
1061
|
+
</RadioGroupPrimitive.Item>
|
|
1030
1062
|
{/each}
|
|
1031
|
-
</
|
|
1063
|
+
</RadioGroupPrimitive.Root>
|
|
1032
1064
|
</div>
|
|
1033
1065
|
{/if}
|
|
1034
1066
|
|
|
@@ -40,7 +40,12 @@ interface Props {
|
|
|
40
40
|
minStrokeLength?: number;
|
|
41
41
|
/** Minimum characters for typed signature */
|
|
42
42
|
minTypedLength?: number;
|
|
43
|
-
/**
|
|
43
|
+
/**
|
|
44
|
+
* Extra validation, run on each new signature value once the built-in
|
|
45
|
+
* checks (strokes / typed length) pass. Return `{ valid: false, error }`
|
|
46
|
+
* to reject it: the error shows under the pad like the built-in ones and
|
|
47
|
+
* onComplete does not fire. onValueChange still receives the value.
|
|
48
|
+
*/
|
|
44
49
|
validate?: (data: SignatureData) => SignatureValidationResult;
|
|
45
50
|
/** Whether to show consent checkbox */
|
|
46
51
|
showConsent?: boolean;
|