@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.
- package/dist/lib/components/Combobox.svelte +2 -2
- package/dist/lib/components/DataTable.svelte +15 -6
- package/dist/lib/components/DataTable.svelte.d.ts +1 -1
- package/dist/lib/components/DateTimePicker.svelte +31 -34
- package/dist/lib/components/DropdownMenu.svelte +52 -30
- package/dist/lib/components/DropdownMenu.svelte.d.ts +5 -0
- package/dist/lib/components/FileUpload.svelte +5 -19
- package/dist/lib/components/MultiSelect.svelte +2 -2
- package/dist/lib/components/NumberInput.svelte +15 -2
- package/dist/lib/components/Select.svelte +19 -5
- package/dist/lib/components/Select.svelte.d.ts +4 -0
- package/dist/lib/components/Signature.svelte +15 -4
- package/dist/lib/components/Signature.svelte.d.ts +1 -1
- package/dist/lib/components/Slider.svelte +8 -6
- package/dist/lib/components/Tabs.svelte +10 -12
- package/dist/lib/components/Toast.svelte +12 -12
- package/dist/lib/components/data-table/DataTableColumnVisibility.svelte +24 -26
- package/dist/lib/components/data-table/DataTableEditableCell.svelte +31 -18
- package/dist/lib/components/data-table/DataTableEditableCell.svelte.d.ts +0 -7
- package/dist/lib/components/data-table/DataTablePagination.svelte +5 -1
- package/dist/lib/components/data-table/DataTableVirtual.svelte +4 -7
- package/dist/lib/components/data-table/DataTableVirtual.svelte.d.ts +3 -0
- package/dist/lib/components/data-table/createDataTable.svelte.d.ts +6 -1
- package/dist/lib/components/data-table/createDataTable.svelte.js +22 -9
- package/dist/lib/components/layout/Sidebar.svelte +28 -0
- package/dist/lib/composables/useAsync.svelte.d.ts +1 -1
- package/dist/lib/composables/useAsync.svelte.js +33 -14
- package/dist/lib/composables/useForm.svelte.js +19 -7
- package/dist/lib/composables/usePersistedForm.svelte.js +37 -10
- package/dist/lib/stores/sidebar.svelte.d.ts +6 -0
- package/dist/lib/stores/sidebar.svelte.js +15 -0
- package/dist/lib/stores/toast.svelte.js +12 -1
- package/dist/lib/utils/date.d.ts +3 -1
- package/dist/lib/utils/date.js +54 -34
- package/dist/lib/utils/form.d.ts +2 -1
- package/dist/lib/utils/form.js +3 -2
- package/package.json +1 -1
|
@@ -79,23 +79,21 @@
|
|
|
79
79
|
updateScrollShadows();
|
|
80
80
|
});
|
|
81
81
|
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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:
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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-
|
|
40
|
-
error: 'text-
|
|
41
|
-
warning: 'text-
|
|
42
|
-
info: 'text-
|
|
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
|
-
//
|
|
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:
|
|
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
|
-
<
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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) =>
|
|
124
|
+
onkeydown={(e: KeyboardEvent) => e.key === 'Enter' && startEditing()}
|
|
100
125
|
>
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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) =>
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
|
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
|
-
|
|
252
|
-
|
|
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
|
-
|
|
258
|
-
|
|
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
|
-
* -
|
|
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
|
-
*
|
|
60
|
+
* Cancel a pending debounced call, settling its promise with undefined
|
|
58
61
|
*/
|
|
59
|
-
|
|
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
|
-
|
|
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
|
-
|
|
126
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
158
|
+
return result.data;
|
|
152
159
|
}
|
|
153
160
|
else {
|
|
154
161
|
errors = parseZodErrors(result.error);
|
|
155
|
-
return
|
|
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
|
-
|
|
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(
|
|
189
|
+
await onSubmit(parsed);
|
|
178
190
|
if (successMessage) {
|
|
179
191
|
toastStore.success(successMessage);
|
|
180
192
|
}
|