@motion-proto/live-tokens 0.85.0 → 0.86.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.
- package/CHANGELOG.md +44 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/editor/component-editor/SlidePagerEditor.svelte +118 -0
- package/src/editor/component-editor/VideoFrameEditor.svelte +73 -0
- package/src/editor/component-editor/VideoLightboxEditor.svelte +95 -0
- package/src/editor/component-editor/registry.ts +40 -1
- package/src/editor/core/sketch/sketchLayer.ts +7 -0
- package/src/editor/overlay/LiveEditorOverlay.svelte +45 -52
- package/src/editor/pages/ComponentEditorPage.svelte +1 -1
- package/src/editor/ui/ThemePanel.svelte +9 -53
- package/src/live-tokens/data/themes/autumn.json +86 -0
- package/src/live-tokens/data/themes/halloween.json +86 -0
- package/src/live-tokens/data/themes/midnight-study.json +86 -0
- package/src/live-tokens/data/themes/ocean.json +86 -0
- package/src/live-tokens/data/themes/royal-velvet.json +86 -0
- package/src/live-tokens/data/themes/sketchy.json +86 -0
- package/src/live-tokens/data/themes/spring-meadow.json +86 -0
- package/src/live-tokens/data/themes/sunset.json +86 -0
- package/src/system/assets/slide-04.webp +0 -0
- package/src/system/assets/slide-05.webp +0 -0
- package/src/system/assets/slide-06.webp +0 -0
- package/src/system/assets/watch-navigation-poster.webp +0 -0
- package/src/system/assets/watch-navigation.mp4 +0 -0
- package/src/system/components/ImageLightbox.svelte +20 -21
- package/src/system/components/SlidePager.svelte +516 -0
- package/src/system/components/VideoFrame.svelte +284 -0
- package/src/system/components/VideoLightbox.svelte +381 -0
- package/src/testing-js/{chunk-RDHCBQ6I.js → chunk-OHU5BHGZ.js} +344 -2
- package/src/testing-js/chunk-OHU5BHGZ.js.map +1 -0
- package/src/testing-js/{chunk-XV5CYADO.js → chunk-Y6R7Z7VK.js} +10 -6
- package/src/testing-js/chunk-Y6R7Z7VK.js.map +1 -0
- package/src/testing-js/component-behavior.contract.js +1 -1
- package/src/testing-js/component-editor.contract.js +1 -1
- package/src/testing-js/component-render.contract.js +1 -1
- package/src/testing-js/index.d.ts +2 -2
- package/src/testing-js/index.js +2 -2
- package/src/testing-js/page-compliance.contract.js +1 -1
- package/src/testing-js/{vitest-BMLIbDs2.d.ts → vitest-5vWtqwWe.d.ts} +2 -2
- package/src/testing-js/vitest.d.ts +1 -1
- package/src/testing-js/vitest.js +2 -2
- package/src/testing-js/chunk-RDHCBQ6I.js.map +0 -1
- package/src/testing-js/chunk-XV5CYADO.js.map +0 -1
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
<script module lang="ts">
|
|
2
|
+
import type { CatalogueEntry } from '../../editor/component-editor/scaffolding/types';
|
|
3
|
+
|
|
4
|
+
export const catalogue = {
|
|
5
|
+
description: 'A poster frame that plays its clip in place when clicked.',
|
|
6
|
+
whenToUse:
|
|
7
|
+
'footage whose motion is the point, standing in a layout where a still would otherwise sit.',
|
|
8
|
+
whenNotToUse: [
|
|
9
|
+
{ when: 'a picture whose detail is the point.', use: 'imagelightbox' },
|
|
10
|
+
{ when: 'decoration.', use: 'image' },
|
|
11
|
+
{ when: 'a clip the reader opens over the page from a thumbnail.', use: 'videolightbox' }
|
|
12
|
+
],
|
|
13
|
+
constraints: ['A clip the deploy does not serve falls back to its poster.'],
|
|
14
|
+
props: {
|
|
15
|
+
chrome:
|
|
16
|
+
'`badge` shows the play badge and hands playback to the native controls. `none` drops both, and a click toggles play.',
|
|
17
|
+
fit: '`fill` scales the clip to the frame. `natural` holds it at its own size on the frame’s ground, which wants width and height.',
|
|
18
|
+
loop: 'true repeats the clip.'
|
|
19
|
+
}
|
|
20
|
+
} satisfies CatalogueEntry;
|
|
21
|
+
</script>
|
|
22
|
+
|
|
23
|
+
<script lang="ts">
|
|
24
|
+
interface Props {
|
|
25
|
+
/** The clip. Served as a static file, so a missing one is survivable. */
|
|
26
|
+
src: string;
|
|
27
|
+
/** The still shown until play, and the fallback when the clip is absent. */
|
|
28
|
+
poster: string;
|
|
29
|
+
alt?: string;
|
|
30
|
+
/** Natural size of the poster. Sets the box before anything loads. */
|
|
31
|
+
width?: number;
|
|
32
|
+
height?: number;
|
|
33
|
+
maxWidth?: number | string;
|
|
34
|
+
loop?: boolean;
|
|
35
|
+
/** "none" drops the badge and the native controls; a click then toggles play. */
|
|
36
|
+
chrome?: 'badge' | 'none';
|
|
37
|
+
/** "natural" holds the clip at its own pixel size on the frame's ground. Needs width and height. */
|
|
38
|
+
fit?: 'fill' | 'natural';
|
|
39
|
+
class?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let {
|
|
43
|
+
src,
|
|
44
|
+
poster,
|
|
45
|
+
alt = '',
|
|
46
|
+
width = undefined,
|
|
47
|
+
height = undefined,
|
|
48
|
+
maxWidth = undefined,
|
|
49
|
+
loop = false,
|
|
50
|
+
chrome = 'badge',
|
|
51
|
+
fit = 'fill',
|
|
52
|
+
class: className = ''
|
|
53
|
+
}: Props = $props();
|
|
54
|
+
|
|
55
|
+
let videoEl: HTMLVideoElement | undefined = $state();
|
|
56
|
+
let started = $state(false);
|
|
57
|
+
let playing = $state(false);
|
|
58
|
+
// A clip the deploy does not serve falls back to the poster. A HEAD answers
|
|
59
|
+
// that on its own; a load error would not, since it fires the same way for a
|
|
60
|
+
// codec the browser cannot decode. An SPA rewrite answers 200 with
|
|
61
|
+
// index.html for a path it has no file for, so the content type settles it.
|
|
62
|
+
let missing = $state(false);
|
|
63
|
+
|
|
64
|
+
$effect(() => {
|
|
65
|
+
let live = true;
|
|
66
|
+
fetch(src, { method: 'HEAD' })
|
|
67
|
+
.then((res) => {
|
|
68
|
+
const type = res.headers.get('content-type') ?? '';
|
|
69
|
+
if (live) missing = !res.ok || !type.startsWith('video/');
|
|
70
|
+
})
|
|
71
|
+
.catch(() => {
|
|
72
|
+
if (live) missing = true;
|
|
73
|
+
});
|
|
74
|
+
return () => {
|
|
75
|
+
live = false;
|
|
76
|
+
};
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const ratio = $derived(width && height ? `${width} / ${height}` : undefined);
|
|
80
|
+
const cap = $derived(typeof maxWidth === 'number' ? `${maxWidth}px` : maxWidth);
|
|
81
|
+
|
|
82
|
+
function start() {
|
|
83
|
+
started = true;
|
|
84
|
+
videoEl?.play();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function toggle() {
|
|
88
|
+
if (!videoEl) return;
|
|
89
|
+
started = true;
|
|
90
|
+
if (videoEl.paused) videoEl.play();
|
|
91
|
+
else videoEl.pause();
|
|
92
|
+
}
|
|
93
|
+
</script>
|
|
94
|
+
|
|
95
|
+
<div class="videoframe" style:max-width={cap}>
|
|
96
|
+
<div
|
|
97
|
+
class="videoframe-frame sketch-surface {className}"
|
|
98
|
+
style:aspect-ratio={ratio}
|
|
99
|
+
data-playing={started ? 'true' : null}
|
|
100
|
+
>
|
|
101
|
+
<div class="videoframe-clip" class:is-natural={fit === 'natural'}>
|
|
102
|
+
{#if missing}
|
|
103
|
+
<img src={poster} {alt} {width} {height} />
|
|
104
|
+
{:else}
|
|
105
|
+
<!-- No caption track ships with the source footage. -->
|
|
106
|
+
<!-- svelte-ignore a11y_media_has_caption -->
|
|
107
|
+
<video
|
|
108
|
+
bind:this={videoEl}
|
|
109
|
+
{src}
|
|
110
|
+
{poster}
|
|
111
|
+
{loop}
|
|
112
|
+
preload="metadata"
|
|
113
|
+
playsinline
|
|
114
|
+
controls={started && chrome === 'badge'}
|
|
115
|
+
onplay={() => (playing = true)}
|
|
116
|
+
onpause={() => (playing = false)}
|
|
117
|
+
></video>
|
|
118
|
+
{/if}
|
|
119
|
+
</div>
|
|
120
|
+
|
|
121
|
+
{#if !missing && (chrome === 'none' || !started)}
|
|
122
|
+
<button
|
|
123
|
+
type="button"
|
|
124
|
+
class="videoframe-trigger"
|
|
125
|
+
onclick={chrome === 'none' ? toggle : start}
|
|
126
|
+
>
|
|
127
|
+
<span class="visually-hidden">{playing ? 'Pause' : 'Play'}: {alt}</span>
|
|
128
|
+
{#if chrome === 'badge'}
|
|
129
|
+
<span class="videoframe-badge sketch-chip" aria-hidden="true">
|
|
130
|
+
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5.5v13l11-6.5z" /></svg>
|
|
131
|
+
</span>
|
|
132
|
+
{/if}
|
|
133
|
+
</button>
|
|
134
|
+
{/if}
|
|
135
|
+
</div>
|
|
136
|
+
</div>
|
|
137
|
+
|
|
138
|
+
<style>
|
|
139
|
+
:global(:root) {
|
|
140
|
+
--videoframe-frame-surface: var(--surface-neutral-lowest);
|
|
141
|
+
/* The ground behind the clip. Only shows where the clip leaves the frame
|
|
142
|
+
unfilled, so it wants the footage's own background colour. */
|
|
143
|
+
--videoframe-clip-surface: var(--color-transparent);
|
|
144
|
+
--videoframe-frame-border: var(--border-neutral);
|
|
145
|
+
--videoframe-frame-border-width: var(--border-width-1);
|
|
146
|
+
--videoframe-frame-radius: var(--radius-2xl);
|
|
147
|
+
|
|
148
|
+
--videoframe-badge-default-surface: var(--surface-brand-lower);
|
|
149
|
+
--videoframe-badge-default-border: var(--border-brand-medium);
|
|
150
|
+
--videoframe-badge-default-border-width: var(--border-width-2);
|
|
151
|
+
--videoframe-badge-default-icon: var(--text-primary);
|
|
152
|
+
--videoframe-badge-default-size: var(--space-64);
|
|
153
|
+
--videoframe-badge-default-icon-size: var(--icon-size-4xl);
|
|
154
|
+
--videoframe-badge-duration: var(--duration-200);
|
|
155
|
+
--videoframe-badge-easing: var(--ease-out-quad);
|
|
156
|
+
|
|
157
|
+
--videoframe-badge-hover-surface: var(--surface-brand);
|
|
158
|
+
--videoframe-badge-hover-border: var(--border-brand-strong);
|
|
159
|
+
--videoframe-badge-hover-icon: var(--text-primary);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
.videoframe {
|
|
163
|
+
display: block;
|
|
164
|
+
width: 100%;
|
|
165
|
+
margin-inline: auto;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
.videoframe-frame {
|
|
169
|
+
position: relative;
|
|
170
|
+
width: 100%;
|
|
171
|
+
background: var(--videoframe-frame-surface);
|
|
172
|
+
border: var(--videoframe-frame-border-width) solid var(--videoframe-frame-border);
|
|
173
|
+
border-radius: var(--videoframe-frame-radius);
|
|
174
|
+
|
|
175
|
+
--sketch-fill: var(--videoframe-frame-surface);
|
|
176
|
+
--sketch-stroke: var(--videoframe-frame-border);
|
|
177
|
+
--sketch-hatch-color: var(--videoframe-frame-border);
|
|
178
|
+
--sketch-radius: var(--videoframe-frame-radius);
|
|
179
|
+
--sketch-shadow: none;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/* The clip lives one level in: the sketch layer forces the drawn part's
|
|
183
|
+
overflow visible, which would let square media corners escape the frame. */
|
|
184
|
+
.videoframe-clip {
|
|
185
|
+
overflow: hidden;
|
|
186
|
+
background: var(--videoframe-clip-surface);
|
|
187
|
+
border-radius: var(--sketch-radius, var(--videoframe-frame-radius));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
.videoframe-clip img,
|
|
191
|
+
.videoframe-clip video {
|
|
192
|
+
display: block;
|
|
193
|
+
width: 100%;
|
|
194
|
+
height: 100%;
|
|
195
|
+
object-fit: contain;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/* Absolute, so the frame's aspect-ratio box stays the pad the clip sits in. */
|
|
199
|
+
.videoframe-clip.is-natural {
|
|
200
|
+
position: absolute;
|
|
201
|
+
inset: 0;
|
|
202
|
+
display: grid;
|
|
203
|
+
place-items: center;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
.videoframe-clip.is-natural img,
|
|
207
|
+
.videoframe-clip.is-natural video {
|
|
208
|
+
width: auto;
|
|
209
|
+
height: auto;
|
|
210
|
+
max-width: 100%;
|
|
211
|
+
max-height: 100%;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
.videoframe-trigger {
|
|
215
|
+
position: absolute;
|
|
216
|
+
inset: 0;
|
|
217
|
+
display: flex;
|
|
218
|
+
align-items: center;
|
|
219
|
+
justify-content: center;
|
|
220
|
+
padding: 0;
|
|
221
|
+
background: none;
|
|
222
|
+
border: none;
|
|
223
|
+
cursor: pointer;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
.videoframe-trigger:focus-visible {
|
|
227
|
+
outline: none;
|
|
228
|
+
box-shadow: var(--ring-focus-md);
|
|
229
|
+
border-radius: var(--sketch-radius, var(--videoframe-frame-radius));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
.videoframe-badge {
|
|
233
|
+
box-sizing: border-box;
|
|
234
|
+
display: flex;
|
|
235
|
+
align-items: center;
|
|
236
|
+
justify-content: center;
|
|
237
|
+
width: var(--videoframe-badge-default-size);
|
|
238
|
+
height: var(--videoframe-badge-default-size);
|
|
239
|
+
color: var(--videoframe-badge-default-icon);
|
|
240
|
+
background: var(--videoframe-badge-default-surface);
|
|
241
|
+
border: var(--videoframe-badge-default-border-width) solid
|
|
242
|
+
var(--videoframe-badge-default-border);
|
|
243
|
+
border-radius: var(--radius-full);
|
|
244
|
+
transition:
|
|
245
|
+
background var(--videoframe-badge-duration) var(--videoframe-badge-easing),
|
|
246
|
+
border-color var(--videoframe-badge-duration) var(--videoframe-badge-easing),
|
|
247
|
+
color var(--videoframe-badge-duration) var(--videoframe-badge-easing);
|
|
248
|
+
|
|
249
|
+
--sketch-fill: var(--videoframe-badge-default-surface);
|
|
250
|
+
--sketch-stroke: var(--videoframe-badge-default-border);
|
|
251
|
+
--sketch-hatch-color: var(--videoframe-badge-default-border);
|
|
252
|
+
--sketch-radius: var(--radius-full);
|
|
253
|
+
--sketch-shadow: none;
|
|
254
|
+
/* Travel is stated in px, and the play triangle is small enough to tear. */
|
|
255
|
+
--sketch-icon-off: var(--sketch-icon-soft);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
.videoframe-badge svg {
|
|
259
|
+
width: var(--videoframe-badge-default-icon-size);
|
|
260
|
+
height: var(--videoframe-badge-default-icon-size);
|
|
261
|
+
/* The triangle's mass sits left of centre; nudge it back onto the axis. */
|
|
262
|
+
margin-inline-start: 6%;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
.videoframe-trigger:hover .videoframe-badge,
|
|
266
|
+
.videoframe-frame.force-hover .videoframe-badge {
|
|
267
|
+
color: var(--videoframe-badge-hover-icon);
|
|
268
|
+
background: var(--videoframe-badge-hover-surface);
|
|
269
|
+
border-color: var(--videoframe-badge-hover-border);
|
|
270
|
+
|
|
271
|
+
--sketch-fill: var(--videoframe-badge-hover-surface);
|
|
272
|
+
--sketch-stroke: var(--videoframe-badge-hover-border);
|
|
273
|
+
--sketch-hatch-color: var(--videoframe-badge-hover-border);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
.visually-hidden {
|
|
277
|
+
position: absolute;
|
|
278
|
+
width: 1px;
|
|
279
|
+
height: 1px;
|
|
280
|
+
overflow: hidden;
|
|
281
|
+
clip-path: inset(50%);
|
|
282
|
+
white-space: nowrap;
|
|
283
|
+
}
|
|
284
|
+
</style>
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
<script module lang="ts">
|
|
2
|
+
import type { CatalogueEntry } from '../../editor/component-editor/scaffolding/types';
|
|
3
|
+
|
|
4
|
+
export const catalogue = {
|
|
5
|
+
description:
|
|
6
|
+
'A small still that fades the page away and plays its clip in the middle of the screen when clicked.',
|
|
7
|
+
whenToUse:
|
|
8
|
+
'a short clip on a plain ground, such as a product shot, that the reader opens from a thumbnail beside the copy.',
|
|
9
|
+
whenNotToUse: [
|
|
10
|
+
{ when: 'a picture whose detail is the point.', use: 'imagelightbox' },
|
|
11
|
+
{ when: 'footage that plays in the layout at full size, with no thumbnail to open.', use: 'videoframe' }
|
|
12
|
+
],
|
|
13
|
+
props: {
|
|
14
|
+
inline: 'true renders the open lightbox in flow, without the thumbnail. The editor preview uses it.',
|
|
15
|
+
loop: 'true repeats the clip.',
|
|
16
|
+
hoverPlay:
|
|
17
|
+
'true plays the thumbnail muted under the pointer, and rewinds it on the way out. A reader who asked for less motion keeps the still.',
|
|
18
|
+
stretch:
|
|
19
|
+
'true spreads the trigger over the nearest positioned ancestor, so a whole card opens the clip. The consumer positions that ancestor.',
|
|
20
|
+
thumbAspect:
|
|
21
|
+
'the ratio the thumbnail box holds, when it should not be the clip\'s own. The clip still opens at its full frame.',
|
|
22
|
+
thumbFit:
|
|
23
|
+
'`cover` fills a thumbnail box the clip does not share the shape of, and crops what will not fit. `contain` keeps the whole frame and letterboxes it.',
|
|
24
|
+
previewStart:
|
|
25
|
+
'seconds into the clip the hover preview starts, for footage that opens on a title card. The clip itself still opens at the beginning.'
|
|
26
|
+
}
|
|
27
|
+
} satisfies CatalogueEntry;
|
|
28
|
+
</script>
|
|
29
|
+
|
|
30
|
+
<script lang="ts">
|
|
31
|
+
import { tick } from 'svelte';
|
|
32
|
+
import { portal } from '../internal/portal';
|
|
33
|
+
|
|
34
|
+
interface Props {
|
|
35
|
+
src: string;
|
|
36
|
+
/** The thumbnail, and the frame shown before the clip starts. */
|
|
37
|
+
poster: string;
|
|
38
|
+
alt?: string;
|
|
39
|
+
/** Natural size of the clip. Sets the thumbnail's box and the open size. */
|
|
40
|
+
width?: number;
|
|
41
|
+
height?: number;
|
|
42
|
+
loop?: boolean;
|
|
43
|
+
/** Renders the open lightbox in flow, without the thumbnail. Used by the editor preview. */
|
|
44
|
+
inline?: boolean;
|
|
45
|
+
/** Plays the thumbnail muted under the pointer. Still for reduced motion. */
|
|
46
|
+
hoverPlay?: boolean;
|
|
47
|
+
/** Spreads the hit area over the nearest positioned ancestor. */
|
|
48
|
+
stretch?: boolean;
|
|
49
|
+
/** The thumbnail box's ratio, when it differs from the clip's own. */
|
|
50
|
+
thumbAspect?: number;
|
|
51
|
+
/** How the still meets a box it does not share the shape of. */
|
|
52
|
+
thumbFit?: 'contain' | 'cover';
|
|
53
|
+
/** Seconds in that the hover preview starts. The open clip still plays from 0. */
|
|
54
|
+
previewStart?: number;
|
|
55
|
+
/** Editor preview hook: `force-hover` on an `inline` lightbox paints the close button's hover. */
|
|
56
|
+
class?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let {
|
|
60
|
+
src,
|
|
61
|
+
poster,
|
|
62
|
+
alt = '',
|
|
63
|
+
width = undefined,
|
|
64
|
+
height = undefined,
|
|
65
|
+
loop = false,
|
|
66
|
+
inline = false,
|
|
67
|
+
hoverPlay = false,
|
|
68
|
+
stretch = false,
|
|
69
|
+
thumbAspect = undefined,
|
|
70
|
+
thumbFit = 'contain',
|
|
71
|
+
previewStart = 0,
|
|
72
|
+
class: className = ''
|
|
73
|
+
}: Props = $props();
|
|
74
|
+
|
|
75
|
+
let thumbEl: HTMLVideoElement | undefined = $state();
|
|
76
|
+
|
|
77
|
+
function previewIn() {
|
|
78
|
+
if (!thumbEl || window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
|
79
|
+
if (thumbEl.currentTime < previewStart) thumbEl.currentTime = previewStart;
|
|
80
|
+
thumbEl.play().catch(() => {});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function previewOut() {
|
|
84
|
+
if (!thumbEl) return;
|
|
85
|
+
thumbEl.pause();
|
|
86
|
+
thumbEl.currentTime = previewStart;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
let triggerEl: HTMLButtonElement | undefined = $state();
|
|
90
|
+
let modalEl: HTMLDivElement | undefined = $state();
|
|
91
|
+
let videoEl: HTMLVideoElement | undefined = $state();
|
|
92
|
+
let closeBtnEl: HTMLButtonElement | undefined = $state();
|
|
93
|
+
// `mounted` keeps the lightbox in the DOM through the fade out, `shown`
|
|
94
|
+
// drives the fade, and `open` is what the trigger reports.
|
|
95
|
+
let mounted = $state(false);
|
|
96
|
+
let shown = $state(false);
|
|
97
|
+
let open = $state(false);
|
|
98
|
+
|
|
99
|
+
const ratio = $derived(width && height ? width / height : undefined);
|
|
100
|
+
// The box the still sits in; the clip itself always opens at `ratio`.
|
|
101
|
+
const tileRatio = $derived(thumbAspect ?? ratio);
|
|
102
|
+
|
|
103
|
+
async function openLightbox() {
|
|
104
|
+
if (open) return;
|
|
105
|
+
open = true;
|
|
106
|
+
mounted = true;
|
|
107
|
+
await tick();
|
|
108
|
+
if (!modalEl) return;
|
|
109
|
+
// Commit the transparent start so the class change below fades in.
|
|
110
|
+
modalEl.getBoundingClientRect();
|
|
111
|
+
shown = true;
|
|
112
|
+
modalEl.focus();
|
|
113
|
+
videoEl?.play();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function closeLightbox() {
|
|
117
|
+
if (!open || !modalEl) return;
|
|
118
|
+
open = false;
|
|
119
|
+
shown = false;
|
|
120
|
+
videoEl?.pause();
|
|
121
|
+
await tick();
|
|
122
|
+
await Promise.all(modalEl.getAnimations().map((a) => a.finished)).catch(() => {});
|
|
123
|
+
if (open) return;
|
|
124
|
+
mounted = false;
|
|
125
|
+
triggerEl?.focus();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// A page may act on keys it hears at the window, such as a deck turning
|
|
129
|
+
// slides, so the lightbox keeps every key to itself while it is open.
|
|
130
|
+
function onModalKey(e: KeyboardEvent) {
|
|
131
|
+
e.stopPropagation();
|
|
132
|
+
if (e.key === 'Escape') {
|
|
133
|
+
closeLightbox();
|
|
134
|
+
} else if (e.key === ' ' && e.target === modalEl) {
|
|
135
|
+
e.preventDefault();
|
|
136
|
+
if (videoEl?.paused) videoEl.play();
|
|
137
|
+
else videoEl?.pause();
|
|
138
|
+
} else if (e.key === 'Tab') {
|
|
139
|
+
e.preventDefault();
|
|
140
|
+
(document.activeElement === closeBtnEl ? modalEl : closeBtnEl)?.focus();
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
</script>
|
|
144
|
+
|
|
145
|
+
{#if inline}
|
|
146
|
+
<div class="videolightbox-modal inline shown {className}" style:--videolightbox-ratio={ratio}>
|
|
147
|
+
<!-- svelte-ignore a11y_media_has_caption -->
|
|
148
|
+
<video class="videolightbox-video" {src} {poster} preload="metadata" playsinline></video>
|
|
149
|
+
<button
|
|
150
|
+
type="button"
|
|
151
|
+
class="videolightbox-close"
|
|
152
|
+
aria-label="Close" tabindex="-1"
|
|
153
|
+
>
|
|
154
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
155
|
+
<path d="M18 6L6 18" />
|
|
156
|
+
<path d="M6 6l12 12" />
|
|
157
|
+
</svg>
|
|
158
|
+
</button>
|
|
159
|
+
</div>
|
|
160
|
+
{:else}
|
|
161
|
+
<div
|
|
162
|
+
class="videolightbox {className}"
|
|
163
|
+
style:aspect-ratio={tileRatio}
|
|
164
|
+
style:--videolightbox-thumb-fit={thumbFit}
|
|
165
|
+
>
|
|
166
|
+
<button
|
|
167
|
+
bind:this={triggerEl}
|
|
168
|
+
type="button"
|
|
169
|
+
class="videolightbox-trigger"
|
|
170
|
+
class:stretch
|
|
171
|
+
aria-label={alt ? `Play video: ${alt}` : 'Play video'}
|
|
172
|
+
aria-haspopup="dialog"
|
|
173
|
+
aria-expanded={open}
|
|
174
|
+
onclick={openLightbox}
|
|
175
|
+
onpointerenter={hoverPlay ? previewIn : undefined}
|
|
176
|
+
onpointerleave={hoverPlay ? previewOut : undefined}
|
|
177
|
+
onfocus={hoverPlay ? previewIn : undefined}
|
|
178
|
+
onblur={hoverPlay ? previewOut : undefined}
|
|
179
|
+
>
|
|
180
|
+
{#if hoverPlay}
|
|
181
|
+
<!-- Muted and silent by design; the sound belongs to the open clip. -->
|
|
182
|
+
<!-- svelte-ignore a11y_media_has_caption -->
|
|
183
|
+
<video
|
|
184
|
+
bind:this={thumbEl}
|
|
185
|
+
class="videolightbox-thumb"
|
|
186
|
+
{src}
|
|
187
|
+
{poster}
|
|
188
|
+
{width}
|
|
189
|
+
{height}
|
|
190
|
+
muted
|
|
191
|
+
loop
|
|
192
|
+
playsinline
|
|
193
|
+
preload="metadata"
|
|
194
|
+
></video>
|
|
195
|
+
{:else}
|
|
196
|
+
<img src={poster} alt="" {width} {height} draggable="false" />
|
|
197
|
+
{/if}
|
|
198
|
+
</button>
|
|
199
|
+
</div>
|
|
200
|
+
|
|
201
|
+
{#if mounted}
|
|
202
|
+
<!-- Clicking anywhere closes it, the clip included; the close button and
|
|
203
|
+
Escape do the same. -->
|
|
204
|
+
<div
|
|
205
|
+
bind:this={modalEl}
|
|
206
|
+
class="videolightbox-modal"
|
|
207
|
+
class:shown
|
|
208
|
+
role="dialog"
|
|
209
|
+
aria-modal="true"
|
|
210
|
+
aria-label={alt}
|
|
211
|
+
tabindex="-1"
|
|
212
|
+
style:--videolightbox-ratio={ratio}
|
|
213
|
+
style:--videolightbox-natural-width={width ? `${width}px` : undefined}
|
|
214
|
+
use:portal
|
|
215
|
+
onclick={closeLightbox}
|
|
216
|
+
onkeydown={onModalKey}
|
|
217
|
+
>
|
|
218
|
+
<!-- No caption track ships with the source footage. -->
|
|
219
|
+
<!-- svelte-ignore a11y_media_has_caption -->
|
|
220
|
+
<video bind:this={videoEl} class="videolightbox-video" {src} {poster} {loop} playsinline></video>
|
|
221
|
+
<button
|
|
222
|
+
bind:this={closeBtnEl}
|
|
223
|
+
type="button"
|
|
224
|
+
class="videolightbox-close"
|
|
225
|
+
aria-label="Close"
|
|
226
|
+
onclick={closeLightbox}
|
|
227
|
+
>
|
|
228
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
229
|
+
<path d="M18 6L6 18" />
|
|
230
|
+
<path d="M6 6l12 12" />
|
|
231
|
+
</svg>
|
|
232
|
+
</button>
|
|
233
|
+
</div>
|
|
234
|
+
{/if}
|
|
235
|
+
{/if}
|
|
236
|
+
|
|
237
|
+
<style>
|
|
238
|
+
:global(:root) {
|
|
239
|
+
--videolightbox-tile-surface: var(--color-transparent);
|
|
240
|
+
--videolightbox-tile-border: var(--color-transparent);
|
|
241
|
+
--videolightbox-tile-border-width: var(--border-width-0);
|
|
242
|
+
--videolightbox-tile-radius: var(--radius-2xl);
|
|
243
|
+
--videolightbox-tile-shadow: var(--shadow-md);
|
|
244
|
+
|
|
245
|
+
--videolightbox-scrim-surface: var(--scrim-high);
|
|
246
|
+
--videolightbox-overlay-padding: var(--space-64);
|
|
247
|
+
--videolightbox-overlay-duration: var(--duration-500);
|
|
248
|
+
--videolightbox-overlay-easing: var(--ease-out-quad);
|
|
249
|
+
|
|
250
|
+
--videolightbox-chrome-surface: var(--surface-neutral-low);
|
|
251
|
+
--videolightbox-chrome-border: var(--border-brand);
|
|
252
|
+
--videolightbox-chrome-border-width: var(--border-width-1);
|
|
253
|
+
--videolightbox-chrome-radius: var(--radius-full);
|
|
254
|
+
--videolightbox-chrome-icon: var(--text-primary);
|
|
255
|
+
--videolightbox-chrome-hover-surface: var(--surface-brand-high);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
.videolightbox {
|
|
259
|
+
width: 100%;
|
|
260
|
+
background: var(--videolightbox-tile-surface);
|
|
261
|
+
border: var(--videolightbox-tile-border-width) solid var(--videolightbox-tile-border);
|
|
262
|
+
border-radius: var(--videolightbox-tile-radius);
|
|
263
|
+
box-shadow: var(--videolightbox-tile-shadow);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/* The clip lives one level in: the sketch layer forces the drawn part's
|
|
267
|
+
overflow visible, which would let the picture's square corners escape. */
|
|
268
|
+
.videolightbox-trigger {
|
|
269
|
+
display: block;
|
|
270
|
+
width: 100%;
|
|
271
|
+
height: 100%;
|
|
272
|
+
padding: 0;
|
|
273
|
+
border: none;
|
|
274
|
+
background: none;
|
|
275
|
+
overflow: hidden;
|
|
276
|
+
border-radius: var(--sketch-radius, var(--videolightbox-tile-radius));
|
|
277
|
+
cursor: pointer;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/* The pad is laid over the ancestor the consumer positioned, so the one
|
|
281
|
+
button keeps the label and the whole card answers the pointer. It rides
|
|
282
|
+
above the consumer's own content, or copy beside the still would swallow
|
|
283
|
+
the pointer before the trigger heard it.
|
|
284
|
+
A transform on anything between here and that ancestor would make itself
|
|
285
|
+
the containing block and shrink the pad back to the still. */
|
|
286
|
+
.videolightbox-trigger.stretch::after {
|
|
287
|
+
content: '';
|
|
288
|
+
position: absolute;
|
|
289
|
+
inset: 0;
|
|
290
|
+
z-index: 1;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
.videolightbox-trigger:focus-visible {
|
|
294
|
+
outline: none;
|
|
295
|
+
box-shadow: var(--ring-focus-md);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
.videolightbox-trigger img,
|
|
299
|
+
.videolightbox-trigger .videolightbox-thumb {
|
|
300
|
+
display: block;
|
|
301
|
+
width: 100%;
|
|
302
|
+
height: 100%;
|
|
303
|
+
object-fit: var(--videolightbox-thumb-fit, contain);
|
|
304
|
+
user-select: none;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
.videolightbox-modal {
|
|
308
|
+
position: fixed;
|
|
309
|
+
inset: 0;
|
|
310
|
+
z-index: var(--z-modal);
|
|
311
|
+
box-sizing: border-box;
|
|
312
|
+
display: grid;
|
|
313
|
+
place-items: center;
|
|
314
|
+
padding: var(--videolightbox-overlay-padding);
|
|
315
|
+
background: var(--videolightbox-scrim-surface);
|
|
316
|
+
container-type: size;
|
|
317
|
+
opacity: 0;
|
|
318
|
+
transition: opacity var(--videolightbox-overlay-duration) var(--videolightbox-overlay-easing);
|
|
319
|
+
cursor: pointer;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
.videolightbox-modal.shown {
|
|
323
|
+
opacity: 1;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
.videolightbox-modal:focus {
|
|
327
|
+
outline: none;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
.videolightbox-modal.inline {
|
|
331
|
+
position: relative;
|
|
332
|
+
inset: auto;
|
|
333
|
+
z-index: auto;
|
|
334
|
+
width: 100%;
|
|
335
|
+
aspect-ratio: 16 / 9;
|
|
336
|
+
cursor: default;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
.videolightbox-close {
|
|
340
|
+
position: absolute;
|
|
341
|
+
top: var(--space-24);
|
|
342
|
+
right: var(--space-24);
|
|
343
|
+
/* 2.75rem = 44px, the min touch-target floor. */
|
|
344
|
+
width: 2.75rem;
|
|
345
|
+
height: 2.75rem;
|
|
346
|
+
display: flex;
|
|
347
|
+
align-items: center;
|
|
348
|
+
justify-content: center;
|
|
349
|
+
padding: 0;
|
|
350
|
+
background: var(--videolightbox-chrome-surface);
|
|
351
|
+
border: var(--videolightbox-chrome-border-width) solid var(--videolightbox-chrome-border);
|
|
352
|
+
border-radius: var(--videolightbox-chrome-radius);
|
|
353
|
+
color: var(--videolightbox-chrome-icon);
|
|
354
|
+
backdrop-filter: blur(var(--blur-md));
|
|
355
|
+
cursor: pointer;
|
|
356
|
+
transition: background var(--duration-200) ease;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
.videolightbox-close:hover,
|
|
360
|
+
.videolightbox-modal:global(.force-hover) .videolightbox-close {
|
|
361
|
+
background: var(--videolightbox-chrome-hover-surface);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
.videolightbox-close:focus-visible {
|
|
365
|
+
outline: none;
|
|
366
|
+
box-shadow: var(--ring-focus-md);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
@media (prefers-reduced-motion: reduce) {
|
|
370
|
+
.videolightbox-modal {
|
|
371
|
+
transition: none;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/* The clip plays at its own pixel size and shrinks only to fit the screen. */
|
|
376
|
+
.videolightbox-video {
|
|
377
|
+
display: block;
|
|
378
|
+
width: min(var(--videolightbox-natural-width, 100cqw), 100cqw, 100cqh * var(--videolightbox-ratio, 16 / 9));
|
|
379
|
+
aspect-ratio: var(--videolightbox-ratio, 16 / 9);
|
|
380
|
+
}
|
|
381
|
+
</style>
|