@classic-homes/theme-svelte 1.16.0 → 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.
@@ -0,0 +1,54 @@
1
+ /**
2
+ * AlertDialog: however it closes — Cancel, confirm, Escape — exactly one of
3
+ * onConfirm / onCancel runs, so `{#if x}<AlertDialog open onCancel=…>` callers
4
+ * can reset their state on Escape too.
5
+ */
6
+ import { describe, it, expect, vi, afterEach } from 'vitest';
7
+ import { mount, unmount, flushSync, tick } from 'svelte';
8
+ import AlertDialog from '../lib/components/AlertDialog.svelte';
9
+ let instance = null;
10
+ afterEach(() => {
11
+ if (instance)
12
+ unmount(instance);
13
+ instance = null;
14
+ document.body.innerHTML = '';
15
+ });
16
+ async function mountOpen() {
17
+ const onConfirm = vi.fn();
18
+ const onCancel = vi.fn();
19
+ instance = mount(AlertDialog, {
20
+ target: document.body,
21
+ props: { open: true, title: 'Delete?', onConfirm, onCancel },
22
+ });
23
+ flushSync();
24
+ await tick();
25
+ const button = (label) => [...document.querySelectorAll('button')].find((b) => b.textContent?.trim() === label);
26
+ return { onConfirm, onCancel, button };
27
+ }
28
+ describe('AlertDialog dismiss', () => {
29
+ it('calls onCancel once on Cancel', async () => {
30
+ const { onConfirm, onCancel, button } = await mountOpen();
31
+ button('Cancel').click();
32
+ flushSync();
33
+ await tick();
34
+ expect(onCancel).toHaveBeenCalledTimes(1);
35
+ expect(onConfirm).not.toHaveBeenCalled();
36
+ });
37
+ it('calls onConfirm once and not onCancel on confirm', async () => {
38
+ const { onConfirm, onCancel, button } = await mountOpen();
39
+ button('Continue').click();
40
+ flushSync();
41
+ await tick();
42
+ expect(onConfirm).toHaveBeenCalledTimes(1);
43
+ expect(onCancel).not.toHaveBeenCalled();
44
+ });
45
+ it('calls onCancel on Escape', async () => {
46
+ const { onConfirm, onCancel } = await mountOpen();
47
+ const content = document.querySelector('[role="alertdialog"]');
48
+ content.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
49
+ flushSync();
50
+ await tick();
51
+ expect(onCancel).toHaveBeenCalledTimes(1);
52
+ expect(onConfirm).not.toHaveBeenCalled();
53
+ });
54
+ });
@@ -0,0 +1,30 @@
1
+ /**
2
+ * DropdownMenu trigger styling
3
+ *
4
+ * `triggerVariant` styles the trigger <button> as a Button variant, so a menu
5
+ * in a header looks like the buttons beside it without app-side classes.
6
+ * Without it the trigger stays unstyled, as before.
7
+ */
8
+ import { describe, it, expect } from 'vitest';
9
+ import { render, screen } from '@testing-library/svelte';
10
+ import { createRawSnippet } from 'svelte';
11
+ import DropdownMenu from '../lib/components/DropdownMenu.svelte';
12
+ import { buttonVariants } from '../lib/components/button-variants.js';
13
+ const trigger = createRawSnippet(() => ({ render: () => '<span>Menu</span>' }));
14
+ const items = [{ label: 'One', href: '/one' }];
15
+ describe('DropdownMenu trigger', () => {
16
+ it('is unstyled by default', () => {
17
+ render(DropdownMenu, { props: { items, trigger, triggerClass: 'custom' } });
18
+ expect(screen.getByRole('button', { name: 'Menu' }).className).toBe('custom');
19
+ });
20
+ it('takes Button variant classes from triggerVariant, plus triggerClass', () => {
21
+ render(DropdownMenu, {
22
+ props: { items, trigger, triggerVariant: 'ghost', triggerClass: 'custom' },
23
+ });
24
+ const button = screen.getByRole('button', { name: 'Menu' });
25
+ for (const cls of buttonVariants({ variant: 'ghost', size: 'sm' }).split(' ')) {
26
+ expect(button.classList.contains(cls)).toBe(true);
27
+ }
28
+ expect(button.classList.contains('custom')).toBe(true);
29
+ });
30
+ });
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Sidebar features docs sites need (Docusaurus parity)
3
+ *
4
+ * - Items nest to any depth: a child with its own children renders as an expandable row
5
+ * instead of being dropped.
6
+ * - Search filters and expands at every depth.
7
+ * - `sidebarAutoCollapse`: opening a section or item closes the others at its level.
8
+ * - DashboardLayout renders a page `footer` below the main content.
9
+ */
10
+ import { describe, it, expect, beforeAll, beforeEach, vi } from 'vitest';
11
+ import { fireEvent, render, screen, within } from '@testing-library/svelte';
12
+ import { createRawSnippet } from 'svelte';
13
+ import DashboardLayout from '../lib/components/layout/DashboardLayout.svelte';
14
+ import { sidebarStore } from '../lib/stores/sidebar.svelte.js';
15
+ const navigation = [
16
+ {
17
+ id: 'setup',
18
+ title: 'Setup',
19
+ collapsible: true,
20
+ expanded: true,
21
+ items: [
22
+ {
23
+ id: 'platforms',
24
+ name: 'Platforms',
25
+ expanded: true,
26
+ children: [
27
+ { id: 'mac', name: 'macOS', href: '/setup/mac' },
28
+ {
29
+ id: 'linux',
30
+ name: 'Linux',
31
+ expanded: true,
32
+ children: [
33
+ { id: 'debian', name: 'Debian', href: '/setup/linux/debian', active: true },
34
+ {
35
+ id: 'rhel',
36
+ name: 'RHEL',
37
+ children: [{ id: 'rhel9', name: 'RHEL 9', href: '/setup/linux/rhel/9' }],
38
+ },
39
+ ],
40
+ },
41
+ ],
42
+ },
43
+ {
44
+ id: 'tools',
45
+ name: 'Tools',
46
+ children: [{ id: 'cli', name: 'CLI', href: '/setup/cli' }],
47
+ },
48
+ ],
49
+ },
50
+ {
51
+ id: 'reference',
52
+ title: 'Reference',
53
+ collapsible: true,
54
+ expanded: false,
55
+ items: [{ id: 'api', name: 'API', href: '/reference/api' }],
56
+ },
57
+ ];
58
+ function renderLayout(props = {}) {
59
+ return render(DashboardLayout, {
60
+ props: {
61
+ appId: `test-parity-${Math.random()}`,
62
+ navigation,
63
+ children: createRawSnippet(() => ({ render: () => '<p>content</p>' })),
64
+ ...props,
65
+ },
66
+ });
67
+ }
68
+ describe('sidebar docs parity', () => {
69
+ beforeAll(() => {
70
+ var _a;
71
+ // Expanding rows runs svelte's slide transition (Web Animations), which jsdom lacks
72
+ (_a = Element.prototype).animate ?? (_a.animate = vi.fn(() => ({
73
+ cancel: vi.fn(),
74
+ finish: vi.fn(),
75
+ onfinish: null,
76
+ oncancel: null,
77
+ })));
78
+ });
79
+ beforeEach(() => {
80
+ localStorage.clear();
81
+ sidebarStore.setMobile(false);
82
+ sidebarStore.setDefaultItems([]);
83
+ sidebarStore.setDefaultSections([]);
84
+ });
85
+ it('renders categories nested deeper than one level', () => {
86
+ renderLayout();
87
+ const debian = screen.getByRole('link', { name: 'Debian' });
88
+ expect(debian).toHaveAttribute('aria-current', 'page');
89
+ // RHEL is a third-level category: an expandable row, collapsed by default
90
+ const rhel = screen.getByRole('button', { name: 'Expand RHEL' });
91
+ expect(screen.queryByRole('link', { name: 'RHEL 9' })).toBeNull();
92
+ return fireEvent.click(rhel).then(() => {
93
+ expect(screen.getByRole('link', { name: 'RHEL 9' })).toHaveAttribute('href', '/setup/linux/rhel/9');
94
+ });
95
+ });
96
+ it('finds and reveals matches at any depth when searching', async () => {
97
+ renderLayout({ searchable: true });
98
+ const search = screen.getByRole('searchbox');
99
+ await fireEvent.input(search, { target: { value: 'rhel 9' } });
100
+ // The search input is debounced
101
+ await vi.waitFor(() => expect(screen.getByRole('link', { name: /RHEL 9/ })).toBeInTheDocument());
102
+ expect(screen.queryByRole('link', { name: /macOS/ })).toBeNull();
103
+ expect(screen.queryByRole('link', { name: /Debian/ })).toBeNull();
104
+ });
105
+ it('closes sibling items and sections when sidebarAutoCollapse is on', async () => {
106
+ renderLayout({ sidebarAutoCollapse: true });
107
+ expect(sidebarStore.isItemExpanded('platforms')).toBe(true);
108
+ await fireEvent.click(screen.getByRole('button', { name: 'Expand Tools' }));
109
+ expect(sidebarStore.isItemExpanded('tools')).toBe(true);
110
+ expect(sidebarStore.isItemExpanded('platforms')).toBe(false);
111
+ expect(sidebarStore.isSectionExpanded('setup')).toBe(true);
112
+ await fireEvent.click(screen.getByRole('button', { name: /Reference/ }));
113
+ expect(sidebarStore.isSectionExpanded('reference')).toBe(true);
114
+ expect(sidebarStore.isSectionExpanded('setup')).toBe(false);
115
+ });
116
+ it('leaves siblings open by default', async () => {
117
+ renderLayout();
118
+ await fireEvent.click(screen.getByRole('button', { name: 'Expand Tools' }));
119
+ expect(sidebarStore.isItemExpanded('platforms')).toBe(true);
120
+ expect(sidebarStore.isItemExpanded('tools')).toBe(true);
121
+ });
122
+ it('renders a page footer below the main content', () => {
123
+ const { container } = renderLayout({
124
+ footer: createRawSnippet(() => ({
125
+ render: () => '<footer>© Classic Homes</footer>',
126
+ })),
127
+ });
128
+ const main = container.querySelector('main#main-content');
129
+ const footer = screen.getByRole('contentinfo');
130
+ expect(footer).toHaveTextContent('© Classic Homes');
131
+ expect(main.compareDocumentPosition(footer) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
132
+ expect(within(main).queryByRole('contentinfo')).toBeNull();
133
+ });
134
+ });
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Sidebar item rendering
3
+ *
4
+ * - An item with an `icon` name and no custom icon renderer draws the theme
5
+ * Icon (it used to draw a placeholder circle). An item without an icon
6
+ * reserves no icon space.
7
+ * - Item links can shrink, so a long name truncates instead of widening the
8
+ * sidebar into a horizontal scroll.
9
+ */
10
+ import { describe, it, expect, beforeEach } from 'vitest';
11
+ import { render, screen } from '@testing-library/svelte';
12
+ import { createRawSnippet } from 'svelte';
13
+ import DashboardLayout from '../lib/components/layout/DashboardLayout.svelte';
14
+ import { sidebarStore } from '../lib/stores/sidebar.svelte.js';
15
+ const ARROW_LEFT_PATH = 'M19 12H5M12 19l-7-7 7-7';
16
+ function renderLayout() {
17
+ return render(DashboardLayout, {
18
+ props: {
19
+ appId: `test-items-${Math.random()}`,
20
+ navigation: [
21
+ {
22
+ id: 'main',
23
+ items: [
24
+ { id: 'back', name: 'Back', href: '/', icon: 'arrow-left' },
25
+ {
26
+ id: 'plain',
27
+ name: 'A rather long page name that will not fit the sidebar',
28
+ href: '/plain',
29
+ },
30
+ ],
31
+ },
32
+ ],
33
+ children: createRawSnippet(() => ({ render: () => '<p>content</p>' })),
34
+ },
35
+ });
36
+ }
37
+ describe('sidebar items', () => {
38
+ beforeEach(() => {
39
+ localStorage.clear();
40
+ sidebarStore.setMobile(false);
41
+ });
42
+ it('draws the named theme icon when no icon renderer is given', () => {
43
+ renderLayout();
44
+ const link = screen.getByRole('link', { name: 'Back' });
45
+ expect(link.querySelector('svg circle')).toBeNull();
46
+ expect(link.querySelector('svg path')?.getAttribute('d')).toBe(ARROW_LEFT_PATH);
47
+ });
48
+ it('reserves no icon space for an item without an icon', () => {
49
+ renderLayout();
50
+ const link = screen.getByRole('link', { name: /A rather long page name/ });
51
+ expect(link.querySelector('svg')).toBeNull();
52
+ expect(link.querySelector('span.h-5.w-5')).toBeNull();
53
+ });
54
+ it('lets item links shrink so long names truncate', () => {
55
+ renderLayout();
56
+ const link = screen.getByRole('link', { name: /A rather long page name/ });
57
+ expect(link.classList.contains('min-w-0')).toBe(true);
58
+ });
59
+ });
@@ -62,19 +62,36 @@
62
62
  children,
63
63
  }: Props = $props();
64
64
 
65
+ // Each time the dialog opens, exactly one of onConfirm / onCancel runs when it
66
+ // closes. Escape and overlay clicks count as cancel, so callers that mount the
67
+ // dialog as `{#if x}<AlertDialog open onCancel={() => (x = null)} />` reset
68
+ // their state however it was dismissed.
69
+ let settled = false;
70
+ $effect.pre(() => {
71
+ if (open) settled = false;
72
+ });
73
+
74
+ function settle(confirmed: boolean) {
75
+ if (settled) return;
76
+ settled = true;
77
+ if (confirmed) onConfirm?.();
78
+ else onCancel?.();
79
+ }
80
+
65
81
  function handleOpenChange(newOpen: boolean) {
66
82
  open = newOpen;
83
+ if (!newOpen) settle(false);
67
84
  onOpenChange?.(newOpen);
68
85
  }
69
86
 
70
87
  function handleCancel() {
71
- onCancel?.();
88
+ settle(false);
72
89
  open = false;
73
90
  onOpenChange?.(false);
74
91
  }
75
92
 
76
93
  function handleConfirm() {
77
- onConfirm?.();
94
+ settle(true);
78
95
  open = false;
79
96
  onOpenChange?.(false);
80
97
  }
@@ -1,37 +1,7 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet } from 'svelte';
3
3
  import { cn } from '../utils.js';
4
- import { tv, type VariantProps } from 'tailwind-variants';
5
-
6
- const buttonVariants = tv({
7
- base: 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50',
8
- variants: {
9
- variant: {
10
- default: 'bg-primary text-primary-foreground shadow hover:bg-primary-hover',
11
- secondary:
12
- 'border-2 border-secondary-border bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary-hover',
13
- destructive:
14
- 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive-hover focus-visible:ring-destructive',
15
- outline:
16
- 'border-2 border-outline bg-transparent text-foreground hover:border-outline-hover hover:bg-primary hover:text-primary-foreground',
17
- ghost: 'hover:bg-muted-hover hover:text-foreground',
18
- link: 'text-primary underline-offset-4 hover:underline',
19
- },
20
- size: {
21
- default: 'h-10 px-4 py-2',
22
- sm: 'h-9 rounded-md px-3 text-sm',
23
- md: 'h-10 px-4 py-2',
24
- lg: 'h-11 rounded-md px-8',
25
- icon: 'h-10 w-10',
26
- },
27
- },
28
- defaultVariants: {
29
- variant: 'default',
30
- size: 'default',
31
- },
32
- });
33
-
34
- type ButtonVariants = VariantProps<typeof buttonVariants>;
4
+ import { buttonVariants, type ButtonVariants } from './button-variants.js';
35
5
 
36
6
  interface Props {
37
7
  variant?: ButtonVariants['variant'];
@@ -1,9 +1,9 @@
1
1
  import type { Snippet } from 'svelte';
2
- declare const Button: import("svelte").Component<{
3
- [key: string]: unknown;
4
- variant?: "default" | "secondary" | "destructive" | "outline" | "ghost" | "link" | undefined;
5
- size?: "default" | "sm" | "md" | "lg" | "icon" | undefined;
6
- type?: "button" | "submit" | "reset";
2
+ import { type ButtonVariants } from './button-variants.js';
3
+ interface Props {
4
+ variant?: ButtonVariants['variant'];
5
+ size?: ButtonVariants['size'];
6
+ type?: 'button' | 'submit' | 'reset';
7
7
  href?: string;
8
8
  disabled?: boolean;
9
9
  loading?: boolean;
@@ -12,6 +12,9 @@ declare const Button: import("svelte").Component<{
12
12
  class?: string;
13
13
  onclick?: (e: MouseEvent) => void;
14
14
  children: Snippet;
15
- }, {}, "">;
15
+ /** Allow passing additional HTML attributes (aria-*, data-*, etc.) */
16
+ [key: string]: unknown;
17
+ }
18
+ declare const Button: import("svelte").Component<Props, {}, "">;
16
19
  type Button = ReturnType<typeof Button>;
17
20
  export default Button;
@@ -3,6 +3,7 @@
3
3
  import { cn } from '../utils.js';
4
4
  import type { Snippet } from 'svelte';
5
5
  import Icon, { type IconName } from './Icon.svelte';
6
+ import { buttonVariants, type ButtonVariants } from './button-variants.js';
6
7
 
7
8
  export interface DropdownMenuItem {
8
9
  type?: 'item' | 'separator' | 'label';
@@ -40,6 +41,13 @@
40
41
  class?: string;
41
42
  /** Classes for the trigger `<button>` (style it here rather than nesting a button in `trigger`) */
42
43
  triggerClass?: string;
44
+ /**
45
+ * Style the trigger as a Button variant (e.g. `ghost` for header navigation).
46
+ * Unset leaves the trigger unstyled, as before; `triggerClass` still applies on top.
47
+ */
48
+ triggerVariant?: ButtonVariants['variant'];
49
+ /** Button size for the trigger, when `triggerVariant` is set. Default: `sm`. */
50
+ triggerSize?: ButtonVariants['size'];
43
51
  /** Accessible name for the trigger `<button>` (for icon-only triggers) */
44
52
  triggerLabel?: string;
45
53
  }
@@ -53,6 +61,8 @@
53
61
  onOpenChange,
54
62
  class: className,
55
63
  triggerClass,
64
+ triggerVariant,
65
+ triggerSize = 'sm',
56
66
  triggerLabel,
57
67
  }: Props = $props();
58
68
 
@@ -111,7 +121,14 @@
111
121
  <DropdownMenuPrimitive.Root bind:open onOpenChange={handleOpenChange}>
112
122
  <DropdownMenuPrimitive.Trigger>
113
123
  {#snippet child({ props })}
114
- <button type="button" {...props} class={triggerClass} aria-label={triggerLabel}>
124
+ <button
125
+ type="button"
126
+ {...props}
127
+ class={triggerVariant
128
+ ? cn(buttonVariants({ variant: triggerVariant, size: triggerSize }), triggerClass)
129
+ : triggerClass}
130
+ aria-label={triggerLabel}
131
+ >
115
132
  {@render trigger()}
116
133
  </button>
117
134
  {/snippet}
@@ -1,5 +1,6 @@
1
1
  import type { Snippet } from 'svelte';
2
2
  import { type IconName } from './Icon.svelte';
3
+ import { type ButtonVariants } from './button-variants.js';
3
4
  export interface DropdownMenuItem {
4
5
  type?: 'item' | 'separator' | 'label';
5
6
  label?: string;
@@ -34,6 +35,13 @@ interface Props {
34
35
  class?: string;
35
36
  /** Classes for the trigger `<button>` (style it here rather than nesting a button in `trigger`) */
36
37
  triggerClass?: string;
38
+ /**
39
+ * Style the trigger as a Button variant (e.g. `ghost` for header navigation).
40
+ * Unset leaves the trigger unstyled, as before; `triggerClass` still applies on top.
41
+ */
42
+ triggerVariant?: ButtonVariants['variant'];
43
+ /** Button size for the trigger, when `triggerVariant` is set. Default: `sm`. */
44
+ triggerSize?: ButtonVariants['size'];
37
45
  /** Accessible name for the trigger `<button>` (for icon-only triggers) */
38
46
  triggerLabel?: string;
39
47
  }
@@ -0,0 +1,119 @@
1
+ import { type VariantProps } from 'tailwind-variants';
2
+ /**
3
+ * Button styles, shared by Button and anything that renders a button-like
4
+ * control it cannot nest a Button inside (e.g. DropdownMenu's trigger).
5
+ */
6
+ export declare const buttonVariants: import("tailwind-variants").TVReturnType<{
7
+ variant: {
8
+ default: string;
9
+ secondary: string;
10
+ destructive: string;
11
+ outline: string;
12
+ ghost: string;
13
+ link: string;
14
+ };
15
+ size: {
16
+ default: string;
17
+ sm: string;
18
+ md: string;
19
+ lg: string;
20
+ icon: string;
21
+ };
22
+ }, undefined, "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50", import("tailwind-variants/dist/config.js").TVConfig<{
23
+ variant: {
24
+ default: string;
25
+ secondary: string;
26
+ destructive: string;
27
+ outline: string;
28
+ ghost: string;
29
+ link: string;
30
+ };
31
+ size: {
32
+ default: string;
33
+ sm: string;
34
+ md: string;
35
+ lg: string;
36
+ icon: string;
37
+ };
38
+ }, {
39
+ variant: {
40
+ default: string;
41
+ secondary: string;
42
+ destructive: string;
43
+ outline: string;
44
+ ghost: string;
45
+ link: string;
46
+ };
47
+ size: {
48
+ default: string;
49
+ sm: string;
50
+ md: string;
51
+ lg: string;
52
+ icon: string;
53
+ };
54
+ }>, {
55
+ variant: {
56
+ default: string;
57
+ secondary: string;
58
+ destructive: string;
59
+ outline: string;
60
+ ghost: string;
61
+ link: string;
62
+ };
63
+ size: {
64
+ default: string;
65
+ sm: string;
66
+ md: string;
67
+ lg: string;
68
+ icon: string;
69
+ };
70
+ }, undefined, import("tailwind-variants").TVReturnType<{
71
+ variant: {
72
+ default: string;
73
+ secondary: string;
74
+ destructive: string;
75
+ outline: string;
76
+ ghost: string;
77
+ link: string;
78
+ };
79
+ size: {
80
+ default: string;
81
+ sm: string;
82
+ md: string;
83
+ lg: string;
84
+ icon: string;
85
+ };
86
+ }, undefined, "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50", import("tailwind-variants/dist/config.js").TVConfig<{
87
+ variant: {
88
+ default: string;
89
+ secondary: string;
90
+ destructive: string;
91
+ outline: string;
92
+ ghost: string;
93
+ link: string;
94
+ };
95
+ size: {
96
+ default: string;
97
+ sm: string;
98
+ md: string;
99
+ lg: string;
100
+ icon: string;
101
+ };
102
+ }, {
103
+ variant: {
104
+ default: string;
105
+ secondary: string;
106
+ destructive: string;
107
+ outline: string;
108
+ ghost: string;
109
+ link: string;
110
+ };
111
+ size: {
112
+ default: string;
113
+ sm: string;
114
+ md: string;
115
+ lg: string;
116
+ icon: string;
117
+ };
118
+ }>, unknown, unknown, undefined>>;
119
+ export type ButtonVariants = VariantProps<typeof buttonVariants>;
@@ -0,0 +1,29 @@
1
+ import { tv } from 'tailwind-variants';
2
+ /**
3
+ * Button styles, shared by Button and anything that renders a button-like
4
+ * control it cannot nest a Button inside (e.g. DropdownMenu's trigger).
5
+ */
6
+ export const buttonVariants = tv({
7
+ base: 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50',
8
+ variants: {
9
+ variant: {
10
+ default: 'bg-primary text-primary-foreground shadow hover:bg-primary-hover',
11
+ secondary: 'border-2 border-secondary-border bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary-hover',
12
+ destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive-hover focus-visible:ring-destructive',
13
+ outline: 'border-2 border-outline bg-transparent text-foreground hover:border-outline-hover hover:bg-primary hover:text-primary-foreground',
14
+ ghost: 'hover:bg-muted-hover hover:text-foreground',
15
+ link: 'text-primary underline-offset-4 hover:underline',
16
+ },
17
+ size: {
18
+ default: 'h-10 px-4 py-2',
19
+ sm: 'h-9 rounded-md px-3 text-sm',
20
+ md: 'h-10 px-4 py-2',
21
+ lg: 'h-11 rounded-md px-8',
22
+ icon: 'h-10 w-10',
23
+ },
24
+ },
25
+ defaultVariants: {
26
+ variant: 'default',
27
+ size: 'default',
28
+ },
29
+ });
@@ -71,6 +71,10 @@
71
71
  userMenu?: Snippet<[User]>;
72
72
  /** Sidebar footer content */
73
73
  sidebarFooter?: Snippet;
74
+ /** Page footer below the main content, e.g. theme `Footer` with link columns */
75
+ footer?: Snippet;
76
+ /** Accordion sidebar: opening a section or item closes the others at its level */
77
+ sidebarAutoCollapse?: boolean;
74
78
  /** Sidebar width when expanded in pixels (default: 256) */
75
79
  expandedWidth?: number;
76
80
  /** Sidebar width when collapsed in pixels (default: 64) */
@@ -122,6 +126,8 @@
122
126
  headerEnd,
123
127
  userMenu,
124
128
  sidebarFooter,
129
+ footer,
130
+ sidebarAutoCollapse = false,
125
131
  expandedWidth = 256,
126
132
  collapsedWidth = 64,
127
133
  searchable = false,
@@ -269,6 +275,7 @@
269
275
  {quickLinksDisplay}
270
276
  quickLinkIcon={quickLinkIcon ?? (icon as Snippet<[QuickLink]> | undefined)}
271
277
  footer={sidebarFooter}
278
+ autoCollapse={sidebarAutoCollapse}
272
279
  {expandedWidth}
273
280
  {collapsedWidth}
274
281
  {searchable}
@@ -334,5 +341,8 @@
334
341
  >
335
342
  {@render children()}
336
343
  </main>
344
+ {#if footer}
345
+ {@render footer()}
346
+ {/if}
337
347
  </div>
338
348
  </AppShell>
@@ -49,6 +49,10 @@ interface Props {
49
49
  userMenu?: Snippet<[User]>;
50
50
  /** Sidebar footer content */
51
51
  sidebarFooter?: Snippet;
52
+ /** Page footer below the main content, e.g. theme `Footer` with link columns */
53
+ footer?: Snippet;
54
+ /** Accordion sidebar: opening a section or item closes the others at its level */
55
+ sidebarAutoCollapse?: boolean;
52
56
  /** Sidebar width when expanded in pixels (default: 256) */
53
57
  expandedWidth?: number;
54
58
  /** Sidebar width when collapsed in pixels (default: 64) */
@@ -69,6 +69,11 @@
69
69
  collapsedWidth?: number;
70
70
  /** Enable search/filter input for navigation */
71
71
  searchable?: boolean;
72
+ /**
73
+ * Accordion navigation: opening a collapsible section or an item closes the others at
74
+ * its level (Docusaurus `autoCollapseCategories`). Ignored while searching.
75
+ */
76
+ autoCollapse?: boolean;
72
77
  /** Placeholder text for search input */
73
78
  searchPlaceholder?: string;
74
79
  /** Enable pinning feature for navigation items */
@@ -109,6 +114,7 @@
109
114
  expandedWidth = 256,
110
115
  collapsedWidth = 64,
111
116
  searchable = false,
117
+ autoCollapse = false,
112
118
  searchPlaceholder = 'Search...',
113
119
  enablePinning = false,
114
120
  pinnedSectionTitle = 'Pinned',
@@ -154,14 +160,17 @@
154
160
 
155
161
  // Helper to find a nav item by ID (including nested children)
156
162
  function findNavItemById(sections: NavSection[], itemId: string): NavItem | undefined {
157
- for (const section of sections) {
158
- for (const item of section.items) {
163
+ const find = (items: NavItem[]): NavItem | undefined => {
164
+ for (const item of items) {
159
165
  if (item.id === itemId) return item;
160
- if (item.children) {
161
- const found = item.children.find((child) => child.id === itemId);
162
- if (found) return found;
163
- }
166
+ const found = item.children && find(item.children);
167
+ if (found) return found;
164
168
  }
169
+ return undefined;
170
+ };
171
+ for (const section of sections) {
172
+ const found = find(section.items);
173
+ if (found) return found;
165
174
  }
166
175
  return undefined;
167
176
  }
@@ -199,23 +208,28 @@
199
208
  // Search state
200
209
  let searchQuery = $state('');
201
210
 
211
+ // Sections an expanding section closes under autoCollapse
212
+ const collapsibleSectionIds = $derived(
213
+ autoCollapse ? roleFilteredNavigation.filter((s) => s.collapsible).map((s) => s.id) : []
214
+ );
215
+
202
216
  // Filter items by search query
217
+ // At any depth: an item stays when it or a descendant matches. A matching item keeps all
218
+ // its children; otherwise only the branches leading to matches remain.
203
219
  function filterItemsBySearch(items: NavItem[], query: string): NavItem[] {
204
- return items
205
- .filter((item) => {
206
- const matches = item.name.toLowerCase().includes(query);
207
- const childMatches = item.children?.some((child) =>
208
- child.name.toLowerCase().includes(query)
209
- );
210
- return matches || childMatches;
211
- })
212
- .map((item) => ({
213
- ...item,
214
- // Keep all children if parent matches, otherwise filter children
215
- children: item.name.toLowerCase().includes(query)
216
- ? item.children
217
- : item.children?.filter((child) => child.name.toLowerCase().includes(query)),
218
- }));
220
+ return items.flatMap((item) => {
221
+ if (item.name.toLowerCase().includes(query)) return [item];
222
+ const children = item.children ? filterItemsBySearch(item.children, query) : [];
223
+ return children.length > 0 ? [{ ...item, children }] : [];
224
+ });
225
+ }
226
+
227
+ function hasMatchingDescendant(item: NavItem, query: string): boolean {
228
+ return (
229
+ item.children?.some(
230
+ (child) => child.name.toLowerCase().includes(query) || hasMatchingDescendant(child, query)
231
+ ) ?? false
232
+ );
219
233
  }
220
234
 
221
235
  // Combined filtered navigation (roles + search)
@@ -270,10 +284,7 @@
270
284
  const collectMatchingParents = (items: NavItem[]) => {
271
285
  items.forEach((item) => {
272
286
  if (item.children) {
273
- const hasMatchingChild = item.children.some((child) =>
274
- child.name.toLowerCase().includes(query)
275
- );
276
- if (hasMatchingChild) {
287
+ if (hasMatchingDescendant(item, query)) {
277
288
  itemsToExpand.push(item.id);
278
289
  }
279
290
  collectMatchingParents(item.children);
@@ -566,6 +577,8 @@
566
577
  onNavigate={handleNavigate}
567
578
  onItemMouseEnter={handleItemMouseEnter}
568
579
  onItemMouseLeave={handleItemMouseLeave}
580
+ autoCollapse={autoCollapse && !searchQuery.trim()}
581
+ siblingSectionIds={collapsibleSectionIds}
569
582
  />
570
583
  {:else}
571
584
  {#if searchQuery.trim()}
@@ -59,6 +59,11 @@ interface Props {
59
59
  collapsedWidth?: number;
60
60
  /** Enable search/filter input for navigation */
61
61
  searchable?: boolean;
62
+ /**
63
+ * Accordion navigation: opening a collapsible section or an item closes the others at
64
+ * its level (Docusaurus `autoCollapseCategories`). Ignored while searching.
65
+ */
66
+ autoCollapse?: boolean;
62
67
  /** Placeholder text for search input */
63
68
  searchPlaceholder?: string;
64
69
  /** Enable pinning feature for navigation items */
@@ -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.16.0",
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",