@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,273 +0,0 @@
1
- import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
- import { render, screen, waitFor } from '@testing-library/svelte';
3
- import Video from '../lib/components/media/Video.svelte';
4
- import MediaEmbed from '../lib/components/media/MediaEmbed.svelte';
5
- import { canPlayHlsNatively, hasHlsLoader, resetHlsLoader, setHlsLoader, } from '../lib/utils/hls.js';
6
- import { cloudflareStream } from '../lib/utils/media.js';
7
- const STREAM_ACCOUNT = { customerCode: 'abc123' };
8
- const HLS_URL = 'https://customer-abc123.cloudflarestream.com/a1b2c3/manifest/video.m3u8';
9
- /** Minimal stand-in for hls.js, enough to observe how the player drives it. */
10
- function createFakeHls() {
11
- const instances = [];
12
- class FakeHls {
13
- constructor(config) {
14
- this.config = config;
15
- this.handlers = new Map();
16
- this.loadSource = vi.fn();
17
- this.attachMedia = vi.fn();
18
- this.destroy = vi.fn();
19
- this.startLoad = vi.fn();
20
- this.recoverMediaError = vi.fn();
21
- this.on = vi.fn((event, callback) => {
22
- this.handlers.set(event, callback);
23
- });
24
- instances.push(this);
25
- }
26
- emit(event, data) {
27
- this.handlers.get(event)?.(event, data);
28
- }
29
- }
30
- FakeHls.isSupported = vi.fn(() => true);
31
- FakeHls.Events = { ERROR: 'hlsError' };
32
- FakeHls.ErrorTypes = { NETWORK_ERROR: 'networkError', MEDIA_ERROR: 'mediaError' };
33
- return { FakeHls, instances };
34
- }
35
- /** jsdom reports no HLS support, matching Chrome and Firefox. */
36
- function stubNativeHls(supported) {
37
- vi.spyOn(HTMLMediaElement.prototype, 'canPlayType').mockImplementation((type) => supported && type.toLowerCase().includes('mpegurl') ? 'maybe' : '');
38
- }
39
- beforeEach(() => {
40
- resetHlsLoader();
41
- vi.spyOn(console, 'warn').mockImplementation(() => { });
42
- });
43
- afterEach(() => {
44
- resetHlsLoader();
45
- vi.restoreAllMocks();
46
- });
47
- describe('canPlayHlsNatively', () => {
48
- it('is false when the browser rejects the HLS MIME types', () => {
49
- stubNativeHls(false);
50
- expect(canPlayHlsNatively()).toBe(false);
51
- });
52
- it('is true when the browser reports playback support, as Safari does', () => {
53
- stubNativeHls(true);
54
- expect(canPlayHlsNatively()).toBe(true);
55
- });
56
- });
57
- describe('setHlsLoader', () => {
58
- it('reports whether a loader is available without loading it', () => {
59
- expect(hasHlsLoader()).toBe(false);
60
- setHlsLoader(() => ({}));
61
- expect(hasHlsLoader()).toBe(true);
62
- });
63
- it('detects a global Hls, as set by the CDN build', () => {
64
- const { FakeHls } = createFakeHls();
65
- globalThis.Hls = FakeHls;
66
- try {
67
- expect(hasHlsLoader()).toBe(true);
68
- }
69
- finally {
70
- delete globalThis.Hls;
71
- }
72
- });
73
- });
74
- describe('Video with an HLS source', () => {
75
- it('plays natively without hls.js where the browser supports it', async () => {
76
- stubNativeHls(true);
77
- const { FakeHls, instances } = createFakeHls();
78
- setHlsLoader(() => FakeHls);
79
- const { container } = render(Video, { props: { src: HLS_URL, label: 'Stream' } });
80
- const video = container.querySelector('video');
81
- await waitFor(() => expect(video.getAttribute('src')).toBe(HLS_URL));
82
- // Safari needs no library, so none should be constructed.
83
- expect(instances).toHaveLength(0);
84
- });
85
- it('attaches hls.js when the browser cannot play HLS natively', async () => {
86
- stubNativeHls(false);
87
- const { FakeHls, instances } = createFakeHls();
88
- setHlsLoader(() => FakeHls);
89
- const { container } = render(Video, { props: { src: HLS_URL, label: 'Stream' } });
90
- await waitFor(() => expect(instances).toHaveLength(1));
91
- expect(instances[0].loadSource).toHaveBeenCalledWith(HLS_URL);
92
- expect(instances[0].attachMedia).toHaveBeenCalledWith(container.querySelector('video'));
93
- });
94
- it('omits the HLS <source> when hls.js drives playback', async () => {
95
- // Leaving a playlist the browser cannot decode would make the element
96
- // report an error before hls.js ever attaches.
97
- stubNativeHls(false);
98
- const { FakeHls } = createFakeHls();
99
- setHlsLoader(() => FakeHls);
100
- const { container } = render(Video, { props: { src: HLS_URL, label: 'Stream' } });
101
- await waitFor(() => expect(container.querySelectorAll('source')).toHaveLength(0));
102
- });
103
- it('keeps progressive fallbacks as <source> alongside a stream', async () => {
104
- stubNativeHls(false);
105
- const { FakeHls } = createFakeHls();
106
- setHlsLoader(() => FakeHls);
107
- const { container } = render(Video, {
108
- props: {
109
- src: [
110
- { src: HLS_URL, type: 'application/vnd.apple.mpegurl' },
111
- { src: '/fallback.mp4', type: 'video/mp4' },
112
- ],
113
- label: 'Stream',
114
- },
115
- });
116
- await waitFor(() => {
117
- const sources = container.querySelectorAll('source');
118
- expect(sources).toHaveLength(1);
119
- expect(sources[0]).toHaveAttribute('src', '/fallback.mp4');
120
- });
121
- });
122
- it('passes hls.js config through', async () => {
123
- stubNativeHls(false);
124
- const { FakeHls, instances } = createFakeHls();
125
- render(Video, {
126
- props: {
127
- src: HLS_URL,
128
- label: 'Stream',
129
- hlsLoader: () => FakeHls,
130
- hlsConfig: { lowLatencyMode: true },
131
- },
132
- });
133
- await waitFor(() => expect(instances).toHaveLength(1));
134
- expect(instances[0].config).toMatchObject({ lowLatencyMode: true });
135
- });
136
- it('starts downloading immediately by default', async () => {
137
- stubNativeHls(false);
138
- const { FakeHls, instances } = createFakeHls();
139
- setHlsLoader(() => FakeHls);
140
- render(Video, { props: { src: HLS_URL, label: 'Stream' } });
141
- await waitFor(() => expect(instances).toHaveLength(1));
142
- expect(instances[0].config).toMatchObject({ autoStartLoad: true });
143
- });
144
- it('holds off downloading until play when preload is none', async () => {
145
- // hls.js fetches through its own loader and ignores the element's
146
- // `preload`, so a stream would otherwise pull segments the moment it
147
- // mounted — starving any other player on the page of bandwidth.
148
- stubNativeHls(false);
149
- const { FakeHls, instances } = createFakeHls();
150
- setHlsLoader(() => FakeHls);
151
- render(Video, { props: { src: HLS_URL, label: 'Stream', preload: 'none' } });
152
- await waitFor(() => expect(instances).toHaveLength(1));
153
- expect(instances[0].config).toMatchObject({ autoStartLoad: false });
154
- expect(instances[0].startLoad).not.toHaveBeenCalled();
155
- await screen.getByRole('button', { name: 'Play video' }).click();
156
- await waitFor(() => expect(instances[0].startLoad).toHaveBeenCalled());
157
- });
158
- it('lets an explicit hlsConfig override the preload-derived default', async () => {
159
- stubNativeHls(false);
160
- const { FakeHls, instances } = createFakeHls();
161
- setHlsLoader(() => FakeHls);
162
- render(Video, {
163
- props: {
164
- src: HLS_URL,
165
- label: 'Stream',
166
- preload: 'none',
167
- hlsConfig: { autoStartLoad: true },
168
- },
169
- });
170
- await waitFor(() => expect(instances).toHaveLength(1));
171
- expect(instances[0].config).toMatchObject({ autoStartLoad: true });
172
- });
173
- it('recovers from non-fatal and recoverable hls.js errors instead of giving up', async () => {
174
- stubNativeHls(false);
175
- const { FakeHls, instances } = createFakeHls();
176
- setHlsLoader(() => FakeHls);
177
- render(Video, { props: { src: HLS_URL, label: 'Stream' } });
178
- await waitFor(() => expect(instances).toHaveLength(1));
179
- instances[0].emit('hlsError', { fatal: false, type: 'networkError' });
180
- expect(instances[0].startLoad).not.toHaveBeenCalled();
181
- instances[0].emit('hlsError', { fatal: true, type: 'networkError' });
182
- expect(instances[0].startLoad).toHaveBeenCalled();
183
- instances[0].emit('hlsError', { fatal: true, type: 'mediaError' });
184
- expect(instances[0].recoverMediaError).toHaveBeenCalled();
185
- });
186
- it('explains itself when hls.js is needed but unavailable', async () => {
187
- stubNativeHls(false);
188
- // No loader registered, and no global Hls.
189
- render(Video, { props: { src: HLS_URL, label: 'Stream' } });
190
- const alert = await screen.findByRole('alert');
191
- expect(alert).toHaveTextContent('This browser cannot play adaptive streams.');
192
- expect(alert).toHaveTextContent(/hls\.js/);
193
- });
194
- it('does not warn about missing captions for a stream, which carries its own', async () => {
195
- stubNativeHls(true);
196
- render(Video, { props: { src: HLS_URL, label: 'Stream' } });
197
- await waitFor(() => {
198
- expect(console.warn).not.toHaveBeenCalledWith(expect.stringContaining('No caption tracks'));
199
- });
200
- });
201
- it('tears hls.js down when the player unmounts', async () => {
202
- stubNativeHls(false);
203
- const { FakeHls, instances } = createFakeHls();
204
- setHlsLoader(() => FakeHls);
205
- const { unmount } = render(Video, { props: { src: HLS_URL, label: 'Stream' } });
206
- await waitFor(() => expect(instances).toHaveLength(1));
207
- unmount();
208
- expect(instances[0].destroy).toHaveBeenCalled();
209
- });
210
- });
211
- describe('Video with a Cloudflare Stream item', () => {
212
- it('takes source, poster, and label from the item', async () => {
213
- stubNativeHls(true);
214
- const item = cloudflareStream('a1b2c3', { ...STREAM_ACCOUNT, title: 'Product tour' });
215
- const { container } = render(Video, { props: { item } });
216
- const video = container.querySelector('video');
217
- expect(screen.getByRole('region', { name: 'Product tour' })).toBeInTheDocument();
218
- expect(video).toHaveAttribute('poster', item.poster);
219
- await waitFor(() => expect(video.getAttribute('src')).toBe(HLS_URL));
220
- });
221
- it('lets explicit props override the item', () => {
222
- stubNativeHls(true);
223
- const item = cloudflareStream('a1b2c3', { ...STREAM_ACCOUNT, title: 'Product tour' });
224
- const { container } = render(Video, {
225
- props: { item, label: 'Custom label', poster: '/own-poster.jpg' },
226
- });
227
- expect(screen.getByRole('region', { name: 'Custom label' })).toBeInTheDocument();
228
- expect(container.querySelector('video')).toHaveAttribute('poster', '/own-poster.jpg');
229
- });
230
- });
231
- describe('MediaEmbed with Cloudflare Stream', () => {
232
- it('shows a facade with a derived Stream thumbnail', () => {
233
- const { container } = render(MediaEmbed, {
234
- props: { provider: 'cloudflare', videoId: 'a1b2c3', customerCode: 'abc', title: 'Tour' },
235
- });
236
- expect(container.querySelector('iframe')).toBeNull();
237
- const facade = screen.getByRole('button', { name: 'Play Tour on Cloudflare Stream' });
238
- expect(facade).toBeInTheDocument();
239
- expect(facade.querySelector('img')).toHaveAttribute('src', expect.stringContaining('customer-abc.cloudflarestream.com/a1b2c3/thumbnails/thumbnail.jpg'));
240
- });
241
- it('injects the Stream player iframe once activated', async () => {
242
- const { container } = render(MediaEmbed, {
243
- props: {
244
- provider: 'cloudflare',
245
- videoId: 'a1b2c3',
246
- customerCode: 'abc',
247
- title: 'Tour',
248
- eager: true,
249
- },
250
- });
251
- const iframe = container.querySelector('iframe');
252
- expect(iframe).toHaveAttribute('src', 'https://customer-abc.cloudflarestream.com/a1b2c3/iframe');
253
- expect(iframe).toHaveAttribute('title', 'Tour');
254
- });
255
- it('resolves the account from a full Stream URL', () => {
256
- const { container } = render(MediaEmbed, {
257
- props: {
258
- url: 'https://customer-abc.cloudflarestream.com/a1b2c3/watch',
259
- title: 'Tour',
260
- eager: true,
261
- },
262
- });
263
- expect(container.querySelector('iframe')).toHaveAttribute('src', 'https://customer-abc.cloudflarestream.com/a1b2c3/iframe');
264
- });
265
- it('renders an error state, not a broken frame, when the account is missing', () => {
266
- const { container } = render(MediaEmbed, {
267
- props: { provider: 'cloudflare', videoId: 'a1b2c3', title: 'Tour' },
268
- });
269
- expect(container.querySelector('iframe')).toBeNull();
270
- expect(screen.getByRole('alert')).toHaveTextContent('This video could not be loaded.');
271
- expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('customerCode'));
272
- });
273
- });
@@ -1,296 +0,0 @@
1
- import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
- import { render, screen, waitFor } from '@testing-library/svelte';
3
- import userEvent from '@testing-library/user-event';
4
- import MediaEmbed from '../lib/components/media/MediaEmbed.svelte';
5
- import MediaGrid from '../lib/components/media/MediaGrid.svelte';
6
- import { buildEmbedSrc, buildMatterportSrc, fetchMatterportMetadata, getEmbedThumbnail, getMediaLabel, isSpatialMedia, isTimeBasedMedia, matterportTour, parseEmbedUrl, } from '../lib/utils/media.js';
7
- const MODEL_ID = 'SxQL3iGyoDo';
8
- beforeEach(() => {
9
- vi.spyOn(console, 'warn').mockImplementation(() => { });
10
- });
11
- afterEach(() => {
12
- vi.restoreAllMocks();
13
- });
14
- describe('buildMatterportSrc', () => {
15
- it('builds the showcase URL with quick start on by default', () => {
16
- // Without `qs`, the space shows a black frame for several seconds.
17
- expect(buildMatterportSrc(MODEL_ID)).toBe(`https://my.matterport.com/show/?m=${MODEL_ID}&qs=1`);
18
- });
19
- it('leaves Matterport’s own defaults alone unless asked', () => {
20
- const src = buildMatterportSrc(MODEL_ID);
21
- for (const untouched of ['brand', 'help', 'hr', 'vr', 'f', 'mls', 'play', 'ts']) {
22
- expect(src).not.toContain(`${untouched}=`);
23
- }
24
- });
25
- it('maps each option to its showcase parameter', () => {
26
- const src = buildMatterportSrc(MODEL_ID, {
27
- autoplay: true,
28
- brand: false,
29
- help: false,
30
- highlightReel: false,
31
- vr: false,
32
- floorSelector: false,
33
- mls: true,
34
- lang: 'fr',
35
- guidedTourStart: 5,
36
- });
37
- expect(src).toContain('play=1');
38
- expect(src).toContain('brand=0');
39
- expect(src).toContain('help=0');
40
- expect(src).toContain('hr=0');
41
- expect(src).toContain('vr=0');
42
- expect(src).toContain('f=0');
43
- expect(src).toContain('mls=1');
44
- expect(src).toContain('lang=fr');
45
- expect(src).toContain('ts=5');
46
- });
47
- it('can turn quick start off', () => {
48
- expect(buildMatterportSrc(MODEL_ID, { quickStart: false })).not.toContain('qs=');
49
- });
50
- it('floors a fractional guided-tour delay, which the player requires', () => {
51
- expect(buildMatterportSrc(MODEL_ID, { guidedTourStart: 5.8 })).toContain('ts=5');
52
- expect(buildMatterportSrc(MODEL_ID, { guidedTourStart: -3 })).toContain('ts=0');
53
- });
54
- it('passes arbitrary showcase parameters through, coercing booleans', () => {
55
- const src = buildMatterportSrc(MODEL_ID, { params: { nozoom: true, tourcta: 0, dh: '1' } });
56
- expect(src).toContain('nozoom=1');
57
- expect(src).toContain('tourcta=0');
58
- expect(src).toContain('dh=1');
59
- });
60
- });
61
- describe('parseEmbedUrl - Matterport', () => {
62
- it('parses a showcase URL', () => {
63
- expect(parseEmbedUrl(`https://my.matterport.com/show/?m=${MODEL_ID}`)).toEqual({
64
- provider: 'matterport',
65
- videoId: MODEL_ID,
66
- });
67
- });
68
- it('ignores the other parameters already on the URL', () => {
69
- expect(parseEmbedUrl(`https://my.matterport.com/show/?m=${MODEL_ID}&brand=0&qs=1`)).toEqual({
70
- provider: 'matterport',
71
- videoId: MODEL_ID,
72
- });
73
- });
74
- it('rejects a Matterport URL carrying no space', () => {
75
- // Marketing and discover pages have no `m` parameter to embed.
76
- expect(parseEmbedUrl('https://matterport.com/discover/space/some-place')).toBeNull();
77
- expect(parseEmbedUrl('https://my.matterport.com/show/')).toBeNull();
78
- });
79
- });
80
- describe('buildEmbedSrc - Matterport', () => {
81
- it('routes the provider to the showcase builder', () => {
82
- expect(buildEmbedSrc('matterport', MODEL_ID)).toBe(`https://my.matterport.com/show/?m=${MODEL_ID}&qs=1`);
83
- });
84
- it('forwards showcase options', () => {
85
- expect(buildEmbedSrc('matterport', MODEL_ID, { matterport: { brand: false } })).toContain('brand=0');
86
- });
87
- it('starts the space moving when the facade is activated', () => {
88
- expect(buildEmbedSrc('matterport', MODEL_ID, { autoplay: true })).toContain('play=1');
89
- });
90
- });
91
- describe('Matterport media items', () => {
92
- it('builds an embed item for a space', () => {
93
- const item = matterportTour(MODEL_ID, { title: 'Show home', caption: 'Ground floor' });
94
- expect(item).toMatchObject({
95
- kind: 'embed',
96
- provider: 'matterport',
97
- id: MODEL_ID,
98
- videoId: MODEL_ID,
99
- title: 'Show home',
100
- caption: 'Ground floor',
101
- });
102
- });
103
- it('carries showcase options onto the item', () => {
104
- const item = matterportTour(MODEL_ID, { brand: false, guidedTourStart: 3 });
105
- expect(item.matterport).toMatchObject({ brand: false, guidedTourStart: 3 });
106
- });
107
- it('is spatial, not time-based, so it gets no play affordance or duration', () => {
108
- const item = matterportTour(MODEL_ID);
109
- expect(isSpatialMedia(item)).toBe(true);
110
- expect(isTimeBasedMedia(item)).toBe(false);
111
- });
112
- it('falls back to a tour label rather than calling itself a video', () => {
113
- expect(getMediaLabel(matterportTour(MODEL_ID), 2)).toBe('3D tour 3');
114
- expect(getMediaLabel(matterportTour(MODEL_ID))).toBe('Untitled 3D tour');
115
- });
116
- it('derives no thumbnail, because Matterport publishes none', () => {
117
- expect(getEmbedThumbnail('matterport', MODEL_ID)).toBeNull();
118
- });
119
- });
120
- describe('fetchMatterportMetadata', () => {
121
- it('returns the space name and the hero thumbnail', async () => {
122
- vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
123
- ok: true,
124
- json: async () => ({
125
- sid: MODEL_ID,
126
- name: 'Construction Site',
127
- image: 'https://cdn/expired.jpg',
128
- images: [
129
- { is_hero: false, signed_src: 'https://cdn/other.jpg' },
130
- {
131
- is_hero: true,
132
- signed_src: 'https://cdn/hero-full.jpg',
133
- thumbnail_signed_src: 'https://cdn/hero-thumb.jpg',
134
- },
135
- ],
136
- }),
137
- }));
138
- await expect(fetchMatterportMetadata(MODEL_ID)).resolves.toEqual({
139
- name: 'Construction Site',
140
- image: 'https://cdn/hero-thumb.jpg',
141
- });
142
- });
143
- it('ignores the top-level image, which Matterport hands out already expired', async () => {
144
- // Requesting that URL answers 410 Gone; only the signatures inside
145
- // `images` are freshly minted.
146
- vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
147
- ok: true,
148
- json: async () => ({
149
- name: 'Space',
150
- image: 'https://cdn/expired.jpg',
151
- images: [{ is_hero: true, thumbnail_signed_src: 'https://cdn/fresh.jpg' }],
152
- }),
153
- }));
154
- const metadata = await fetchMatterportMetadata(MODEL_ID);
155
- expect(metadata?.image).toBe('https://cdn/fresh.jpg');
156
- expect(metadata?.image).not.toContain('expired');
157
- });
158
- it('falls back to the full-size signed image when there is no thumbnail', async () => {
159
- vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
160
- ok: true,
161
- json: async () => ({
162
- name: 'Space',
163
- images: [{ is_hero: true, signed_src: 'https://cdn/full.jpg' }],
164
- }),
165
- }));
166
- await expect(fetchMatterportMetadata(MODEL_ID)).resolves.toMatchObject({
167
- image: 'https://cdn/full.jpg',
168
- });
169
- });
170
- it('uses a non-hero image when the space marks no hero', async () => {
171
- vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
172
- ok: true,
173
- json: async () => ({
174
- name: 'Space',
175
- images: [{ thumbnail_signed_src: 'https://cdn/first.jpg' }],
176
- }),
177
- }));
178
- await expect(fetchMatterportMetadata(MODEL_ID)).resolves.toMatchObject({
179
- image: 'https://cdn/first.jpg',
180
- });
181
- });
182
- it('reports no image when the space has none', async () => {
183
- vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({ name: 'Space', images: [] }) }));
184
- await expect(fetchMatterportMetadata(MODEL_ID)).resolves.toEqual({
185
- name: 'Space',
186
- image: null,
187
- });
188
- });
189
- it('resolves to null on a failed response rather than throwing', async () => {
190
- vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 404 }));
191
- await expect(fetchMatterportMetadata(MODEL_ID)).resolves.toBeNull();
192
- });
193
- it('resolves to null when the request itself fails', async () => {
194
- // A preview is an enhancement; it must never break a render.
195
- vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline')));
196
- await expect(fetchMatterportMetadata(MODEL_ID)).resolves.toBeNull();
197
- });
198
- it('does not call out for an empty id', async () => {
199
- const fetchMock = vi.fn();
200
- vi.stubGlobal('fetch', fetchMock);
201
- await expect(fetchMatterportMetadata('')).resolves.toBeNull();
202
- expect(fetchMock).not.toHaveBeenCalled();
203
- });
204
- });
205
- describe('MediaEmbed with Matterport', () => {
206
- it('shows a tour facade that opens rather than plays', () => {
207
- const { container } = render(MediaEmbed, {
208
- props: { provider: 'matterport', modelId: MODEL_ID, title: 'Show home' },
209
- });
210
- expect(container.querySelector('iframe')).toBeNull();
211
- expect(screen.getByRole('button', { name: 'Open Show home as a 3D tour on Matterport' })).toBeInTheDocument();
212
- });
213
- it('accepts modelId as an alias for videoId', () => {
214
- const { container } = render(MediaEmbed, {
215
- props: { provider: 'matterport', modelId: MODEL_ID, title: 'Show home', eager: true },
216
- });
217
- expect(container.querySelector('iframe')).toHaveAttribute('src', `https://my.matterport.com/show/?m=${MODEL_ID}&qs=1`);
218
- });
219
- it('grants the permissions the showcase needs for VR', () => {
220
- const { container } = render(MediaEmbed, {
221
- props: { provider: 'matterport', modelId: MODEL_ID, title: 'Show home', eager: true },
222
- });
223
- const iframe = container.querySelector('iframe');
224
- expect(iframe.getAttribute('allow')).toContain('xr-spatial-tracking');
225
- expect(iframe).toHaveAttribute('allowfullscreen');
226
- });
227
- it('resolves the space from a full showcase URL', () => {
228
- const { container } = render(MediaEmbed, {
229
- props: {
230
- url: `https://my.matterport.com/show/?m=${MODEL_ID}`,
231
- title: 'Show home',
232
- eager: true,
233
- },
234
- });
235
- expect(container.querySelector('iframe')?.getAttribute('src')).toContain(`m=${MODEL_ID}`);
236
- });
237
- it('makes no request to Matterport by default', async () => {
238
- const fetchMock = vi.fn();
239
- vi.stubGlobal('fetch', fetchMock);
240
- render(MediaEmbed, {
241
- props: { provider: 'matterport', modelId: MODEL_ID, title: 'Show home' },
242
- });
243
- await waitFor(() => expect(fetchMock).not.toHaveBeenCalled());
244
- });
245
- it('looks up the preview image only when asked', async () => {
246
- const fetchMock = vi.fn().mockResolvedValue({
247
- ok: true,
248
- json: async () => ({
249
- name: 'Construction Site',
250
- images: [{ is_hero: true, thumbnail_signed_src: 'https://cdn/preview.jpg' }],
251
- }),
252
- });
253
- vi.stubGlobal('fetch', fetchMock);
254
- const { container } = render(MediaEmbed, {
255
- props: { provider: 'matterport', modelId: MODEL_ID, title: 'Show home', fetchMetadata: true },
256
- });
257
- await waitFor(() => expect(fetchMock).toHaveBeenCalledOnce());
258
- await waitFor(() => expect(container.querySelector('img')).toHaveAttribute('src', 'https://cdn/preview.jpg'));
259
- });
260
- it('skips the lookup when a poster was supplied', async () => {
261
- const fetchMock = vi.fn();
262
- vi.stubGlobal('fetch', fetchMock);
263
- render(MediaEmbed, {
264
- props: {
265
- provider: 'matterport',
266
- modelId: MODEL_ID,
267
- title: 'Show home',
268
- fetchMetadata: true,
269
- poster: '/own.jpg',
270
- },
271
- });
272
- await waitFor(() => expect(fetchMock).not.toHaveBeenCalled());
273
- });
274
- it('activates the showcase on click, with the space set moving', async () => {
275
- const user = userEvent.setup();
276
- const { container } = render(MediaEmbed, {
277
- props: { provider: 'matterport', modelId: MODEL_ID, title: 'Show home' },
278
- });
279
- await user.click(screen.getByRole('button', { name: /Open Show home/ }));
280
- const src = container.querySelector('iframe')?.getAttribute('src') ?? '';
281
- expect(src).toContain(`m=${MODEL_ID}`);
282
- expect(src).toContain('play=1');
283
- });
284
- it('reports an unresolvable tour as a tour, not a video', () => {
285
- render(MediaEmbed, { props: { provider: 'matterport', title: 'Show home' } });
286
- expect(screen.getByRole('alert')).toHaveTextContent('This 3D tour could not be loaded.');
287
- });
288
- });
289
- describe('MediaGrid with Matterport tours', () => {
290
- it('names tour tiles and shows no duration badge', () => {
291
- const items = [matterportTour(MODEL_ID, { title: 'Show home' }), matterportTour('AbCdEfGhIjK')];
292
- render(MediaGrid, { props: { items, label: 'Tours', lightbox: false } });
293
- expect(screen.getByRole('button', { name: 'Show home' })).toBeInTheDocument();
294
- expect(screen.getByRole('button', { name: '3D tour 2' })).toBeInTheDocument();
295
- });
296
- });