@classic-homes/theme-svelte 1.15.3 → 1.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/__tests__/alert-dialog-dismiss.test.js +54 -0
- package/dist/__tests__/dropdown-trigger.test.js +30 -0
- package/dist/__tests__/public-layout-drawer.test.js +116 -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/DataPanel.svelte +13 -3
- 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/Header.svelte +3 -1
- package/dist/lib/components/layout/PublicLayout.svelte +151 -44
- package/dist/lib/components/layout/PublicLayout.svelte.d.ts +8 -10
- package/dist/lib/components/layout/Sidebar.svelte +45 -27
- 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,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PublicLayout mobileNavStyle="drawer": the hamburger opens the Sidebar drawer
|
|
3
|
+
* over the page instead of expanding a dropdown inside the header (which pushed
|
|
4
|
+
* the page down and, once scrolled, sat on the header's translucent bar).
|
|
5
|
+
*/
|
|
6
|
+
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
7
|
+
import { mount, unmount, flushSync, createRawSnippet } from 'svelte';
|
|
8
|
+
import PublicLayout from '../lib/components/layout/PublicLayout.svelte';
|
|
9
|
+
const children = createRawSnippet(() => ({ render: () => '<p>Page</p>' }));
|
|
10
|
+
const navigation = [
|
|
11
|
+
{ id: 'inventory', name: 'Inventory', href: '/inventory', active: true },
|
|
12
|
+
{ id: 'pricing', name: 'Pricing', href: '/pricing' },
|
|
13
|
+
];
|
|
14
|
+
let instance = null;
|
|
15
|
+
let host;
|
|
16
|
+
function phoneWidth(matches) {
|
|
17
|
+
vi.spyOn(window, 'matchMedia').mockImplementation((query) => ({
|
|
18
|
+
matches: query.startsWith('(max-width') ? matches : false,
|
|
19
|
+
media: query,
|
|
20
|
+
onchange: null,
|
|
21
|
+
addEventListener: vi.fn(),
|
|
22
|
+
removeEventListener: vi.fn(),
|
|
23
|
+
addListener: vi.fn(),
|
|
24
|
+
removeListener: vi.fn(),
|
|
25
|
+
dispatchEvent: vi.fn(),
|
|
26
|
+
}));
|
|
27
|
+
}
|
|
28
|
+
function mountLayout(props) {
|
|
29
|
+
host = document.createElement('div');
|
|
30
|
+
document.body.appendChild(host);
|
|
31
|
+
instance = mount(PublicLayout, { target: host, props: { navigation, children, ...props } });
|
|
32
|
+
flushSync();
|
|
33
|
+
const hamburger = () => host.querySelector('button[aria-label="Open menu"]');
|
|
34
|
+
return {
|
|
35
|
+
open: () => {
|
|
36
|
+
hamburger().click();
|
|
37
|
+
flushSync();
|
|
38
|
+
},
|
|
39
|
+
drawer: () => host.querySelector('aside[aria-label="Main navigation"]'),
|
|
40
|
+
dropdown: () => host.querySelector('nav[aria-label="Mobile navigation"]'),
|
|
41
|
+
header: () => host.querySelector('header'),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
if (instance)
|
|
46
|
+
unmount(instance);
|
|
47
|
+
instance = null;
|
|
48
|
+
host?.remove();
|
|
49
|
+
vi.restoreAllMocks();
|
|
50
|
+
});
|
|
51
|
+
describe('PublicLayout drawer nav', () => {
|
|
52
|
+
it('opens the Sidebar drawer over the page on a phone', () => {
|
|
53
|
+
phoneWidth(true);
|
|
54
|
+
const layout = mountLayout({ mobileNavStyle: 'drawer' });
|
|
55
|
+
// Closed drawer is off-canvas (jsdom does not reflect `inert`)
|
|
56
|
+
expect(layout.drawer()?.className).toContain('-translate-x-full');
|
|
57
|
+
layout.open();
|
|
58
|
+
expect(layout.drawer()?.className).not.toContain('-translate-x-full');
|
|
59
|
+
expect(layout.drawer()?.textContent).toContain('Pricing');
|
|
60
|
+
// No dropdown inside the header
|
|
61
|
+
expect(layout.dropdown()?.textContent ?? '').not.toContain('Pricing');
|
|
62
|
+
// Drawer and scrim lift over the elevated header and page sheets
|
|
63
|
+
const layer = layout.drawer().parentElement;
|
|
64
|
+
expect(layer.style.getPropertyValue('--z-sidebar')).toBe('70');
|
|
65
|
+
expect(layer.querySelector('[aria-hidden="true"].fixed.inset-0')).not.toBeNull();
|
|
66
|
+
});
|
|
67
|
+
it('renders no drawer above the breakpoint', () => {
|
|
68
|
+
phoneWidth(false);
|
|
69
|
+
const layout = mountLayout({ mobileNavStyle: 'drawer' });
|
|
70
|
+
expect(layout.drawer()).toBeNull();
|
|
71
|
+
});
|
|
72
|
+
it('keeps the dropdown by default, on a solid background', () => {
|
|
73
|
+
phoneWidth(true);
|
|
74
|
+
const layout = mountLayout({});
|
|
75
|
+
layout.open();
|
|
76
|
+
expect(layout.drawer()).toBeNull();
|
|
77
|
+
expect(layout.dropdown()?.textContent).toContain('Pricing');
|
|
78
|
+
expect(layout.dropdown()?.className).toContain('bg-background');
|
|
79
|
+
expect(layout.header().className).toContain('z-50');
|
|
80
|
+
});
|
|
81
|
+
it('gives a parent with children a chevron menu in the header, and keeps the link', () => {
|
|
82
|
+
phoneWidth(false);
|
|
83
|
+
host = document.createElement('div');
|
|
84
|
+
document.body.appendChild(host);
|
|
85
|
+
instance = mount(PublicLayout, {
|
|
86
|
+
target: host,
|
|
87
|
+
props: {
|
|
88
|
+
children,
|
|
89
|
+
navigation: [
|
|
90
|
+
{
|
|
91
|
+
id: 'inventory',
|
|
92
|
+
name: 'Inventory',
|
|
93
|
+
href: '/inventory',
|
|
94
|
+
active: true,
|
|
95
|
+
children: [
|
|
96
|
+
{ id: 'inventory-community', name: 'Community', href: '/inventory?view=community' },
|
|
97
|
+
{
|
|
98
|
+
id: 'inventory-collection',
|
|
99
|
+
name: 'Collection',
|
|
100
|
+
href: '/inventory?view=collection',
|
|
101
|
+
},
|
|
102
|
+
],
|
|
103
|
+
},
|
|
104
|
+
{ id: 'pricing', name: 'Pricing', href: '/pricing' },
|
|
105
|
+
],
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
flushSync();
|
|
109
|
+
const link = host.querySelector('header a[href="/inventory"]');
|
|
110
|
+
expect(link?.getAttribute('aria-current')).toBe('page');
|
|
111
|
+
const trigger = host.querySelector('header button[aria-label="Inventory options"]');
|
|
112
|
+
expect(trigger).not.toBeNull();
|
|
113
|
+
// Only parents with children get one
|
|
114
|
+
expect(host.querySelector('header button[aria-label="Pricing options"]')).toBeNull();
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -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;
|
|
@@ -280,6 +280,16 @@
|
|
|
280
280
|
*/
|
|
281
281
|
const isMobileCard = $derived(variant === 'card' && isMobile);
|
|
282
282
|
|
|
283
|
+
/**
|
|
284
|
+
* Bottom padding of the phone card: the home-indicator inset less the
|
|
285
|
+
* header's own 12px bottom padding and 8px more. The whole 34px iPhone inset
|
|
286
|
+
* stood the collapsed card 106px tall; this keeps the title about 26px above
|
|
287
|
+
* the screen edge, clear of the indicator, at 86px. The strip below the
|
|
288
|
+
* header is padding, so a swipe that starts there never reaches the header's
|
|
289
|
+
* pointer handler.
|
|
290
|
+
*/
|
|
291
|
+
const CARD_SAFE_AREA_PAD = 'max(0px, calc(env(safe-area-inset-bottom, 0px) - 20px))';
|
|
292
|
+
|
|
283
293
|
// Whether the panel is currently presented as a modal dialog (detached, or an
|
|
284
294
|
// expanded mobile card). In that state we trap focus for a11y parity with React.
|
|
285
295
|
// A desktop card is a non-modal floating sheet with no scrim, so it must not
|
|
@@ -1193,15 +1203,15 @@
|
|
|
1193
1203
|
// Full-width phone sheet at its expanded height; `height` above is how
|
|
1194
1204
|
// much of it shows. The part below the fold is slid off-screen with
|
|
1195
1205
|
// `translate` (composited, and it composes with the mount keyframe's
|
|
1196
|
-
// `transform`).
|
|
1206
|
+
// `transform`). CARD_SAFE_AREA_PAD is added to the sheet and padded
|
|
1197
1207
|
// out, so the collapsed header sits above the home indicator instead
|
|
1198
1208
|
// of inside the strip iOS claims for its swipe-to-leave gesture.
|
|
1199
1209
|
return {
|
|
1200
1210
|
bottom: '0',
|
|
1201
1211
|
left: `${defaultLeftOffset}px`,
|
|
1202
1212
|
width: `${defaultCardWidth}px`,
|
|
1203
|
-
height: `calc(${cardExpandedHeight}px +
|
|
1204
|
-
'padding-bottom':
|
|
1213
|
+
height: `calc(${cardExpandedHeight}px + ${CARD_SAFE_AREA_PAD})`,
|
|
1214
|
+
'padding-bottom': CARD_SAFE_AREA_PAD,
|
|
1205
1215
|
translate: `0 ${Math.max(0, cardExpandedHeight - height)}px`,
|
|
1206
1216
|
};
|
|
1207
1217
|
}
|
|
@@ -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
|
}
|