@classic-homes/theme-svelte 1.22.0 → 1.22.1
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/lib/components/Alert.svelte +8 -10
- package/dist/lib/components/DataPanel.svelte +19 -5
- package/dist/lib/components/HeaderSearch.svelte +18 -10
- package/dist/lib/components/Skeleton.svelte.d.ts +1 -1
- package/dist/lib/components/layout/AppShell.svelte +1 -1
- package/dist/lib/components/layout/DashboardLayout.svelte +15 -6
- package/dist/lib/components/layout/Footer.svelte +6 -6
- package/dist/lib/components/layout/FormPageLayout.svelte +4 -3
- package/dist/lib/components/layout/Sidebar.svelte +25 -21
- package/dist/lib/components/media/Image.svelte.d.ts +1 -1
- package/dist/lib/components/media/Lightbox.svelte +13 -9
- package/dist/lib/components/media/MediaCarousel.svelte +5 -0
- package/dist/lib/components/media/MediaGrid.svelte +4 -1
- package/dist/lib/components/notifications/NotificationDialog.svelte +8 -5
- package/dist/lib/components/notifications/NotificationItem.svelte +16 -6
- package/dist/lib/components/studio/InsertPalette.svelte +4 -1
- package/dist/lib/components/studio/StudioCanvas.svelte +23 -2
- package/dist/lib/components/studio/StudioCanvas.svelte.d.ts +5 -1
- package/dist/lib/composables/useFocusTrap.svelte.js +9 -2
- package/dist/lib/notices/sanitize.js +10 -3
- package/dist/lib/notices/store.d.ts +3 -3
- package/dist/lib/notices/store.js +14 -2
- package/dist/lib/notices/types.d.ts +8 -8
- package/dist/lib/notices/types.js +5 -1
- package/dist/lib/stores/sidebar.svelte.d.ts +6 -0
- package/dist/lib/stores/sidebar.svelte.js +61 -26
- package/dist/lib/utils/media.js +4 -3
- package/dist/lib/utils/url.d.ts +10 -0
- package/dist/lib/utils/url.js +21 -0
- package/dist/lib/utils.js +14 -1
- package/package.json +5 -4
- package/dist/__tests__/FileUpload.test.js +0 -45
- package/dist/__tests__/a11y/Alert.a11y.test.js +0 -84
- package/dist/__tests__/a11y/AlertDialog.a11y.test.js +0 -126
- package/dist/__tests__/a11y/Button.a11y.test.js +0 -66
- package/dist/__tests__/a11y/Card.a11y.test.js +0 -77
- package/dist/__tests__/a11y/Combobox.a11y.test.js +0 -209
- package/dist/__tests__/a11y/Dialog.a11y.test.js +0 -103
- package/dist/__tests__/a11y/DropdownMenu.a11y.test.js +0 -132
- package/dist/__tests__/a11y/FileUpload.a11y.test.js +0 -158
- package/dist/__tests__/a11y/FilterToolbar.a11y.test.js +0 -62
- package/dist/__tests__/a11y/Icon.a11y.test.js +0 -119
- package/dist/__tests__/a11y/Input.a11y.test.js +0 -96
- package/dist/__tests__/a11y/LiveRegion.a11y.test.js +0 -102
- package/dist/__tests__/a11y/Media.a11y.test.js +0 -145
- package/dist/__tests__/a11y/PublicLayout.a11y.test.js +0 -139
- package/dist/__tests__/a11y/SegmentedControl.a11y.test.js +0 -50
- package/dist/__tests__/a11y/Switch.a11y.test.js +0 -95
- package/dist/__tests__/a11y/Tabs.a11y.test.js +0 -116
- package/dist/__tests__/a11y/Textarea.a11y.test.js +0 -111
- package/dist/__tests__/a11y/Toolbar.a11y.test.js +0 -31
- package/dist/__tests__/a11y/ViewToggle.a11y.test.js +0 -40
- package/dist/__tests__/action-header.test.js +0 -150
- package/dist/__tests__/alert-dialog-dismiss.test.js +0 -54
- package/dist/__tests__/code-field.test.js +0 -65
- package/dist/__tests__/control-text-color.test.js +0 -29
- package/dist/__tests__/data-panel-card-gestures.test.js +0 -138
- package/dist/__tests__/data-panel-viewport.test.js +0 -45
- package/dist/__tests__/dropdown-trigger.test.js +0 -39
- package/dist/__tests__/header-search.test.js +0 -90
- package/dist/__tests__/media-components.test.js +0 -206
- package/dist/__tests__/media-hls.test.js +0 -273
- package/dist/__tests__/media-matterport.test.js +0 -296
- package/dist/__tests__/media-players.test.js +0 -244
- package/dist/__tests__/media-scrubber.test.js +0 -44
- package/dist/__tests__/media-utils.test.js +0 -427
- package/dist/__tests__/nav-access.test.js +0 -145
- package/dist/__tests__/page-header.test.js +0 -36
- package/dist/__tests__/public-layout-drawer.test.js +0 -116
- package/dist/__tests__/setup.js +0 -56
- package/dist/__tests__/sidebar-collapse.test.js +0 -58
- package/dist/__tests__/sidebar-docs-parity.test.js +0 -134
- package/dist/__tests__/sidebar-expansion.test.js +0 -59
- package/dist/__tests__/sidebar-items.test.js +0 -59
- package/dist/__tests__/sidebar-mobile.test.js +0 -86
- package/dist/__tests__/studio-canvas-item.svelte.test.js +0 -98
- package/dist/__tests__/studio.test.js +0 -240
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { render, screen } from '@testing-library/svelte';
|
|
3
|
-
import { axe } from 'vitest-axe';
|
|
4
|
-
import { createRawSnippet } from 'svelte';
|
|
5
|
-
import Toolbar from '../../lib/components/Toolbar.svelte';
|
|
6
|
-
const snippet = (html) => createRawSnippet(() => ({ render: () => html }));
|
|
7
|
-
describe('Toolbar accessibility', () => {
|
|
8
|
-
it('should have no axe violations', async () => {
|
|
9
|
-
const { container } = render(Toolbar, {
|
|
10
|
-
props: {
|
|
11
|
-
label: 'Inventory tools',
|
|
12
|
-
children: snippet('<button type="button">Filters</button>'),
|
|
13
|
-
end: snippet('<button type="button">Map</button>'),
|
|
14
|
-
},
|
|
15
|
-
});
|
|
16
|
-
expect(await axe(container)).toHaveNoViolations();
|
|
17
|
-
});
|
|
18
|
-
it('is a labelled group that renders both slots', () => {
|
|
19
|
-
render(Toolbar, {
|
|
20
|
-
props: {
|
|
21
|
-
label: 'Inventory tools',
|
|
22
|
-
children: snippet('<span>89 homes</span>'),
|
|
23
|
-
end: snippet('<button type="button">Map</button>'),
|
|
24
|
-
},
|
|
25
|
-
});
|
|
26
|
-
const group = screen.getByRole('group', { name: 'Inventory tools' });
|
|
27
|
-
expect(group).toHaveClass('bg-toolbar');
|
|
28
|
-
expect(screen.getByText('89 homes')).toBeInTheDocument();
|
|
29
|
-
expect(screen.getByRole('button', { name: 'Map' })).toBeInTheDocument();
|
|
30
|
-
});
|
|
31
|
-
});
|
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
-
import { render, screen } from '@testing-library/svelte';
|
|
3
|
-
import userEvent from '@testing-library/user-event';
|
|
4
|
-
import { axe } from 'vitest-axe';
|
|
5
|
-
import ViewToggle from '../../lib/components/ViewToggle.svelte';
|
|
6
|
-
describe('ViewToggle accessibility', () => {
|
|
7
|
-
it('should have no axe violations', async () => {
|
|
8
|
-
const { container } = render(ViewToggle);
|
|
9
|
-
expect(await axe(container)).toHaveNoViolations();
|
|
10
|
-
});
|
|
11
|
-
it('exposes list / grid / map radios with accessible names', () => {
|
|
12
|
-
render(ViewToggle);
|
|
13
|
-
expect(screen.getByRole('radiogroup', { name: 'View' })).toBeInTheDocument();
|
|
14
|
-
expect(screen.getByRole('radio', { name: 'List view' })).toHaveAttribute('aria-checked', 'true');
|
|
15
|
-
expect(screen.getByRole('radio', { name: 'Grid view' })).toBeInTheDocument();
|
|
16
|
-
expect(screen.getByRole('radio', { name: 'Map view' })).toBeInTheDocument();
|
|
17
|
-
});
|
|
18
|
-
it('changes the view with the keyboard and reports it', async () => {
|
|
19
|
-
const user = userEvent.setup();
|
|
20
|
-
const onValueChange = vi.fn();
|
|
21
|
-
render(ViewToggle, { props: { onValueChange } });
|
|
22
|
-
screen.getByRole('radio', { name: 'List view' }).focus();
|
|
23
|
-
await user.keyboard('{ArrowRight}');
|
|
24
|
-
expect(screen.getByRole('radio', { name: 'Grid view' })).toHaveAttribute('aria-checked', 'true');
|
|
25
|
-
expect(onValueChange).toHaveBeenLastCalledWith('grid');
|
|
26
|
-
});
|
|
27
|
-
it('honours a custom option set and value', () => {
|
|
28
|
-
render(ViewToggle, {
|
|
29
|
-
props: {
|
|
30
|
-
label: 'Layout',
|
|
31
|
-
value: 'cards',
|
|
32
|
-
options: [
|
|
33
|
-
{ value: 'table', icon: 'list', label: 'Table' },
|
|
34
|
-
{ value: 'cards', icon: 'grid', label: 'Cards' },
|
|
35
|
-
],
|
|
36
|
-
},
|
|
37
|
-
});
|
|
38
|
-
expect(screen.getByRole('radio', { name: 'Cards' })).toHaveAttribute('aria-checked', 'true');
|
|
39
|
-
});
|
|
40
|
-
});
|
|
@@ -1,150 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
-
import { render, screen, within } from '@testing-library/svelte';
|
|
3
|
-
import userEvent from '@testing-library/user-event';
|
|
4
|
-
import { axe } from 'vitest-axe';
|
|
5
|
-
import { createRawSnippet } from 'svelte';
|
|
6
|
-
import ActionHeader from '../lib/components/ActionHeader.svelte';
|
|
7
|
-
// Bits UI leaves `pointer-events: none` on <body> after a menu closes in jsdom.
|
|
8
|
-
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
|
9
|
-
const snippet = (html) => createRawSnippet(() => ({ render: () => html }));
|
|
10
|
-
const actions = [
|
|
11
|
-
{ label: 'Export', icon: 'download', onSelect: () => { } },
|
|
12
|
-
{ label: 'Add sign', icon: 'plus', priority: 'primary', onSelect: () => { } },
|
|
13
|
-
{ label: 'Archive', priority: 'overflow', onSelect: () => { } },
|
|
14
|
-
{ label: 'Delete', priority: 'overflow', destructive: true, onSelect: () => { } },
|
|
15
|
-
{ label: 'Secret', priority: 'overflow', hidden: true, onSelect: () => { } },
|
|
16
|
-
];
|
|
17
|
-
async function openMenu(container, selector) {
|
|
18
|
-
const wrapper = container.querySelector(selector);
|
|
19
|
-
await user.click(within(wrapper).getByRole('button', { name: 'More actions' }));
|
|
20
|
-
return screen.findByRole('menu');
|
|
21
|
-
}
|
|
22
|
-
describe('ActionHeader', () => {
|
|
23
|
-
it('renders title and subtitle with the requested heading level', () => {
|
|
24
|
-
render(ActionHeader, {
|
|
25
|
-
props: { title: 'Signs', subtitle: 'All signage', headingLevel: 2 },
|
|
26
|
-
});
|
|
27
|
-
expect(screen.getByRole('heading', { level: 2, name: 'Signs' })).toBeInTheDocument();
|
|
28
|
-
expect(screen.getByText('All signage')).toBeInTheDocument();
|
|
29
|
-
});
|
|
30
|
-
it('styles the title per heading level: serif italic h1, sans h2', () => {
|
|
31
|
-
const { unmount } = render(ActionHeader, { props: { title: 'Homes' } });
|
|
32
|
-
expect(screen.getByRole('heading', { level: 1 })).toHaveClass('font-serif', 'italic');
|
|
33
|
-
unmount();
|
|
34
|
-
render(ActionHeader, { props: { title: 'Flying Horse', headingLevel: 2 } });
|
|
35
|
-
expect(screen.getByRole('heading', { level: 2 })).toHaveClass('font-sans', 'font-semibold');
|
|
36
|
-
});
|
|
37
|
-
it('renders the caption under the actions', () => {
|
|
38
|
-
const { container } = render(ActionHeader, {
|
|
39
|
-
props: { title: 'Homes', caption: 'Updated 5 minutes ago', actions },
|
|
40
|
-
});
|
|
41
|
-
expect(screen.getByText('Updated 5 minutes ago')).toHaveClass('ah-caption');
|
|
42
|
-
expect(container.querySelector('header')).toHaveAttribute('data-has-caption');
|
|
43
|
-
});
|
|
44
|
-
it('defaults to h1 and renders no action cluster without actions', () => {
|
|
45
|
-
const { container } = render(ActionHeader, { props: { title: 'Account' } });
|
|
46
|
-
expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument();
|
|
47
|
-
expect(container.querySelector('.ah-actions')).toBeNull();
|
|
48
|
-
expect(screen.queryByRole('button')).toBeNull();
|
|
49
|
-
});
|
|
50
|
-
it('renders a labelled back link', () => {
|
|
51
|
-
render(ActionHeader, { props: { title: 'Sign 12', backHref: '/signs', backLabel: 'Signs' } });
|
|
52
|
-
const back = screen.getByRole('link', { name: 'Back to Signs' });
|
|
53
|
-
expect(back).toHaveAttribute('href', '/signs');
|
|
54
|
-
});
|
|
55
|
-
it('places primary and secondary actions inline, and omits hidden ones', () => {
|
|
56
|
-
const { container } = render(ActionHeader, { props: { title: 'Signs', actions } });
|
|
57
|
-
const secondary = container.querySelector('.ah-secondary');
|
|
58
|
-
expect(within(secondary).getByRole('button', { name: 'Export' })).toBeInTheDocument();
|
|
59
|
-
expect(screen.getByRole('button', { name: 'Add sign' })).toBeInTheDocument();
|
|
60
|
-
expect(screen.queryByText('Secret')).toBeNull();
|
|
61
|
-
});
|
|
62
|
-
it('wide menu holds only overflow actions, destructive last', async () => {
|
|
63
|
-
const { container } = render(ActionHeader, { props: { title: 'Signs', actions } });
|
|
64
|
-
const menu = await openMenu(container, '.ah-menu-wide');
|
|
65
|
-
const names = within(menu)
|
|
66
|
-
.getAllByRole('menuitem')
|
|
67
|
-
.map((el) => el.textContent?.trim());
|
|
68
|
-
expect(names).toEqual(['Archive', 'Delete']);
|
|
69
|
-
const separator = within(menu).queryByRole('separator') ?? menu.querySelector('.h-px');
|
|
70
|
-
expect(separator).not.toBeNull();
|
|
71
|
-
});
|
|
72
|
-
it('narrow menu folds secondary actions in before overflow', async () => {
|
|
73
|
-
const { container } = render(ActionHeader, { props: { title: 'Signs', actions } });
|
|
74
|
-
const menu = await openMenu(container, '.ah-menu-narrow');
|
|
75
|
-
const names = within(menu)
|
|
76
|
-
.getAllByRole('menuitem')
|
|
77
|
-
.map((el) => el.textContent?.trim());
|
|
78
|
-
expect(names).toEqual(['Export', 'Archive', 'Delete']);
|
|
79
|
-
});
|
|
80
|
-
it('with no secondary actions renders a single overflow menu', () => {
|
|
81
|
-
const { container } = render(ActionHeader, {
|
|
82
|
-
props: {
|
|
83
|
-
title: 'Sign',
|
|
84
|
-
actions: [
|
|
85
|
-
{ label: 'Check in', priority: 'primary' },
|
|
86
|
-
{ label: 'Delete', priority: 'overflow', destructive: true },
|
|
87
|
-
],
|
|
88
|
-
},
|
|
89
|
-
});
|
|
90
|
-
expect(container.querySelector('.ah-menu-narrow')).toBeNull();
|
|
91
|
-
expect(screen.getAllByRole('button', { name: 'More actions' })).toHaveLength(1);
|
|
92
|
-
});
|
|
93
|
-
it('collapseAt="never" keeps every non-primary action inline with no menu', () => {
|
|
94
|
-
render(ActionHeader, { props: { title: 'Signs', actions, collapseAt: 'never' } });
|
|
95
|
-
expect(screen.getByRole('button', { name: 'Archive' })).toBeInTheDocument();
|
|
96
|
-
expect(screen.queryByRole('button', { name: 'More actions' })).toBeNull();
|
|
97
|
-
});
|
|
98
|
-
it('auto collapse uses lg with 2+ secondary actions, md otherwise', () => {
|
|
99
|
-
const { container, unmount } = render(ActionHeader, { props: { title: 'A', actions } });
|
|
100
|
-
expect(container.querySelector('header')).toHaveAttribute('data-collapse', 'md');
|
|
101
|
-
unmount();
|
|
102
|
-
const two = [{ label: 'One' }, { label: 'Two' }];
|
|
103
|
-
const second = render(ActionHeader, { props: { title: 'B', actions: two } });
|
|
104
|
-
expect(second.container.querySelector('header')).toHaveAttribute('data-collapse', 'lg');
|
|
105
|
-
});
|
|
106
|
-
it('calls onSelect for inline and menu actions', async () => {
|
|
107
|
-
const onPrimary = vi.fn();
|
|
108
|
-
const onOverflow = vi.fn();
|
|
109
|
-
const { container } = render(ActionHeader, {
|
|
110
|
-
props: {
|
|
111
|
-
title: 'Signs',
|
|
112
|
-
actions: [
|
|
113
|
-
{ label: 'Add', priority: 'primary', onSelect: onPrimary },
|
|
114
|
-
{ label: 'Archive', priority: 'overflow', onSelect: onOverflow },
|
|
115
|
-
],
|
|
116
|
-
},
|
|
117
|
-
});
|
|
118
|
-
await user.click(screen.getByRole('button', { name: 'Add' }));
|
|
119
|
-
expect(onPrimary).toHaveBeenCalledOnce();
|
|
120
|
-
const menu = await openMenu(container, '.ah');
|
|
121
|
-
await user.click(within(menu).getByRole('menuitem', { name: 'Archive' }));
|
|
122
|
-
expect(onOverflow).toHaveBeenCalledOnce();
|
|
123
|
-
});
|
|
124
|
-
it('renders badges, tools and toolbar snippets', () => {
|
|
125
|
-
render(ActionHeader, {
|
|
126
|
-
props: {
|
|
127
|
-
title: 'Sign 12',
|
|
128
|
-
badges: snippet('<span>Active</span>'),
|
|
129
|
-
tools: snippet('<button type="button" aria-label="Related">R</button>'),
|
|
130
|
-
toolbar: snippet('<div>View toggle</div>'),
|
|
131
|
-
},
|
|
132
|
-
});
|
|
133
|
-
expect(screen.getByText('Active')).toBeInTheDocument();
|
|
134
|
-
expect(screen.getByRole('button', { name: 'Related' })).toBeInTheDocument();
|
|
135
|
-
expect(screen.getByText('View toggle')).toBeInTheDocument();
|
|
136
|
-
});
|
|
137
|
-
it('has no axe violations', async () => {
|
|
138
|
-
const { container } = render(ActionHeader, {
|
|
139
|
-
props: {
|
|
140
|
-
title: 'Sign 12',
|
|
141
|
-
subtitle: 'Main entrance monument',
|
|
142
|
-
backHref: '/signs',
|
|
143
|
-
backLabel: 'Signs',
|
|
144
|
-
actions,
|
|
145
|
-
badges: snippet('<span>Active</span>'),
|
|
146
|
-
},
|
|
147
|
-
});
|
|
148
|
-
expect(await axe(container)).toHaveNoViolations();
|
|
149
|
-
});
|
|
150
|
-
});
|
|
@@ -1,54 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* CodeField: Tab indents instead of leaving the field, with Escape as the way
|
|
3
|
-
* out, and indenting a selection works line by line.
|
|
4
|
-
*/
|
|
5
|
-
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
6
|
-
import { mount, unmount, flushSync } from 'svelte';
|
|
7
|
-
import CodeField from '../lib/components/CodeField.svelte';
|
|
8
|
-
let instance = null;
|
|
9
|
-
function render(props) {
|
|
10
|
-
const host = document.createElement('div');
|
|
11
|
-
document.body.appendChild(host);
|
|
12
|
-
instance = mount(CodeField, { target: host, props });
|
|
13
|
-
flushSync();
|
|
14
|
-
return host.querySelector('textarea');
|
|
15
|
-
}
|
|
16
|
-
afterEach(() => {
|
|
17
|
-
if (instance)
|
|
18
|
-
unmount(instance);
|
|
19
|
-
instance = null;
|
|
20
|
-
document.body.innerHTML = '';
|
|
21
|
-
});
|
|
22
|
-
function press(field, key, shiftKey = false) {
|
|
23
|
-
const event = new KeyboardEvent('keydown', { key, shiftKey, bubbles: true, cancelable: true });
|
|
24
|
-
field.dispatchEvent(event);
|
|
25
|
-
return event;
|
|
26
|
-
}
|
|
27
|
-
describe('CodeField', () => {
|
|
28
|
-
it('is a labelled, monospaced, unspellchecked field with its keyboard hint', () => {
|
|
29
|
-
const field = render({ value: 'graph TD', label: 'Diagram source' });
|
|
30
|
-
expect(field.getAttribute('aria-label')).toBe('Diagram source');
|
|
31
|
-
expect(field.getAttribute('spellcheck')).toBe('false');
|
|
32
|
-
expect(field.className).toContain('font-mono');
|
|
33
|
-
const hint = document.getElementById(field.getAttribute('aria-describedby').split(' ')[0]);
|
|
34
|
-
expect(hint?.textContent).toMatch(/Escape, then Tab/);
|
|
35
|
-
});
|
|
36
|
-
it('indents at the cursor on Tab and reports the new value', () => {
|
|
37
|
-
const onValueChange = vi.fn();
|
|
38
|
-
const field = render({ value: 'ab', label: 'Code', onValueChange });
|
|
39
|
-
field.setSelectionRange(1, 1);
|
|
40
|
-
expect(press(field, 'Tab').defaultPrevented).toBe(true);
|
|
41
|
-
expect(field.value).toBe('a b');
|
|
42
|
-
expect(onValueChange).toHaveBeenLastCalledWith('a b');
|
|
43
|
-
});
|
|
44
|
-
it('indents and outdents every selected line', () => {
|
|
45
|
-
const field = render({ value: 'one\ntwo\nthree', label: 'Code' });
|
|
46
|
-
field.setSelectionRange(0, 7);
|
|
47
|
-
press(field, 'Tab');
|
|
48
|
-
expect(field.value).toBe(' one\n two\nthree');
|
|
49
|
-
field.setSelectionRange(0, 11);
|
|
50
|
-
press(field, 'Tab', true);
|
|
51
|
-
expect(field.value).toBe('one\ntwo\nthree');
|
|
52
|
-
});
|
|
53
|
-
it('lets Tab leave after Escape', () => {
|
|
54
|
-
const field = render({ value: 'x', label: 'Code' });
|
|
55
|
-
press(field, 'Escape');
|
|
56
|
-
expect(press(field, 'Tab').defaultPrevented).toBe(false);
|
|
57
|
-
expect(field.value).toBe('x');
|
|
58
|
-
// and only once
|
|
59
|
-
expect(press(field, 'Tab').defaultPrevented).toBe(true);
|
|
60
|
-
});
|
|
61
|
-
it('does not indent a read-only field', () => {
|
|
62
|
-
const field = render({ value: 'x', label: 'Code', readonly: true });
|
|
63
|
-
expect(press(field, 'Tab').defaultPrevented).toBe(false);
|
|
64
|
-
});
|
|
65
|
-
});
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { render, screen } from '@testing-library/svelte';
|
|
3
|
-
import Input from '../lib/components/Input.svelte';
|
|
4
|
-
import Textarea from '../lib/components/Textarea.svelte';
|
|
5
|
-
import Select from '../lib/components/Select.svelte';
|
|
6
|
-
import PasswordInput from '../lib/components/PasswordInput.svelte';
|
|
7
|
-
// Form controls paint their own light surface (bg-background), so they must
|
|
8
|
-
// also set their own text color. Otherwise a dark parent that sets white text
|
|
9
|
-
// (Toolbar's text-toolbar-foreground) leaks into the value: white on white.
|
|
10
|
-
describe('light-surface controls own their text color', () => {
|
|
11
|
-
it('Input', () => {
|
|
12
|
-
render(Input, { props: { 'aria-label': 'Search' } });
|
|
13
|
-
expect(screen.getByRole('textbox', { name: 'Search' })).toHaveClass('bg-background', 'text-foreground');
|
|
14
|
-
});
|
|
15
|
-
it('Textarea', () => {
|
|
16
|
-
render(Textarea, { props: { 'aria-label': 'Notes' } });
|
|
17
|
-
expect(screen.getByRole('textbox', { name: 'Notes' })).toHaveClass('bg-background', 'text-foreground');
|
|
18
|
-
});
|
|
19
|
-
it('PasswordInput', () => {
|
|
20
|
-
const { container } = render(PasswordInput, { props: { id: 'pw' } });
|
|
21
|
-
expect(container.querySelector('#pw')).toHaveClass('bg-background', 'text-foreground');
|
|
22
|
-
});
|
|
23
|
-
it('Select trigger', () => {
|
|
24
|
-
const { container } = render(Select, {
|
|
25
|
-
props: { id: 'status', options: [{ value: 'a', label: 'Active' }], value: 'a' },
|
|
26
|
-
});
|
|
27
|
-
expect(container.querySelector('#status')).toHaveClass('bg-background', 'text-foreground');
|
|
28
|
-
});
|
|
29
|
-
});
|
|
@@ -1,138 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* DataPanel phone card: gestures and layout.
|
|
3
|
-
*
|
|
4
|
-
* - A cancelled pointer (iOS's swipe up from the home indicator to leave the
|
|
5
|
-
* app) must not expand the card. It used to be handled like pointerup, so a
|
|
6
|
-
* quick cancel read as a tap.
|
|
7
|
-
* - The phone card is laid out at its expanded height and slid with
|
|
8
|
-
* `translate`, padded by the safe-area inset so its collapsed header sits
|
|
9
|
-
* above the home indicator.
|
|
10
|
-
*/
|
|
11
|
-
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
12
|
-
import { mount, unmount, flushSync, createRawSnippet } from 'svelte';
|
|
13
|
-
import DataPanel from '../lib/components/DataPanel.svelte';
|
|
14
|
-
const children = createRawSnippet(() => ({ render: () => '<p>Legend body</p>' }));
|
|
15
|
-
let instance = null;
|
|
16
|
-
let host;
|
|
17
|
-
function pointer(type, target, y) {
|
|
18
|
-
const event = new MouseEvent(type, { bubbles: true, clientX: 100, clientY: y });
|
|
19
|
-
Object.defineProperty(event, 'pointerId', { value: 1 });
|
|
20
|
-
target.dispatchEvent(event);
|
|
21
|
-
}
|
|
22
|
-
function mountCard(props = {}) {
|
|
23
|
-
host = document.createElement('div');
|
|
24
|
-
document.body.appendChild(host);
|
|
25
|
-
instance = mount(DataPanel, {
|
|
26
|
-
target: host,
|
|
27
|
-
props: {
|
|
28
|
-
title: 'Legend & Information',
|
|
29
|
-
variant: 'card',
|
|
30
|
-
disableClose: true,
|
|
31
|
-
mobileBreakpoint: 768,
|
|
32
|
-
children,
|
|
33
|
-
...props,
|
|
34
|
-
},
|
|
35
|
-
});
|
|
36
|
-
flushSync();
|
|
37
|
-
return {
|
|
38
|
-
panel: host.querySelector('[role="dialog"]'),
|
|
39
|
-
header: host.querySelector('[data-panel-header]'),
|
|
40
|
-
};
|
|
41
|
-
}
|
|
42
|
-
beforeEach(() => {
|
|
43
|
-
var _a, _b, _c, _d;
|
|
44
|
-
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(390);
|
|
45
|
-
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(800);
|
|
46
|
-
vi.spyOn(document.documentElement, 'clientWidth', 'get').mockReturnValue(390);
|
|
47
|
-
// jsdom has no pointer capture
|
|
48
|
-
(_a = HTMLElement.prototype).setPointerCapture ?? (_a.setPointerCapture = () => { });
|
|
49
|
-
(_b = HTMLElement.prototype).releasePointerCapture ?? (_b.releasePointerCapture = () => { });
|
|
50
|
-
(_c = HTMLElement.prototype).hasPointerCapture ?? (_c.hasPointerCapture = () => false);
|
|
51
|
-
// ...nor Web Animations, which the scrim's fade transition uses
|
|
52
|
-
(_d = Element.prototype).animate ?? (_d.animate = function () {
|
|
53
|
-
const animation = {
|
|
54
|
-
onfinish: null,
|
|
55
|
-
cancel() { },
|
|
56
|
-
finished: Promise.resolve(),
|
|
57
|
-
};
|
|
58
|
-
queueMicrotask(() => animation.onfinish?.());
|
|
59
|
-
return animation;
|
|
60
|
-
});
|
|
61
|
-
});
|
|
62
|
-
afterEach(() => {
|
|
63
|
-
if (instance)
|
|
64
|
-
unmount(instance);
|
|
65
|
-
instance = null;
|
|
66
|
-
host?.remove();
|
|
67
|
-
vi.useRealTimers();
|
|
68
|
-
vi.restoreAllMocks();
|
|
69
|
-
});
|
|
70
|
-
describe('DataPanel card pointer cancel', () => {
|
|
71
|
-
it('does not expand when the OS cancels the pointer', () => {
|
|
72
|
-
const onExpandedChange = vi.fn();
|
|
73
|
-
const { header } = mountCard({ expanded: false, onExpandedChange });
|
|
74
|
-
pointer('pointerdown', header, 790);
|
|
75
|
-
flushSync();
|
|
76
|
-
pointer('pointercancel', header, 790);
|
|
77
|
-
flushSync();
|
|
78
|
-
expect(onExpandedChange).not.toHaveBeenCalled();
|
|
79
|
-
});
|
|
80
|
-
it('does not expand on a fast upward cancel either', () => {
|
|
81
|
-
const onExpandedChange = vi.fn();
|
|
82
|
-
const { header } = mountCard({ expanded: false, onExpandedChange });
|
|
83
|
-
pointer('pointerdown', header, 790);
|
|
84
|
-
flushSync();
|
|
85
|
-
pointer('pointermove', header, 700);
|
|
86
|
-
flushSync();
|
|
87
|
-
pointer('pointercancel', header, 600);
|
|
88
|
-
flushSync();
|
|
89
|
-
expect(onExpandedChange).not.toHaveBeenCalled();
|
|
90
|
-
});
|
|
91
|
-
it('still expands on a tap', () => {
|
|
92
|
-
const onExpandedChange = vi.fn();
|
|
93
|
-
const { header } = mountCard({ expanded: false, onExpandedChange });
|
|
94
|
-
pointer('pointerdown', header, 790);
|
|
95
|
-
flushSync();
|
|
96
|
-
pointer('pointerup', header, 790);
|
|
97
|
-
flushSync();
|
|
98
|
-
expect(onExpandedChange).toHaveBeenCalledWith(true);
|
|
99
|
-
});
|
|
100
|
-
});
|
|
101
|
-
describe('DataPanel phone card layout', () => {
|
|
102
|
-
it('slides a full-height sheet instead of resizing it, clear of the home indicator', () => {
|
|
103
|
-
const { panel, header } = mountCard({ expanded: false });
|
|
104
|
-
// 0.85 of an 800px viewport
|
|
105
|
-
// jsdom cannot parse env() (it drops padding-bottom and garbles height),
|
|
106
|
-
// so the safe-area padding itself is checked in a browser
|
|
107
|
-
expect(panel.style.height).toContain('680px');
|
|
108
|
-
expect(panel.style.height).toContain('safe-area-inset-bottom');
|
|
109
|
-
// Collapsed: everything but the 72px header is below the fold
|
|
110
|
-
expect(panel.style.getPropertyValue('translate')).toBe('0 608px');
|
|
111
|
-
expect(header.classList.contains('touch-none')).toBe(true);
|
|
112
|
-
});
|
|
113
|
-
it('keeps content mounted while the card animates closed', () => {
|
|
114
|
-
vi.useFakeTimers();
|
|
115
|
-
// Uncontrolled: the chevron drives expanded state
|
|
116
|
-
const { panel } = mountCard();
|
|
117
|
-
const chevron = () => host.querySelector('button[title="Expand"], button[title="Collapse"]');
|
|
118
|
-
if (chevron().title === 'Expand')
|
|
119
|
-
chevron().click();
|
|
120
|
-
flushSync();
|
|
121
|
-
expect(panel.style.getPropertyValue('translate')).toBe('0 0px');
|
|
122
|
-
expect(host.textContent).toContain('Legend body');
|
|
123
|
-
chevron().click();
|
|
124
|
-
flushSync();
|
|
125
|
-
expect(panel.style.getPropertyValue('translate')).toBe('0 608px');
|
|
126
|
-
expect(host.textContent).toContain('Legend body');
|
|
127
|
-
vi.advanceTimersByTime(400);
|
|
128
|
-
flushSync();
|
|
129
|
-
expect(host.textContent).not.toContain('Legend body');
|
|
130
|
-
});
|
|
131
|
-
it('leaves the desktop card sized by height', () => {
|
|
132
|
-
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1280);
|
|
133
|
-
vi.spyOn(document.documentElement, 'clientWidth', 'get').mockReturnValue(1280);
|
|
134
|
-
const { panel } = mountCard({ expanded: false });
|
|
135
|
-
expect(panel.style.height).toBe('72px');
|
|
136
|
-
expect(panel.style.getPropertyValue('translate')).toBe('');
|
|
137
|
-
});
|
|
138
|
-
});
|
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* DataPanel card sizing uses the layout viewport, not window.innerWidth.
|
|
3
|
-
*
|
|
4
|
-
* innerWidth includes a vertical scrollbar; the layout width does not. With
|
|
5
|
-
* a 15px scrollbar the card used to be 16px from the left edge and 1px from
|
|
6
|
-
* the right, because both its width and its centred offset came from the
|
|
7
|
-
* wider number.
|
|
8
|
-
*/
|
|
9
|
-
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
10
|
-
import { mount, unmount, flushSync, createRawSnippet } from 'svelte';
|
|
11
|
-
import DataPanel from '../lib/components/DataPanel.svelte';
|
|
12
|
-
const children = createRawSnippet(() => ({ render: () => '<p>Legend</p>' }));
|
|
13
|
-
let instance = null;
|
|
14
|
-
afterEach(() => {
|
|
15
|
-
if (instance)
|
|
16
|
-
unmount(instance);
|
|
17
|
-
instance = null;
|
|
18
|
-
vi.restoreAllMocks();
|
|
19
|
-
});
|
|
20
|
-
describe('DataPanel card viewport width', () => {
|
|
21
|
-
it('insets the card equally when a scrollbar makes innerWidth wider than the layout', () => {
|
|
22
|
-
// 390px window with a 15px vertical scrollbar: 375px of layout
|
|
23
|
-
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(390);
|
|
24
|
-
vi.spyOn(document.documentElement, 'clientWidth', 'get').mockReturnValue(375);
|
|
25
|
-
const host = document.createElement('div');
|
|
26
|
-
document.body.appendChild(host);
|
|
27
|
-
instance = mount(DataPanel, {
|
|
28
|
-
target: host,
|
|
29
|
-
props: {
|
|
30
|
-
title: 'Legend & Information',
|
|
31
|
-
variant: 'card',
|
|
32
|
-
expanded: false,
|
|
33
|
-
disableClose: true,
|
|
34
|
-
mobileBreakpoint: 768,
|
|
35
|
-
cardConfig: { maxWidth: 480, horizontalMargin: 16 },
|
|
36
|
-
children,
|
|
37
|
-
},
|
|
38
|
-
});
|
|
39
|
-
flushSync();
|
|
40
|
-
const panel = host.querySelector('[role="dialog"]');
|
|
41
|
-
expect(panel.style.left).toBe('16px');
|
|
42
|
-
// 375 - 2 * 16, so the right margin is 16px too
|
|
43
|
-
expect(panel.style.width).toBe('343px');
|
|
44
|
-
});
|
|
45
|
-
});
|
|
@@ -1,39 +0,0 @@
|
|
|
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
|
-
});
|