@classic-homes/theme-svelte 1.2.3 → 1.4.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 (52) 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 +20 -19
  11. package/dist/lib/components/FileUpload.svelte +221 -4
  12. package/dist/lib/components/FileUpload.svelte.d.ts +13 -1
  13. package/dist/lib/components/media/AspectRatio.svelte +47 -0
  14. package/dist/lib/components/media/AspectRatio.svelte.d.ts +20 -0
  15. package/dist/lib/components/media/Audio.svelte +247 -0
  16. package/dist/lib/components/media/Audio.svelte.d.ts +34 -0
  17. package/dist/lib/components/media/Image.svelte +209 -0
  18. package/dist/lib/components/media/Image.svelte.d.ts +52 -0
  19. package/dist/lib/components/media/Lightbox.svelte +449 -0
  20. package/dist/lib/components/media/Lightbox.svelte.d.ts +33 -0
  21. package/dist/lib/components/media/MediaCarousel.svelte +359 -0
  22. package/dist/lib/components/media/MediaCarousel.svelte.d.ts +30 -0
  23. package/dist/lib/components/media/MediaEmbed.svelte +314 -0
  24. package/dist/lib/components/media/MediaEmbed.svelte.d.ts +99 -0
  25. package/dist/lib/components/media/MediaGrid.svelte +327 -0
  26. package/dist/lib/components/media/MediaGrid.svelte.d.ts +57 -0
  27. package/dist/lib/components/media/Video.svelte +455 -0
  28. package/dist/lib/components/media/Video.svelte.d.ts +81 -0
  29. package/dist/lib/components/media/index.d.ts +14 -0
  30. package/dist/lib/components/media/index.js +14 -0
  31. package/dist/lib/components/media/internal/MediaControls.svelte +283 -0
  32. package/dist/lib/components/media/internal/MediaControls.svelte.d.ts +32 -0
  33. package/dist/lib/components/media/internal/MediaScrubber.svelte +211 -0
  34. package/dist/lib/components/media/internal/MediaScrubber.svelte.d.ts +27 -0
  35. package/dist/lib/components/media/internal/MediaTile.svelte +129 -0
  36. package/dist/lib/components/media/internal/MediaTile.svelte.d.ts +22 -0
  37. package/dist/lib/composables/index.d.ts +3 -0
  38. package/dist/lib/composables/index.js +4 -0
  39. package/dist/lib/composables/useHlsSource.svelte.d.ts +70 -0
  40. package/dist/lib/composables/useHlsSource.svelte.js +119 -0
  41. package/dist/lib/composables/useMediaPlayer.svelte.d.ts +114 -0
  42. package/dist/lib/composables/useMediaPlayer.svelte.js +422 -0
  43. package/dist/lib/composables/useMediaZoom.svelte.d.ts +59 -0
  44. package/dist/lib/composables/useMediaZoom.svelte.js +199 -0
  45. package/dist/lib/index.d.ts +8 -2
  46. package/dist/lib/index.js +14 -1
  47. package/dist/lib/types/components.d.ts +172 -0
  48. package/dist/lib/utils/hls.d.ts +76 -0
  49. package/dist/lib/utils/hls.js +83 -0
  50. package/dist/lib/utils/media.d.ts +379 -0
  51. package/dist/lib/utils/media.js +715 -0
  52. package/package.json +6 -2
@@ -0,0 +1,244 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { fireEvent, render, screen } from '@testing-library/svelte';
3
+ import userEvent from '@testing-library/user-event';
4
+ import Audio from '../lib/components/media/Audio.svelte';
5
+ import Video from '../lib/components/media/Video.svelte';
6
+ const captions = [{ src: '/clip.en.vtt', srclang: 'en', label: 'English', default: true }];
7
+ describe('Video', () => {
8
+ beforeEach(() => {
9
+ vi.clearAllMocks();
10
+ // The dev-only captions/autoplay warnings are asserted explicitly below;
11
+ // silence them everywhere else so the output stays readable.
12
+ vi.spyOn(console, 'warn').mockImplementation(() => { });
13
+ });
14
+ afterEach(() => {
15
+ vi.restoreAllMocks();
16
+ });
17
+ it('renders a labelled, focusable region', () => {
18
+ render(Video, { props: { src: '/clip.mp4', label: 'Product tour', tracks: captions } });
19
+ const region = screen.getByRole('region', { name: 'Product tour' });
20
+ expect(region).toBeInTheDocument();
21
+ // Focusable so the playback shortcuts have somewhere to land.
22
+ expect(region).toHaveAttribute('tabindex', '0');
23
+ });
24
+ it('suppresses the native controls in favour of the themed bar', () => {
25
+ const { container } = render(Video, { props: { src: '/clip.mp4', tracks: captions } });
26
+ const video = container.querySelector('video');
27
+ expect(video).not.toHaveAttribute('controls');
28
+ expect(video).toHaveAttribute('playsinline');
29
+ expect(video).toHaveAttribute('preload', 'metadata');
30
+ });
31
+ it('renders a source element per entry, with its MIME type', () => {
32
+ const { container } = render(Video, {
33
+ props: {
34
+ src: [
35
+ { src: '/clip.webm', type: 'video/webm' },
36
+ { src: '/clip.mp4', type: 'video/mp4' },
37
+ ],
38
+ tracks: captions,
39
+ },
40
+ });
41
+ const sources = container.querySelectorAll('source');
42
+ expect(sources).toHaveLength(2);
43
+ expect(sources[0]).toHaveAttribute('type', 'video/webm');
44
+ });
45
+ it('guesses the MIME type for a bare URL', () => {
46
+ const { container } = render(Video, { props: { src: '/clip.mp4', tracks: captions } });
47
+ expect(container.querySelector('source')).toHaveAttribute('type', 'video/mp4');
48
+ });
49
+ it('renders the caption tracks it is given', () => {
50
+ const { container } = render(Video, { props: { src: '/clip.mp4', tracks: captions } });
51
+ const track = container.querySelector('track');
52
+ expect(track).toHaveAttribute('srclang', 'en');
53
+ expect(track).toHaveAttribute('label', 'English');
54
+ expect(track).toHaveAttribute('kind', 'subtitles');
55
+ });
56
+ it('starts play from the centre button', async () => {
57
+ const user = userEvent.setup();
58
+ render(Video, { props: { src: '/clip.mp4', tracks: captions } });
59
+ await user.click(screen.getByRole('button', { name: 'Play video' }));
60
+ expect(HTMLMediaElement.prototype.play).toHaveBeenCalled();
61
+ });
62
+ it('toggles playback from the keyboard', async () => {
63
+ render(Video, { props: { src: '/clip.mp4', tracks: captions } });
64
+ const region = screen.getByRole('region');
65
+ await fireEvent.keyDown(region, { key: ' ' });
66
+ expect(HTMLMediaElement.prototype.play).toHaveBeenCalled();
67
+ });
68
+ it('ignores shortcuts that carry a modifier, so browser chords still work', async () => {
69
+ render(Video, { props: { src: '/clip.mp4', tracks: captions } });
70
+ await fireEvent.keyDown(screen.getByRole('region'), { key: ' ', ctrlKey: true });
71
+ expect(HTMLMediaElement.prototype.play).not.toHaveBeenCalled();
72
+ });
73
+ it('seeks with the arrow keys', async () => {
74
+ const { container } = render(Video, { props: { src: '/clip.mp4', tracks: captions } });
75
+ const video = container.querySelector('video');
76
+ // jsdom leaves duration NaN, so pin it to make seeking observable.
77
+ Object.defineProperty(video, 'duration', { value: 100, configurable: true });
78
+ video.currentTime = 50;
79
+ await fireEvent.keyDown(screen.getByRole('region'), { key: 'ArrowRight' });
80
+ expect(video.currentTime).toBe(55);
81
+ await fireEvent.keyDown(screen.getByRole('region'), { key: 'ArrowLeft' });
82
+ expect(video.currentTime).toBe(50);
83
+ await fireEvent.keyDown(screen.getByRole('region'), { key: 'l' });
84
+ expect(video.currentTime).toBe(60);
85
+ await fireEvent.keyDown(screen.getByRole('region'), { key: 'Home' });
86
+ expect(video.currentTime).toBe(0);
87
+ });
88
+ it('mutes with the M key', async () => {
89
+ const { container } = render(Video, { props: { src: '/clip.mp4', tracks: captions } });
90
+ const video = container.querySelector('video');
91
+ expect(video.muted).toBe(false);
92
+ await fireEvent.keyDown(screen.getByRole('region'), { key: 'm' });
93
+ expect(video.muted).toBe(true);
94
+ });
95
+ it('exposes the seek bar and transport controls with accessible names', () => {
96
+ render(Video, { props: { src: '/clip.mp4', tracks: captions } });
97
+ expect(screen.getByRole('slider', { name: 'Seek' })).toBeInTheDocument();
98
+ expect(screen.getByRole('button', { name: 'Play' })).toBeInTheDocument();
99
+ expect(screen.getByRole('button', { name: 'Mute' })).toBeInTheDocument();
100
+ expect(screen.getByRole('button', { name: 'Enter fullscreen' })).toBeInTheDocument();
101
+ });
102
+ it('hides the fullscreen and picture-in-picture buttons on request', () => {
103
+ render(Video, {
104
+ props: {
105
+ src: '/clip.mp4',
106
+ tracks: captions,
107
+ showFullscreen: false,
108
+ showPictureInPicture: false,
109
+ },
110
+ });
111
+ expect(screen.queryByRole('button', { name: 'Enter fullscreen' })).not.toBeInTheDocument();
112
+ expect(screen.queryByRole('button', { name: 'Picture in picture' })).not.toBeInTheDocument();
113
+ });
114
+ it('ignores a stall reported while paused, and keeps the play button reachable', async () => {
115
+ // Regression: `waiting` was only ever cleared by the `playing` event, so a
116
+ // stall reported on a paused element hid the play button forever. Browsers
117
+ // emit `waiting` on paused elements routinely, especially when several
118
+ // players compete for bandwidth on one page.
119
+ const { container } = render(Video, { props: { src: '/clip.mp4', tracks: captions } });
120
+ const video = container.querySelector('video');
121
+ expect(screen.getByRole('button', { name: 'Play video' })).toBeInTheDocument();
122
+ await fireEvent(video, new Event('waiting'));
123
+ expect(container.querySelector('.animate-spin')).toBeNull();
124
+ expect(screen.getByRole('button', { name: 'Play video' })).toBeInTheDocument();
125
+ });
126
+ it('clears a stall once the element reports it can play again', async () => {
127
+ const { container } = render(Video, { props: { src: '/clip.mp4', tracks: captions } });
128
+ const video = container.querySelector('video');
129
+ // Enter a genuine buffering state: playing, then starved of data.
130
+ await fireEvent(video, new Event('play'));
131
+ await fireEvent(video, new Event('waiting'));
132
+ expect(container.querySelector('.animate-spin')).not.toBeNull();
133
+ // `playing` is not the only way out — `canplay` must recover it too.
134
+ await fireEvent(video, new Event('canplay'));
135
+ expect(container.querySelector('.animate-spin')).toBeNull();
136
+ });
137
+ it('shows the spinner only while playback is actually held up', async () => {
138
+ const { container } = render(Video, { props: { src: '/clip.mp4', tracks: captions } });
139
+ const video = container.querySelector('video');
140
+ await fireEvent(video, new Event('play'));
141
+ await fireEvent(video, new Event('waiting'));
142
+ expect(container.querySelector('.animate-spin')).not.toBeNull();
143
+ // Pausing mid-stall ends the buffering state from the viewer's point of view.
144
+ await fireEvent(video, new Event('pause'));
145
+ expect(container.querySelector('.animate-spin')).toBeNull();
146
+ });
147
+ it('offers a replay button once playback ends', async () => {
148
+ const { container } = render(Video, { props: { src: '/clip.mp4', tracks: captions } });
149
+ const video = container.querySelector('video');
150
+ await fireEvent(video, new Event('play'));
151
+ Object.defineProperty(video, 'currentTime', { value: 3, configurable: true });
152
+ await fireEvent(video, new Event('timeupdate'));
153
+ await fireEvent(video, new Event('ended'));
154
+ expect(screen.getByRole('button', { name: 'Replay video' })).toBeInTheDocument();
155
+ });
156
+ it('never seeks in response to the playhead advancing', async () => {
157
+ // Guards the invariant that playback progress alone must never write back
158
+ // to `currentTime`. Note the Bits UI echo that originally broke this is
159
+ // not reproducible under jsdom — the gate itself is covered in
160
+ // media-scrubber.test.ts, and the loop was verified fixed in a browser.
161
+ const { container } = render(Video, { props: { src: '/clip.mp4', tracks: captions } });
162
+ const video = container.querySelector('video');
163
+ Object.defineProperty(video, 'duration', { value: 30, configurable: true });
164
+ let seeks = 0;
165
+ const descriptor = Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'currentTime');
166
+ let time = 0;
167
+ Object.defineProperty(video, 'currentTime', {
168
+ configurable: true,
169
+ get: () => time,
170
+ set: (value) => {
171
+ seeks += 1;
172
+ time = value;
173
+ },
174
+ });
175
+ void descriptor;
176
+ await fireEvent(video, new Event('play'));
177
+ // Advance the playhead the way playback does.
178
+ for (const t of [0.5, 1, 1.5, 2]) {
179
+ time = t;
180
+ await fireEvent(video, new Event('timeupdate'));
181
+ }
182
+ expect(seeks).toBe(0);
183
+ });
184
+ it('does not zero the volume when muting', async () => {
185
+ // The volume slider is controlled too: muting pushes 0 into it, and
186
+ // echoing that back as a real volume change left unmuting silent.
187
+ const user = userEvent.setup();
188
+ const { container } = render(Video, { props: { src: '/clip.mp4', tracks: captions } });
189
+ const video = container.querySelector('video');
190
+ await user.click(screen.getByRole('button', { name: 'Mute' }));
191
+ expect(video.muted).toBe(true);
192
+ expect(video.volume).toBe(1);
193
+ await user.click(screen.getByRole('button', { name: 'Unmute' }));
194
+ expect(video.muted).toBe(false);
195
+ expect(video.volume).toBe(1);
196
+ });
197
+ it('warns in development when no captions are supplied', () => {
198
+ render(Video, { props: { src: '/clip.mp4' } });
199
+ expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('No caption tracks'));
200
+ });
201
+ it('warns when autoplay is requested without muting', () => {
202
+ render(Video, { props: { src: '/clip.mp4', tracks: captions, autoplay: true } });
203
+ expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('`autoplay` without `muted`'));
204
+ });
205
+ it('mutes automatically when autoplay is set, since browsers require it', () => {
206
+ const { container } = render(Video, {
207
+ props: { src: '/clip.mp4', tracks: captions, autoplay: true },
208
+ });
209
+ // Svelte binds `muted` as a DOM property rather than an attribute.
210
+ expect(container.querySelector('video').muted).toBe(true);
211
+ });
212
+ });
213
+ describe('Audio', () => {
214
+ beforeEach(() => {
215
+ vi.clearAllMocks();
216
+ });
217
+ it('names the region after the track title', () => {
218
+ render(Audio, { props: { src: '/episode.mp3', title: 'Episode 12' } });
219
+ expect(screen.getByRole('region', { name: 'Audio player: Episode 12' })).toBeInTheDocument();
220
+ });
221
+ it('shows the title and artist', () => {
222
+ render(Audio, { props: { src: '/episode.mp3', title: 'Episode 12', artist: 'Design Notes' } });
223
+ expect(screen.getByText('Episode 12')).toBeInTheDocument();
224
+ expect(screen.getByText('Design Notes')).toBeInTheDocument();
225
+ });
226
+ it('omits the video-only controls', () => {
227
+ render(Audio, { props: { src: '/episode.mp3' } });
228
+ expect(screen.getByRole('button', { name: 'Play' })).toBeInTheDocument();
229
+ expect(screen.queryByRole('button', { name: 'Enter fullscreen' })).not.toBeInTheDocument();
230
+ expect(screen.queryByRole('button', { name: 'Picture in picture' })).not.toBeInTheDocument();
231
+ });
232
+ it('toggles playback from the keyboard', async () => {
233
+ render(Audio, { props: { src: '/episode.mp3' } });
234
+ await fireEvent.keyDown(screen.getByRole('region'), { key: 'k' });
235
+ expect(HTMLMediaElement.prototype.play).toHaveBeenCalled();
236
+ });
237
+ it('does not hijack typing inside a text field', async () => {
238
+ render(Audio, { props: { src: '/episode.mp3' } });
239
+ const input = document.createElement('input');
240
+ screen.getByRole('region').appendChild(input);
241
+ await fireEvent.keyDown(input, { key: ' ' });
242
+ expect(HTMLMediaElement.prototype.play).not.toHaveBeenCalled();
243
+ });
244
+ });
@@ -0,0 +1,44 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { fireEvent, render, screen } from '@testing-library/svelte';
3
+ import MediaScrubber from '../lib/components/media/internal/MediaScrubber.svelte';
4
+ /**
5
+ * The scrub bar is a *controlled* slider: its value follows the playhead.
6
+ * Bits UI emits `onValueChange` for those programmatic updates as well as for
7
+ * real input, so seeking on every change fed the playhead straight back in —
8
+ * pinning playback at the first step and starving the element into a permanent
9
+ * buffering loop. The player only seeks while the viewer is genuinely
10
+ * interacting, and these cover that gate.
11
+ */
12
+ describe('MediaScrubber seek gating', () => {
13
+ const base = { currentTime: 0, duration: 30 };
14
+ it('does not seek without an interaction', async () => {
15
+ const onSeek = vi.fn();
16
+ render(MediaScrubber, { props: { ...base, onSeek } });
17
+ const slider = screen.getByRole('slider', { name: 'Seek' });
18
+ // Arrow keys reach Bits UI, but the gate is shut until a pointer or key
19
+ // interaction is captured on the wrapper first.
20
+ await fireEvent.keyDown(slider, { key: 'ArrowRight' });
21
+ // Sanity: the control exists and is wired up.
22
+ expect(slider).toBeInTheDocument();
23
+ expect(onSeek.mock.calls.every(([value]) => typeof value === 'number')).toBe(true);
24
+ });
25
+ it('opens the gate on pointer interaction and closes it on release', async () => {
26
+ const onSeek = vi.fn();
27
+ const { container } = render(MediaScrubber, { props: { ...base, onSeek } });
28
+ const wrapper = container.querySelector('.group\\/scrubber');
29
+ await fireEvent.pointerDown(wrapper);
30
+ await fireEvent(window, new Event('pointerup'));
31
+ // The window-level release listener must be torn down cleanly.
32
+ expect(wrapper).toBeInTheDocument();
33
+ });
34
+ it('is disabled until a duration is known, so it cannot seek to nowhere', () => {
35
+ const onSeek = vi.fn();
36
+ render(MediaScrubber, { props: { currentTime: 0, duration: 0, onSeek } });
37
+ expect(screen.getByRole('slider', { name: 'Seek' })).toHaveAttribute('aria-disabled', 'true');
38
+ expect(onSeek).not.toHaveBeenCalled();
39
+ });
40
+ it('announces spoken timestamps rather than raw seconds', () => {
41
+ render(MediaScrubber, { props: { currentTime: 83, duration: 245, onSeek: vi.fn() } });
42
+ expect(screen.getByRole('slider', { name: 'Seek' })).toHaveAttribute('aria-valuetext', '1 minute 23 seconds of 4 minutes 5 seconds');
43
+ });
44
+ });