@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.
- package/dist/__tests__/alert-dialog-dismiss.test.js +54 -0
- package/dist/__tests__/dropdown-trigger.test.js +30 -0
- package/dist/__tests__/sidebar-docs-parity.test.js +134 -0
- package/dist/__tests__/sidebar-items.test.js +59 -0
- package/dist/lib/components/AlertDialog.svelte +19 -2
- package/dist/lib/components/Button.svelte +1 -31
- package/dist/lib/components/Button.svelte.d.ts +9 -6
- package/dist/lib/components/DropdownMenu.svelte +18 -1
- package/dist/lib/components/DropdownMenu.svelte.d.ts +8 -0
- package/dist/lib/components/button-variants.d.ts +119 -0
- package/dist/lib/components/button-variants.js +29 -0
- package/dist/lib/components/layout/DashboardLayout.svelte +10 -0
- package/dist/lib/components/layout/DashboardLayout.svelte.d.ts +4 -0
- package/dist/lib/components/layout/Sidebar.svelte +38 -25
- package/dist/lib/components/layout/Sidebar.svelte.d.ts +5 -0
- package/dist/lib/components/layout/sidebar/SidebarFlyout.svelte +54 -32
- package/dist/lib/components/layout/sidebar/SidebarNavItem.svelte +62 -35
- package/dist/lib/components/layout/sidebar/SidebarNavItem.svelte.d.ts +5 -0
- package/dist/lib/components/layout/sidebar/SidebarSection.svelte +16 -1
- package/dist/lib/components/layout/sidebar/SidebarSection.svelte.d.ts +4 -0
- package/dist/lib/index.d.ts +1 -0
- package/dist/lib/index.js +1 -0
- package/dist/lib/stores/sidebar.svelte.d.ts +4 -4
- package/dist/lib/stores/sidebar.svelte.js +12 -4
- package/package.json +1 -1
|
@@ -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
|
-
|
|
88
|
+
settle(false);
|
|
72
89
|
open = false;
|
|
73
90
|
onOpenChange?.(false);
|
|
74
91
|
}
|
|
75
92
|
|
|
76
93
|
function handleConfirm() {
|
|
77
|
-
|
|
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 {
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
variant?:
|
|
5
|
-
size?:
|
|
6
|
-
type?:
|
|
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
|
|
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
|
-
|
|
158
|
-
for (const item of
|
|
163
|
+
const find = (items: NavItem[]): NavItem | undefined => {
|
|
164
|
+
for (const item of items) {
|
|
159
165
|
if (item.id === itemId) return item;
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
.
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
-
<
|
|
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
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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>;
|
package/dist/lib/index.d.ts
CHANGED
|
@@ -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);
|