@classic-homes/theme-svelte 1.16.0 → 1.18.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 +39 -0
- package/dist/__tests__/sidebar-docs-parity.test.js +134 -0
- package/dist/__tests__/sidebar-items.test.js +59 -0
- package/dist/__tests__/sidebar-mobile.test.js +13 -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 +35 -1
- package/dist/lib/components/DropdownMenu.svelte.d.ts +13 -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 +44 -12
- 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 +68 -37
- package/dist/lib/components/layout/sidebar/SidebarNavItem.svelte.d.ts +5 -0
- package/dist/lib/components/layout/sidebar/SidebarSection.svelte +21 -4
- 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 +10 -4
- package/dist/lib/stores/sidebar.svelte.js +25 -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,39 @@
|
|
|
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
|
+
it('adds a disclosure chevron only with showChevron', () => {
|
|
31
|
+
const { unmount } = render(DropdownMenu, { props: { items, trigger } });
|
|
32
|
+
expect(screen.getByRole('button', { name: 'Menu' }).querySelector('svg')).toBeNull();
|
|
33
|
+
unmount();
|
|
34
|
+
render(DropdownMenu, { props: { items, trigger, triggerVariant: 'ghost', showChevron: true } });
|
|
35
|
+
const chevron = screen.getByRole('button', { name: 'Menu' }).querySelector('svg');
|
|
36
|
+
expect(chevron).not.toBeNull();
|
|
37
|
+
expect(chevron?.getAttribute('aria-hidden')).toBe('true');
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -70,4 +70,17 @@ describe('sidebar mobile semantics', () => {
|
|
|
70
70
|
sidebarStore.setMobile(false);
|
|
71
71
|
expect(sidebarStore.isOpen).toBe(false);
|
|
72
72
|
});
|
|
73
|
+
it('does not animate expansion until the frames after initialize', async () => {
|
|
74
|
+
const appId = freshAppId();
|
|
75
|
+
localStorage.setItem(`classic-theme-sidebar-${appId}-sections`, JSON.stringify(['a']));
|
|
76
|
+
// A fresh store instance would start false; the singleton may already be settled
|
|
77
|
+
// from earlier tests, so check the reset-on-initialize path via a new appId.
|
|
78
|
+
sidebarStore.animate = false;
|
|
79
|
+
sidebarStore.initialize(appId);
|
|
80
|
+
expect(sidebarStore.isSectionExpanded('a')).toBe(true);
|
|
81
|
+
expect(sidebarStore.animate).toBe(false);
|
|
82
|
+
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
|
83
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
84
|
+
expect(sidebarStore.animate).toBe(true);
|
|
85
|
+
});
|
|
73
86
|
});
|
|
@@ -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,8 +41,20 @@
|
|
|
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;
|
|
53
|
+
/**
|
|
54
|
+
* Show a disclosure chevron after the trigger content, turned while the menu is open,
|
|
55
|
+
* so a text trigger (e.g. header navigation) reads as a menu rather than a link.
|
|
56
|
+
*/
|
|
57
|
+
showChevron?: boolean;
|
|
45
58
|
}
|
|
46
59
|
|
|
47
60
|
let {
|
|
@@ -53,7 +66,10 @@
|
|
|
53
66
|
onOpenChange,
|
|
54
67
|
class: className,
|
|
55
68
|
triggerClass,
|
|
69
|
+
triggerVariant,
|
|
70
|
+
triggerSize = 'sm',
|
|
56
71
|
triggerLabel,
|
|
72
|
+
showChevron = false,
|
|
57
73
|
}: Props = $props();
|
|
58
74
|
|
|
59
75
|
function handleOpenChange(newOpen: boolean) {
|
|
@@ -111,8 +127,26 @@
|
|
|
111
127
|
<DropdownMenuPrimitive.Root bind:open onOpenChange={handleOpenChange}>
|
|
112
128
|
<DropdownMenuPrimitive.Trigger>
|
|
113
129
|
{#snippet child({ props })}
|
|
114
|
-
<button
|
|
130
|
+
<button
|
|
131
|
+
type="button"
|
|
132
|
+
{...props}
|
|
133
|
+
class={triggerVariant
|
|
134
|
+
? cn(buttonVariants({ variant: triggerVariant, size: triggerSize }), triggerClass)
|
|
135
|
+
: triggerClass}
|
|
136
|
+
aria-label={triggerLabel}
|
|
137
|
+
>
|
|
115
138
|
{@render trigger()}
|
|
139
|
+
{#if showChevron}
|
|
140
|
+
<Icon
|
|
141
|
+
name="chevron-down"
|
|
142
|
+
size={16}
|
|
143
|
+
class={cn(
|
|
144
|
+
'ml-1 shrink-0 opacity-60 transition-transform duration-200 motion-reduce:transition-none',
|
|
145
|
+
open && 'rotate-180'
|
|
146
|
+
)}
|
|
147
|
+
aria-hidden="true"
|
|
148
|
+
/>
|
|
149
|
+
{/if}
|
|
116
150
|
</button>
|
|
117
151
|
{/snippet}
|
|
118
152
|
</DropdownMenuPrimitive.Trigger>
|
|
@@ -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,8 +35,20 @@ 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;
|
|
47
|
+
/**
|
|
48
|
+
* Show a disclosure chevron after the trigger content, turned while the menu is open,
|
|
49
|
+
* so a text trigger (e.g. header navigation) reads as a menu rather than a link.
|
|
50
|
+
*/
|
|
51
|
+
showChevron?: boolean;
|
|
39
52
|
}
|
|
40
53
|
declare const DropdownMenu: import("svelte").Component<Props, {}, "open">;
|
|
41
54
|
type DropdownMenu = ReturnType<typeof DropdownMenu>;
|
|
@@ -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
|
+
});
|