@classic-homes/theme-svelte 1.15.3 → 1.17.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.
Files changed (30) hide show
  1. package/dist/__tests__/alert-dialog-dismiss.test.js +54 -0
  2. package/dist/__tests__/dropdown-trigger.test.js +30 -0
  3. package/dist/__tests__/public-layout-drawer.test.js +116 -0
  4. package/dist/__tests__/sidebar-docs-parity.test.js +134 -0
  5. package/dist/__tests__/sidebar-items.test.js +59 -0
  6. package/dist/lib/components/AlertDialog.svelte +19 -2
  7. package/dist/lib/components/Button.svelte +1 -31
  8. package/dist/lib/components/Button.svelte.d.ts +9 -6
  9. package/dist/lib/components/DataPanel.svelte +13 -3
  10. package/dist/lib/components/DropdownMenu.svelte +18 -1
  11. package/dist/lib/components/DropdownMenu.svelte.d.ts +8 -0
  12. package/dist/lib/components/button-variants.d.ts +119 -0
  13. package/dist/lib/components/button-variants.js +29 -0
  14. package/dist/lib/components/layout/DashboardLayout.svelte +10 -0
  15. package/dist/lib/components/layout/DashboardLayout.svelte.d.ts +4 -0
  16. package/dist/lib/components/layout/Header.svelte +3 -1
  17. package/dist/lib/components/layout/PublicLayout.svelte +151 -44
  18. package/dist/lib/components/layout/PublicLayout.svelte.d.ts +8 -10
  19. package/dist/lib/components/layout/Sidebar.svelte +45 -27
  20. package/dist/lib/components/layout/Sidebar.svelte.d.ts +5 -0
  21. package/dist/lib/components/layout/sidebar/SidebarFlyout.svelte +54 -32
  22. package/dist/lib/components/layout/sidebar/SidebarNavItem.svelte +62 -35
  23. package/dist/lib/components/layout/sidebar/SidebarNavItem.svelte.d.ts +5 -0
  24. package/dist/lib/components/layout/sidebar/SidebarSection.svelte +16 -1
  25. package/dist/lib/components/layout/sidebar/SidebarSection.svelte.d.ts +4 -0
  26. package/dist/lib/index.d.ts +1 -0
  27. package/dist/lib/index.js +1 -0
  28. package/dist/lib/stores/sidebar.svelte.d.ts +4 -4
  29. package/dist/lib/stores/sidebar.svelte.js +12 -4
  30. package/package.json +1 -1
@@ -95,6 +95,59 @@
95
95
  }
96
96
  </script>
97
97
 
98
+ {#snippet childLinks(children: NavItem[], depth: number)}
99
+ {#each children as child (child.id)}
100
+ {#if child.href}
101
+ <a
102
+ href={child.disabled ? undefined : child.href}
103
+ class={cn(
104
+ 'flex items-center gap-2 rounded-md px-3 py-2 text-sm',
105
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1',
106
+ isLight
107
+ ? 'text-foreground hover:bg-accent hover:text-accent-foreground'
108
+ : 'text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
109
+ child.active &&
110
+ (isLight
111
+ ? 'bg-primary/10 text-primary'
112
+ : 'bg-sidebar-primary text-sidebar-primary-foreground'),
113
+ child.disabled && 'opacity-50 pointer-events-none cursor-not-allowed'
114
+ )}
115
+ style="margin-left: {0.5 + depth * 0.75}rem"
116
+ target={child.external ? '_blank' : undefined}
117
+ rel={child.external ? 'noopener noreferrer' : undefined}
118
+ aria-disabled={child.disabled || undefined}
119
+ tabindex={child.disabled ? -1 : undefined}
120
+ onclick={(e) => {
121
+ if (child.disabled) {
122
+ e.preventDefault();
123
+ return;
124
+ }
125
+ onNavigate?.(child);
126
+ }}
127
+ role="menuitem"
128
+ >
129
+ {child.name}
130
+ {@render navBadge(child.badge)}
131
+ </a>
132
+ {:else if child.children?.length}
133
+ <!-- A nested group without its own page: a label over its entries -->
134
+ <div
135
+ class={cn(
136
+ 'px-3 pt-2 pb-1 text-xs font-semibold',
137
+ isLight ? 'text-muted-foreground' : 'text-sidebar-foreground/60'
138
+ )}
139
+ style="margin-left: {0.5 + depth * 0.75}rem"
140
+ role="presentation"
141
+ >
142
+ {child.name}
143
+ </div>
144
+ {/if}
145
+ {#if child.children?.length}
146
+ {@render childLinks(child.children, depth + 1)}
147
+ {/if}
148
+ {/each}
149
+ {/snippet}
150
+
98
151
  {#snippet navBadge(badge: string | number | undefined)}
99
152
  {#if badge !== undefined}
100
153
  <span
@@ -169,38 +222,7 @@
169
222
  <div
170
223
  class={cn('border-t mt-1 pt-1', isLight ? 'border-border' : 'border-sidebar-foreground/20')}
171
224
  >
172
- {#each item.children as child}
173
- <a
174
- href={child.disabled ? undefined : child.href}
175
- class={cn(
176
- 'flex items-center gap-2 rounded-md px-3 py-2 text-sm ml-2',
177
- 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1',
178
- isLight
179
- ? 'text-foreground hover:bg-accent hover:text-accent-foreground'
180
- : 'text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
181
- child.active &&
182
- (isLight
183
- ? 'bg-primary/10 text-primary'
184
- : 'bg-sidebar-primary text-sidebar-primary-foreground'),
185
- child.disabled && 'opacity-50 pointer-events-none cursor-not-allowed'
186
- )}
187
- target={child.external ? '_blank' : undefined}
188
- rel={child.external ? 'noopener noreferrer' : undefined}
189
- aria-disabled={child.disabled || undefined}
190
- tabindex={child.disabled ? -1 : undefined}
191
- onclick={(e) => {
192
- if (child.disabled) {
193
- e.preventDefault();
194
- return;
195
- }
196
- onNavigate?.(child);
197
- }}
198
- role="menuitem"
199
- >
200
- {child.name}
201
- {@render navBadge(child.badge)}
202
- </a>
203
- {/each}
225
+ {@render childLinks(item.children, 0)}
204
226
  </div>
205
227
  {/if}
206
228
  </div>
@@ -3,7 +3,7 @@
3
3
  * SidebarNavItem - Individual navigation item component
4
4
  *
5
5
  * Handles:
6
- * - Items with children (expandable submenu)
6
+ * - Items with children (expandable submenu), nested to any depth
7
7
  * - Items without children (simple links/buttons)
8
8
  * - Collapsed mode (icons only with flyout)
9
9
  * - Disabled and loading states
@@ -13,6 +13,8 @@
13
13
  import type { NavItem } from '../../../types/layout.js';
14
14
  import { cn } from '../../../utils.js';
15
15
  import { sidebarStore } from '../../../stores/sidebar.svelte.js';
16
+ import Icon, { type IconName } from '../../Icon.svelte';
17
+ import SidebarNavItem from './SidebarNavItem.svelte';
16
18
 
17
19
  interface Props {
18
20
  /** The navigation item to render */
@@ -39,6 +41,10 @@
39
41
  onMouseEnter?: (item: NavItem, event: MouseEvent) => void;
40
42
  /** Callback when mouse leaves item (for flyout) */
41
43
  onMouseLeave?: () => void;
44
+ /** Expanding this item closes `siblingIds`, and its children behave the same way */
45
+ autoCollapse?: boolean;
46
+ /** IDs of expandable items alongside this one */
47
+ siblingIds?: string[];
42
48
  }
43
49
 
44
50
  let {
@@ -54,6 +60,8 @@
54
60
  onNavigate,
55
61
  onMouseEnter,
56
62
  onMouseLeave,
63
+ autoCollapse = false,
64
+ siblingIds = [],
57
65
  }: Props = $props();
58
66
 
59
67
  // Highlight matching text in item names
@@ -79,9 +87,14 @@
79
87
  const isExpanded = $derived(sidebarStore.isItemExpanded(item.id));
80
88
 
81
89
  function toggleItem() {
82
- sidebarStore.toggleItem(item.id);
90
+ sidebarStore.toggleItem(item.id, autoCollapse ? siblingIds : []);
83
91
  }
84
92
 
93
+ // Children that can expand: siblings to each other under autoCollapse
94
+ const expandableChildIds = $derived(
95
+ autoCollapse ? (item.children ?? []).filter((c) => c.children?.length).map((c) => c.id) : []
96
+ );
97
+
85
98
  function getItemStateClasses(active?: boolean, disabled?: boolean): string {
86
99
  return cn(
87
100
  isLight &&
@@ -142,10 +155,10 @@
142
155
  {@render icon(navItem)}
143
156
  </span>
144
157
  {:else if navItem.icon}
158
+ <!-- No icon renderer given: draw the named theme icon. Items without an
159
+ icon render nothing, so they take no gutter. -->
145
160
  <span class="h-5 w-5 shrink-0 flex items-center justify-center">
146
- <svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
147
- <circle cx="12" cy="12" r="10" stroke-width="2" />
148
- </svg>
161
+ <Icon name={navItem.icon as IconName} size={20} />
149
162
  </span>
150
163
  {/if}
151
164
  {/snippet}
@@ -264,7 +277,7 @@
264
277
  <a
265
278
  href={item.disabled ? undefined : item.href}
266
279
  class={cn(
267
- 'flex flex-1 items-center gap-3 px-3 py-2 text-sm font-medium',
280
+ 'flex min-w-0 flex-1 items-center gap-3 px-3 py-2 text-sm font-medium',
268
281
  'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1',
269
282
  'group-hover:underline underline-offset-2',
270
283
  isLight && !item.active && 'text-foreground group-hover:text-accent-foreground',
@@ -292,7 +305,7 @@
292
305
  <button
293
306
  type="button"
294
307
  class={cn(
295
- 'flex flex-1 items-center gap-3 px-3 py-2 text-sm font-medium text-left',
308
+ 'flex min-w-0 flex-1 items-center gap-3 px-3 py-2 text-sm font-medium text-left',
296
309
  'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1',
297
310
  isLight && !item.active && 'text-foreground group-hover:text-accent-foreground',
298
311
  isLight && item.active && 'text-primary',
@@ -359,33 +372,47 @@
359
372
 
360
373
  {#if isExpanded}
361
374
  <ul class="mt-1 space-y-1 pl-4 overflow-hidden" transition:slide={{ duration: 200 }}>
362
- {#each item.children as child}
375
+ {#each item.children ?? [] as child (child.id)}
363
376
  <li>
364
- <a
365
- href={child.disabled ? undefined : child.href}
366
- class={cn(
367
- 'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium',
368
- 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1',
369
- getItemStateClasses(child.active, child.disabled)
370
- )}
371
- target={child.external ? '_blank' : undefined}
372
- rel={child.external ? 'noopener noreferrer' : undefined}
373
- aria-current={child.active ? 'page' : undefined}
374
- aria-disabled={child.disabled || undefined}
375
- tabindex={child.disabled ? -1 : undefined}
376
- onclick={(e) => {
377
- if (child.disabled) {
378
- e.preventDefault();
379
- return;
380
- }
381
- onNavigate?.(child);
382
- }}
383
- >
384
- {@render navIcon(child)}
385
- {@render highlightedName(child.name, highlightText(child.name, searchQuery))}
386
- {@render navBadge(child.badge)}
387
- {#if child.external}{@render externalIndicator()}{/if}
388
- </a>
377
+ {#if child.children?.length}
378
+ <!-- A nested category: same expandable row, one level deeper -->
379
+ <SidebarNavItem
380
+ item={child}
381
+ {isLight}
382
+ {isMobile}
383
+ {icon}
384
+ {searchQuery}
385
+ {onNavigate}
386
+ {autoCollapse}
387
+ siblingIds={expandableChildIds}
388
+ />
389
+ {:else}
390
+ <a
391
+ href={child.disabled ? undefined : child.href}
392
+ class={cn(
393
+ 'flex min-w-0 items-center gap-3 rounded-md px-3 py-2 text-sm font-medium',
394
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1',
395
+ getItemStateClasses(child.active, child.disabled)
396
+ )}
397
+ target={child.external ? '_blank' : undefined}
398
+ rel={child.external ? 'noopener noreferrer' : undefined}
399
+ aria-current={child.active ? 'page' : undefined}
400
+ aria-disabled={child.disabled || undefined}
401
+ tabindex={child.disabled ? -1 : undefined}
402
+ onclick={(e) => {
403
+ if (child.disabled) {
404
+ e.preventDefault();
405
+ return;
406
+ }
407
+ onNavigate?.(child);
408
+ }}
409
+ >
410
+ {@render navIcon(child)}
411
+ {@render highlightedName(child.name, highlightText(child.name, searchQuery))}
412
+ {@render navBadge(child.badge)}
413
+ {#if child.external}{@render externalIndicator()}{/if}
414
+ </a>
415
+ {/if}
389
416
  </li>
390
417
  {/each}
391
418
  </ul>
@@ -408,7 +435,7 @@
408
435
  <a
409
436
  href={item.disabled ? undefined : item.href}
410
437
  class={cn(
411
- 'flex flex-1 items-center gap-3 px-3 py-2 text-sm font-medium relative',
438
+ 'flex min-w-0 flex-1 items-center gap-3 px-3 py-2 text-sm font-medium relative',
412
439
  'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1',
413
440
  isLight && !item.active && 'text-foreground group-hover:text-accent-foreground',
414
441
  isLight && item.active && 'text-primary',
@@ -455,7 +482,7 @@
455
482
  <button
456
483
  type="button"
457
484
  class={cn(
458
- 'flex flex-1 items-center gap-3 px-3 py-2 text-sm font-medium text-left relative',
485
+ 'flex min-w-0 flex-1 items-center gap-3 px-3 py-2 text-sm font-medium text-left relative',
459
486
  'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1',
460
487
  isLight && !item.active && 'text-foreground group-hover:text-accent-foreground',
461
488
  isLight && item.active && 'text-primary',
@@ -1,5 +1,6 @@
1
1
  import type { Snippet } from 'svelte';
2
2
  import type { NavItem } from '../../../types/layout.js';
3
+ import SidebarNavItem from './SidebarNavItem.svelte';
3
4
  interface Props {
4
5
  /** The navigation item to render */
5
6
  item: NavItem;
@@ -27,6 +28,10 @@ interface Props {
27
28
  onMouseEnter?: (item: NavItem, event: MouseEvent) => void;
28
29
  /** Callback when mouse leaves item (for flyout) */
29
30
  onMouseLeave?: () => void;
31
+ /** Expanding this item closes `siblingIds`, and its children behave the same way */
32
+ autoCollapse?: boolean;
33
+ /** IDs of expandable items alongside this one */
34
+ siblingIds?: string[];
30
35
  }
31
36
  declare const SidebarNavItem: import("svelte").Component<Props, {}, "">;
32
37
  type SidebarNavItem = ReturnType<typeof SidebarNavItem>;
@@ -36,6 +36,10 @@
36
36
  onItemMouseEnter?: (item: NavItem, event: MouseEvent) => void;
37
37
  /** Callback when mouse leaves an item (for flyout) */
38
38
  onItemMouseLeave?: () => void;
39
+ /** Opening this section (or an item in it) closes its siblings */
40
+ autoCollapse?: boolean;
41
+ /** IDs of the sections alongside this one, closed on expand when `autoCollapse` is set */
42
+ siblingSectionIds?: string[];
39
43
  }
40
44
 
41
45
  let {
@@ -51,12 +55,19 @@
51
55
  onNavigate,
52
56
  onItemMouseEnter,
53
57
  onItemMouseLeave,
58
+ autoCollapse = false,
59
+ siblingSectionIds = [],
54
60
  }: Props = $props();
55
61
 
62
+ // Items here that can expand: the siblings an expanding item closes under autoCollapse
63
+ const expandableItemIds = $derived(
64
+ autoCollapse ? section.items.filter((i) => i.children?.length).map((i) => i.id) : []
65
+ );
66
+
56
67
  const isExpanded = $derived(sidebarStore.isSectionExpanded(section.id));
57
68
 
58
69
  function toggleSection() {
59
- sidebarStore.toggleSection(section.id);
70
+ sidebarStore.toggleSection(section.id, autoCollapse ? siblingSectionIds : []);
60
71
  }
61
72
  </script>
62
73
 
@@ -125,6 +136,8 @@
125
136
  {onNavigate}
126
137
  onMouseEnter={onItemMouseEnter}
127
138
  onMouseLeave={onItemMouseLeave}
139
+ {autoCollapse}
140
+ siblingIds={expandableItemIds}
128
141
  />
129
142
  </li>
130
143
  {/each}
@@ -151,6 +164,8 @@
151
164
  {onNavigate}
152
165
  onMouseEnter={onItemMouseEnter}
153
166
  onMouseLeave={onItemMouseLeave}
167
+ {autoCollapse}
168
+ siblingIds={expandableItemIds}
154
169
  />
155
170
  </li>
156
171
  {/each}
@@ -27,6 +27,10 @@ interface Props {
27
27
  onItemMouseEnter?: (item: NavItem, event: MouseEvent) => void;
28
28
  /** Callback when mouse leaves an item (for flyout) */
29
29
  onItemMouseLeave?: () => void;
30
+ /** Opening this section (or an item in it) closes its siblings */
31
+ autoCollapse?: boolean;
32
+ /** IDs of the sections alongside this one, closed on expand when `autoCollapse` is set */
33
+ siblingSectionIds?: string[];
30
34
  }
31
35
  declare const SidebarSection: import("svelte").Component<Props, {}, "">;
32
36
  type SidebarSection = ReturnType<typeof SidebarSection>;
@@ -7,6 +7,7 @@
7
7
  * All components use Svelte 5 runes syntax ($props, $state, $derived, etc.)
8
8
  */
9
9
  export { default as Button } from './components/Button.svelte';
10
+ export { buttonVariants, type ButtonVariants } from './components/button-variants.js';
10
11
  export { default as Input } from './components/Input.svelte';
11
12
  export { default as PasswordInput } from './components/PasswordInput.svelte';
12
13
  export { default as Label } from './components/Label.svelte';
package/dist/lib/index.js CHANGED
@@ -8,6 +8,7 @@
8
8
  */
9
9
  // Core UI Components
10
10
  export { default as Button } from './components/Button.svelte';
11
+ export { buttonVariants } from './components/button-variants.js';
11
12
  export { default as Input } from './components/Input.svelte';
12
13
  export { default as PasswordInput } from './components/PasswordInput.svelte';
13
14
  export { default as Label } from './components/Label.svelte';
@@ -59,13 +59,13 @@ declare class SidebarStore {
59
59
  */
60
60
  setMobile(isMobile: boolean): void;
61
61
  /**
62
- * Toggle section expansion state
62
+ * Toggle section expansion state. When expanding, sections in `collapseOnExpand` close.
63
63
  */
64
- toggleSection(sectionId: string): void;
64
+ toggleSection(sectionId: string, collapseOnExpand?: string[]): void;
65
65
  /**
66
- * Toggle item expansion state
66
+ * Toggle item expansion state. When expanding, items in `collapseOnExpand` close.
67
67
  */
68
- toggleItem(itemId: string): void;
68
+ toggleItem(itemId: string, collapseOnExpand?: string[]): void;
69
69
  /**
70
70
  * Check if a section is expanded
71
71
  */
@@ -160,29 +160,37 @@ class SidebarStore {
160
160
  }
161
161
  }
162
162
  /**
163
- * Toggle section expansion state
163
+ * Toggle section expansion state. When expanding, sections in `collapseOnExpand` close.
164
164
  */
165
- toggleSection(sectionId) {
165
+ toggleSection(sectionId, collapseOnExpand = []) {
166
166
  const newSet = new Set(this.expandedSections);
167
167
  if (newSet.has(sectionId)) {
168
168
  newSet.delete(sectionId);
169
169
  }
170
170
  else {
171
171
  newSet.add(sectionId);
172
+ // Accordion behaviour: opening one closes these (its siblings)
173
+ for (const id of collapseOnExpand)
174
+ if (id !== sectionId)
175
+ newSet.delete(id);
172
176
  }
173
177
  this.expandedSections = newSet;
174
178
  __classPrivateFieldGet(this, _SidebarStore_instances, "m", _SidebarStore_persistExpansion).call(this);
175
179
  }
176
180
  /**
177
- * Toggle item expansion state
181
+ * Toggle item expansion state. When expanding, items in `collapseOnExpand` close.
178
182
  */
179
- toggleItem(itemId) {
183
+ toggleItem(itemId, collapseOnExpand = []) {
180
184
  const newSet = new Set(this.expandedItems);
181
185
  if (newSet.has(itemId)) {
182
186
  newSet.delete(itemId);
183
187
  }
184
188
  else {
185
189
  newSet.add(itemId);
190
+ // Accordion behaviour: opening one closes these (its siblings)
191
+ for (const id of collapseOnExpand)
192
+ if (id !== itemId)
193
+ newSet.delete(id);
186
194
  }
187
195
  this.expandedItems = newSet;
188
196
  __classPrivateFieldGet(this, _SidebarStore_instances, "m", _SidebarStore_persistExpansion).call(this);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classic-homes/theme-svelte",
3
- "version": "1.15.3",
3
+ "version": "1.17.0",
4
4
  "description": "Svelte components for the Classic theme system",
5
5
  "type": "module",
6
6
  "svelte": "./dist/lib/index.js",