@classic-homes/theme-svelte 1.22.0 → 1.22.2

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.
Files changed (110) hide show
  1. package/dist/lib/components/Alert.svelte +8 -10
  2. package/dist/lib/components/Combobox.svelte +2 -2
  3. package/dist/lib/components/DataPanel.svelte +19 -5
  4. package/dist/lib/components/DataTable.svelte +15 -6
  5. package/dist/lib/components/DataTable.svelte.d.ts +1 -1
  6. package/dist/lib/components/DateTimePicker.svelte +31 -34
  7. package/dist/lib/components/DropdownMenu.svelte +52 -30
  8. package/dist/lib/components/DropdownMenu.svelte.d.ts +5 -0
  9. package/dist/lib/components/FileUpload.svelte +5 -19
  10. package/dist/lib/components/HeaderSearch.svelte +18 -10
  11. package/dist/lib/components/MultiSelect.svelte +2 -2
  12. package/dist/lib/components/NumberInput.svelte +15 -2
  13. package/dist/lib/components/Select.svelte +19 -5
  14. package/dist/lib/components/Select.svelte.d.ts +4 -0
  15. package/dist/lib/components/Signature.svelte +15 -4
  16. package/dist/lib/components/Signature.svelte.d.ts +1 -1
  17. package/dist/lib/components/Skeleton.svelte.d.ts +1 -1
  18. package/dist/lib/components/Slider.svelte +8 -6
  19. package/dist/lib/components/Tabs.svelte +10 -12
  20. package/dist/lib/components/Toast.svelte +12 -12
  21. package/dist/lib/components/data-table/DataTableColumnVisibility.svelte +24 -26
  22. package/dist/lib/components/data-table/DataTableEditableCell.svelte +31 -18
  23. package/dist/lib/components/data-table/DataTableEditableCell.svelte.d.ts +0 -7
  24. package/dist/lib/components/data-table/DataTablePagination.svelte +5 -1
  25. package/dist/lib/components/data-table/DataTableVirtual.svelte +4 -7
  26. package/dist/lib/components/data-table/DataTableVirtual.svelte.d.ts +3 -0
  27. package/dist/lib/components/data-table/createDataTable.svelte.d.ts +6 -1
  28. package/dist/lib/components/data-table/createDataTable.svelte.js +22 -9
  29. package/dist/lib/components/layout/AppShell.svelte +1 -1
  30. package/dist/lib/components/layout/DashboardLayout.svelte +15 -6
  31. package/dist/lib/components/layout/Footer.svelte +6 -6
  32. package/dist/lib/components/layout/FormPageLayout.svelte +4 -3
  33. package/dist/lib/components/layout/Sidebar.svelte +53 -21
  34. package/dist/lib/components/media/Image.svelte.d.ts +1 -1
  35. package/dist/lib/components/media/Lightbox.svelte +13 -9
  36. package/dist/lib/components/media/MediaCarousel.svelte +5 -0
  37. package/dist/lib/components/media/MediaGrid.svelte +4 -1
  38. package/dist/lib/components/notifications/NotificationDialog.svelte +8 -5
  39. package/dist/lib/components/notifications/NotificationItem.svelte +16 -6
  40. package/dist/lib/components/studio/InsertPalette.svelte +4 -1
  41. package/dist/lib/components/studio/StudioCanvas.svelte +23 -2
  42. package/dist/lib/components/studio/StudioCanvas.svelte.d.ts +5 -1
  43. package/dist/lib/composables/useAsync.svelte.d.ts +1 -1
  44. package/dist/lib/composables/useAsync.svelte.js +33 -14
  45. package/dist/lib/composables/useFocusTrap.svelte.js +9 -2
  46. package/dist/lib/composables/useForm.svelte.js +19 -7
  47. package/dist/lib/composables/usePersistedForm.svelte.js +37 -10
  48. package/dist/lib/notices/sanitize.js +10 -3
  49. package/dist/lib/notices/store.d.ts +3 -3
  50. package/dist/lib/notices/store.js +14 -2
  51. package/dist/lib/notices/types.d.ts +8 -8
  52. package/dist/lib/notices/types.js +5 -1
  53. package/dist/lib/stores/sidebar.svelte.d.ts +12 -0
  54. package/dist/lib/stores/sidebar.svelte.js +76 -26
  55. package/dist/lib/stores/toast.svelte.js +12 -1
  56. package/dist/lib/utils/date.d.ts +3 -1
  57. package/dist/lib/utils/date.js +54 -34
  58. package/dist/lib/utils/form.d.ts +2 -1
  59. package/dist/lib/utils/form.js +3 -2
  60. package/dist/lib/utils/media.js +4 -3
  61. package/dist/lib/utils/url.d.ts +10 -0
  62. package/dist/lib/utils/url.js +21 -0
  63. package/dist/lib/utils.js +14 -1
  64. package/package.json +5 -4
  65. package/dist/__tests__/FileUpload.test.js +0 -45
  66. package/dist/__tests__/a11y/Alert.a11y.test.js +0 -84
  67. package/dist/__tests__/a11y/AlertDialog.a11y.test.js +0 -126
  68. package/dist/__tests__/a11y/Button.a11y.test.js +0 -66
  69. package/dist/__tests__/a11y/Card.a11y.test.js +0 -77
  70. package/dist/__tests__/a11y/Combobox.a11y.test.js +0 -209
  71. package/dist/__tests__/a11y/Dialog.a11y.test.js +0 -103
  72. package/dist/__tests__/a11y/DropdownMenu.a11y.test.js +0 -132
  73. package/dist/__tests__/a11y/FileUpload.a11y.test.js +0 -158
  74. package/dist/__tests__/a11y/FilterToolbar.a11y.test.js +0 -62
  75. package/dist/__tests__/a11y/Icon.a11y.test.js +0 -119
  76. package/dist/__tests__/a11y/Input.a11y.test.js +0 -96
  77. package/dist/__tests__/a11y/LiveRegion.a11y.test.js +0 -102
  78. package/dist/__tests__/a11y/Media.a11y.test.js +0 -145
  79. package/dist/__tests__/a11y/PublicLayout.a11y.test.js +0 -139
  80. package/dist/__tests__/a11y/SegmentedControl.a11y.test.js +0 -50
  81. package/dist/__tests__/a11y/Switch.a11y.test.js +0 -95
  82. package/dist/__tests__/a11y/Tabs.a11y.test.js +0 -116
  83. package/dist/__tests__/a11y/Textarea.a11y.test.js +0 -111
  84. package/dist/__tests__/a11y/Toolbar.a11y.test.js +0 -31
  85. package/dist/__tests__/a11y/ViewToggle.a11y.test.js +0 -40
  86. package/dist/__tests__/action-header.test.js +0 -150
  87. package/dist/__tests__/alert-dialog-dismiss.test.js +0 -54
  88. package/dist/__tests__/code-field.test.js +0 -65
  89. package/dist/__tests__/control-text-color.test.js +0 -29
  90. package/dist/__tests__/data-panel-card-gestures.test.js +0 -138
  91. package/dist/__tests__/data-panel-viewport.test.js +0 -45
  92. package/dist/__tests__/dropdown-trigger.test.js +0 -39
  93. package/dist/__tests__/header-search.test.js +0 -90
  94. package/dist/__tests__/media-components.test.js +0 -206
  95. package/dist/__tests__/media-hls.test.js +0 -273
  96. package/dist/__tests__/media-matterport.test.js +0 -296
  97. package/dist/__tests__/media-players.test.js +0 -244
  98. package/dist/__tests__/media-scrubber.test.js +0 -44
  99. package/dist/__tests__/media-utils.test.js +0 -427
  100. package/dist/__tests__/nav-access.test.js +0 -145
  101. package/dist/__tests__/page-header.test.js +0 -36
  102. package/dist/__tests__/public-layout-drawer.test.js +0 -116
  103. package/dist/__tests__/setup.js +0 -56
  104. package/dist/__tests__/sidebar-collapse.test.js +0 -58
  105. package/dist/__tests__/sidebar-docs-parity.test.js +0 -134
  106. package/dist/__tests__/sidebar-expansion.test.js +0 -59
  107. package/dist/__tests__/sidebar-items.test.js +0 -59
  108. package/dist/__tests__/sidebar-mobile.test.js +0 -86
  109. package/dist/__tests__/studio-canvas-item.svelte.test.js +0 -98
  110. package/dist/__tests__/studio.test.js +0 -240
@@ -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
- });
@@ -1,90 +0,0 @@
1
- import { describe, expect, it, vi } from 'vitest';
2
- import { render, screen, waitFor, within } from '@testing-library/svelte';
3
- import userEvent from '@testing-library/user-event';
4
- import { axe } from 'vitest-axe';
5
- import HeaderSearch from '../lib/components/HeaderSearch.svelte';
6
- const groups = [
7
- {
8
- title: 'Pages',
9
- items: [
10
- { id: 'p1', title: 'Dashboard', href: '/dashboard', icon: 'home' },
11
- { id: 'p2', title: 'Documents', href: '/documents' },
12
- ],
13
- },
14
- {
15
- title: 'Selections',
16
- items: [
17
- { id: 's1', title: 'Quartz countertop', description: 'Kitchen', category: 'Selection' },
18
- ],
19
- },
20
- ];
21
- async function openSearch(props = {}) {
22
- // A dialog left open by the previous test can leave pointer-events: none on body.
23
- const user = userEvent.setup({ pointerEventsCheck: 0 });
24
- const result = render(HeaderSearch, { props: { results: groups, ...props } });
25
- await user.click(screen.getByRole('button', { name: 'Search' }));
26
- const input = await screen.findByRole('combobox');
27
- return { user, input, ...result };
28
- }
29
- describe('HeaderSearch', () => {
30
- it('renders a heading for each result group', async () => {
31
- await openSearch();
32
- const pages = screen.getByRole('group', { name: 'Pages' });
33
- expect(within(pages).getAllByRole('option')).toHaveLength(2);
34
- const selections = screen.getByRole('group', { name: 'Selections' });
35
- expect(within(selections).getByRole('option', { name: /Quartz countertop/ })).toBeInTheDocument();
36
- });
37
- it('renders flat results without headings', async () => {
38
- const flat = [{ id: 'a', title: 'Alpha' }];
39
- await openSearch({ results: flat });
40
- expect(screen.getByRole('option', { name: /Alpha/ })).toBeInTheDocument();
41
- expect(screen.queryByText('Pages')).not.toBeInTheDocument();
42
- });
43
- it('moves the active result across groups and points the input at it', async () => {
44
- const { user, input } = await openSearch();
45
- const options = screen.getAllByRole('option');
46
- expect(input).toHaveAttribute('aria-activedescendant', options[0].id);
47
- await user.keyboard('{ArrowDown}{ArrowDown}');
48
- expect(input).toHaveAttribute('aria-activedescendant', options[2].id);
49
- expect(options[2]).toHaveAttribute('aria-selected', 'true');
50
- expect(options[0]).toHaveAttribute('aria-selected', 'false');
51
- });
52
- it('selects the active result on Enter and closes', async () => {
53
- const onSelect = vi.fn();
54
- const { user } = await openSearch({ onSelect });
55
- await user.keyboard('{ArrowDown}{ArrowDown}{Enter}');
56
- expect(onSelect).toHaveBeenCalledWith(groups[1].items[0]);
57
- await waitFor(() => expect(screen.queryByRole('combobox')).not.toBeInTheDocument());
58
- });
59
- it('shows result icons only when asked', async () => {
60
- const { unmount } = await openSearch();
61
- expect(screen.getByRole('option', { name: /Dashboard/ }).querySelector('svg')).toBeNull();
62
- unmount();
63
- await openSearch({ showIcons: true });
64
- expect(screen.getByRole('option', { name: /Dashboard/ }).querySelector('svg')).not.toBeNull();
65
- });
66
- it('switches secondary text and icon to the accent foreground on the active row', async () => {
67
- const { user } = await openSearch({ showIcons: true });
68
- const dashboard = screen.getByRole('option', { name: /Dashboard/ });
69
- const quartz = screen.getByRole('option', { name: /Quartz countertop/ });
70
- // Active row: icon and category follow the accent foreground.
71
- expect(dashboard.querySelector('svg')).toHaveClass('text-accent-foreground/80');
72
- // Inactive row: description and category stay muted.
73
- expect(within(quartz).getByText('Kitchen')).toHaveClass('text-muted-foreground');
74
- expect(within(quartz).getByText('Selection')).toHaveClass('text-muted-foreground');
75
- await user.hover(quartz);
76
- expect(within(quartz).getByText('Kitchen')).toHaveClass('text-accent-foreground/80');
77
- expect(within(quartz).getByText('Selection')).toHaveClass('text-accent-foreground/80');
78
- expect(dashboard.querySelector('svg')).toHaveClass('text-muted-foreground');
79
- });
80
- it('caps the results list at the given height', async () => {
81
- await openSearch({ resultsMaxHeight: '60vh' });
82
- const listbox = screen.getByRole('listbox');
83
- expect(listbox.parentElement).toHaveStyle({ maxHeight: '60vh' });
84
- });
85
- it('has no axe violations with grouped results open', async () => {
86
- await openSearch();
87
- const dialog = screen.getByRole('dialog');
88
- expect(await axe(dialog)).toHaveNoViolations();
89
- });
90
- });
@@ -1,206 +0,0 @@
1
- import { describe, expect, it, vi } from 'vitest';
2
- import { fireEvent, render, screen, waitFor } from '@testing-library/svelte';
3
- import userEvent from '@testing-library/user-event';
4
- import Image from '../lib/components/media/Image.svelte';
5
- import MediaEmbed from '../lib/components/media/MediaEmbed.svelte';
6
- import MediaGrid from '../lib/components/media/MediaGrid.svelte';
7
- import Lightbox from '../lib/components/media/Lightbox.svelte';
8
- const photos = [
9
- { id: '1', kind: 'image', src: '/one.jpg', alt: 'First photo' },
10
- { id: '2', kind: 'image', src: '/two.jpg', alt: 'Second photo' },
11
- { id: '3', kind: 'image', src: '/three.jpg', alt: 'Third photo' },
12
- ];
13
- describe('Image', () => {
14
- it('renders the image with its alt text and lazy-loads by default', () => {
15
- render(Image, { props: { src: '/photo.jpg', alt: 'A landscape' } });
16
- const img = screen.getByAltText('A landscape');
17
- expect(img).toBeInTheDocument();
18
- expect(img).toHaveAttribute('loading', 'lazy');
19
- expect(img).toHaveAttribute('decoding', 'async');
20
- });
21
- it('honours an explicit loading override', () => {
22
- render(Image, { props: { src: '/hero.jpg', alt: 'Hero', loading: 'eager' } });
23
- expect(screen.getByAltText('Hero')).toHaveAttribute('loading', 'eager');
24
- });
25
- it('swaps in the fallback when the source fails', async () => {
26
- render(Image, { props: { src: '/missing.jpg', alt: 'Missing' } });
27
- const img = screen.getByAltText('Missing');
28
- await fireEvent.error(img);
29
- await waitFor(() => {
30
- expect(screen.queryByAltText('Missing')).not.toBeInTheDocument();
31
- });
32
- // The fallback icon carries the alt text so the failure is still described.
33
- expect(screen.getByText('Failed to load: Missing')).toBeInTheDocument();
34
- });
35
- it('reports load and error through the callbacks', async () => {
36
- const onload = vi.fn();
37
- render(Image, { props: { src: '/photo.jpg', alt: 'Photo', onload } });
38
- await fireEvent.load(screen.getByAltText('Photo'));
39
- expect(onload).toHaveBeenCalledOnce();
40
- });
41
- it('applies sizing classes to the element that carries the size', async () => {
42
- // Regression: `class` used to land on the ratio box's inner, absolutely
43
- // inset element, so `w-24` and friends were silently dropped.
44
- const { container } = render(Image, {
45
- props: { src: '/photo.jpg', alt: 'Photo', ratio: 'square', class: 'w-24' },
46
- });
47
- const sized = container.querySelector('.w-24');
48
- expect(sized).not.toBeNull();
49
- expect(getComputedStyle(sized).position).not.toBe('absolute');
50
- });
51
- it('renders a blurred placeholder behind the image while loading', () => {
52
- const { container } = render(Image, {
53
- props: { src: '/photo.jpg', alt: 'Photo', placeholder: 'data:image/gif;base64,AAAA' },
54
- });
55
- const placeholder = container.querySelector('img[aria-hidden="true"]');
56
- expect(placeholder).toHaveAttribute('src', 'data:image/gif;base64,AAAA');
57
- // Decorative, so it must not be exposed as a second image.
58
- expect(placeholder).toHaveAttribute('alt', '');
59
- });
60
- });
61
- describe('MediaEmbed', () => {
62
- it('shows a facade and requests nothing from the provider before activation', () => {
63
- const { container } = render(MediaEmbed, {
64
- props: { url: 'https://youtu.be/dQw4w9WgXcQ', title: 'A song' },
65
- });
66
- expect(container.querySelector('iframe')).toBeNull();
67
- expect(screen.getByRole('button', { name: 'Play A song on YouTube' })).toBeInTheDocument();
68
- });
69
- it('injects a privacy-domain iframe once the facade is clicked', async () => {
70
- const user = userEvent.setup();
71
- const { container } = render(MediaEmbed, {
72
- props: { url: 'https://youtu.be/dQw4w9WgXcQ', title: 'A song' },
73
- });
74
- await user.click(screen.getByRole('button', { name: /Play A song/ }));
75
- const iframe = container.querySelector('iframe');
76
- expect(iframe).not.toBeNull();
77
- expect(iframe).toHaveAttribute('title', 'A song');
78
- expect(iframe?.getAttribute('src')).toContain('youtube-nocookie.com');
79
- // Autoplay is only added after the click, satisfying the user-gesture rule.
80
- expect(iframe?.getAttribute('src')).toContain('autoplay=1');
81
- });
82
- it('renders an error state for an unrecognised URL rather than a broken frame', () => {
83
- const { container } = render(MediaEmbed, {
84
- props: { url: 'https://example.com/video', title: 'Unknown' },
85
- });
86
- expect(container.querySelector('iframe')).toBeNull();
87
- expect(screen.getByRole('alert')).toHaveTextContent('This video could not be loaded.');
88
- });
89
- });
90
- describe('MediaGrid', () => {
91
- it('renders one labelled tile per item', () => {
92
- render(MediaGrid, { props: { items: photos, label: 'Project photos' } });
93
- expect(screen.getByRole('list', { name: 'Project photos' })).toBeInTheDocument();
94
- expect(screen.getByRole('button', { name: 'First photo' })).toBeInTheDocument();
95
- expect(screen.getByRole('button', { name: 'Third photo' })).toBeInTheDocument();
96
- });
97
- it('keeps a single tab stop by default', () => {
98
- render(MediaGrid, { props: { items: photos } });
99
- const tiles = screen.getAllByRole('button');
100
- expect(tiles[0]).toHaveAttribute('tabindex', '0');
101
- expect(tiles[1]).toHaveAttribute('tabindex', '-1');
102
- });
103
- it('makes every tile tabbable when singleTabStop is off', () => {
104
- render(MediaGrid, { props: { items: photos, singleTabStop: false } });
105
- for (const tile of screen.getAllByRole('button')) {
106
- expect(tile).toHaveAttribute('tabindex', '0');
107
- }
108
- });
109
- it('moves focus with the arrow keys', async () => {
110
- render(MediaGrid, { props: { items: photos, lightbox: false } });
111
- const tiles = screen.getAllByRole('button');
112
- tiles[0].focus();
113
- await fireEvent.keyDown(screen.getByRole('list'), { key: 'ArrowRight' });
114
- expect(document.activeElement).toBe(tiles[1]);
115
- await fireEvent.keyDown(screen.getByRole('list'), { key: 'End' });
116
- expect(document.activeElement).toBe(tiles[2]);
117
- await fireEvent.keyDown(screen.getByRole('list'), { key: 'Home' });
118
- expect(document.activeElement).toBe(tiles[0]);
119
- });
120
- it('reports the selected index without opening a lightbox when disabled', async () => {
121
- const user = userEvent.setup();
122
- const onSelect = vi.fn();
123
- render(MediaGrid, { props: { items: photos, lightbox: false, onSelect } });
124
- await user.click(screen.getByRole('button', { name: 'Second photo' }));
125
- expect(onSelect).toHaveBeenCalledWith(1, photos[1]);
126
- expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
127
- });
128
- it('renders an empty state rather than a bare list', () => {
129
- render(MediaGrid, { props: { items: [] } });
130
- expect(screen.getByText('No media to show.')).toBeInTheDocument();
131
- expect(screen.queryByRole('list')).not.toBeInTheDocument();
132
- });
133
- });
134
- describe('Lightbox', () => {
135
- it('renders nothing until it is opened', () => {
136
- render(Lightbox, { props: { items: photos } });
137
- expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
138
- });
139
- it('exposes an accessible name taken from the active item', async () => {
140
- render(Lightbox, { props: { items: photos, open: true } });
141
- const dialog = await screen.findByRole('dialog');
142
- expect(dialog).toHaveAccessibleName('First photo');
143
- });
144
- it('navigates between items with the arrow keys', async () => {
145
- render(Lightbox, { props: { items: photos, open: true } });
146
- const dialog = await screen.findByRole('dialog');
147
- expect(dialog).toHaveAccessibleName('First photo');
148
- await fireEvent.keyDown(dialog, { key: 'ArrowRight' });
149
- await waitFor(() => expect(dialog).toHaveAccessibleName('Second photo'));
150
- await fireEvent.keyDown(dialog, { key: 'End' });
151
- await waitFor(() => expect(dialog).toHaveAccessibleName('Third photo'));
152
- // Wraps by default, so Next from the last item returns to the first.
153
- await fireEvent.keyDown(dialog, { key: 'ArrowRight' });
154
- await waitFor(() => expect(dialog).toHaveAccessibleName('First photo'));
155
- });
156
- it('stops at the ends when looping is disabled', async () => {
157
- render(Lightbox, { props: { items: photos, open: true, loop: false } });
158
- const dialog = await screen.findByRole('dialog');
159
- await fireEvent.keyDown(dialog, { key: 'ArrowLeft' });
160
- await waitFor(() => expect(dialog).toHaveAccessibleName('First photo'));
161
- });
162
- it('offers labelled navigation and close controls', async () => {
163
- render(Lightbox, { props: { items: photos, open: true } });
164
- await screen.findByRole('dialog');
165
- expect(screen.getByRole('button', { name: 'Previous item' })).toBeInTheDocument();
166
- expect(screen.getByRole('button', { name: 'Next item' })).toBeInTheDocument();
167
- expect(screen.getByRole('button', { name: 'Close viewer' })).toBeInTheDocument();
168
- });
169
- it('hides the navigation controls for a single item', async () => {
170
- render(Lightbox, { props: { items: [photos[0]], open: true } });
171
- await screen.findByRole('dialog');
172
- expect(screen.queryByRole('button', { name: 'Next item' })).not.toBeInTheDocument();
173
- });
174
- it('renders an empty state instead of a blank stage', async () => {
175
- render(Lightbox, { props: { items: [], open: true } });
176
- await screen.findByRole('dialog');
177
- expect(screen.getByText('No media to show.')).toBeInTheDocument();
178
- });
179
- it('keeps a zoom applied instead of snapping it straight back', async () => {
180
- // Regression: the effect that resets zoom on navigation used to read the
181
- // zoom state while tracking, so it subscribed to the scale it had just
182
- // written and undid every zoom immediately.
183
- const user = userEvent.setup();
184
- render(Lightbox, { props: { items: photos, open: true } });
185
- const dialog = await screen.findByRole('dialog');
186
- const image = dialog.querySelector('img');
187
- expect(image.style.transform).toContain('scale(1)');
188
- await user.click(screen.getByRole('button', { name: 'Zoom in' }));
189
- await waitFor(() => expect(image.style.transform).toContain('scale(1.5)'));
190
- // Zoom out is only enabled once zoomed, so its state proves the same thing.
191
- expect(screen.getByRole('button', { name: 'Zoom out' })).toBeEnabled();
192
- });
193
- it('resets zoom when the item changes', async () => {
194
- const user = userEvent.setup();
195
- render(Lightbox, { props: { items: photos, open: true } });
196
- const dialog = await screen.findByRole('dialog');
197
- await user.click(screen.getByRole('button', { name: 'Zoom in' }));
198
- await waitFor(() => expect(dialog.querySelector('img').style.transform).toContain('scale(1.5)'));
199
- await user.click(screen.getByRole('button', { name: 'Next item' }));
200
- await waitFor(() => {
201
- const next = dialog.querySelector('img');
202
- expect(next.style.transform).toContain('scale(1)');
203
- expect(next.style.transform).not.toContain('scale(1.5)');
204
- });
205
- });
206
- });