@classic-homes/theme-svelte 1.21.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.
Files changed (79) hide show
  1. package/dist/lib/components/Alert.svelte +8 -10
  2. package/dist/lib/components/DataPanel.svelte +19 -5
  3. package/dist/lib/components/HeaderSearch.svelte +18 -10
  4. package/dist/lib/components/Skeleton.svelte.d.ts +1 -1
  5. package/dist/lib/components/layout/AppShell.svelte +1 -1
  6. package/dist/lib/components/layout/DashboardLayout.svelte +15 -6
  7. package/dist/lib/components/layout/Footer.svelte +6 -6
  8. package/dist/lib/components/layout/FormPageLayout.svelte +4 -3
  9. package/dist/lib/components/layout/Sidebar.svelte +25 -21
  10. package/dist/lib/components/media/Image.svelte.d.ts +1 -1
  11. package/dist/lib/components/media/Lightbox.svelte +13 -9
  12. package/dist/lib/components/media/MediaCarousel.svelte +5 -0
  13. package/dist/lib/components/media/MediaGrid.svelte +4 -1
  14. package/dist/lib/components/notifications/NotificationDialog.svelte +8 -5
  15. package/dist/lib/components/notifications/NotificationItem.svelte +16 -6
  16. package/dist/lib/components/studio/InsertPalette.svelte +4 -1
  17. package/dist/lib/components/studio/StudioCanvas.svelte +23 -2
  18. package/dist/lib/components/studio/StudioCanvas.svelte.d.ts +5 -1
  19. package/dist/lib/components/studio/StudioCanvasItem.svelte +31 -4
  20. package/dist/lib/components/studio/StudioCanvasItem.svelte.d.ts +7 -0
  21. package/dist/lib/composables/useFocusTrap.svelte.js +9 -2
  22. package/dist/lib/notices/sanitize.js +10 -3
  23. package/dist/lib/notices/store.d.ts +3 -3
  24. package/dist/lib/notices/store.js +14 -2
  25. package/dist/lib/notices/types.d.ts +8 -8
  26. package/dist/lib/notices/types.js +5 -1
  27. package/dist/lib/stores/sidebar.svelte.d.ts +6 -0
  28. package/dist/lib/stores/sidebar.svelte.js +61 -26
  29. package/dist/lib/utils/media.js +4 -3
  30. package/dist/lib/utils/url.d.ts +10 -0
  31. package/dist/lib/utils/url.js +21 -0
  32. package/dist/lib/utils.js +14 -1
  33. package/package.json +5 -4
  34. package/dist/__tests__/FileUpload.test.js +0 -45
  35. package/dist/__tests__/a11y/Alert.a11y.test.js +0 -84
  36. package/dist/__tests__/a11y/AlertDialog.a11y.test.js +0 -126
  37. package/dist/__tests__/a11y/Button.a11y.test.js +0 -66
  38. package/dist/__tests__/a11y/Card.a11y.test.js +0 -77
  39. package/dist/__tests__/a11y/Combobox.a11y.test.js +0 -209
  40. package/dist/__tests__/a11y/Dialog.a11y.test.js +0 -103
  41. package/dist/__tests__/a11y/DropdownMenu.a11y.test.js +0 -132
  42. package/dist/__tests__/a11y/FileUpload.a11y.test.js +0 -158
  43. package/dist/__tests__/a11y/FilterToolbar.a11y.test.js +0 -62
  44. package/dist/__tests__/a11y/Icon.a11y.test.js +0 -119
  45. package/dist/__tests__/a11y/Input.a11y.test.js +0 -96
  46. package/dist/__tests__/a11y/LiveRegion.a11y.test.js +0 -102
  47. package/dist/__tests__/a11y/Media.a11y.test.js +0 -145
  48. package/dist/__tests__/a11y/PublicLayout.a11y.test.js +0 -139
  49. package/dist/__tests__/a11y/SegmentedControl.a11y.test.js +0 -50
  50. package/dist/__tests__/a11y/Switch.a11y.test.js +0 -95
  51. package/dist/__tests__/a11y/Tabs.a11y.test.js +0 -116
  52. package/dist/__tests__/a11y/Textarea.a11y.test.js +0 -111
  53. package/dist/__tests__/a11y/Toolbar.a11y.test.js +0 -31
  54. package/dist/__tests__/a11y/ViewToggle.a11y.test.js +0 -40
  55. package/dist/__tests__/action-header.test.js +0 -150
  56. package/dist/__tests__/alert-dialog-dismiss.test.js +0 -54
  57. package/dist/__tests__/code-field.test.js +0 -65
  58. package/dist/__tests__/control-text-color.test.js +0 -29
  59. package/dist/__tests__/data-panel-card-gestures.test.js +0 -138
  60. package/dist/__tests__/data-panel-viewport.test.js +0 -45
  61. package/dist/__tests__/dropdown-trigger.test.js +0 -39
  62. package/dist/__tests__/header-search.test.js +0 -90
  63. package/dist/__tests__/media-components.test.js +0 -206
  64. package/dist/__tests__/media-hls.test.js +0 -273
  65. package/dist/__tests__/media-matterport.test.js +0 -296
  66. package/dist/__tests__/media-players.test.js +0 -244
  67. package/dist/__tests__/media-scrubber.test.js +0 -44
  68. package/dist/__tests__/media-utils.test.js +0 -427
  69. package/dist/__tests__/nav-access.test.js +0 -145
  70. package/dist/__tests__/page-header.test.js +0 -36
  71. package/dist/__tests__/public-layout-drawer.test.js +0 -116
  72. package/dist/__tests__/setup.js +0 -56
  73. package/dist/__tests__/sidebar-collapse.test.js +0 -58
  74. package/dist/__tests__/sidebar-docs-parity.test.js +0 -134
  75. package/dist/__tests__/sidebar-expansion.test.js +0 -59
  76. package/dist/__tests__/sidebar-items.test.js +0 -59
  77. package/dist/__tests__/sidebar-mobile.test.js +0 -86
  78. package/dist/__tests__/studio-canvas-item-reveal.test.js +0 -50
  79. package/dist/__tests__/studio.test.js +0 -240
@@ -1,145 +0,0 @@
1
- import { beforeEach, describe, expect, it, vi } from 'vitest';
2
- import { render, screen } from '@testing-library/svelte';
3
- import { axe } from 'vitest-axe';
4
- import Audio from '../../lib/components/media/Audio.svelte';
5
- import Image from '../../lib/components/media/Image.svelte';
6
- import Lightbox from '../../lib/components/media/Lightbox.svelte';
7
- import MediaCarousel from '../../lib/components/media/MediaCarousel.svelte';
8
- import MediaEmbed from '../../lib/components/media/MediaEmbed.svelte';
9
- import MediaGrid from '../../lib/components/media/MediaGrid.svelte';
10
- import Video from '../../lib/components/media/Video.svelte';
11
- const items = [
12
- { id: '1', kind: 'image', src: '/one.jpg', alt: 'A harbour at dawn', caption: 'Dawn' },
13
- { id: '2', kind: 'image', src: '/two.jpg', alt: 'A field of wheat' },
14
- { id: '3', kind: 'video', src: '/clip.mp4', poster: '/clip.jpg', title: 'Walkthrough' },
15
- ];
16
- const captions = [{ src: '/clip.en.vtt', srclang: 'en', label: 'English', default: true }];
17
- beforeEach(() => {
18
- // Video warns when captions are missing; those cases are covered elsewhere.
19
- vi.spyOn(console, 'warn').mockImplementation(() => { });
20
- });
21
- describe('Image accessibility', () => {
22
- it('has no axe violations', async () => {
23
- const { container } = render(Image, { props: { src: '/photo.jpg', alt: 'A landscape' } });
24
- expect(await axe(container)).toHaveNoViolations();
25
- });
26
- it('has no violations with a decorative empty alt', async () => {
27
- const { container } = render(Image, { props: { src: '/divider.png', alt: '' } });
28
- expect(await axe(container)).toHaveNoViolations();
29
- });
30
- it('exposes the alt text as the image name', () => {
31
- render(Image, { props: { src: '/photo.jpg', alt: 'A landscape' } });
32
- expect(screen.getByRole('img', { name: 'A landscape' })).toBeInTheDocument();
33
- });
34
- });
35
- describe('Video accessibility', () => {
36
- it('has no axe violations', async () => {
37
- const { container } = render(Video, {
38
- props: { src: '/clip.mp4', label: 'Product tour', tracks: captions },
39
- });
40
- expect(await axe(container)).toHaveNoViolations();
41
- });
42
- it('names the region and every transport control', () => {
43
- render(Video, { props: { src: '/clip.mp4', label: 'Product tour', tracks: captions } });
44
- expect(screen.getByRole('region')).toHaveAccessibleName('Product tour');
45
- expect(screen.getByRole('button', { name: 'Play' })).toBeInTheDocument();
46
- expect(screen.getByRole('slider', { name: 'Seek' })).toBeInTheDocument();
47
- expect(screen.getByRole('slider', { name: 'Volume' })).toBeInTheDocument();
48
- });
49
- it('announces the seek position as a spoken timestamp, not raw seconds', () => {
50
- render(Video, { props: { src: '/clip.mp4', tracks: captions } });
51
- expect(screen.getByRole('slider', { name: 'Seek' })).toHaveAttribute('aria-valuetext', '0 seconds of 0 seconds');
52
- });
53
- });
54
- describe('Audio accessibility', () => {
55
- it('has no axe violations', async () => {
56
- const { container } = render(Audio, {
57
- props: { src: '/episode.mp3', title: 'Episode 12', artist: 'Design Notes' },
58
- });
59
- expect(await axe(container)).toHaveNoViolations();
60
- });
61
- });
62
- describe('MediaEmbed accessibility', () => {
63
- it('has no axe violations in its facade state', async () => {
64
- const { container } = render(MediaEmbed, {
65
- props: { url: 'https://youtu.be/dQw4w9WgXcQ', title: 'A song' },
66
- });
67
- expect(await axe(container)).toHaveNoViolations();
68
- });
69
- it('names the facade with the provider and title', () => {
70
- render(MediaEmbed, { props: { url: 'https://youtu.be/dQw4w9WgXcQ', title: 'A song' } });
71
- expect(screen.getByRole('button')).toHaveAccessibleName('Play A song on YouTube');
72
- });
73
- it('has no axe violations for a Matterport tour facade', async () => {
74
- const { container } = render(MediaEmbed, {
75
- props: { provider: 'matterport', modelId: 'SxQL3iGyoDo', title: 'Show home' },
76
- });
77
- expect(await axe(container)).toHaveNoViolations();
78
- });
79
- it('says a tour opens rather than plays', () => {
80
- render(MediaEmbed, {
81
- props: { provider: 'matterport', modelId: 'SxQL3iGyoDo', title: 'Show home' },
82
- });
83
- expect(screen.getByRole('button')).toHaveAccessibleName('Open Show home as a 3D tour on Matterport');
84
- });
85
- });
86
- describe('MediaGrid accessibility', () => {
87
- it('has no axe violations', async () => {
88
- const { container } = render(MediaGrid, { props: { items, label: 'Project photos' } });
89
- expect(await axe(container)).toHaveNoViolations();
90
- });
91
- it('names the list and every tile', () => {
92
- render(MediaGrid, { props: { items, label: 'Project photos' } });
93
- expect(screen.getByRole('list')).toHaveAccessibleName('Project photos');
94
- expect(screen.getByRole('button', { name: 'A harbour at dawn' })).toBeInTheDocument();
95
- expect(screen.getByRole('button', { name: 'Walkthrough' })).toBeInTheDocument();
96
- });
97
- it('has no violations in the masonry variant', async () => {
98
- const { container } = render(MediaGrid, { props: { items, variant: 'masonry' } });
99
- expect(await axe(container)).toHaveNoViolations();
100
- });
101
- });
102
- describe('MediaCarousel accessibility', () => {
103
- it('has no axe violations', async () => {
104
- const { container } = render(MediaCarousel, {
105
- props: { items, label: 'Project gallery' },
106
- });
107
- expect(await axe(container)).toHaveNoViolations();
108
- });
109
- it('carries the carousel and slide role descriptions', () => {
110
- render(MediaCarousel, { props: { items, label: 'Project gallery' } });
111
- const carousel = screen.getByRole('region', { name: 'Project gallery' });
112
- expect(carousel).toHaveAttribute('aria-roledescription', 'carousel');
113
- const slides = screen.getAllByRole('group');
114
- expect(slides[0]).toHaveAttribute('aria-roledescription', 'slide');
115
- expect(slides[0]).toHaveAccessibleName('1 of 3');
116
- });
117
- it('offers a pause control whenever autoplay is enabled (WCAG 2.2.2)', () => {
118
- render(MediaCarousel, { props: { items, autoplay: true } });
119
- expect(screen.getByRole('button', { name: 'Stop automatic slideshow' })).toBeInTheDocument();
120
- });
121
- it('has no violations with the thumbnail strip shown', async () => {
122
- const { container } = render(MediaCarousel, { props: { items, showThumbnails: true } });
123
- expect(await axe(container)).toHaveNoViolations();
124
- });
125
- });
126
- describe('Lightbox accessibility', () => {
127
- it('has no axe violations when open', async () => {
128
- render(Lightbox, { props: { items, open: true } });
129
- await screen.findByRole('dialog');
130
- // The dialog is portalled to the body, so axe has to run against it.
131
- expect(await axe(document.body)).toHaveNoViolations();
132
- });
133
- it('is a modal dialog with an accessible name and description', async () => {
134
- render(Lightbox, { props: { items, open: true } });
135
- const dialog = await screen.findByRole('dialog');
136
- expect(dialog).toHaveAttribute('aria-modal', 'true');
137
- expect(dialog).toHaveAccessibleName('A harbour at dawn');
138
- expect(dialog).toHaveAccessibleDescription('Dawn');
139
- });
140
- it('has no violations with the thumbnail strip shown', async () => {
141
- render(Lightbox, { props: { items, open: true, showThumbnails: true } });
142
- await screen.findByRole('dialog');
143
- expect(await axe(document.body)).toHaveNoViolations();
144
- });
145
- });
@@ -1,139 +0,0 @@
1
- import { describe, it, expect } 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 { createRawSnippet } from 'svelte';
6
- import PublicLayout from '../../lib/components/layout/PublicLayout.svelte';
7
- function textSnippet(text) {
8
- return createRawSnippet(() => ({
9
- render: () => `<span>${text}</span>`,
10
- }));
11
- }
12
- const navigation = [
13
- { name: 'Orientation', href: '/orientation' },
14
- { name: 'Home Info', href: '/home-information-sheet' },
15
- { name: 'Warranty Transfer', href: '/warranty-transfer', active: true },
16
- ];
17
- const footerLinks = [
18
- {
19
- title: 'Resources',
20
- items: [
21
- { name: 'Documentation', href: '/docs' },
22
- { name: 'GitHub', href: 'https://github.com', external: true },
23
- ],
24
- },
25
- ];
26
- function renderLayout(extra = {}) {
27
- return render(PublicLayout, {
28
- props: { navigation, footerLinks, children: textSnippet('Page content'), ...extra },
29
- });
30
- }
31
- /** A drawer CTA that calls the `close` argument PublicLayout hands to mobileNavEnd. */
32
- function ctaSnippet(label) {
33
- return createRawSnippet((args) => ({
34
- render: () => `<button type="button">${label}</button>`,
35
- setup: (el) => {
36
- el.addEventListener('click', () => args().close());
37
- },
38
- }));
39
- }
40
- /**
41
- * jsdom has no layout engine, so getBoundingClientRect() returns zeros and target
42
- * size cannot be measured here. These assert the classes that produce a 44x44px
43
- * target (WCAG 2.5.5); the geometry itself is verified in a real browser.
44
- */
45
- describe('PublicLayout accessibility', () => {
46
- it('should have no axe violations', async () => {
47
- const { container } = renderLayout();
48
- expect(await axe(container)).toHaveNoViolations();
49
- });
50
- it('should expand desktop nav links to a 44px touch target', () => {
51
- renderLayout();
52
- const nav = screen.getByRole('navigation', { name: 'Main navigation' });
53
- const links = [...nav.querySelectorAll(':scope > a')];
54
- expect(links).toHaveLength(navigation.length);
55
- for (const link of links) {
56
- // 20px text line box + 2 x 12px padding = 44px border box; -m-3 cancels
57
- // it in layout so the nav's gap-6 spacing is unchanged.
58
- expect(link.className).toContain('p-3');
59
- expect(link.className).toContain('-m-3');
60
- expect(link.className).not.toContain('after:');
61
- }
62
- });
63
- it('should give the logo link a 44px-tall touch target', () => {
64
- renderLayout();
65
- const logo = screen.getByRole('banner').querySelector('a[href="/"]');
66
- expect(logo?.className).toContain('relative');
67
- expect(logo?.className).toContain('after:h-11');
68
- });
69
- it('should give mobile drawer links a 44px touch target', async () => {
70
- renderLayout();
71
- // The drawer is only in the DOM while the menu is open.
72
- await userEvent.click(screen.getByRole('button', { name: 'Open menu' }));
73
- const drawerNav = screen.getByRole('navigation', { name: 'Mobile navigation' });
74
- for (const link of drawerNav.querySelectorAll('a')) {
75
- expect(link.className).toContain('py-3');
76
- }
77
- });
78
- it('should give footer links a 44px-tall full-width touch target', () => {
79
- renderLayout();
80
- const footerNav = screen.getByRole('navigation', { name: 'Footer navigation' });
81
- for (const link of footerNav.querySelectorAll('a')) {
82
- // block + py-3 = 12 + 20 + 12; the list carries no space-y, so rows never overlap
83
- expect(link.className).toContain('block');
84
- expect(link.className).toContain('py-3');
85
- }
86
- });
87
- it('should render headerEnd unwrapped when there is no mobileNavEnd', () => {
88
- renderLayout({ headerEnd: textSnippet('Service Request') });
89
- const cta = screen.getByText('Service Request');
90
- expect(cta.parentElement?.className).not.toContain('hidden');
91
- });
92
- it('should hide headerEnd below the mobile breakpoint when mobileNavEnd is given', () => {
93
- renderLayout({
94
- headerEnd: textSnippet('Service Request'),
95
- mobileNavEnd: ctaSnippet('Drawer CTA'),
96
- });
97
- const cta = screen.getByText('Service Request');
98
- expect(cta.parentElement?.className).toContain('hidden');
99
- expect(cta.parentElement?.className).toContain('md:contents');
100
- });
101
- it('should switch the header to the drawer at the given mobileBreakpoint', () => {
102
- renderLayout({
103
- mobileBreakpoint: 'lg',
104
- headerEnd: textSnippet('Service Request'),
105
- mobileNavEnd: ctaSnippet('Drawer CTA'),
106
- });
107
- const nav = screen.getByRole('navigation', { name: 'Main navigation' });
108
- expect(nav.className).toContain('lg:flex');
109
- expect(nav.className).not.toContain('md:flex');
110
- expect(screen.getByRole('button', { name: 'Open menu' }).className).toContain('lg:hidden');
111
- expect(screen.getByText('Service Request').parentElement?.className).toContain('lg:contents');
112
- });
113
- it('should render mobileNavEnd in the drawer and close it via the close argument', async () => {
114
- renderLayout({
115
- headerEnd: textSnippet('Service Request'),
116
- mobileNavEnd: ctaSnippet('Drawer CTA'),
117
- });
118
- expect(screen.queryByRole('button', { name: 'Drawer CTA' })).toBeNull();
119
- await userEvent.click(screen.getByRole('button', { name: 'Open menu' }));
120
- const drawerNav = screen.getByRole('navigation', { name: 'Mobile navigation' });
121
- const cta = screen.getByRole('button', { name: 'Drawer CTA' });
122
- expect(drawerNav.contains(cta)).toBe(true);
123
- await userEvent.click(cta);
124
- expect(screen.queryByRole('navigation', { name: 'Mobile navigation' })).toBeNull();
125
- });
126
- it('should cue hover for a trackpad pointer that reports hover: none', () => {
127
- renderLayout();
128
- const nav = screen.getByRole('navigation', { name: 'Main navigation' });
129
- const footerNav = screen.getByRole('navigation', { name: 'Footer navigation' });
130
- // hoverOnlyWhenSupported gates hover: behind (hover: hover) and (pointer: fine),
131
- // which iPadOS never matches even with a trackpad attached.
132
- for (const link of [
133
- ...nav.querySelectorAll(':scope > a'),
134
- ...footerNav.querySelectorAll('a'),
135
- ]) {
136
- expect(link.className).toContain('[@media(any-pointer:fine)]:[&:hover]:');
137
- }
138
- });
139
- });
@@ -1,50 +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 SegmentedControl from '../../lib/components/SegmentedControl.svelte';
6
- const options = [
7
- { value: 'community', label: 'Community' },
8
- { value: 'collection', label: 'Collection' },
9
- { value: 'floor-plan', label: 'Floor Plan', disabled: true },
10
- ];
11
- describe('SegmentedControl accessibility', () => {
12
- it('should have no axe violations', async () => {
13
- const { container } = render(SegmentedControl, {
14
- props: { options, label: 'Group homes by' },
15
- });
16
- expect(await axe(container)).toHaveNoViolations();
17
- });
18
- it('is a labelled radiogroup of radios', () => {
19
- render(SegmentedControl, { props: { options, label: 'Group homes by' } });
20
- const group = screen.getByRole('radiogroup', { name: 'Group homes by' });
21
- expect(group).toBeInTheDocument();
22
- expect(screen.getAllByRole('radio')).toHaveLength(3);
23
- });
24
- it('selects the first enabled option by default', () => {
25
- render(SegmentedControl, { props: { options, label: 'Group homes by' } });
26
- expect(screen.getByRole('radio', { name: 'Community' })).toHaveAttribute('aria-checked', 'true');
27
- expect(screen.getByRole('radio', { name: 'Floor Plan' })).toBeDisabled();
28
- });
29
- it('moves selection with arrow keys and skips disabled options', async () => {
30
- const user = userEvent.setup();
31
- const onValueChange = vi.fn();
32
- render(SegmentedControl, { props: { options, label: 'Group homes by', onValueChange } });
33
- const community = screen.getByRole('radio', { name: 'Community' });
34
- const collection = screen.getByRole('radio', { name: 'Collection' });
35
- community.focus();
36
- await user.keyboard('{ArrowRight}');
37
- expect(collection).toHaveAttribute('aria-checked', 'true');
38
- expect(onValueChange).toHaveBeenLastCalledWith('collection');
39
- // Floor Plan is disabled, so the next step loops back to Community
40
- await user.keyboard('{ArrowRight}');
41
- expect(community).toHaveAttribute('aria-checked', 'true');
42
- });
43
- it('never deselects: clicking the selected option keeps it checked', async () => {
44
- const user = userEvent.setup();
45
- render(SegmentedControl, { props: { options, label: 'Group homes by' } });
46
- const community = screen.getByRole('radio', { name: 'Community' });
47
- await user.click(community);
48
- expect(community).toHaveAttribute('aria-checked', 'true');
49
- });
50
- });
@@ -1,95 +0,0 @@
1
- import { describe, it, expect } 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 Switch from '../../lib/components/Switch.svelte';
6
- describe('Switch accessibility', () => {
7
- it('should have no axe violations with aria-label', async () => {
8
- const { container } = render(Switch, {
9
- props: { 'aria-label': 'Enable notifications' },
10
- });
11
- const results = await axe(container);
12
- expect(results).toHaveNoViolations();
13
- });
14
- it('should have no axe violations with aria-labelledby', async () => {
15
- const { container } = render(Switch, {
16
- props: { 'aria-labelledby': 'switch-label' },
17
- });
18
- const labelSpan = document.createElement('span');
19
- labelSpan.id = 'switch-label';
20
- labelSpan.textContent = 'Enable notifications';
21
- container.insertBefore(labelSpan, container.firstChild);
22
- const results = await axe(container);
23
- expect(results).toHaveNoViolations();
24
- });
25
- it('should have no axe violations when checked', async () => {
26
- const { container } = render(Switch, {
27
- props: { 'aria-label': 'Enable feature', checked: true },
28
- });
29
- const results = await axe(container);
30
- expect(results).toHaveNoViolations();
31
- });
32
- it('should be focusable', async () => {
33
- render(Switch, {
34
- props: { 'aria-label': 'Test switch' },
35
- });
36
- const switchElement = screen.getByRole('switch');
37
- await userEvent.tab();
38
- expect(switchElement).toHaveFocus();
39
- });
40
- it('should be togglable with keyboard (Space)', async () => {
41
- let checked = false;
42
- render(Switch, {
43
- props: {
44
- 'aria-label': 'Test switch',
45
- onchange: (value) => {
46
- checked = value;
47
- },
48
- },
49
- });
50
- await userEvent.tab();
51
- await userEvent.keyboard(' ');
52
- expect(checked).toBe(true);
53
- });
54
- it('should not be focusable when disabled', async () => {
55
- render(Switch, {
56
- props: { 'aria-label': 'Disabled switch', disabled: true },
57
- });
58
- const switchElement = screen.getByRole('switch');
59
- expect(switchElement).toBeDisabled();
60
- });
61
- it('should have correct aria-checked state when unchecked', () => {
62
- render(Switch, {
63
- props: { 'aria-label': 'Test switch', checked: false },
64
- });
65
- const switchElement = screen.getByRole('switch');
66
- expect(switchElement).toHaveAttribute('aria-checked', 'false');
67
- });
68
- it('should have correct aria-checked state when checked', () => {
69
- render(Switch, {
70
- props: { 'aria-label': 'Test switch', checked: true },
71
- });
72
- const switchElement = screen.getByRole('switch');
73
- expect(switchElement).toHaveAttribute('aria-checked', 'true');
74
- });
75
- it('should support aria-labelledby', () => {
76
- const { container } = render(Switch, {
77
- props: { 'aria-labelledby': 'label' },
78
- });
79
- const labelSpan = document.createElement('span');
80
- labelSpan.id = 'label';
81
- labelSpan.textContent = 'Custom Label';
82
- container.insertBefore(labelSpan, container.firstChild);
83
- const switchElement = screen.getByRole('switch');
84
- expect(switchElement).toHaveAttribute('aria-labelledby', 'label');
85
- });
86
- it('should have visible focus indicator', async () => {
87
- render(Switch, {
88
- props: { 'aria-label': 'Test switch' },
89
- });
90
- const switchElement = screen.getByRole('switch');
91
- await userEvent.tab();
92
- expect(switchElement).toHaveFocus();
93
- // Focus styles are applied via focus-visible classes
94
- });
95
- });
@@ -1,116 +0,0 @@
1
- import { describe, it, expect } 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 { createRawSnippet } from 'svelte';
6
- import Tabs from '../../lib/components/Tabs.svelte';
7
- describe('Tabs accessibility', () => {
8
- const defaultTabs = [
9
- { id: 'tab1', label: 'Tab 1' },
10
- { id: 'tab2', label: 'Tab 2' },
11
- { id: 'tab3', label: 'Tab 3' },
12
- ];
13
- it('should have no axe violations', async () => {
14
- const { container } = render(Tabs, {
15
- props: { tabs: defaultTabs, label: 'Test tabs', id: 'test-tabs' },
16
- });
17
- // Add tab panels so aria-controls references are valid
18
- defaultTabs.forEach((tab, i) => {
19
- const panel = document.createElement('div');
20
- panel.id = `test-tabs-panel-${tab.id}`;
21
- panel.setAttribute('role', 'tabpanel');
22
- panel.setAttribute('aria-labelledby', `test-tabs-tab-${tab.id}`);
23
- panel.textContent = `Panel ${i + 1}`;
24
- if (i > 0)
25
- panel.hidden = true;
26
- container.appendChild(panel);
27
- });
28
- const results = await axe(container);
29
- expect(results).toHaveNoViolations();
30
- });
31
- it('should have proper tablist role', () => {
32
- render(Tabs, {
33
- props: { tabs: defaultTabs, label: 'Test tabs' },
34
- });
35
- const tablist = screen.getByRole('tablist');
36
- expect(tablist).toBeInTheDocument();
37
- });
38
- it('should have proper tab roles', () => {
39
- render(Tabs, {
40
- props: { tabs: defaultTabs, label: 'Test tabs' },
41
- });
42
- const tabs = screen.getAllByRole('tab');
43
- expect(tabs).toHaveLength(3);
44
- });
45
- it('should have accessible label on tablist', () => {
46
- render(Tabs, {
47
- props: { tabs: defaultTabs, label: 'Account settings' },
48
- });
49
- const tablist = screen.getByRole('tablist');
50
- expect(tablist).toHaveAttribute('aria-label', 'Account settings');
51
- });
52
- it('should indicate selected tab with aria-selected', () => {
53
- render(Tabs, {
54
- props: { tabs: defaultTabs, value: 'tab1', label: 'Test tabs' },
55
- });
56
- const tabs = screen.getAllByRole('tab');
57
- expect(tabs[0]).toHaveAttribute('aria-selected', 'true');
58
- expect(tabs[1]).toHaveAttribute('aria-selected', 'false');
59
- expect(tabs[2]).toHaveAttribute('aria-selected', 'false');
60
- });
61
- it('should navigate with arrow keys', async () => {
62
- render(Tabs, {
63
- props: { tabs: defaultTabs, label: 'Test tabs' },
64
- });
65
- const tabs = screen.getAllByRole('tab');
66
- // Focus first tab
67
- tabs[0].focus();
68
- expect(tabs[0]).toHaveFocus();
69
- // Navigate right
70
- await userEvent.keyboard('{ArrowRight}');
71
- expect(tabs[1]).toHaveFocus();
72
- // Navigate right again
73
- await userEvent.keyboard('{ArrowRight}');
74
- expect(tabs[2]).toHaveFocus();
75
- // Navigate left
76
- await userEvent.keyboard('{ArrowLeft}');
77
- expect(tabs[1]).toHaveFocus();
78
- });
79
- it('should activate tab with click', async () => {
80
- const user = userEvent.setup();
81
- render(Tabs, {
82
- props: { tabs: defaultTabs, value: 'tab1', label: 'Test tabs' },
83
- });
84
- const tabs = screen.getAllByRole('tab');
85
- // Click second tab
86
- await user.click(tabs[1]);
87
- expect(tabs[1]).toHaveAttribute('aria-selected', 'true');
88
- });
89
- it('should omit aria-controls when no panel exists', () => {
90
- // Without children there is no panel in the DOM; an aria-controls pointing
91
- // at a missing element is an ARIA validity failure (axe: aria-valid-attr-value)
92
- render(Tabs, {
93
- props: { tabs: defaultTabs, label: 'Test tabs' },
94
- });
95
- for (const tab of screen.getAllByRole('tab')) {
96
- expect(tab).not.toHaveAttribute('aria-controls');
97
- }
98
- });
99
- it('should have aria-controls referencing a real panel when children exist', () => {
100
- const children = createRawSnippet(() => ({
101
- render: () => '<div id="test-tabs-panel-tab1" role="tabpanel">Panel 1</div>',
102
- }));
103
- render(Tabs, {
104
- props: { tabs: defaultTabs, value: 'tab1', id: 'test-tabs', label: 'Test tabs', children },
105
- });
106
- const tabs = screen.getAllByRole('tab');
107
- const selectedTab = tabs.find((tab) => tab.getAttribute('aria-selected') === 'true');
108
- expect(selectedTab).toHaveAttribute('aria-controls', 'test-tabs-panel-tab1');
109
- // The reference must resolve to an element that exists
110
- expect(document.getElementById('test-tabs-panel-tab1')).not.toBeNull();
111
- // Unselected tabs have no panel mounted, so no aria-controls
112
- for (const tab of tabs.filter((t) => t !== selectedTab)) {
113
- expect(tab).not.toHaveAttribute('aria-controls');
114
- }
115
- });
116
- });
@@ -1,111 +0,0 @@
1
- import { describe, it, expect } 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 Textarea from '../../lib/components/Textarea.svelte';
6
- describe('Textarea accessibility', () => {
7
- it('should have no axe violations with aria-label', async () => {
8
- const { container } = render(Textarea, {
9
- props: { 'aria-label': 'Description' },
10
- });
11
- const results = await axe(container);
12
- expect(results).toHaveNoViolations();
13
- });
14
- it('should have no axe violations with aria-invalid', async () => {
15
- const { container } = render(Textarea, {
16
- props: {
17
- 'aria-label': 'Description',
18
- 'aria-invalid': true,
19
- 'aria-describedby': 'error',
20
- },
21
- });
22
- const errorSpan = document.createElement('span');
23
- errorSpan.id = 'error';
24
- errorSpan.textContent = 'Description is required';
25
- container.appendChild(errorSpan);
26
- const results = await axe(container);
27
- expect(results).toHaveNoViolations();
28
- });
29
- it('should be focusable', async () => {
30
- render(Textarea, {
31
- props: { 'aria-label': 'Test textarea' },
32
- });
33
- const textarea = screen.getByRole('textbox');
34
- await userEvent.tab();
35
- expect(textarea).toHaveFocus();
36
- });
37
- it('should support aria-describedby for help text', () => {
38
- const { container } = render(Textarea, {
39
- props: { 'aria-label': 'Comments', 'aria-describedby': 'help' },
40
- });
41
- const helpSpan = document.createElement('span');
42
- helpSpan.id = 'help';
43
- helpSpan.textContent = 'Enter your comments here';
44
- container.appendChild(helpSpan);
45
- const textarea = screen.getByRole('textbox');
46
- expect(textarea).toHaveAttribute('aria-describedby', 'help');
47
- });
48
- it('should support aria-labelledby', () => {
49
- const { container } = render(Textarea, {
50
- props: { 'aria-labelledby': 'label' },
51
- });
52
- const labelSpan = document.createElement('span');
53
- labelSpan.id = 'label';
54
- labelSpan.textContent = 'Custom Label';
55
- container.insertBefore(labelSpan, container.firstChild);
56
- const textarea = screen.getByRole('textbox');
57
- expect(textarea).toHaveAttribute('aria-labelledby', 'label');
58
- });
59
- it('should apply error styling when aria-invalid is true', () => {
60
- render(Textarea, {
61
- props: { 'aria-label': 'Description', 'aria-invalid': true },
62
- });
63
- const textarea = screen.getByRole('textbox');
64
- expect(textarea).toHaveAttribute('aria-invalid', 'true');
65
- expect(textarea.className).toContain('border-destructive');
66
- });
67
- it('should apply error styling when aria-invalid is "true" string', () => {
68
- render(Textarea, {
69
- props: { 'aria-label': 'Description', 'aria-invalid': 'true' },
70
- });
71
- const textarea = screen.getByRole('textbox');
72
- expect(textarea.className).toContain('border-destructive');
73
- });
74
- it('should not apply error styling when aria-invalid is false', () => {
75
- render(Textarea, {
76
- props: { 'aria-label': 'Description', 'aria-invalid': false },
77
- });
78
- const textarea = screen.getByRole('textbox');
79
- expect(textarea.className).not.toContain('border-destructive');
80
- });
81
- it('should be disabled when disabled prop is true', () => {
82
- render(Textarea, {
83
- props: { 'aria-label': 'Disabled textarea', disabled: true },
84
- });
85
- const textarea = screen.getByRole('textbox');
86
- expect(textarea).toBeDisabled();
87
- });
88
- it('should support required attribute', () => {
89
- render(Textarea, {
90
- props: { 'aria-label': 'Required field', required: true },
91
- });
92
- const textarea = screen.getByRole('textbox');
93
- expect(textarea).toBeRequired();
94
- });
95
- it('should support readonly attribute', () => {
96
- render(Textarea, {
97
- props: { 'aria-label': 'Readonly field', readonly: true },
98
- });
99
- const textarea = screen.getByRole('textbox');
100
- expect(textarea).toHaveAttribute('readonly');
101
- });
102
- it('should have visible focus indicator', async () => {
103
- render(Textarea, {
104
- props: { 'aria-label': 'Test textarea' },
105
- });
106
- const textarea = screen.getByRole('textbox');
107
- await userEvent.tab();
108
- expect(textarea).toHaveFocus();
109
- // Focus styles are applied via focus-visible classes
110
- });
111
- });