@classic-homes/theme-svelte 1.22.1 → 1.22.2

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 (37) hide show
  1. package/dist/lib/components/Combobox.svelte +2 -2
  2. package/dist/lib/components/DataTable.svelte +15 -6
  3. package/dist/lib/components/DataTable.svelte.d.ts +1 -1
  4. package/dist/lib/components/DateTimePicker.svelte +31 -34
  5. package/dist/lib/components/DropdownMenu.svelte +52 -30
  6. package/dist/lib/components/DropdownMenu.svelte.d.ts +5 -0
  7. package/dist/lib/components/FileUpload.svelte +5 -19
  8. package/dist/lib/components/MultiSelect.svelte +2 -2
  9. package/dist/lib/components/NumberInput.svelte +15 -2
  10. package/dist/lib/components/Select.svelte +19 -5
  11. package/dist/lib/components/Select.svelte.d.ts +4 -0
  12. package/dist/lib/components/Signature.svelte +15 -4
  13. package/dist/lib/components/Signature.svelte.d.ts +1 -1
  14. package/dist/lib/components/Slider.svelte +8 -6
  15. package/dist/lib/components/Tabs.svelte +10 -12
  16. package/dist/lib/components/Toast.svelte +12 -12
  17. package/dist/lib/components/data-table/DataTableColumnVisibility.svelte +24 -26
  18. package/dist/lib/components/data-table/DataTableEditableCell.svelte +31 -18
  19. package/dist/lib/components/data-table/DataTableEditableCell.svelte.d.ts +0 -7
  20. package/dist/lib/components/data-table/DataTablePagination.svelte +5 -1
  21. package/dist/lib/components/data-table/DataTableVirtual.svelte +4 -7
  22. package/dist/lib/components/data-table/DataTableVirtual.svelte.d.ts +3 -0
  23. package/dist/lib/components/data-table/createDataTable.svelte.d.ts +6 -1
  24. package/dist/lib/components/data-table/createDataTable.svelte.js +22 -9
  25. package/dist/lib/components/layout/Sidebar.svelte +28 -0
  26. package/dist/lib/composables/useAsync.svelte.d.ts +1 -1
  27. package/dist/lib/composables/useAsync.svelte.js +33 -14
  28. package/dist/lib/composables/useForm.svelte.js +19 -7
  29. package/dist/lib/composables/usePersistedForm.svelte.js +37 -10
  30. package/dist/lib/stores/sidebar.svelte.d.ts +6 -0
  31. package/dist/lib/stores/sidebar.svelte.js +15 -0
  32. package/dist/lib/stores/toast.svelte.js +12 -1
  33. package/dist/lib/utils/date.d.ts +3 -1
  34. package/dist/lib/utils/date.js +54 -34
  35. package/dist/lib/utils/form.d.ts +2 -1
  36. package/dist/lib/utils/form.js +3 -2
  37. package/package.json +1 -1
@@ -79,23 +79,21 @@
79
79
  updateScrollShadows();
80
80
  });
81
81
 
82
- // Create a reactive object for context
83
- // Initialize with empty values - the effect will set them immediately
84
- const tabsState = $state({ activeTab: '', tabsId: '' });
85
-
86
- // Keep context state in sync with props (runs immediately and on changes)
87
- $effect(() => {
88
- tabsState.activeTab = value;
89
- tabsState.tabsId = id;
82
+ // Context for TabPanel children. Getters read the props directly, so the
83
+ // active panel is right on the first render and during SSR (an $effect
84
+ // would only fill it in after hydration).
85
+ setContext('tabs', {
86
+ get activeTab() {
87
+ return value;
88
+ },
89
+ get tabsId() {
90
+ return id;
91
+ },
90
92
  });
91
93
 
92
- // Set up context for TabPanel children (done once at component init)
93
- setContext('tabs', tabsState);
94
-
95
94
  function selectTab(tabId: string) {
96
95
  if (tabs.find((t) => t.id === tabId && t.disabled)) return;
97
96
  value = tabId;
98
- tabsState.activeTab = tabId;
99
97
  onValueChange?.(tabId);
100
98
  }
101
99
 
@@ -6,17 +6,17 @@
6
6
 
7
7
  type ToastType = 'success' | 'error' | 'warning' | 'info';
8
8
 
9
+ // Status tokens (light/dark aware) over an opaque card surface: the tint is a
10
+ // ::before layer so the toast never shows the page through it, and text stays
11
+ // foreground-on-card for contrast in both modes.
9
12
  const toastVariants = tv({
10
- base: 'group pointer-events-auto relative flex w-full items-start gap-3 overflow-hidden rounded-lg border p-4 shadow-lg transition-all',
13
+ base: "group pointer-events-auto relative isolate flex w-full items-start gap-3 overflow-hidden rounded-lg border bg-card p-4 text-card-foreground shadow-lg transition-all before:absolute before:inset-0 before:-z-10 before:content-['']",
11
14
  variants: {
12
15
  type: {
13
- success:
14
- 'bg-green-50 text-green-800 border-green-200 dark:bg-green-900/20 dark:text-green-400 dark:border-green-800',
15
- error:
16
- 'bg-red-50 text-red-800 border-red-200 dark:bg-red-900/20 dark:text-red-400 dark:border-red-800',
17
- warning:
18
- 'bg-yellow-50 text-yellow-800 border-yellow-200 dark:bg-yellow-900/20 dark:text-yellow-400 dark:border-yellow-800',
19
- info: 'bg-blue-50 text-blue-800 border-blue-200 dark:bg-blue-900/20 dark:text-blue-400 dark:border-blue-800',
16
+ success: 'border-success/40 before:bg-success/10',
17
+ error: 'border-destructive/40 before:bg-destructive/10',
18
+ warning: 'border-warning/40 before:bg-warning/10',
19
+ info: 'border-info/40 before:bg-info/10',
20
20
  },
21
21
  },
22
22
  defaultVariants: {
@@ -36,10 +36,10 @@
36
36
  };
37
37
 
38
38
  const iconColors: Record<ToastType, string> = {
39
- success: 'text-green-500 dark:text-green-400',
40
- error: 'text-red-500 dark:text-red-400',
41
- warning: 'text-yellow-500 dark:text-yellow-400',
42
- info: 'text-blue-500 dark:text-blue-400',
39
+ success: 'text-success',
40
+ error: 'text-destructive',
41
+ warning: 'text-warning',
42
+ info: 'text-info',
43
43
  };
44
44
 
45
45
  interface Props {
@@ -7,7 +7,6 @@
7
7
  */
8
8
  import type { DataTableInstance } from './createDataTable.svelte.js';
9
9
  import DropdownMenu from '../DropdownMenu.svelte';
10
- import Button from '../Button.svelte';
11
10
  import { cn } from '../../utils.js';
12
11
 
13
12
  interface Props {
@@ -19,13 +18,13 @@
19
18
 
20
19
  let { table, class: className }: Props = $props();
21
20
 
22
- // Build menu items with checkmarks for visible columns
21
+ // One checkbox item per column, checked while the column is visible
23
22
  const menuItems = $derived.by(() => {
24
23
  return table.allColumns.map((column) => {
25
- const isVisible = !table.hiddenColumns.has(column.id);
26
24
  return {
27
25
  type: 'item' as const,
28
- label: `${isVisible ? '✓ ' : ' '}${column.header}`,
26
+ label: column.header,
27
+ checked: !table.hiddenColumns.has(column.id),
29
28
  onSelect: () => {
30
29
  table.toggleColumnVisibility(column.id);
31
30
  },
@@ -35,32 +34,31 @@
35
34
  </script>
36
35
 
37
36
  <div class={cn('relative', className)}>
37
+ <!-- DropdownMenu renders the trigger <button>; style it via triggerVariant
38
+ rather than nesting a Button inside it. -->
38
39
  <DropdownMenu
39
40
  items={[{ type: 'label', label: 'Toggle columns' }, { type: 'separator' }, ...menuItems]}
41
+ triggerVariant="outline"
42
+ triggerSize="sm"
43
+ triggerClass="ml-auto"
40
44
  >
41
45
  {#snippet trigger()}
42
- <Button variant="outline" size="sm" class="ml-auto" aria-label="Toggle column visibility">
43
- <svg
44
- class="mr-2 h-4 w-4"
45
- fill="none"
46
- viewBox="0 0 24 24"
47
- stroke="currentColor"
48
- stroke-width="2"
49
- aria-hidden="true"
50
- >
51
- <path
52
- stroke-linecap="round"
53
- stroke-linejoin="round"
54
- d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
55
- />
56
- <path
57
- stroke-linecap="round"
58
- stroke-linejoin="round"
59
- d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
60
- />
61
- </svg>
62
- Columns
63
- </Button>
46
+ <svg
47
+ class="mr-2 h-4 w-4"
48
+ fill="none"
49
+ viewBox="0 0 24 24"
50
+ stroke="currentColor"
51
+ stroke-width="2"
52
+ aria-hidden="true"
53
+ >
54
+ <path
55
+ stroke-linecap="round"
56
+ stroke-linejoin="round"
57
+ d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
58
+ />
59
+ <path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
60
+ </svg>
61
+ Columns
64
62
  {/snippet}
65
63
  </DropdownMenu>
66
64
  </div>
@@ -6,6 +6,7 @@
6
6
  * Supports text, number, and select inputs.
7
7
  * Works with the native Svelte 5 DataTable implementation.
8
8
  */
9
+ import { tick } from 'svelte';
9
10
  import type { DataTableColumn } from './types.js';
10
11
  import type { DataTableRow } from './createDataTable.svelte.js';
11
12
  import { cn } from '../../utils.js';
@@ -29,6 +30,7 @@
29
30
 
30
31
  let isEditing = $state(false);
31
32
  let editValue = $state<string>('');
33
+ let buttonEl = $state<HTMLButtonElement | null>(null);
32
34
 
33
35
  // Get the current display value
34
36
  const displayValue = $derived.by(() => {
@@ -70,13 +72,21 @@
70
72
  editValue = '';
71
73
  }
72
74
 
75
+ // After a keyboard save/cancel the input is gone; return focus to the cell.
76
+ async function refocusCell() {
77
+ await tick();
78
+ buttonEl?.focus();
79
+ }
80
+
73
81
  function handleKeydown(e: KeyboardEvent) {
74
82
  if (e.key === 'Enter') {
75
83
  e.preventDefault();
76
84
  saveEdit();
85
+ refocusCell();
77
86
  } else if (e.key === 'Escape') {
78
87
  e.preventDefault();
79
88
  cancelEdit();
89
+ refocusCell();
80
90
  } else if (e.key === 'Tab') {
81
91
  saveEdit();
82
92
  }
@@ -91,29 +101,32 @@
91
101
  }
92
102
  </script>
93
103
 
94
- {#if isEditable}
104
+ {#if isEditable && isEditing}
105
+ <!-- The input sits beside, not inside, the button: an input in a button is
106
+ invalid, and its Enter would bubble to the button and reopen the editor. -->
107
+ <div class={cn('relative w-full', className)}>
108
+ <input
109
+ use:autofocus
110
+ type="text"
111
+ value={editValue}
112
+ oninput={(e: Event) => (editValue = (e.target as HTMLInputElement).value)}
113
+ onkeydown={handleKeydown}
114
+ onblur={handleBlur}
115
+ class="h-7 w-full rounded border border-input bg-background px-2 text-base sm:text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
116
+ />
117
+ </div>
118
+ {:else if isEditable}
95
119
  <button
120
+ bind:this={buttonEl}
96
121
  type="button"
97
122
  class={cn('relative w-full text-left hover:bg-muted-hover', className)}
98
123
  ondblclick={handleDoubleClick}
99
- onkeydown={(e: KeyboardEvent) => !isEditing && e.key === 'Enter' && startEditing()}
124
+ onkeydown={(e: KeyboardEvent) => e.key === 'Enter' && startEditing()}
100
125
  >
101
- {#if isEditing}
102
- <input
103
- use:autofocus
104
- type="text"
105
- value={editValue}
106
- oninput={(e: Event) => (editValue = (e.target as HTMLInputElement).value)}
107
- onkeydown={handleKeydown}
108
- onblur={handleBlur}
109
- class="h-7 w-full rounded border border-input bg-background px-2 text-base sm:text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
110
- />
111
- {:else}
112
- <span class="border-b border-dashed border-transparent hover:border-primary">
113
- {displayValue}
114
- </span>
115
- <span class="sr-only">(double-click to edit)</span>
116
- {/if}
126
+ <span class="border-b border-dashed border-transparent hover:border-primary">
127
+ {displayValue}
128
+ </span>
129
+ <span class="sr-only">(double-click to edit)</span>
117
130
  </button>
118
131
  {:else}
119
132
  <span class={cn('relative', className)}>
@@ -1,10 +1,3 @@
1
- /**
2
- * DataTableEditableCell - Inline editable cell component
3
- *
4
- * Allows users to edit cell values in place.
5
- * Supports text, number, and select inputs.
6
- * Works with the native Svelte 5 DataTable implementation.
7
- */
8
1
  import type { DataTableColumn } from './types.js';
9
2
  import type { DataTableRow } from './createDataTable.svelte.js';
10
3
  declare function $$render<T extends object>(): {
@@ -28,6 +28,9 @@
28
28
  class: className,
29
29
  }: Props = $props();
30
30
 
31
+ const uid = $props.id();
32
+ const pageSizeLabelId = `datatable-page-size-${uid}`;
33
+
31
34
  function handlePageSizeChange(value: string) {
32
35
  table.setPageSize(Number(value));
33
36
  }
@@ -45,8 +48,9 @@
45
48
  <div class="flex flex-wrap items-center gap-x-4 gap-y-2 sm:gap-x-6 lg:gap-x-8">
46
49
  <!-- Page size selector -->
47
50
  <div class="flex items-center gap-2">
48
- <span class="text-sm text-muted-foreground">Rows per page</span>
51
+ <span id={pageSizeLabelId} class="text-sm text-muted-foreground">Rows per page</span>
49
52
  <Select
53
+ aria-labelledby={pageSizeLabelId}
50
54
  value={String(table.pageSize)}
51
55
  options={pageSizeOptions.map((size) => ({
52
56
  value: String(size),
@@ -14,6 +14,9 @@
14
14
  * ```svelte
15
15
  * <DataTableVirtual {table} height="600px" rowHeight={48} />
16
16
  * ```
17
+ *
18
+ * Create the table with `enablePagination: false` so `table.rows` holds every
19
+ * row rather than one page.
17
20
  */
18
21
  import type { DataTableInstance, DataTableRow } from './createDataTable.svelte.js';
19
22
  import type { DataTableColumn } from './types.js';
@@ -96,12 +99,6 @@
96
99
  }
97
100
  }
98
101
 
99
- // Handle row selection
100
- function handleSelectRow(rowId: string, e: MouseEvent) {
101
- e.stopPropagation();
102
- table.toggleRowSelection(rowId);
103
- }
104
-
105
102
  // Handle header click for sorting
106
103
  function handleHeaderClick(columnId: string, sortable: boolean | undefined) {
107
104
  if (!sortable) return;
@@ -184,7 +181,7 @@
184
181
  checked={row.isSelected}
185
182
  onCheckedChange={() => table.toggleRowSelection(row.id)}
186
183
  aria-label={`Select row ${index + 1}`}
187
- onclick={(e: MouseEvent) => handleSelectRow(row.id, e)}
184
+ onclick={(e: MouseEvent) => e.stopPropagation()}
188
185
  />
189
186
  </div>
190
187
  {/if}
@@ -13,6 +13,9 @@
13
13
  * ```svelte
14
14
  * <DataTableVirtual {table} height="600px" rowHeight={48} />
15
15
  * ```
16
+ *
17
+ * Create the table with `enablePagination: false` so `table.rows` holds every
18
+ * row rather than one page.
16
19
  */
17
20
  import type { DataTableInstance } from './createDataTable.svelte.js';
18
21
  declare function $$render<T extends object>(): {
@@ -32,7 +32,7 @@ export interface DataTableOptions<T> {
32
32
  data: T[];
33
33
  /** Column definitions */
34
34
  columns: DataTableColumn<T>[];
35
- /** Get unique row ID (defaults to index) */
35
+ /** Get unique row ID (defaults to the row's index in `data`) */
36
36
  getRowId?: (row: T, index: number) => string;
37
37
  /** Initial sort column ID */
38
38
  initialSortColumn?: string;
@@ -54,6 +54,11 @@ export interface DataTableOptions<T> {
54
54
  manualFiltering?: boolean;
55
55
  /** Use server-side pagination (skip client-side pagination) */
56
56
  manualPagination?: boolean;
57
+ /**
58
+ * Slice rows into pages (default true). Set false to get every row in
59
+ * `rows`, e.g. for a DataTable without a pager or for DataTableVirtual.
60
+ */
61
+ enablePagination?: boolean;
57
62
  /** Total page count (required for manual pagination) */
58
63
  pageCount?: number;
59
64
  /** Callback when sort changes */
@@ -162,16 +162,26 @@ export function createDataTable(optionsFn) {
162
162
  : Math.ceil(totalRows / pageSize) || 1);
163
163
  // Clamp pageIndex when data changes
164
164
  const clampedPageIndex = $derived(Math.min(Math.max(0, pageIndex), Math.max(0, totalPages - 1)));
165
- const paginatedData = $derived(options.manualPagination
165
+ const paginatedData = $derived(options.manualPagination || options.enablePagination === false
166
166
  ? sortedData // In manual mode, data is already paginated from server
167
167
  : sortedData.slice(clampedPageIndex * pageSize, (clampedPageIndex + 1) * pageSize));
168
168
  // === ROW HELPERS ===
169
- const rows = $derived(paginatedData.map((row, index) => {
170
- const globalIndex = options.manualPagination ? index : clampedPageIndex * pageSize + index;
171
- const id = getRowId(row, globalIndex);
169
+ // Index of each row in the source data, so row ids (and with them the
170
+ // selection) follow the row rather than its position after sort/filter/page.
171
+ const sourceIndex = $derived.by(() => {
172
+ const map = new Map();
173
+ data.forEach((row, i) => {
174
+ if (!map.has(row))
175
+ map.set(row, i);
176
+ });
177
+ return map;
178
+ });
179
+ const rows = $derived(paginatedData.map((row) => {
180
+ const index = sourceIndex.get(row) ?? -1;
181
+ const id = getRowId(row, index);
172
182
  return {
173
183
  id,
174
- index: globalIndex,
184
+ index,
175
185
  original: row,
176
186
  isSelected: selectedRows.has(id),
177
187
  };
@@ -248,14 +258,17 @@ export function createDataTable(optionsFn) {
248
258
  }
249
259
  function nextPage() {
250
260
  if (clampedPageIndex < totalPages - 1) {
251
- pageIndex = clampedPageIndex + 1;
252
- options.onPaginationChange?.({ pageIndex: clampedPageIndex + 1, pageSize });
261
+ // Read before assigning: clampedPageIndex recomputes from pageIndex.
262
+ const next = clampedPageIndex + 1;
263
+ pageIndex = next;
264
+ options.onPaginationChange?.({ pageIndex: next, pageSize });
253
265
  }
254
266
  }
255
267
  function previousPage() {
256
268
  if (clampedPageIndex > 0) {
257
- pageIndex = clampedPageIndex - 1;
258
- options.onPaginationChange?.({ pageIndex: clampedPageIndex - 1, pageSize });
269
+ const previous = clampedPageIndex - 1;
270
+ pageIndex = previous;
271
+ options.onPaginationChange?.({ pageIndex: previous, pageSize });
259
272
  }
260
273
  }
261
274
  function firstPage() {
@@ -323,6 +323,34 @@
323
323
  sidebarStore.setDefaultItems(defaultExpanded);
324
324
  });
325
325
 
326
+ // Keep the active item visible: open the section and parent items holding
327
+ // it when it changes. Declared after the defaults effects so it runs after
328
+ // them: while nothing is saved, every new navigation re-applies the
329
+ // defaults, which would close it again. Once the user has toggled (saved),
330
+ // a section they closed stays closed until the active item moves.
331
+ let revealedActiveId: string | null = null;
332
+ $effect(() => {
333
+ type ActivePath = { id: string; parents: string[] };
334
+ const findActive = (items: NavItem[], parents: string[]): ActivePath | null => {
335
+ for (const item of items) {
336
+ if (item.active) return { id: item.id, parents };
337
+ const found = item.children && findActive(item.children, [...parents, item.id]);
338
+ if (found) return found;
339
+ }
340
+ return null;
341
+ };
342
+ for (const section of roleFilteredNavigation) {
343
+ const active = findActive(section.items, []);
344
+ if (!active) continue;
345
+ if (active.id !== revealedActiveId || !sidebarStore.hasPersistedExpansionState) {
346
+ sidebarStore.revealExpansion([section.id], active.parents);
347
+ }
348
+ revealedActiveId = active.id;
349
+ return;
350
+ }
351
+ revealedActiveId = null;
352
+ });
353
+
326
354
  // Flyout state for collapsed sidebar
327
355
  let hoveredFlyoutItem = $state<NavItem | null>(null);
328
356
  let flyoutTop = $state(0);
@@ -6,7 +6,7 @@
6
6
  * - Error handling with toast integration
7
7
  * - Success/error callbacks
8
8
  * - Retry functionality
9
- * - Abort/cancel support
9
+ * - Overlapping calls: only the latest call (and none after reset) updates state
10
10
  * - Debounce support
11
11
  *
12
12
  * @example
@@ -6,7 +6,7 @@
6
6
  * - Error handling with toast integration
7
7
  * - Success/error callbacks
8
8
  * - Retry functionality
9
- * - Abort/cancel support
9
+ * - Overlapping calls: only the latest call (and none after reset) updates state
10
10
  * - Debounce support
11
11
  *
12
12
  * @example
@@ -51,21 +51,35 @@ export function useAsync(options) {
51
51
  // Derived state
52
52
  const isSuccess = $derived(isExecuted && !error && !isLoading);
53
53
  const isError = $derived(isExecuted && !!error);
54
- // Debounce timer
54
+ // Debounce timer, and the resolver of the call it is holding back
55
55
  let debounceTimer;
56
+ let pendingResolve;
57
+ // Id of the latest call; results from older calls are ignored
58
+ let callId = 0;
56
59
  /**
57
- * Execute the async function
60
+ * Cancel a pending debounced call, settling its promise with undefined
58
61
  */
59
- async function execute(...args) {
60
- // Clear any pending debounce
62
+ function cancelPending() {
61
63
  if (debounceTimer) {
62
64
  clearTimeout(debounceTimer);
63
65
  debounceTimer = undefined;
64
66
  }
67
+ pendingResolve?.(undefined);
68
+ pendingResolve = undefined;
69
+ }
70
+ /**
71
+ * Execute the async function
72
+ */
73
+ async function execute(...args) {
74
+ // Clear any pending debounce
75
+ cancelPending();
65
76
  // If debounce is set, delay execution
66
77
  if (debounceMs && debounceMs > 0) {
67
78
  return new Promise((resolve) => {
79
+ pendingResolve = resolve;
68
80
  debounceTimer = setTimeout(async () => {
81
+ debounceTimer = undefined;
82
+ pendingResolve = undefined;
69
83
  const result = await executeImmediate(...args);
70
84
  resolve(result);
71
85
  }, debounceMs);
@@ -77,12 +91,16 @@ export function useAsync(options) {
77
91
  * Execute immediately without debounce
78
92
  */
79
93
  async function executeImmediate(...args) {
94
+ const id = ++callId;
80
95
  lastArgs = args;
81
96
  isLoading = true;
82
97
  error = undefined;
83
98
  isExecuted = true;
84
99
  try {
85
100
  const result = await fn(...args);
101
+ // A newer call (or reset) superseded this one: leave state alone
102
+ if (id !== callId)
103
+ return result;
86
104
  data = result;
87
105
  if (onSuccess) {
88
106
  onSuccess(result);
@@ -93,6 +111,8 @@ export function useAsync(options) {
93
111
  return result;
94
112
  }
95
113
  catch (err) {
114
+ if (id !== callId)
115
+ return undefined;
96
116
  const normalizedError = err instanceof Error ? err : new Error(String(err));
97
117
  error = normalizedError;
98
118
  if (onError) {
@@ -104,7 +124,8 @@ export function useAsync(options) {
104
124
  return undefined;
105
125
  }
106
126
  finally {
107
- isLoading = false;
127
+ if (id === callId)
128
+ isLoading = false;
108
129
  }
109
130
  }
110
131
  /**
@@ -121,11 +142,9 @@ export function useAsync(options) {
121
142
  * Reset state to initial values
122
143
  */
123
144
  function reset() {
124
- // Clear any pending debounce
125
- if (debounceTimer) {
126
- clearTimeout(debounceTimer);
127
- debounceTimer = undefined;
128
- }
145
+ // Clear any pending debounce, and drop the result of any call in flight
146
+ cancelPending();
147
+ callId++;
129
148
  data = initialData;
130
149
  error = undefined;
131
150
  isLoading = false;
@@ -153,11 +172,11 @@ export function useAsync(options) {
153
172
  function clearError() {
154
173
  error = undefined;
155
174
  }
156
- // Execute immediately if requested
157
- if (immediate && immediateArgs) {
175
+ // Execute immediately if requested (zero-argument functions need no immediateArgs)
176
+ if (immediate) {
158
177
  // Use $effect-like behavior by queueing the execution
159
178
  queueMicrotask(() => {
160
- execute(...immediateArgs);
179
+ execute(...(immediateArgs ?? []));
161
180
  });
162
181
  }
163
182
  return {
@@ -46,8 +46,9 @@ import { toastStore } from '../stores/toast.svelte.js';
46
46
  */
47
47
  export function useForm(options) {
48
48
  const { schema, initialValues, onSubmit, onError, showToastOnError = true, resetOnSuccess = false, errorMessage, successMessage, } = options;
49
- // Deep clone to avoid reference issues
50
- const cloneInitialValues = () => JSON.parse(JSON.stringify(initialValues));
49
+ // Deep clone to avoid reference issues. $state.snapshot keeps Date, Map, Set
50
+ // and File values (a JSON round-trip would turn a Date into a string).
51
+ const cloneInitialValues = () => $state.snapshot(initialValues);
51
52
  // Reactive state using Svelte 5 runes
52
53
  let data = $state(cloneInitialValues());
53
54
  let errors = $state({});
@@ -145,14 +146,20 @@ export function useForm(options) {
145
146
  * Validate the entire form
146
147
  */
147
148
  function validate() {
149
+ return parseForm() !== null;
150
+ }
151
+ /**
152
+ * Validate the entire form, returning the parsed (transformed) data or null
153
+ */
154
+ function parseForm() {
148
155
  const result = schema.safeParse(data);
149
156
  if (result.success) {
150
157
  errors = {};
151
- return true;
158
+ return result.data;
152
159
  }
153
160
  else {
154
161
  errors = parseZodErrors(result.error);
155
- return false;
162
+ return null;
156
163
  }
157
164
  }
158
165
  /**
@@ -160,12 +167,17 @@ export function useForm(options) {
160
167
  */
161
168
  async function handleSubmit(event) {
162
169
  event?.preventDefault();
170
+ // Ignore a second submit (e.g. a double click) while one is in flight
171
+ if (isSubmitting)
172
+ return;
163
173
  // Clear previous errors
164
174
  errors = {};
165
175
  globalError = '';
166
176
  isSubmitted = true;
167
- // Validate all fields
168
- if (!validate()) {
177
+ // Validate all fields. onSubmit gets the schema's output, so transforms,
178
+ // coercions and defaults apply, as a plain object rather than the proxy.
179
+ const parsed = parseForm();
180
+ if (parsed === null) {
169
181
  const errorCount = Object.keys(errors).length;
170
182
  if (showToastOnError) {
171
183
  toastStore.error(errorMessage || `Please fix ${errorCount} validation error${errorCount > 1 ? 's' : ''}`);
@@ -174,7 +186,7 @@ export function useForm(options) {
174
186
  }
175
187
  isSubmitting = true;
176
188
  try {
177
- await onSubmit(data);
189
+ await onSubmit(parsed);
178
190
  if (successMessage) {
179
191
  toastStore.success(successMessage);
180
192
  }