@vectoriox/iox-ui 5.37.0 → 5.39.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/actions-core/index.d.ts +6 -1
- package/composite-core/index.d.ts +684 -28
- package/design-core/index.d.ts +452 -6
- package/fesm2022/vectoriox-iox-ui-actions-core.mjs +12 -4
- package/fesm2022/vectoriox-iox-ui-actions-core.mjs.map +1 -1
- package/fesm2022/vectoriox-iox-ui-composite-core.mjs +1761 -143
- package/fesm2022/vectoriox-iox-ui-composite-core.mjs.map +1 -1
- package/fesm2022/vectoriox-iox-ui-design-core.mjs +555 -6
- package/fesm2022/vectoriox-iox-ui-design-core.mjs.map +1 -1
- package/fesm2022/vectoriox-iox-ui-generation-core.mjs +534 -12
- package/fesm2022/vectoriox-iox-ui-generation-core.mjs.map +1 -1
- package/fesm2022/vectoriox-iox-ui-media-core.mjs +571 -0
- package/fesm2022/vectoriox-iox-ui-media-core.mjs.map +1 -0
- package/fesm2022/vectoriox-iox-ui.mjs +494 -127
- package/fesm2022/vectoriox-iox-ui.mjs.map +1 -1
- package/generation-core/index.d.ts +210 -4
- package/index.d.ts +217 -30
- package/media-core/index.d.ts +277 -0
- package/media-core/package.json +3 -0
- package/package.json +5 -1
- package/src/actions-core/element-actions.ts +12 -3
- package/src/composite-core/accordion-runtime.ts +3 -15
- package/src/composite-core/accordion.ts +4 -2
- package/src/composite-core/composite-kinds.ts +121 -7
- package/src/composite-core/composite-node.ts +4 -0
- package/src/composite-core/composite-runtime.ts +68 -0
- package/src/composite-core/exclusive-runtime.ts +358 -0
- package/src/composite-core/lightbox.ts +192 -0
- package/src/composite-core/public-api.ts +8 -2
- package/src/composite-core/slider-runtime.ts +180 -0
- package/src/composite-core/slider.ts +378 -0
- package/src/composite-core/structure.ts +38 -5
- package/src/composite-core/tabs.ts +628 -0
- package/src/design-core/contrast-audit.ts +5 -0
- package/src/design-core/element-styles.ts +34 -3
- package/src/design-core/flavor-spec.generated.ts +517 -2
- package/src/generation-core/baseline-roles.ts +2 -0
- package/src/generation-core/compose-site.ts +147 -4
- package/src/generation-core/node-baselines.generated.ts +156 -6
- package/src/generation-core/patterns.ts +20 -0
- package/src/generation-core/site-spec.generated.ts +210 -0
- package/src/media-core/public-api.ts +9 -0
- package/src/media-core/video-runtime.ts +239 -0
- package/src/media-core/video.ts +443 -0
- package/styles/iox-design.css +112 -0
|
@@ -0,0 +1,571 @@
|
|
|
1
|
+
import { ConsentCategory, hasConsent, CONSENT_COOKIE_NAME, decode, setCategoryDecision, encode, cookieAttributes } from '@vectoriox/iox-ui/consent-core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* VIDEO (CC3 — complex-components plan §7b, board 5). A LEAF type with two sources:
|
|
5
|
+
* - an uploaded FILE renders as a real `<video>` (poster, `preload="metadata"`, captions, controls);
|
|
6
|
+
* - a YouTube / Vimeo address is ALWAYS a FACADE (D5): the studio's poster and a play button named after the video,
|
|
7
|
+
* behind the site's consent gate (Marketing). The player's `<iframe>` exists only after a click AND a grant —
|
|
8
|
+
* never in the server's HTML, never on the builder canvas.
|
|
9
|
+
*
|
|
10
|
+
* `planVideo(node, consent)` is the ONE decision — the only code that knows the URL shapes, the embed hosts and the
|
|
11
|
+
* consent category. Both engine paths and the canvas render from it (`videoInnerHtml`), and the runtime
|
|
12
|
+
* (`video-runtime.ts`) builds the player's address from the same plan.
|
|
13
|
+
*/
|
|
14
|
+
const VIDEO_NODE_TYPE = 'Video';
|
|
15
|
+
var VideoSource;
|
|
16
|
+
(function (VideoSource) {
|
|
17
|
+
VideoSource["File"] = "file";
|
|
18
|
+
VideoSource["YouTube"] = "youtube";
|
|
19
|
+
VideoSource["Vimeo"] = "vimeo";
|
|
20
|
+
})(VideoSource || (VideoSource = {}));
|
|
21
|
+
var VideoMode;
|
|
22
|
+
(function (VideoMode) {
|
|
23
|
+
/** In the page, with the player's controls. */
|
|
24
|
+
VideoMode["Inline"] = "inline";
|
|
25
|
+
/** Muted, looped, no controls, an always-visible pause button (WCAG 2.2.2) — an uploaded file only. */
|
|
26
|
+
VideoMode["Background"] = "background";
|
|
27
|
+
})(VideoMode || (VideoMode = {}));
|
|
28
|
+
/** The node's traits (Bindings ▸ Media · Behaviour · Accessibility · Privacy). */
|
|
29
|
+
var VideoTrait;
|
|
30
|
+
(function (VideoTrait) {
|
|
31
|
+
/** Not `source`: across the builder `source` names a DATA source (a Repeater's, a Slider's). */
|
|
32
|
+
VideoTrait["Source"] = "videoSource";
|
|
33
|
+
VideoTrait["Src"] = "src";
|
|
34
|
+
VideoTrait["Poster"] = "poster";
|
|
35
|
+
VideoTrait["Mode"] = "mode";
|
|
36
|
+
VideoTrait["Autoplay"] = "autoplay";
|
|
37
|
+
VideoTrait["Title"] = "title";
|
|
38
|
+
VideoTrait["Captions"] = "captions";
|
|
39
|
+
VideoTrait["CaptionsLang"] = "captionsLang";
|
|
40
|
+
VideoTrait["Consent"] = "consentCategory";
|
|
41
|
+
})(VideoTrait || (VideoTrait = {}));
|
|
42
|
+
/** What a plan renders. */
|
|
43
|
+
var VideoKind;
|
|
44
|
+
(function (VideoKind) {
|
|
45
|
+
/** An uploaded file: a real `<video>`. */
|
|
46
|
+
VideoKind["File"] = "file";
|
|
47
|
+
/** YouTube / Vimeo: poster + play, the player loaded on a click after consent. */
|
|
48
|
+
VideoKind["Facade"] = "facade";
|
|
49
|
+
/** No address yet: the poster blank (DS3) and nothing to play. */
|
|
50
|
+
VideoKind["Empty"] = "empty";
|
|
51
|
+
})(VideoKind || (VideoKind = {}));
|
|
52
|
+
const VIDEO_PROVIDER_LABEL = {
|
|
53
|
+
[VideoSource.YouTube]: 'YouTube',
|
|
54
|
+
[VideoSource.Vimeo]: 'Vimeo',
|
|
55
|
+
};
|
|
56
|
+
/** Who the visitor's IP address and cookies go to, named in the consent prompt. */
|
|
57
|
+
const VIDEO_PROVIDER_COMPANY = {
|
|
58
|
+
[VideoSource.YouTube]: 'Google',
|
|
59
|
+
[VideoSource.Vimeo]: 'Vimeo',
|
|
60
|
+
};
|
|
61
|
+
/** The two hosts a player ever loads from — youtube-nocookie and Vimeo's player (with Do Not Track). */
|
|
62
|
+
const VIDEO_EMBED_ORIGINS = ['https://www.youtube-nocookie.com', 'https://player.vimeo.com'];
|
|
63
|
+
const YOUTUBE_ID = /^[A-Za-z0-9_-]{11}$/;
|
|
64
|
+
const VIMEO_ID = /^\d{6,12}$/;
|
|
65
|
+
const VIMEO_HASH = /^[0-9a-f]{6,20}$/i;
|
|
66
|
+
const YOUTUBE_HOSTS = new Set(['youtube.com', 'www.youtube.com', 'm.youtube.com', 'music.youtube.com', 'youtube-nocookie.com', 'www.youtube-nocookie.com']);
|
|
67
|
+
const YOUTUBE_SHORT_HOSTS = new Set(['youtu.be', 'www.youtu.be']);
|
|
68
|
+
const YOUTUBE_PATH_PREFIXES = ['embed', 'shorts', 'live', 'v'];
|
|
69
|
+
const VIMEO_HOSTS = new Set(['vimeo.com', 'www.vimeo.com', 'player.vimeo.com']);
|
|
70
|
+
function toUrl(raw) {
|
|
71
|
+
const text = raw.trim();
|
|
72
|
+
if (!text)
|
|
73
|
+
return null;
|
|
74
|
+
try {
|
|
75
|
+
return new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(text) ? text : `https://${text}`);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
function parseYouTube(url) {
|
|
82
|
+
const host = url.hostname.toLowerCase();
|
|
83
|
+
const segments = url.pathname.split('/').filter(Boolean);
|
|
84
|
+
let id = null;
|
|
85
|
+
if (YOUTUBE_SHORT_HOSTS.has(host))
|
|
86
|
+
id = segments[0] ?? null;
|
|
87
|
+
else if (YOUTUBE_HOSTS.has(host)) {
|
|
88
|
+
if (segments[0] === 'watch')
|
|
89
|
+
id = url.searchParams.get('v');
|
|
90
|
+
else if (YOUTUBE_PATH_PREFIXES.includes(segments[0] ?? ''))
|
|
91
|
+
id = segments[1] ?? null;
|
|
92
|
+
}
|
|
93
|
+
else
|
|
94
|
+
return null;
|
|
95
|
+
return id && YOUTUBE_ID.test(id) ? { source: VideoSource.YouTube, id } : null;
|
|
96
|
+
}
|
|
97
|
+
function parseVimeo(url) {
|
|
98
|
+
if (!VIMEO_HOSTS.has(url.hostname.toLowerCase()))
|
|
99
|
+
return null;
|
|
100
|
+
const segments = url.pathname.split('/').filter(Boolean);
|
|
101
|
+
// player.vimeo.com/video/<id>, vimeo.com/<id>[/<hash>], vimeo.com/channels/<c>/<id>, vimeo.com/groups/<g>/videos/<id>
|
|
102
|
+
const index = segments.findIndex(s => VIMEO_ID.test(s));
|
|
103
|
+
if (index < 0)
|
|
104
|
+
return null;
|
|
105
|
+
const id = segments[index];
|
|
106
|
+
const hash = url.searchParams.get('h') ?? segments[index + 1];
|
|
107
|
+
return { source: VideoSource.Vimeo, id, ...(hash && VIMEO_HASH.test(hash) ? { hash } : {}) };
|
|
108
|
+
}
|
|
109
|
+
/** The embed a pasted address names — a YouTube or Vimeo watch, share, embed or short link — or null. */
|
|
110
|
+
function parseVideoUrl(raw) {
|
|
111
|
+
const url = typeof raw === 'string' ? toUrl(raw) : null;
|
|
112
|
+
if (!url || !/^https?:$/.test(url.protocol))
|
|
113
|
+
return null;
|
|
114
|
+
return parseYouTube(url) ?? parseVimeo(url);
|
|
115
|
+
}
|
|
116
|
+
/** The source a node plays: its declared source when it is one, else what its address is. */
|
|
117
|
+
function videoSourceOf(src, declared) {
|
|
118
|
+
if (declared === VideoSource.YouTube || declared === VideoSource.Vimeo || declared === VideoSource.File)
|
|
119
|
+
return declared;
|
|
120
|
+
return parseVideoUrl(typeof src === 'string' ? src : '')?.source ?? VideoSource.File;
|
|
121
|
+
}
|
|
122
|
+
/** The player's address — loaded only after a click and a grant. Autoplays (the visitor just pressed play). */
|
|
123
|
+
function videoEmbedUrl(ref) {
|
|
124
|
+
if (ref.source === VideoSource.YouTube) {
|
|
125
|
+
return `https://www.youtube-nocookie.com/embed/${encodeURIComponent(ref.id)}?autoplay=1&rel=0&playsinline=1`;
|
|
126
|
+
}
|
|
127
|
+
return `https://player.vimeo.com/video/${encodeURIComponent(ref.id)}?autoplay=1&dnt=1${ref.hash ? `&h=${encodeURIComponent(ref.hash)}` : ''}`;
|
|
128
|
+
}
|
|
129
|
+
function videoThumbnailRequest(ref) {
|
|
130
|
+
if (ref.source === VideoSource.YouTube) {
|
|
131
|
+
return { kind: 'image', url: `https://i.ytimg.com/vi/${encodeURIComponent(ref.id)}/hqdefault.jpg`, imageHosts: ['i.ytimg.com'] };
|
|
132
|
+
}
|
|
133
|
+
const page = `https://vimeo.com/${ref.id}${ref.hash ? `/${ref.hash}` : ''}`;
|
|
134
|
+
return { kind: 'oembed', url: `https://vimeo.com/api/oembed.json?url=${encodeURIComponent(page)}&width=1280`, imageHosts: ['i.vimeocdn.com'] };
|
|
135
|
+
}
|
|
136
|
+
/** A stored poster's object name — one per video, so fetching it again overwrites rather than piles up. */
|
|
137
|
+
function videoPosterFileName(ref) {
|
|
138
|
+
return `video-poster-${ref.source}-${ref.id}.jpg`;
|
|
139
|
+
}
|
|
140
|
+
/** A content value of a Video node, saved (`props`) or builder (`traits[].default`) shape. */
|
|
141
|
+
function videoProp(node, name) {
|
|
142
|
+
if (!node)
|
|
143
|
+
return undefined;
|
|
144
|
+
if (node.props && Object.prototype.hasOwnProperty.call(node.props, name))
|
|
145
|
+
return node.props[name];
|
|
146
|
+
return node.traits?.find(t => t?.name === name)?.default;
|
|
147
|
+
}
|
|
148
|
+
const str = (v) => (typeof v === 'string' ? v.trim() : '');
|
|
149
|
+
const truthy = (v) => v === true || v === 'true';
|
|
150
|
+
/** The consent category an embed waits for — Marketing by default (D5); an uploaded file waits for none. */
|
|
151
|
+
function videoConsentCategory(node, source = videoSourceOf(videoProp(node, VideoTrait.Src), videoProp(node, VideoTrait.Source))) {
|
|
152
|
+
if (source === VideoSource.File)
|
|
153
|
+
return null;
|
|
154
|
+
const declared = videoProp(node, VideoTrait.Consent);
|
|
155
|
+
return declared === ConsentCategory.Analytics ? ConsentCategory.Analytics : ConsentCategory.Marketing;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* The ONE decision for a Video node and a visitor's consent (null = none recorded — the server always passes null,
|
|
159
|
+
* so its HTML is always the facade). A poster that is a provider's thumbnail URL is dropped: the poster is the
|
|
160
|
+
* studio's own image (or the thumbnail the server fetched and stored for it), never a request to a third party.
|
|
161
|
+
*/
|
|
162
|
+
function planVideo(node, consent) {
|
|
163
|
+
const rawSrc = str(videoProp(node, VideoTrait.Src));
|
|
164
|
+
const source = videoSourceOf(rawSrc, videoProp(node, VideoTrait.Source));
|
|
165
|
+
const ref = source === VideoSource.File ? null : parseVideoUrl(rawSrc);
|
|
166
|
+
const background = source === VideoSource.File && videoProp(node, VideoTrait.Mode) === VideoMode.Background;
|
|
167
|
+
const consentCategory = videoConsentCategory(node, source);
|
|
168
|
+
const granted = consentCategory ? hasConsent(consent, consentCategory) : true;
|
|
169
|
+
const kind = source === VideoSource.File ? (rawSrc ? VideoKind.File : VideoKind.Empty) : (ref ? VideoKind.Facade : VideoKind.Empty);
|
|
170
|
+
const poster = str(videoProp(node, VideoTrait.Poster));
|
|
171
|
+
return {
|
|
172
|
+
kind,
|
|
173
|
+
source,
|
|
174
|
+
ref,
|
|
175
|
+
src: source === VideoSource.File ? rawSrc : '',
|
|
176
|
+
poster: isThirdPartyPoster(poster) ? '' : poster,
|
|
177
|
+
title: str(videoProp(node, VideoTrait.Title)),
|
|
178
|
+
mode: background ? VideoMode.Background : VideoMode.Inline,
|
|
179
|
+
background,
|
|
180
|
+
autoplay: background || (source === VideoSource.File && truthy(videoProp(node, VideoTrait.Autoplay))),
|
|
181
|
+
muted: background || truthy(videoProp(node, VideoTrait.Autoplay)),
|
|
182
|
+
loop: background,
|
|
183
|
+
controls: !background,
|
|
184
|
+
captions: background ? '' : str(videoProp(node, VideoTrait.Captions)),
|
|
185
|
+
captionsLang: str(videoProp(node, VideoTrait.CaptionsLang)) || 'en',
|
|
186
|
+
consentCategory,
|
|
187
|
+
granted,
|
|
188
|
+
embedUrl: kind === VideoKind.Facade && granted && ref ? videoEmbedUrl(ref) : null,
|
|
189
|
+
providerLabel: ref ? VIDEO_PROVIDER_LABEL[ref.source] : '',
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
const THIRD_PARTY_POSTER_HOSTS = ['ytimg.com', 'youtube.com', 'vimeocdn.com', 'vimeo.com', 'googleusercontent.com'];
|
|
193
|
+
/** A poster served by the video's provider — a request to a third party before consent, so it never renders. */
|
|
194
|
+
function isThirdPartyPoster(url) {
|
|
195
|
+
const parsed = url ? toUrl(url) : null;
|
|
196
|
+
if (!parsed || !/^https?:$/.test(parsed.protocol))
|
|
197
|
+
return false;
|
|
198
|
+
const host = parsed.hostname.toLowerCase();
|
|
199
|
+
return THIRD_PARTY_POSTER_HOSTS.some(h => host === h || host.endsWith(`.${h}`));
|
|
200
|
+
}
|
|
201
|
+
const VIDEO_WORDS = {
|
|
202
|
+
en: {
|
|
203
|
+
play: (title, provider) => (provider ? `Play: ${title} (${provider})` : `Play: ${title}`),
|
|
204
|
+
playUntitled: provider => (provider ? `Play video (${provider})` : 'Play video'),
|
|
205
|
+
pauseBackground: 'Pause background video',
|
|
206
|
+
playBackground: 'Play background video',
|
|
207
|
+
promptLabel: 'This video needs your permission',
|
|
208
|
+
promptLead: provider => `This video is hosted by ${provider}.`,
|
|
209
|
+
promptBody: company => `Playing it lets ${company} set cookies and see your IP address. Nothing has loaded yet.`,
|
|
210
|
+
promptCompactLead: provider => `Plays from ${provider},`,
|
|
211
|
+
promptCompactBody: 'which sets cookies and sees your IP address. Nothing has loaded yet.',
|
|
212
|
+
allow: 'Allow and play',
|
|
213
|
+
settings: 'Privacy settings',
|
|
214
|
+
},
|
|
215
|
+
he: {
|
|
216
|
+
play: (title, provider) => (provider ? `הפעלה: ${title} (${provider})` : `הפעלה: ${title}`),
|
|
217
|
+
playUntitled: provider => (provider ? `הפעלת הסרטון (${provider})` : 'הפעלת הסרטון'),
|
|
218
|
+
pauseBackground: 'השהיית סרטון הרקע',
|
|
219
|
+
playBackground: 'הפעלת סרטון הרקע',
|
|
220
|
+
promptLabel: 'הסרטון הזה צריך את רשותך',
|
|
221
|
+
promptLead: provider => `הסרטון מתארח ב-${provider}.`,
|
|
222
|
+
promptBody: company => `ההפעלה מאפשרת ל-${company} להגדיר קובצי Cookie ולראות את כתובת ה-IP שלך. עדיין לא נטען דבר.`,
|
|
223
|
+
promptCompactLead: provider => `מתנגן מ-${provider},`,
|
|
224
|
+
promptCompactBody: 'שמגדיר קובצי Cookie ורואה את כתובת ה-IP שלך. עדיין לא נטען דבר.',
|
|
225
|
+
allow: 'לאשר ולהפעיל',
|
|
226
|
+
settings: 'הגדרות פרטיות',
|
|
227
|
+
},
|
|
228
|
+
};
|
|
229
|
+
/** The words for a page language (`he`, `he-IL` → Hebrew; anything else → English). */
|
|
230
|
+
function videoWords(lang) {
|
|
231
|
+
return (lang ?? '').toLowerCase().startsWith('he') ? VIDEO_WORDS.he : VIDEO_WORDS.en;
|
|
232
|
+
}
|
|
233
|
+
/** The play button's accessible name. */
|
|
234
|
+
function videoPlayLabel(plan, words = VIDEO_WORDS.en) {
|
|
235
|
+
return plan.title ? words.play(plan.title, plan.providerLabel) : words.playUntitled(plan.providerLabel);
|
|
236
|
+
}
|
|
237
|
+
// ── markup (one builder for both engine paths and the canvas) ────────────────────────────────────────
|
|
238
|
+
var VideoClass;
|
|
239
|
+
(function (VideoClass) {
|
|
240
|
+
VideoClass["Root"] = "iox-video";
|
|
241
|
+
VideoClass["Poster"] = "iox-video__poster";
|
|
242
|
+
VideoClass["Play"] = "iox-video__play";
|
|
243
|
+
VideoClass["Badge"] = "iox-video__badge";
|
|
244
|
+
VideoClass["Media"] = "iox-video__media";
|
|
245
|
+
VideoClass["Frame"] = "iox-video__frame";
|
|
246
|
+
VideoClass["Toggle"] = "iox-video__toggle";
|
|
247
|
+
VideoClass["Consent"] = "iox-video__consent";
|
|
248
|
+
VideoClass["ConsentText"] = "iox-video__consent-text";
|
|
249
|
+
/** The prompt's text on a wide player / on a narrow one (a container query shows one). */
|
|
250
|
+
VideoClass["ConsentWide"] = "iox-video__consent-wide";
|
|
251
|
+
VideoClass["ConsentCompact"] = "iox-video__consent-compact";
|
|
252
|
+
VideoClass["ConsentRow"] = "iox-video__consent-row";
|
|
253
|
+
VideoClass["Allow"] = "iox-video__allow";
|
|
254
|
+
VideoClass["Settings"] = "iox-video__settings";
|
|
255
|
+
})(VideoClass || (VideoClass = {}));
|
|
256
|
+
var VideoAttribute;
|
|
257
|
+
(function (VideoAttribute) {
|
|
258
|
+
/** On the node element: its kind (`file` · `facade` · `empty`) — what the runtime attaches to. */
|
|
259
|
+
VideoAttribute["Root"] = "data-iox-video";
|
|
260
|
+
VideoAttribute["Source"] = "data-iox-video-source";
|
|
261
|
+
VideoAttribute["Id"] = "data-iox-video-id";
|
|
262
|
+
VideoAttribute["Hash"] = "data-iox-video-hash";
|
|
263
|
+
VideoAttribute["Mode"] = "data-iox-video-mode";
|
|
264
|
+
VideoAttribute["Autoplay"] = "data-iox-video-autoplay";
|
|
265
|
+
VideoAttribute["Consent"] = "data-iox-video-consent";
|
|
266
|
+
VideoAttribute["Title"] = "data-iox-video-title";
|
|
267
|
+
/** The poster is the token blank (no image): the shared CSS fills it with `surface-2`. */
|
|
268
|
+
VideoAttribute["PosterEmpty"] = "data-iox-video-poster-empty";
|
|
269
|
+
/** The player is loaded (after the click and the grant). */
|
|
270
|
+
VideoAttribute["Playing"] = "data-iox-video-playing";
|
|
271
|
+
/** The consent prompt is showing. */
|
|
272
|
+
VideoAttribute["Asking"] = "data-iox-video-asking";
|
|
273
|
+
})(VideoAttribute || (VideoAttribute = {}));
|
|
274
|
+
var VideoIcon;
|
|
275
|
+
(function (VideoIcon) {
|
|
276
|
+
VideoIcon["Play"] = "ph-fill ph-play";
|
|
277
|
+
VideoIcon["Pause"] = "ph-fill ph-pause";
|
|
278
|
+
})(VideoIcon || (VideoIcon = {}));
|
|
279
|
+
/** The attributes the node's own element carries (both engine paths, the canvas). */
|
|
280
|
+
function videoRootAttributes(plan) {
|
|
281
|
+
const attrs = { [VideoAttribute.Root]: plan.kind, [VideoAttribute.Source]: plan.source };
|
|
282
|
+
if (plan.ref) {
|
|
283
|
+
attrs[VideoAttribute.Id] = plan.ref.id;
|
|
284
|
+
if (plan.ref.hash)
|
|
285
|
+
attrs[VideoAttribute.Hash] = plan.ref.hash;
|
|
286
|
+
}
|
|
287
|
+
if (plan.background)
|
|
288
|
+
attrs[VideoAttribute.Mode] = VideoMode.Background;
|
|
289
|
+
if (plan.autoplay)
|
|
290
|
+
attrs[VideoAttribute.Autoplay] = '';
|
|
291
|
+
if (plan.consentCategory)
|
|
292
|
+
attrs[VideoAttribute.Consent] = plan.consentCategory;
|
|
293
|
+
if (plan.title)
|
|
294
|
+
attrs[VideoAttribute.Title] = plan.title;
|
|
295
|
+
if (!plan.poster)
|
|
296
|
+
attrs[VideoAttribute.PosterEmpty] = '';
|
|
297
|
+
return attrs;
|
|
298
|
+
}
|
|
299
|
+
const iconHtml = (cls) => `<i class="${cls}" aria-hidden="true"></i>`;
|
|
300
|
+
/** The element's inner markup. The server's HTML for a facade holds no `<iframe>` and requests nothing from
|
|
301
|
+
* the provider: only the studio's poster, the play button and the (hidden) consent prompt. */
|
|
302
|
+
function videoInnerHtml(plan, options) {
|
|
303
|
+
const { esc } = options;
|
|
304
|
+
const words = options.words ?? VIDEO_WORDS.en;
|
|
305
|
+
const poster = plan.poster ? `<img class="${VideoClass.Poster}" src="${esc(plan.poster)}" alt="" loading="lazy" decoding="async">` : '';
|
|
306
|
+
const label = esc(videoPlayLabel(plan, words));
|
|
307
|
+
const playSpan = `<span class="${VideoClass.Play}" aria-hidden="true">${iconHtml(VideoIcon.Play)}</span>`;
|
|
308
|
+
const badge = plan.providerLabel ? `<span class="${VideoClass.Badge}">${iconHtml(VideoIcon.Play)}${esc(plan.providerLabel)}</span>` : '';
|
|
309
|
+
if (options.canvas || plan.kind === VideoKind.Empty) {
|
|
310
|
+
return `${poster}${plan.kind === VideoKind.Empty && !options.canvas ? '' : playSpan}${badge}`;
|
|
311
|
+
}
|
|
312
|
+
if (plan.kind === VideoKind.File) {
|
|
313
|
+
const media = [
|
|
314
|
+
`<video class="${VideoClass.Media}" src="${esc(plan.src)}" preload="metadata" playsinline`,
|
|
315
|
+
plan.poster ? ` poster="${esc(plan.poster)}"` : '',
|
|
316
|
+
// A captions file is fetched with CORS (a <track> from another origin loads only so): the studio's bucket
|
|
317
|
+
// answers CORS for every object, the video included.
|
|
318
|
+
plan.captions ? ' crossorigin="anonymous"' : '',
|
|
319
|
+
plan.controls ? ' controls' : '',
|
|
320
|
+
plan.muted ? ' muted' : '',
|
|
321
|
+
plan.loop ? ' loop' : '',
|
|
322
|
+
plan.background ? ' aria-hidden="true" tabindex="-1"' : (plan.title ? ` aria-label="${esc(plan.title)}"` : ''),
|
|
323
|
+
'>',
|
|
324
|
+
plan.captions ? `<track kind="captions" src="${esc(plan.captions)}" srclang="${esc(plan.captionsLang)}" label="${esc(plan.captionsLang)}" default>` : '',
|
|
325
|
+
'</video>',
|
|
326
|
+
].join('');
|
|
327
|
+
if (plan.background) {
|
|
328
|
+
// Rendered paused (the server cannot know the visitor's motion setting): the runtime starts it when motion
|
|
329
|
+
// is allowed and turns this into the PAUSE button. Always visible (WCAG 2.2.2).
|
|
330
|
+
return `${media}<button type="button" class="${VideoClass.Toggle}" aria-label="${esc(words.playBackground)}" aria-pressed="true">${iconHtml(VideoIcon.Play)}</button>`;
|
|
331
|
+
}
|
|
332
|
+
return `${media}<button type="button" class="${VideoClass.Play}" aria-label="${label}">${iconHtml(VideoIcon.Play)}</button>`;
|
|
333
|
+
}
|
|
334
|
+
const company = plan.ref ? VIDEO_PROVIDER_COMPANY[plan.ref.source] : '';
|
|
335
|
+
const prompt = `<div class="${VideoClass.Consent}" role="group" aria-label="${esc(words.promptLabel)}" hidden>` +
|
|
336
|
+
`<p class="${VideoClass.ConsentText} ${VideoClass.ConsentWide}"><b>${esc(words.promptLead(plan.providerLabel))}</b> ${esc(words.promptBody(company))}</p>` +
|
|
337
|
+
`<p class="${VideoClass.ConsentText} ${VideoClass.ConsentCompact}"><b>${esc(words.promptCompactLead(plan.providerLabel))}</b> ${esc(words.promptCompactBody)}</p>` +
|
|
338
|
+
`<div class="${VideoClass.ConsentRow}">` +
|
|
339
|
+
`<button type="button" class="${VideoClass.Allow}">${esc(words.allow)}</button>` +
|
|
340
|
+
`<button type="button" class="${VideoClass.Settings}">${esc(words.settings)}</button>` +
|
|
341
|
+
`</div></div>`;
|
|
342
|
+
return `${poster}<button type="button" class="${VideoClass.Play}" aria-label="${label}">${iconHtml(VideoIcon.Play)}</button>${badge}${prompt}`;
|
|
343
|
+
}
|
|
344
|
+
/** Serialise attributes for a string renderer (a present boolean attribute is written bare). */
|
|
345
|
+
function serializeVideoAttributes(attrs, esc) {
|
|
346
|
+
return Object.entries(attrs).map(([name, value]) => (value === '' ? ` ${name}` : ` ${name}="${esc(value)}"`)).join('');
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* The Video's RUNTIME (CC3, board 5) — DOM only, ONE copy for both engine paths (the component path attaches it from
|
|
351
|
+
* its component, the string path from the page's interaction service; `attachVideoAt` is idempotent). It reads the
|
|
352
|
+
* markup `videoInnerHtml` writes. The builder canvas never attaches it (the canvas shows the poster only).
|
|
353
|
+
*
|
|
354
|
+
* - FACADE: play → the visitor's consent grants the category → the player's `<iframe>` replaces the poster, focused,
|
|
355
|
+
* autoplaying (the visitor just pressed play). Not granted → the consent prompt: "Allow and play" grants the
|
|
356
|
+
* category in the site's ONE consent record (the banner and its preferences read the same cookie) and plays;
|
|
357
|
+
* "Privacy settings" opens the site's own preferences dialog.
|
|
358
|
+
* - FILE: the play button starts the `<video>`; muted autoplay only when motion is allowed.
|
|
359
|
+
* - BACKGROUND: muted and looped; it plays only when motion is allowed (never under reduced motion), and its
|
|
360
|
+
* pause button is always there (WCAG 2.2.2).
|
|
361
|
+
*/
|
|
362
|
+
/** Ask the page to record a category the visitor granted (cancelable: the site's consent service handles it). */
|
|
363
|
+
const CONSENT_GRANT_EVENT = 'iox-consent-grant';
|
|
364
|
+
/** Ask the page to open its consent preferences (cancelable: handled = a dialog opened). */
|
|
365
|
+
const CONSENT_PREFERENCES_EVENT = 'iox-consent-preferences';
|
|
366
|
+
const REDUCED_MOTION = '(prefers-reduced-motion: reduce)';
|
|
367
|
+
const RUNTIME_KEY = '__ioxVideo';
|
|
368
|
+
const IFRAME_ALLOW = 'autoplay; encrypted-media; picture-in-picture; fullscreen';
|
|
369
|
+
/** The cookie the consent record lives in, read from a document. */
|
|
370
|
+
function readConsentRecord(doc) {
|
|
371
|
+
const raw = (doc.cookie ?? '').split('; ').find(c => c.startsWith(`${CONSENT_COOKIE_NAME}=`));
|
|
372
|
+
return decode(raw ? decodeURIComponent(raw.slice(CONSENT_COOKIE_NAME.length + 1)) : null);
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* The default transport: the page's own consent service answers the events (the engine records the decision, tells
|
|
376
|
+
* Consent Mode, re-syncs the banner's switches); a page with no such service (the admin's Preview) records the cookie
|
|
377
|
+
* itself, through the same consent-core decision, so the record is the same shape either way.
|
|
378
|
+
*/
|
|
379
|
+
function documentConsentTransport(doc) {
|
|
380
|
+
return {
|
|
381
|
+
read: () => readConsentRecord(doc),
|
|
382
|
+
grant: category => {
|
|
383
|
+
const event = new CustomEvent(CONSENT_GRANT_EVENT, { detail: { category }, cancelable: true });
|
|
384
|
+
doc.dispatchEvent(event);
|
|
385
|
+
if (event.defaultPrevented)
|
|
386
|
+
return;
|
|
387
|
+
const previous = readConsentRecord(doc);
|
|
388
|
+
const anonymousId = previous?.anonymousId ?? randomId();
|
|
389
|
+
const decision = setCategoryDecision(previous, category, true, anonymousId, previous?.policyVersion ?? '1');
|
|
390
|
+
const secure = doc.location?.protocol === 'https:';
|
|
391
|
+
doc.cookie = `${CONSENT_COOKIE_NAME}=${encodeURIComponent(encode(decision))}; ${cookieAttributes(12, secure)}`;
|
|
392
|
+
},
|
|
393
|
+
openPreferences: () => {
|
|
394
|
+
const event = new CustomEvent(CONSENT_PREFERENCES_EVENT, { cancelable: true });
|
|
395
|
+
doc.dispatchEvent(event);
|
|
396
|
+
return event.defaultPrevented;
|
|
397
|
+
},
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
function randomId() {
|
|
401
|
+
const c = globalThis.crypto;
|
|
402
|
+
return c?.randomUUID ? c.randomUUID() : Math.random().toString(36).slice(2);
|
|
403
|
+
}
|
|
404
|
+
/** The embed a facade root names (its provider, id and hash attributes). */
|
|
405
|
+
function videoRefOf(root) {
|
|
406
|
+
const source = root.getAttribute(VideoAttribute.Source);
|
|
407
|
+
const id = root.getAttribute(VideoAttribute.Id);
|
|
408
|
+
if ((source !== VideoSource.YouTube && source !== VideoSource.Vimeo) || !id)
|
|
409
|
+
return null;
|
|
410
|
+
const hash = root.getAttribute(VideoAttribute.Hash);
|
|
411
|
+
return { source, id, ...(hash ? { hash } : {}) };
|
|
412
|
+
}
|
|
413
|
+
class VideoRuntime {
|
|
414
|
+
constructor(root, options = {}) {
|
|
415
|
+
this.root = root;
|
|
416
|
+
this.cleanups = [];
|
|
417
|
+
this.doc = root.ownerDocument;
|
|
418
|
+
this.consent = options.consent ?? documentConsentTransport(this.doc);
|
|
419
|
+
const view = this.doc.defaultView;
|
|
420
|
+
this.reduced = options.reducedMotion ?? (() => !!view?.matchMedia?.(REDUCED_MOTION)?.matches);
|
|
421
|
+
this.words = options.words ?? videoWords(this.doc.documentElement?.getAttribute('lang'));
|
|
422
|
+
const kind = root.getAttribute(VideoAttribute.Root);
|
|
423
|
+
if (kind === VideoKind.Facade)
|
|
424
|
+
this.attachFacade();
|
|
425
|
+
else if (kind === VideoKind.File)
|
|
426
|
+
this.attachFile();
|
|
427
|
+
}
|
|
428
|
+
destroy() {
|
|
429
|
+
this.cleanups.splice(0).forEach(fn => fn());
|
|
430
|
+
delete this.root[RUNTIME_KEY];
|
|
431
|
+
}
|
|
432
|
+
/** The category a facade waits for (Marketing unless the node named another). */
|
|
433
|
+
get category() {
|
|
434
|
+
const declared = this.root.getAttribute(VideoAttribute.Consent);
|
|
435
|
+
return declared === ConsentCategory.Analytics ? ConsentCategory.Analytics : ConsentCategory.Marketing;
|
|
436
|
+
}
|
|
437
|
+
on(el, type, fn) {
|
|
438
|
+
if (!el)
|
|
439
|
+
return;
|
|
440
|
+
el.addEventListener(type, fn);
|
|
441
|
+
this.cleanups.push(() => el.removeEventListener(type, fn));
|
|
442
|
+
}
|
|
443
|
+
// ── facade ────────────────────────────────────────────────────────────────────────────────────
|
|
444
|
+
attachFacade() {
|
|
445
|
+
const play = this.root.querySelector(`button.${VideoClass.Play}`);
|
|
446
|
+
const allow = this.root.querySelector(`.${VideoClass.Allow}`);
|
|
447
|
+
const settings = this.root.querySelector(`.${VideoClass.Settings}`);
|
|
448
|
+
this.on(play, 'click', () => this.requestPlay());
|
|
449
|
+
this.on(allow, 'click', () => {
|
|
450
|
+
this.consent.grant(this.category);
|
|
451
|
+
this.loadPlayer();
|
|
452
|
+
});
|
|
453
|
+
this.on(settings, 'click', () => { this.consent.openPreferences(); });
|
|
454
|
+
}
|
|
455
|
+
/** Play pressed: the player when the category is granted, else the consent prompt. */
|
|
456
|
+
requestPlay() {
|
|
457
|
+
if (hasConsent(this.consent.read(), this.category)) {
|
|
458
|
+
this.loadPlayer();
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
const prompt = this.root.querySelector(`.${VideoClass.Consent}`);
|
|
462
|
+
if (!prompt)
|
|
463
|
+
return;
|
|
464
|
+
prompt.hidden = false;
|
|
465
|
+
this.root.setAttribute(VideoAttribute.Asking, '');
|
|
466
|
+
const play = this.root.querySelector(`button.${VideoClass.Play}`);
|
|
467
|
+
if (play)
|
|
468
|
+
play.hidden = true;
|
|
469
|
+
this.root.querySelector(`.${VideoClass.Allow}`)?.focus();
|
|
470
|
+
}
|
|
471
|
+
/** The player — created only now, after the click and the grant. */
|
|
472
|
+
loadPlayer() {
|
|
473
|
+
const ref = videoRefOf(this.root);
|
|
474
|
+
if (!ref || this.root.hasAttribute(VideoAttribute.Playing))
|
|
475
|
+
return;
|
|
476
|
+
const frame = this.doc.createElement('iframe');
|
|
477
|
+
frame.className = VideoClass.Frame;
|
|
478
|
+
frame.src = videoEmbedUrl(ref);
|
|
479
|
+
frame.title = this.root.getAttribute(VideoAttribute.Title) || this.words.playUntitled(VIDEO_PROVIDER_LABEL[ref.source]);
|
|
480
|
+
frame.setAttribute('allow', IFRAME_ALLOW);
|
|
481
|
+
frame.setAttribute('allowfullscreen', '');
|
|
482
|
+
frame.setAttribute('referrerpolicy', 'strict-origin-when-cross-origin');
|
|
483
|
+
this.root.replaceChildren(frame);
|
|
484
|
+
this.root.removeAttribute(VideoAttribute.Asking);
|
|
485
|
+
this.root.setAttribute(VideoAttribute.Playing, '');
|
|
486
|
+
frame.focus();
|
|
487
|
+
}
|
|
488
|
+
// ── uploaded file ─────────────────────────────────────────────────────────────────────────────
|
|
489
|
+
attachFile() {
|
|
490
|
+
const video = this.root.querySelector(`video.${VideoClass.Media}`);
|
|
491
|
+
if (!video)
|
|
492
|
+
return;
|
|
493
|
+
if (this.root.getAttribute(VideoAttribute.Mode) === VideoMode.Background) {
|
|
494
|
+
this.attachBackground(video);
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
const play = this.root.querySelector(`button.${VideoClass.Play}`);
|
|
498
|
+
const hidePlay = () => { if (play)
|
|
499
|
+
play.hidden = true; };
|
|
500
|
+
this.on(play, 'click', () => { hidePlay(); void video.play()?.catch?.(() => undefined); video.focus(); });
|
|
501
|
+
this.on(video, 'play', hidePlay);
|
|
502
|
+
// Autoplay (muted) only while motion is allowed — never under reduced motion.
|
|
503
|
+
if (this.root.hasAttribute(VideoAttribute.Autoplay) && !this.reduced()) {
|
|
504
|
+
video.muted = true;
|
|
505
|
+
void video.play()?.catch?.(() => undefined);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
attachBackground(video) {
|
|
509
|
+
const toggle = this.root.querySelector(`.${VideoClass.Toggle}`);
|
|
510
|
+
video.muted = true;
|
|
511
|
+
const sync = () => {
|
|
512
|
+
const paused = video.paused;
|
|
513
|
+
if (!toggle)
|
|
514
|
+
return;
|
|
515
|
+
toggle.setAttribute('aria-pressed', paused ? 'true' : 'false');
|
|
516
|
+
toggle.setAttribute('aria-label', paused ? this.words.playBackground : this.words.pauseBackground);
|
|
517
|
+
const icon = toggle.querySelector('i');
|
|
518
|
+
if (icon)
|
|
519
|
+
icon.className = paused ? VideoIcon.Play : VideoIcon.Pause;
|
|
520
|
+
};
|
|
521
|
+
this.on(video, 'play', sync);
|
|
522
|
+
this.on(video, 'pause', sync);
|
|
523
|
+
this.on(toggle, 'click', () => {
|
|
524
|
+
if (video.paused)
|
|
525
|
+
void video.play()?.catch?.(() => undefined);
|
|
526
|
+
else
|
|
527
|
+
video.pause();
|
|
528
|
+
sync();
|
|
529
|
+
});
|
|
530
|
+
// It moves only when motion is allowed; under reduced motion it rests on its poster, paused.
|
|
531
|
+
if (!this.reduced())
|
|
532
|
+
void video.play()?.catch?.(() => sync());
|
|
533
|
+
sync();
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
/** Run the video at `el` (a `[data-iox-video]` element) once; null when it is not one or already runs. */
|
|
537
|
+
function attachVideoAt(el, options = {}) {
|
|
538
|
+
if (!el?.hasAttribute?.(VideoAttribute.Root))
|
|
539
|
+
return null;
|
|
540
|
+
const host = el;
|
|
541
|
+
if (host[RUNTIME_KEY])
|
|
542
|
+
return null;
|
|
543
|
+
const runtime = new VideoRuntime(el, options);
|
|
544
|
+
host[RUNTIME_KEY] = runtime;
|
|
545
|
+
return runtime;
|
|
546
|
+
}
|
|
547
|
+
/** Every video under `container` that is not running yet (the string path's globals and pages). */
|
|
548
|
+
function attachVideos(container, options = {}) {
|
|
549
|
+
return Array.from(container.querySelectorAll(`[${VideoAttribute.Root}]`))
|
|
550
|
+
.map(el => attachVideoAt(el, options))
|
|
551
|
+
.filter((r) => !!r);
|
|
552
|
+
}
|
|
553
|
+
/** The runtime already running at `el`, if any. */
|
|
554
|
+
function videoRuntimeOf(el) {
|
|
555
|
+
return el?.[RUNTIME_KEY] ?? null;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* @vectoriox/iox-ui/media-core — media decisions shared by the builder, both engine paths and iox-cms (CC3).
|
|
560
|
+
* FRAMEWORK-FREE: `planVideo` is the one place that knows a YouTube / Vimeo address, the player's host and the consent
|
|
561
|
+
* category a facade waits for; the runtime is DOM-only. iox-cms reads `parseVideoUrl` / `videoThumbnailRequest` to
|
|
562
|
+
* fetch and store an embed's poster (the visitor never asks the provider for it).
|
|
563
|
+
* ⚠️ Consumers wire the usual trio — ng-package raw-ts assets, jest moduleNameMapper, tsconfig paths.
|
|
564
|
+
*/
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* Generated bundle index. Do not edit.
|
|
568
|
+
*/
|
|
569
|
+
|
|
570
|
+
export { CONSENT_GRANT_EVENT, CONSENT_PREFERENCES_EVENT, VIDEO_EMBED_ORIGINS, VIDEO_NODE_TYPE, VIDEO_PROVIDER_COMPANY, VIDEO_PROVIDER_LABEL, VIDEO_WORDS, VideoAttribute, VideoClass, VideoIcon, VideoKind, VideoMode, VideoRuntime, VideoSource, VideoTrait, attachVideoAt, attachVideos, documentConsentTransport, isThirdPartyPoster, parseVideoUrl, planVideo, readConsentRecord, serializeVideoAttributes, videoConsentCategory, videoEmbedUrl, videoInnerHtml, videoPlayLabel, videoPosterFileName, videoProp, videoRefOf, videoRootAttributes, videoRuntimeOf, videoSourceOf, videoThumbnailRequest, videoWords };
|
|
571
|
+
//# sourceMappingURL=vectoriox-iox-ui-media-core.mjs.map
|