@classic-homes/theme-svelte 1.25.1 → 1.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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 +29 -3
- 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,5 +1,5 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import { Popover
|
|
2
|
+
import { Popover } from 'bits-ui';
|
|
3
3
|
import { tick } from 'svelte';
|
|
4
4
|
import { cn } from '../utils.js';
|
|
5
5
|
import { parseDate, toISOString, parseDateInput } from '../utils/date.js';
|
|
@@ -194,6 +194,99 @@
|
|
|
194
194
|
return days;
|
|
195
195
|
});
|
|
196
196
|
|
|
197
|
+
// The days split into weeks, the rows of the day grid
|
|
198
|
+
const calendarWeeks = $derived.by(() => {
|
|
199
|
+
const weeks: (number | null)[][] = [];
|
|
200
|
+
for (let i = 0; i < calendarDays.length; i += 7) {
|
|
201
|
+
const week = calendarDays.slice(i, i + 7);
|
|
202
|
+
while (week.length < 7) week.push(null);
|
|
203
|
+
weeks.push(week);
|
|
204
|
+
}
|
|
205
|
+
return weeks;
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
const daysInViewMonth = $derived(new Date(viewYear, viewMonth + 1, 0).getDate());
|
|
209
|
+
|
|
210
|
+
// Roving focus within the day grid: the one day with tabindex=0. Falls back
|
|
211
|
+
// to the selected day, then today, then the 1st when unset or out of range.
|
|
212
|
+
let focusedDay = $state<number | null>(null);
|
|
213
|
+
const today = new Date();
|
|
214
|
+
const activeDay = $derived.by(() => {
|
|
215
|
+
if (focusedDay !== null && focusedDay <= daysInViewMonth) return focusedDay;
|
|
216
|
+
if (value && value.getMonth() === viewMonth && value.getFullYear() === viewYear) {
|
|
217
|
+
return value.getDate();
|
|
218
|
+
}
|
|
219
|
+
if (today.getMonth() === viewMonth && today.getFullYear() === viewYear) {
|
|
220
|
+
return today.getDate();
|
|
221
|
+
}
|
|
222
|
+
return 1;
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
let dayGrid = $state<HTMLElement | null>(null);
|
|
226
|
+
|
|
227
|
+
function dayLabel(day: number): string {
|
|
228
|
+
return new Date(viewYear, viewMonth, day).toLocaleDateString('en-US', {
|
|
229
|
+
weekday: 'long',
|
|
230
|
+
month: 'long',
|
|
231
|
+
day: 'numeric',
|
|
232
|
+
year: 'numeric',
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function isToday(day: number): boolean {
|
|
237
|
+
return (
|
|
238
|
+
today.getDate() === day && today.getMonth() === viewMonth && today.getFullYear() === viewYear
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Move grid focus to a date (any month), switching the view to it */
|
|
243
|
+
async function focusDate(date: Date) {
|
|
244
|
+
viewMonth = date.getMonth();
|
|
245
|
+
viewYear = date.getFullYear();
|
|
246
|
+
focusedDay = date.getDate();
|
|
247
|
+
await tick();
|
|
248
|
+
dayGrid?.querySelector<HTMLElement>(`[data-day="${focusedDay}"]`)?.focus();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Grid keyboard model (APG date picker dialog): arrows move a day / a week,
|
|
252
|
+
// Home/End to the week's start/end, PageUp/PageDown a month (Shift: a year).
|
|
253
|
+
function handleDayKeyDown(e: KeyboardEvent, day: number) {
|
|
254
|
+
const current = new Date(viewYear, viewMonth, day);
|
|
255
|
+
const next = new Date(current);
|
|
256
|
+
switch (e.key) {
|
|
257
|
+
case 'ArrowLeft':
|
|
258
|
+
next.setDate(day - 1);
|
|
259
|
+
break;
|
|
260
|
+
case 'ArrowRight':
|
|
261
|
+
next.setDate(day + 1);
|
|
262
|
+
break;
|
|
263
|
+
case 'ArrowUp':
|
|
264
|
+
next.setDate(day - 7);
|
|
265
|
+
break;
|
|
266
|
+
case 'ArrowDown':
|
|
267
|
+
next.setDate(day + 7);
|
|
268
|
+
break;
|
|
269
|
+
case 'Home':
|
|
270
|
+
next.setDate(day - current.getDay());
|
|
271
|
+
break;
|
|
272
|
+
case 'End':
|
|
273
|
+
next.setDate(day + (6 - current.getDay()));
|
|
274
|
+
break;
|
|
275
|
+
case 'PageUp':
|
|
276
|
+
case 'PageDown': {
|
|
277
|
+
const step = (e.key === 'PageUp' ? -1 : 1) * (e.shiftKey ? 12 : 1);
|
|
278
|
+
const target = new Date(viewYear, viewMonth + step, 1);
|
|
279
|
+
const lastDay = new Date(target.getFullYear(), target.getMonth() + 1, 0).getDate();
|
|
280
|
+
next.setFullYear(target.getFullYear(), target.getMonth(), Math.min(day, lastDay));
|
|
281
|
+
break;
|
|
282
|
+
}
|
|
283
|
+
default:
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
e.preventDefault();
|
|
287
|
+
focusDate(next);
|
|
288
|
+
}
|
|
289
|
+
|
|
197
290
|
// Generate time options
|
|
198
291
|
const hourOptions = $derived(
|
|
199
292
|
timeFormat === '12h'
|
|
@@ -285,6 +378,7 @@
|
|
|
285
378
|
}
|
|
286
379
|
|
|
287
380
|
function prevMonth() {
|
|
381
|
+
focusedDay = null;
|
|
288
382
|
if (viewMonth === 0) {
|
|
289
383
|
viewMonth = 11;
|
|
290
384
|
viewYear--;
|
|
@@ -294,6 +388,7 @@
|
|
|
294
388
|
}
|
|
295
389
|
|
|
296
390
|
function nextMonth() {
|
|
391
|
+
focusedDay = null;
|
|
297
392
|
if (viewMonth === 11) {
|
|
298
393
|
viewMonth = 0;
|
|
299
394
|
viewYear++;
|
|
@@ -414,6 +509,15 @@
|
|
|
414
509
|
];
|
|
415
510
|
|
|
416
511
|
const dayNames = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];
|
|
512
|
+
const fullDayNames = [
|
|
513
|
+
'Sunday',
|
|
514
|
+
'Monday',
|
|
515
|
+
'Tuesday',
|
|
516
|
+
'Wednesday',
|
|
517
|
+
'Thursday',
|
|
518
|
+
'Friday',
|
|
519
|
+
'Saturday',
|
|
520
|
+
];
|
|
417
521
|
</script>
|
|
418
522
|
|
|
419
523
|
{#snippet pickerContent()}
|
|
@@ -499,7 +603,7 @@
|
|
|
499
603
|
<path d="m15 18-6-6 6-6" />
|
|
500
604
|
</svg>
|
|
501
605
|
</button>
|
|
502
|
-
<span class="text-sm font-medium">
|
|
606
|
+
<span id="{componentId}-month" class="text-sm font-medium" aria-live="polite">
|
|
503
607
|
{monthNames[viewMonth]}
|
|
504
608
|
{viewYear}
|
|
505
609
|
</span>
|
|
@@ -525,39 +629,60 @@
|
|
|
525
629
|
</button>
|
|
526
630
|
</div>
|
|
527
631
|
|
|
528
|
-
<!-- Day
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
{#if day === null}
|
|
543
|
-
<div class="h-8 w-8"></div>
|
|
544
|
-
{:else}
|
|
545
|
-
<button
|
|
546
|
-
type="button"
|
|
547
|
-
onclick={() => selectDate(day)}
|
|
548
|
-
disabled={isDateDisabled(day)}
|
|
549
|
-
class={cn(
|
|
550
|
-
'h-8 w-8 inline-flex items-center justify-center rounded-md text-sm bg-transparent',
|
|
551
|
-
'hover:bg-accent hover:text-accent-foreground',
|
|
552
|
-
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
|
553
|
-
'disabled:pointer-events-none disabled:opacity-50',
|
|
554
|
-
isDateSelected(day) &&
|
|
555
|
-
'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground'
|
|
556
|
-
)}
|
|
632
|
+
<!-- Day grid: a grid of weeks with one roving tab stop; arrow keys move
|
|
633
|
+
between days (see handleDayKeyDown). -->
|
|
634
|
+
<div
|
|
635
|
+
bind:this={dayGrid}
|
|
636
|
+
role="grid"
|
|
637
|
+
aria-labelledby="{componentId}-month"
|
|
638
|
+
class="flex flex-col gap-1"
|
|
639
|
+
>
|
|
640
|
+
<div role="row" class="grid grid-cols-7 gap-1 mb-1">
|
|
641
|
+
{#each dayNames as dayName, i}
|
|
642
|
+
<div
|
|
643
|
+
role="columnheader"
|
|
644
|
+
aria-label={fullDayNames[i]}
|
|
645
|
+
class="h-8 w-8 flex items-center justify-center text-xs text-muted-foreground font-medium"
|
|
557
646
|
>
|
|
558
|
-
{
|
|
559
|
-
</
|
|
560
|
-
{/
|
|
647
|
+
<span aria-hidden="true">{dayName}</span>
|
|
648
|
+
</div>
|
|
649
|
+
{/each}
|
|
650
|
+
</div>
|
|
651
|
+
|
|
652
|
+
{#each calendarWeeks as week}
|
|
653
|
+
<div role="row" class="grid grid-cols-7 gap-1">
|
|
654
|
+
{#each week as day}
|
|
655
|
+
{#if day === null}
|
|
656
|
+
<div role="gridcell" class="h-8 w-8"></div>
|
|
657
|
+
{:else}
|
|
658
|
+
{@const selected = isDateSelected(day)}
|
|
659
|
+
{@const unavailable = isDateDisabled(day)}
|
|
660
|
+
<div role="gridcell" aria-selected={selected}>
|
|
661
|
+
<button
|
|
662
|
+
type="button"
|
|
663
|
+
data-day={day}
|
|
664
|
+
tabindex={day === activeDay ? 0 : -1}
|
|
665
|
+
aria-label={dayLabel(day)}
|
|
666
|
+
aria-disabled={unavailable ? 'true' : undefined}
|
|
667
|
+
aria-current={isToday(day) ? 'date' : undefined}
|
|
668
|
+
onclick={() => selectDate(day)}
|
|
669
|
+
onkeydown={(e) => handleDayKeyDown(e, day)}
|
|
670
|
+
onfocus={() => (focusedDay = day)}
|
|
671
|
+
class={cn(
|
|
672
|
+
'h-8 w-8 inline-flex items-center justify-center rounded-md text-sm bg-transparent',
|
|
673
|
+
'hover:bg-accent hover:text-accent-foreground',
|
|
674
|
+
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
|
675
|
+
'aria-disabled:cursor-not-allowed aria-disabled:opacity-50 aria-disabled:hover:bg-transparent aria-disabled:hover:text-current',
|
|
676
|
+
selected &&
|
|
677
|
+
'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground'
|
|
678
|
+
)}
|
|
679
|
+
>
|
|
680
|
+
{day}
|
|
681
|
+
</button>
|
|
682
|
+
</div>
|
|
683
|
+
{/if}
|
|
684
|
+
{/each}
|
|
685
|
+
</div>
|
|
561
686
|
{/each}
|
|
562
687
|
</div>
|
|
563
688
|
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { Dialog as DialogPrimitive } from 'bits-ui';
|
|
3
3
|
import { cn } from '../utils.js';
|
|
4
4
|
import type { Snippet } from 'svelte';
|
|
5
|
+
import TriggerChild, { type TriggerSnippetProps } from './internal/TriggerChild.svelte';
|
|
5
6
|
|
|
6
7
|
interface Props {
|
|
7
8
|
/** Whether the dialog is open */
|
|
@@ -18,8 +19,13 @@
|
|
|
18
19
|
size?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'full';
|
|
19
20
|
/** Content inside the dialog */
|
|
20
21
|
children: Snippet;
|
|
21
|
-
/**
|
|
22
|
-
|
|
22
|
+
/**
|
|
23
|
+
* Optional trigger element. It receives `{ props }`: spread them on your
|
|
24
|
+
* own button (`{#snippet trigger({ props })}<Button {...props}>Open</Button>{/snippet}`)
|
|
25
|
+
* so that button is the trigger and gets focus back on close. A snippet
|
|
26
|
+
* that ignores them still works, wrapped in a span trigger as before.
|
|
27
|
+
*/
|
|
28
|
+
trigger?: Snippet<[TriggerSnippetProps]>;
|
|
23
29
|
/** Optional footer element */
|
|
24
30
|
footer?: Snippet;
|
|
25
31
|
/** Optional custom header content (replaces title/description) */
|
|
@@ -62,6 +68,10 @@
|
|
|
62
68
|
onCloseAutoFocus,
|
|
63
69
|
}: Props = $props();
|
|
64
70
|
|
|
71
|
+
const uid = $props.id();
|
|
72
|
+
// Changes once if the trigger snippet ignores its props (see TriggerChild)
|
|
73
|
+
let triggerId = $state(`${uid}-trigger`);
|
|
74
|
+
|
|
65
75
|
function handleOpenChange(newOpen: boolean) {
|
|
66
76
|
open = newOpen;
|
|
67
77
|
onOpenChange?.(newOpen);
|
|
@@ -75,11 +85,9 @@
|
|
|
75
85
|
|
|
76
86
|
<DialogPrimitive.Root bind:open onOpenChange={handleOpenChange}>
|
|
77
87
|
{#if trigger}
|
|
78
|
-
<DialogPrimitive.Trigger>
|
|
88
|
+
<DialogPrimitive.Trigger id={triggerId}>
|
|
79
89
|
{#snippet child({ props })}
|
|
80
|
-
<
|
|
81
|
-
{@render trigger()}
|
|
82
|
-
</span>
|
|
90
|
+
<TriggerChild {props} {trigger} onfallback={() => (triggerId = `${uid}-trigger-wrap`)} />
|
|
83
91
|
{/snippet}
|
|
84
92
|
</DialogPrimitive.Trigger>
|
|
85
93
|
{/if}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Snippet } from 'svelte';
|
|
2
|
+
import { type TriggerSnippetProps } from './internal/TriggerChild.svelte';
|
|
2
3
|
interface Props {
|
|
3
4
|
/** Whether the dialog is open */
|
|
4
5
|
open?: boolean;
|
|
@@ -14,8 +15,13 @@ interface Props {
|
|
|
14
15
|
size?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'full';
|
|
15
16
|
/** Content inside the dialog */
|
|
16
17
|
children: Snippet;
|
|
17
|
-
/**
|
|
18
|
-
|
|
18
|
+
/**
|
|
19
|
+
* Optional trigger element. It receives `{ props }`: spread them on your
|
|
20
|
+
* own button (`{#snippet trigger({ props })}<Button {...props}>Open</Button>{/snippet}`)
|
|
21
|
+
* so that button is the trigger and gets focus back on close. A snippet
|
|
22
|
+
* that ignores them still works, wrapped in a span trigger as before.
|
|
23
|
+
*/
|
|
24
|
+
trigger?: Snippet<[TriggerSnippetProps]>;
|
|
19
25
|
/** Optional footer element */
|
|
20
26
|
footer?: Snippet;
|
|
21
27
|
/** Optional custom header content (replaces title/description) */
|
|
@@ -37,8 +37,11 @@
|
|
|
37
37
|
error?: string;
|
|
38
38
|
/** Callback when files are selected */
|
|
39
39
|
onFilesChange?: (files: FileMetadata[]) => void;
|
|
40
|
-
/**
|
|
41
|
-
|
|
40
|
+
/**
|
|
41
|
+
* Callback to handle file upload (optional - if not provided, files are stored locally).
|
|
42
|
+
* `signal` aborts when the file is removed mid-upload; pass it to `fetch`.
|
|
43
|
+
*/
|
|
44
|
+
onUpload?: (file: File, options: { signal: AbortSignal }) => Promise<FileMetadata>;
|
|
42
45
|
/** Label text */
|
|
43
46
|
label?: string;
|
|
44
47
|
/** Hint text shown below the drop zone */
|
|
@@ -92,6 +95,13 @@
|
|
|
92
95
|
/** Map of file IDs to their AbortControllers for cancellation */
|
|
93
96
|
const uploadControllers = new Map<string, AbortController>();
|
|
94
97
|
|
|
98
|
+
/**
|
|
99
|
+
* Slots held by batches still reading previews. `files` only grows once a
|
|
100
|
+
* preview is read, so without this two quick drops could both pass the
|
|
101
|
+
* `maxFiles` check.
|
|
102
|
+
*/
|
|
103
|
+
let reservedSlots = 0;
|
|
104
|
+
|
|
95
105
|
const displayError = $derived(externalError || internalError);
|
|
96
106
|
|
|
97
107
|
/**
|
|
@@ -274,9 +284,12 @@
|
|
|
274
284
|
|
|
275
285
|
// Generate preview for images
|
|
276
286
|
if (file.type.startsWith('image/')) {
|
|
277
|
-
|
|
287
|
+
// An unreadable file falls back to the icon instead of hanging the batch
|
|
288
|
+
metadata.url = await new Promise<string | undefined>((resolve) => {
|
|
278
289
|
const reader = new FileReader();
|
|
279
290
|
reader.onload = () => resolve(reader.result as string);
|
|
291
|
+
reader.onerror = () => resolve(undefined);
|
|
292
|
+
reader.onabort = () => resolve(undefined);
|
|
280
293
|
reader.readAsDataURL(file);
|
|
281
294
|
});
|
|
282
295
|
} else if (file.type.startsWith('video/')) {
|
|
@@ -310,23 +323,27 @@
|
|
|
310
323
|
internalError = undefined;
|
|
311
324
|
const fileArray = Array.from(selectedFiles);
|
|
312
325
|
|
|
313
|
-
// Check max files limit
|
|
314
|
-
if (files.length + fileArray.length > maxFiles) {
|
|
326
|
+
// Check max files limit, counting batches that are still being read
|
|
327
|
+
if (files.length + reservedSlots + fileArray.length > maxFiles) {
|
|
315
328
|
internalError = `Maximum ${maxFiles} files allowed`;
|
|
316
329
|
return;
|
|
317
330
|
}
|
|
318
331
|
|
|
319
332
|
// Track upload promises for waiting on completion
|
|
320
333
|
const uploadPromises: Promise<void>[] = [];
|
|
334
|
+
reservedSlots += fileArray.length;
|
|
321
335
|
|
|
322
336
|
for (const file of fileArray) {
|
|
323
337
|
const validationError = validateFile(file);
|
|
324
338
|
if (validationError) {
|
|
325
339
|
internalError = validationError;
|
|
340
|
+
reservedSlots--;
|
|
326
341
|
continue;
|
|
327
342
|
}
|
|
328
343
|
|
|
329
344
|
const metadata = await createFileMetadata(file);
|
|
345
|
+
// The slot passes from the reservation to `files`
|
|
346
|
+
reservedSlots--;
|
|
330
347
|
|
|
331
348
|
// Add file to list immediately with uploading state
|
|
332
349
|
files = [...files, metadata];
|
|
@@ -338,7 +355,7 @@
|
|
|
338
355
|
|
|
339
356
|
const uploadPromise = (async () => {
|
|
340
357
|
try {
|
|
341
|
-
const result = await onUpload(file);
|
|
358
|
+
const result = await onUpload(file, { signal: controller.signal });
|
|
342
359
|
|
|
343
360
|
// Check if upload was cancelled (file removed during upload)
|
|
344
361
|
if (controller.signal.aborted) return;
|
|
@@ -30,8 +30,13 @@ interface Props {
|
|
|
30
30
|
error?: string;
|
|
31
31
|
/** Callback when files are selected */
|
|
32
32
|
onFilesChange?: (files: FileMetadata[]) => void;
|
|
33
|
-
/**
|
|
34
|
-
|
|
33
|
+
/**
|
|
34
|
+
* Callback to handle file upload (optional - if not provided, files are stored locally).
|
|
35
|
+
* `signal` aborts when the file is removed mid-upload; pass it to `fetch`.
|
|
36
|
+
*/
|
|
37
|
+
onUpload?: (file: File, options: {
|
|
38
|
+
signal: AbortSignal;
|
|
39
|
+
}) => Promise<FileMetadata>;
|
|
35
40
|
/** Label text */
|
|
36
41
|
label?: string;
|
|
37
42
|
/** Hint text shown below the drop zone */
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { Combobox as ComboboxPrimitive } from 'bits-ui';
|
|
3
3
|
import { cn } from '../utils.js';
|
|
4
|
-
import {
|
|
4
|
+
import { computeDescribedBy } from '../utils/form.js';
|
|
5
5
|
import Spinner from './Spinner.svelte';
|
|
6
6
|
import Label from './Label.svelte';
|
|
7
7
|
import LiveRegion from './LiveRegion.svelte';
|
|
@@ -69,9 +69,9 @@
|
|
|
69
69
|
class: className,
|
|
70
70
|
}: Props = $props();
|
|
71
71
|
|
|
72
|
-
// Generate unique IDs for accessibility
|
|
73
|
-
const
|
|
74
|
-
const componentId = $derived(id || `multiselect-${
|
|
72
|
+
// Generate unique IDs for accessibility ($props.id() is SSR-stable)
|
|
73
|
+
const uid = $props.id();
|
|
74
|
+
const componentId = $derived(id || `multiselect-${uid}`);
|
|
75
75
|
const hintId = $derived(`${componentId}-hint`);
|
|
76
76
|
const errorId = $derived(`${componentId}-error`);
|
|
77
77
|
|
|
@@ -137,9 +137,6 @@
|
|
|
137
137
|
};
|
|
138
138
|
});
|
|
139
139
|
|
|
140
|
-
// Determine if we need a container wrapper
|
|
141
|
-
const hasWrapper = $derived(!!label || !!hint || !!error);
|
|
142
|
-
|
|
143
140
|
// Generate announcement message for screen readers
|
|
144
141
|
const resultsAnnouncement = $derived.by(() => {
|
|
145
142
|
if (loading) return 'Loading options...';
|
|
@@ -158,155 +155,19 @@
|
|
|
158
155
|
});
|
|
159
156
|
</script>
|
|
160
157
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
</Label>
|
|
171
|
-
{/if}
|
|
172
|
-
|
|
173
|
-
<div class={cn('w-full', className)}>
|
|
174
|
-
<!-- Selected tags -->
|
|
175
|
-
{#if selectedLabels.length > 0}
|
|
176
|
-
<div class="flex flex-wrap gap-1 mb-2">
|
|
177
|
-
{#each value as selectedValue}
|
|
178
|
-
{@const labelText = options.find((o) => o.value === selectedValue)?.label}
|
|
179
|
-
{#if labelText}
|
|
180
|
-
<span
|
|
181
|
-
class="inline-flex items-center gap-1 rounded-md bg-muted px-2 py-1 text-xs font-medium text-foreground"
|
|
182
|
-
>
|
|
183
|
-
{labelText}
|
|
184
|
-
<button
|
|
185
|
-
type="button"
|
|
186
|
-
onclick={() => removeValue(selectedValue)}
|
|
187
|
-
class="rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none"
|
|
188
|
-
{disabled}
|
|
189
|
-
aria-label={`Remove ${labelText}`}
|
|
190
|
-
>
|
|
191
|
-
<svg
|
|
192
|
-
xmlns="http://www.w3.org/2000/svg"
|
|
193
|
-
width="14"
|
|
194
|
-
height="14"
|
|
195
|
-
viewBox="0 0 24 24"
|
|
196
|
-
fill="none"
|
|
197
|
-
stroke="currentColor"
|
|
198
|
-
stroke-width="2"
|
|
199
|
-
stroke-linecap="round"
|
|
200
|
-
stroke-linejoin="round"
|
|
201
|
-
>
|
|
202
|
-
<path d="M18 6 6 18" />
|
|
203
|
-
<path d="m6 6 12 12" />
|
|
204
|
-
</svg>
|
|
205
|
-
</button>
|
|
206
|
-
</span>
|
|
207
|
-
{/if}
|
|
208
|
-
{/each}
|
|
209
|
-
</div>
|
|
158
|
+
<!-- One structure whether or not there is a label, hint or error, so the
|
|
159
|
+
focused control is never remounted when an error appears. -->
|
|
160
|
+
<div class="space-y-2">
|
|
161
|
+
{#if label}
|
|
162
|
+
<Label for={componentId} {disabled}>
|
|
163
|
+
{label}
|
|
164
|
+
{#if required}
|
|
165
|
+
<span class="text-destructive ml-0.5" aria-hidden="true">*</span>
|
|
166
|
+
<span class="sr-only">(required)</span>
|
|
210
167
|
{/if}
|
|
168
|
+
</Label>
|
|
169
|
+
{/if}
|
|
211
170
|
|
|
212
|
-
<ComboboxPrimitive.Root
|
|
213
|
-
type="multiple"
|
|
214
|
-
{disabled}
|
|
215
|
-
{required}
|
|
216
|
-
{name}
|
|
217
|
-
bind:open
|
|
218
|
-
onOpenChange={handleOpenChange}
|
|
219
|
-
onValueChange={handleValueChange}
|
|
220
|
-
{value}
|
|
221
|
-
>
|
|
222
|
-
<div class="relative">
|
|
223
|
-
<ComboboxPrimitive.Input
|
|
224
|
-
id={componentId}
|
|
225
|
-
placeholder={value.length > 0 ? `${value.length} selected` : placeholder}
|
|
226
|
-
oninput={handleSearchInput}
|
|
227
|
-
onfocus={() => (open = true)}
|
|
228
|
-
onclick={() => (open = true)}
|
|
229
|
-
class={cn(
|
|
230
|
-
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background text-foreground px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
|
231
|
-
error && 'border-destructive focus:ring-destructive'
|
|
232
|
-
)}
|
|
233
|
-
aria-invalid={error ? 'true' : undefined}
|
|
234
|
-
aria-describedby={describedBy}
|
|
235
|
-
/>
|
|
236
|
-
</div>
|
|
237
|
-
|
|
238
|
-
<!-- Portal to <body>: escapes transformed/overflow-hidden ancestors
|
|
239
|
-
(e.g. Dialog panels) that would clip the dropdown. -->
|
|
240
|
-
<ComboboxPrimitive.Portal>
|
|
241
|
-
<ComboboxPrimitive.Content
|
|
242
|
-
class="isolate relative z-50 min-w-[var(--bits-floating-anchor-width)] overflow-hidden rounded-md border border-border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2"
|
|
243
|
-
sideOffset={4}
|
|
244
|
-
collisionPadding={8}
|
|
245
|
-
avoidCollisions={true}
|
|
246
|
-
>
|
|
247
|
-
<div class="p-1 max-h-72 overflow-y-auto">
|
|
248
|
-
{#if loading}
|
|
249
|
-
<div class="flex items-center justify-center py-6">
|
|
250
|
-
<Spinner size="sm" />
|
|
251
|
-
<span class="ml-2 text-sm text-muted-foreground">Loading...</span>
|
|
252
|
-
</div>
|
|
253
|
-
{:else if filteredOptions.length === 0}
|
|
254
|
-
<div class="py-6 text-center text-sm text-muted-foreground">
|
|
255
|
-
{emptyMessage}
|
|
256
|
-
</div>
|
|
257
|
-
{:else}
|
|
258
|
-
{#each filteredOptions as option}
|
|
259
|
-
{@const isSelected = value.includes(option.value)}
|
|
260
|
-
{@const isDisabledByMax = maxReached && !isSelected}
|
|
261
|
-
<ComboboxPrimitive.Item
|
|
262
|
-
value={option.value}
|
|
263
|
-
label={option.label}
|
|
264
|
-
disabled={option.disabled || isDisabledByMax}
|
|
265
|
-
class="relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-default"
|
|
266
|
-
>
|
|
267
|
-
<span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
|
268
|
-
{#if isSelected}
|
|
269
|
-
<svg
|
|
270
|
-
xmlns="http://www.w3.org/2000/svg"
|
|
271
|
-
width="16"
|
|
272
|
-
height="16"
|
|
273
|
-
viewBox="0 0 24 24"
|
|
274
|
-
fill="none"
|
|
275
|
-
stroke="currentColor"
|
|
276
|
-
stroke-width="2"
|
|
277
|
-
stroke-linecap="round"
|
|
278
|
-
stroke-linejoin="round"
|
|
279
|
-
class="h-4 w-4"
|
|
280
|
-
>
|
|
281
|
-
<polyline points="20 6 9 17 4 12" />
|
|
282
|
-
</svg>
|
|
283
|
-
{:else}
|
|
284
|
-
<span class="h-3.5 w-3.5 rounded-sm border border-primary"></span>
|
|
285
|
-
{/if}
|
|
286
|
-
</span>
|
|
287
|
-
{option.label}
|
|
288
|
-
</ComboboxPrimitive.Item>
|
|
289
|
-
{/each}
|
|
290
|
-
{/if}
|
|
291
|
-
</div>
|
|
292
|
-
</ComboboxPrimitive.Content>
|
|
293
|
-
</ComboboxPrimitive.Portal>
|
|
294
|
-
</ComboboxPrimitive.Root>
|
|
295
|
-
</div>
|
|
296
|
-
|
|
297
|
-
{#if hint && !error}
|
|
298
|
-
<p id={hintId} class="text-sm text-muted-foreground">
|
|
299
|
-
{hint}
|
|
300
|
-
</p>
|
|
301
|
-
{/if}
|
|
302
|
-
|
|
303
|
-
{#if error}
|
|
304
|
-
<p id={errorId} class="text-sm text-destructive" role="alert" aria-live="polite">
|
|
305
|
-
{error}
|
|
306
|
-
</p>
|
|
307
|
-
{/if}
|
|
308
|
-
</div>
|
|
309
|
-
{:else}
|
|
310
171
|
<div class={cn('w-full', className)}>
|
|
311
172
|
<!-- Selected tags -->
|
|
312
173
|
{#if selectedLabels.length > 0}
|
|
@@ -430,7 +291,19 @@
|
|
|
430
291
|
</ComboboxPrimitive.Portal>
|
|
431
292
|
</ComboboxPrimitive.Root>
|
|
432
293
|
</div>
|
|
433
|
-
|
|
294
|
+
|
|
295
|
+
{#if hint && !error}
|
|
296
|
+
<p id={hintId} class="text-sm text-muted-foreground">
|
|
297
|
+
{hint}
|
|
298
|
+
</p>
|
|
299
|
+
{/if}
|
|
300
|
+
|
|
301
|
+
{#if error}
|
|
302
|
+
<p id={errorId} class="text-sm text-destructive" role="alert" aria-live="polite">
|
|
303
|
+
{error}
|
|
304
|
+
</p>
|
|
305
|
+
{/if}
|
|
306
|
+
</div>
|
|
434
307
|
|
|
435
308
|
<!-- Live region for screen reader announcements -->
|
|
436
309
|
<LiveRegion message={resultsAnnouncement} politeness="polite" />
|