@classic-homes/theme-svelte 1.22.1 → 1.22.3

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 (39) 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/components/layout/sidebar/SidebarNavItem.svelte +66 -50
  27. package/dist/lib/components/layout/sidebar/SidebarNavItem.svelte.d.ts +2 -0
  28. package/dist/lib/composables/useAsync.svelte.d.ts +1 -1
  29. package/dist/lib/composables/useAsync.svelte.js +33 -14
  30. package/dist/lib/composables/useForm.svelte.js +19 -7
  31. package/dist/lib/composables/usePersistedForm.svelte.js +37 -10
  32. package/dist/lib/stores/sidebar.svelte.d.ts +6 -0
  33. package/dist/lib/stores/sidebar.svelte.js +15 -0
  34. package/dist/lib/stores/toast.svelte.js +12 -1
  35. package/dist/lib/utils/date.d.ts +3 -1
  36. package/dist/lib/utils/date.js +54 -34
  37. package/dist/lib/utils/form.d.ts +2 -1
  38. package/dist/lib/utils/form.js +3 -2
  39. 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);
@@ -45,6 +45,8 @@
45
45
  autoCollapse?: boolean;
46
46
  /** IDs of expandable items alongside this one */
47
47
  siblingIds?: string[];
48
+ /** Rendered inside another item's submenu panel (internal) */
49
+ nested?: boolean;
48
50
  }
49
51
 
50
52
  let {
@@ -62,6 +64,7 @@
62
64
  onMouseLeave,
63
65
  autoCollapse = false,
64
66
  siblingIds = [],
67
+ nested = false,
65
68
  }: Props = $props();
66
69
 
67
70
  // Highlight matching text in item names
@@ -99,11 +102,11 @@
99
102
  return cn(
100
103
  isLight &&
101
104
  !active &&
102
- 'text-foreground bg-background hover:bg-accent hover:text-accent-foreground',
105
+ 'text-foreground bg-transparent hover:bg-accent hover:text-accent-foreground',
103
106
  isLight && active && 'bg-primary/10 text-primary',
104
107
  !isLight &&
105
108
  !active &&
106
- 'text-sidebar-foreground bg-sidebar hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
109
+ 'text-sidebar-foreground bg-transparent hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
107
110
  !isLight && active && 'bg-sidebar-primary text-sidebar-primary-foreground',
108
111
  disabled && 'opacity-50 pointer-events-none cursor-not-allowed'
109
112
  );
@@ -266,9 +269,12 @@
266
269
  <div
267
270
  class={cn(
268
271
  'group flex w-full items-center rounded-md',
269
- isLight && !item.active && 'bg-background hover:bg-accent',
272
+ // Inside a submenu panel rows sit on the panel's grey, so no rest fill
273
+ isLight && !item.active && (nested ? 'bg-transparent' : 'bg-background'),
274
+ isLight && !item.active && 'hover:bg-accent',
270
275
  isLight && item.active && 'bg-primary/10',
271
- !isLight && !item.active && 'bg-sidebar hover:bg-sidebar-accent',
276
+ !isLight && !item.active && (nested ? 'bg-transparent' : 'bg-sidebar'),
277
+ !isLight && !item.active && 'hover:bg-sidebar-accent',
272
278
  !isLight && item.active && 'bg-sidebar-primary'
273
279
  )}
274
280
  >
@@ -372,54 +378,64 @@
372
378
  </div>
373
379
 
374
380
  {#if isExpanded}
375
- <ul
376
- class="mt-1 space-y-1 pl-4 overflow-hidden"
381
+ <!-- Submenu panel: an inset grey surface at the top level so open children
382
+ read as separate from the main list. Deeper levels get a left rail
383
+ instead, so the grey never stacks darker -->
384
+ <div
385
+ class={cn(
386
+ 'mt-1 overflow-hidden',
387
+ !nested && 'ml-3 rounded-md p-1',
388
+ !nested && (isLight ? 'bg-muted/50' : 'bg-sidebar-accent/30')
389
+ )}
377
390
  transition:slide={{ duration: sidebarStore.animate ? 200 : 0 }}
378
391
  >
379
- {#each item.children ?? [] as child (child.id)}
380
- <li>
381
- {#if child.children?.length}
382
- <!-- A nested category: same expandable row, one level deeper -->
383
- <SidebarNavItem
384
- item={child}
385
- {isLight}
386
- {isMobile}
387
- {icon}
388
- {searchQuery}
389
- {onNavigate}
390
- {autoCollapse}
391
- siblingIds={expandableChildIds}
392
- />
393
- {:else}
394
- <a
395
- href={child.disabled ? undefined : child.href}
396
- class={cn(
397
- 'flex min-w-0 items-center gap-3 rounded-md px-3 py-2 text-sm font-medium',
398
- 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1',
399
- getItemStateClasses(child.active, child.disabled)
400
- )}
401
- target={child.external ? '_blank' : undefined}
402
- rel={child.external ? 'noopener noreferrer' : undefined}
403
- aria-current={child.active ? 'page' : undefined}
404
- aria-disabled={child.disabled || undefined}
405
- tabindex={child.disabled ? -1 : undefined}
406
- onclick={(e) => {
407
- if (child.disabled) {
408
- e.preventDefault();
409
- return;
410
- }
411
- onNavigate?.(child);
412
- }}
413
- >
414
- {@render navIcon(child)}
415
- {@render highlightedName(child.name, highlightText(child.name, searchQuery))}
416
- {@render navBadge(child.badge)}
417
- {#if child.external}{@render externalIndicator()}{/if}
418
- </a>
419
- {/if}
420
- </li>
421
- {/each}
422
- </ul>
392
+ <ul class={cn('space-y-1', nested && 'ml-3 border-l border-outline/40 pl-1')}>
393
+ {#each item.children ?? [] as child (child.id)}
394
+ <li>
395
+ {#if child.children?.length}
396
+ <!-- A nested category: same expandable row, one level deeper -->
397
+ <SidebarNavItem
398
+ item={child}
399
+ {isLight}
400
+ {isMobile}
401
+ {icon}
402
+ {searchQuery}
403
+ {onNavigate}
404
+ {autoCollapse}
405
+ siblingIds={expandableChildIds}
406
+ nested
407
+ />
408
+ {:else}
409
+ <a
410
+ href={child.disabled ? undefined : child.href}
411
+ class={cn(
412
+ 'flex min-w-0 items-center gap-3 rounded-md px-3 py-2 text-sm font-medium',
413
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1',
414
+ getItemStateClasses(child.active, child.disabled)
415
+ )}
416
+ target={child.external ? '_blank' : undefined}
417
+ rel={child.external ? 'noopener noreferrer' : undefined}
418
+ aria-current={child.active ? 'page' : undefined}
419
+ aria-disabled={child.disabled || undefined}
420
+ tabindex={child.disabled ? -1 : undefined}
421
+ onclick={(e) => {
422
+ if (child.disabled) {
423
+ e.preventDefault();
424
+ return;
425
+ }
426
+ onNavigate?.(child);
427
+ }}
428
+ >
429
+ {@render navIcon(child)}
430
+ {@render highlightedName(child.name, highlightText(child.name, searchQuery))}
431
+ {@render navBadge(child.badge)}
432
+ {#if child.external}{@render externalIndicator()}{/if}
433
+ </a>
434
+ {/if}
435
+ </li>
436
+ {/each}
437
+ </ul>
438
+ </div>
423
439
  {/if}
424
440
  </div>
425
441
  {:else}
@@ -32,6 +32,8 @@ interface Props {
32
32
  autoCollapse?: boolean;
33
33
  /** IDs of expandable items alongside this one */
34
34
  siblingIds?: string[];
35
+ /** Rendered inside another item's submenu panel (internal) */
36
+ nested?: boolean;
35
37
  }
36
38
  declare const SidebarNavItem: import("svelte").Component<Props, {}, "">;
37
39
  type SidebarNavItem = ReturnType<typeof SidebarNavItem>;
@@ -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