@classic-homes/theme-svelte 1.2.2 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +43 -0
  2. package/dist/__tests__/a11y/Media.a11y.test.js +145 -0
  3. package/dist/__tests__/media-components.test.js +206 -0
  4. package/dist/__tests__/media-hls.test.js +273 -0
  5. package/dist/__tests__/media-matterport.test.js +296 -0
  6. package/dist/__tests__/media-players.test.js +244 -0
  7. package/dist/__tests__/media-scrubber.test.js +44 -0
  8. package/dist/__tests__/media-utils.test.js +427 -0
  9. package/dist/__tests__/setup.js +50 -0
  10. package/dist/lib/components/DataPanel.svelte +8 -0
  11. package/dist/lib/components/DataPanel.svelte.d.ts +6 -0
  12. package/dist/lib/components/DataPanelTab.svelte +38 -8
  13. package/dist/lib/components/DataPanelTab.svelte.d.ts +7 -0
  14. package/dist/lib/components/FileUpload.svelte +221 -4
  15. package/dist/lib/components/FileUpload.svelte.d.ts +13 -1
  16. package/dist/lib/components/media/AspectRatio.svelte +47 -0
  17. package/dist/lib/components/media/AspectRatio.svelte.d.ts +20 -0
  18. package/dist/lib/components/media/Audio.svelte +247 -0
  19. package/dist/lib/components/media/Audio.svelte.d.ts +34 -0
  20. package/dist/lib/components/media/Image.svelte +209 -0
  21. package/dist/lib/components/media/Image.svelte.d.ts +52 -0
  22. package/dist/lib/components/media/Lightbox.svelte +449 -0
  23. package/dist/lib/components/media/Lightbox.svelte.d.ts +33 -0
  24. package/dist/lib/components/media/MediaCarousel.svelte +359 -0
  25. package/dist/lib/components/media/MediaCarousel.svelte.d.ts +30 -0
  26. package/dist/lib/components/media/MediaEmbed.svelte +314 -0
  27. package/dist/lib/components/media/MediaEmbed.svelte.d.ts +99 -0
  28. package/dist/lib/components/media/MediaGrid.svelte +327 -0
  29. package/dist/lib/components/media/MediaGrid.svelte.d.ts +57 -0
  30. package/dist/lib/components/media/Video.svelte +455 -0
  31. package/dist/lib/components/media/Video.svelte.d.ts +81 -0
  32. package/dist/lib/components/media/index.d.ts +14 -0
  33. package/dist/lib/components/media/index.js +14 -0
  34. package/dist/lib/components/media/internal/MediaControls.svelte +283 -0
  35. package/dist/lib/components/media/internal/MediaControls.svelte.d.ts +32 -0
  36. package/dist/lib/components/media/internal/MediaScrubber.svelte +211 -0
  37. package/dist/lib/components/media/internal/MediaScrubber.svelte.d.ts +27 -0
  38. package/dist/lib/components/media/internal/MediaTile.svelte +129 -0
  39. package/dist/lib/components/media/internal/MediaTile.svelte.d.ts +22 -0
  40. package/dist/lib/composables/index.d.ts +3 -0
  41. package/dist/lib/composables/index.js +4 -0
  42. package/dist/lib/composables/useHlsSource.svelte.d.ts +70 -0
  43. package/dist/lib/composables/useHlsSource.svelte.js +119 -0
  44. package/dist/lib/composables/useMediaPlayer.svelte.d.ts +114 -0
  45. package/dist/lib/composables/useMediaPlayer.svelte.js +422 -0
  46. package/dist/lib/composables/useMediaZoom.svelte.d.ts +59 -0
  47. package/dist/lib/composables/useMediaZoom.svelte.js +199 -0
  48. package/dist/lib/index.d.ts +8 -2
  49. package/dist/lib/index.js +14 -1
  50. package/dist/lib/types/components.d.ts +172 -0
  51. package/dist/lib/utils/hls.d.ts +76 -0
  52. package/dist/lib/utils/hls.js +83 -0
  53. package/dist/lib/utils/media.d.ts +379 -0
  54. package/dist/lib/utils/media.js +715 -0
  55. package/package.json +6 -2
package/README.md CHANGED
@@ -102,6 +102,49 @@ Or in your root layout:
102
102
  - `DropdownMenu` - Dropdown menus
103
103
  - `Avatar` - User avatars
104
104
 
105
+ ### Media
106
+
107
+ - `AspectRatio` - Constrains content to a fixed width-to-height ratio
108
+ - `Image` - Responsive image with lazy loading, placeholders, and error fallback
109
+ - `Video` - Video player with themed controls, full keyboard support, and HLS/Cloudflare Stream playback
110
+ - `Audio` - Compact audio player with themed controls
111
+ - `MediaEmbed` - Lazy YouTube, Vimeo, Cloudflare Stream, and Matterport 3D tour embeds that load nothing until clicked
112
+ - `MediaGrid` - Responsive gallery of media thumbnails
113
+ - `MediaCarousel` - Swipeable media slides with dots, thumbnails, and optional autoplay
114
+ - `Lightbox` - Full-screen media viewer with zoom, pan, and keyboard navigation
115
+
116
+ #### Adaptive streaming and Cloudflare Stream
117
+
118
+ `Video` plays HLS playlists, including Cloudflare Stream, with the same controls as a self-hosted file. Safari plays HLS natively; other browsers need [hls.js](https://github.com/video-dev/hls.js), an **optional peer dependency**:
119
+
120
+ ```bash
121
+ npm install hls.js
122
+ ```
123
+
124
+ ```ts
125
+ // once, at app startup
126
+ import { setHlsLoader } from '@classic-homes/theme-svelte';
127
+
128
+ setHlsLoader(() => import('hls.js').then((m) => m.default));
129
+ ```
130
+
131
+ The import lives in your code rather than in this package, so consumers who never play a stream are not forced to install hls.js to build.
132
+
133
+ ```svelte
134
+ <script lang="ts">
135
+ import { Video, cloudflareStream } from '@classic-homes/theme-svelte';
136
+
137
+ const tour = cloudflareStream('a1b2c3d4e5f6', {
138
+ customerCode: 'abc123',
139
+ title: 'Product tour',
140
+ });
141
+ </script>
142
+
143
+ <Video item={tour} />
144
+ ```
145
+
146
+ `cloudflareStream()` derives the HLS playlist and poster from Stream's URL scheme with no API call. Pass a signed playback token in place of the video id for private videos. For Cloudflare's own player, use `MediaEmbed` with `provider="cloudflare"`.
147
+
105
148
  ### Branding
106
149
 
107
150
  - `LogoMain` - Main logo component
@@ -0,0 +1,145 @@
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
+ });
@@ -0,0 +1,206 @@
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
+ });