@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,443 @@
|
|
|
1
|
+
import { ConsentCategory, ConsentDecision, hasConsent } 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
|
+
|
|
15
|
+
export const VIDEO_NODE_TYPE = 'Video';
|
|
16
|
+
|
|
17
|
+
export enum VideoSource {
|
|
18
|
+
File = 'file',
|
|
19
|
+
YouTube = 'youtube',
|
|
20
|
+
Vimeo = 'vimeo',
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export enum VideoMode {
|
|
24
|
+
/** In the page, with the player's controls. */
|
|
25
|
+
Inline = 'inline',
|
|
26
|
+
/** Muted, looped, no controls, an always-visible pause button (WCAG 2.2.2) — an uploaded file only. */
|
|
27
|
+
Background = 'background',
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The node's traits (Bindings ▸ Media · Behaviour · Accessibility · Privacy). */
|
|
31
|
+
export enum VideoTrait {
|
|
32
|
+
/** Not `source`: across the builder `source` names a DATA source (a Repeater's, a Slider's). */
|
|
33
|
+
Source = 'videoSource',
|
|
34
|
+
Src = 'src',
|
|
35
|
+
Poster = 'poster',
|
|
36
|
+
Mode = 'mode',
|
|
37
|
+
Autoplay = 'autoplay',
|
|
38
|
+
Title = 'title',
|
|
39
|
+
Captions = 'captions',
|
|
40
|
+
CaptionsLang = 'captionsLang',
|
|
41
|
+
Consent = 'consentCategory',
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** What a plan renders. */
|
|
45
|
+
export enum VideoKind {
|
|
46
|
+
/** An uploaded file: a real `<video>`. */
|
|
47
|
+
File = 'file',
|
|
48
|
+
/** YouTube / Vimeo: poster + play, the player loaded on a click after consent. */
|
|
49
|
+
Facade = 'facade',
|
|
50
|
+
/** No address yet: the poster blank (DS3) and nothing to play. */
|
|
51
|
+
Empty = 'empty',
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A recognised embed: which provider, its video id and (Vimeo, unlisted) its privacy hash. */
|
|
55
|
+
export interface VideoRef {
|
|
56
|
+
source: VideoSource.YouTube | VideoSource.Vimeo;
|
|
57
|
+
id: string;
|
|
58
|
+
hash?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const VIDEO_PROVIDER_LABEL: Readonly<Record<VideoRef['source'], string>> = {
|
|
62
|
+
[VideoSource.YouTube]: 'YouTube',
|
|
63
|
+
[VideoSource.Vimeo]: 'Vimeo',
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** Who the visitor's IP address and cookies go to, named in the consent prompt. */
|
|
67
|
+
export const VIDEO_PROVIDER_COMPANY: Readonly<Record<VideoRef['source'], string>> = {
|
|
68
|
+
[VideoSource.YouTube]: 'Google',
|
|
69
|
+
[VideoSource.Vimeo]: 'Vimeo',
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** The two hosts a player ever loads from — youtube-nocookie and Vimeo's player (with Do Not Track). */
|
|
73
|
+
export const VIDEO_EMBED_ORIGINS: ReadonlyArray<string> = ['https://www.youtube-nocookie.com', 'https://player.vimeo.com'];
|
|
74
|
+
|
|
75
|
+
const YOUTUBE_ID = /^[A-Za-z0-9_-]{11}$/;
|
|
76
|
+
const VIMEO_ID = /^\d{6,12}$/;
|
|
77
|
+
const VIMEO_HASH = /^[0-9a-f]{6,20}$/i;
|
|
78
|
+
const YOUTUBE_HOSTS = new Set(['youtube.com', 'www.youtube.com', 'm.youtube.com', 'music.youtube.com', 'youtube-nocookie.com', 'www.youtube-nocookie.com']);
|
|
79
|
+
const YOUTUBE_SHORT_HOSTS = new Set(['youtu.be', 'www.youtu.be']);
|
|
80
|
+
const YOUTUBE_PATH_PREFIXES = ['embed', 'shorts', 'live', 'v'];
|
|
81
|
+
const VIMEO_HOSTS = new Set(['vimeo.com', 'www.vimeo.com', 'player.vimeo.com']);
|
|
82
|
+
|
|
83
|
+
function toUrl(raw: string): URL | null {
|
|
84
|
+
const text = raw.trim();
|
|
85
|
+
if (!text) return null;
|
|
86
|
+
try {
|
|
87
|
+
return new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(text) ? text : `https://${text}`);
|
|
88
|
+
} catch {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function parseYouTube(url: URL): VideoRef | null {
|
|
94
|
+
const host = url.hostname.toLowerCase();
|
|
95
|
+
const segments = url.pathname.split('/').filter(Boolean);
|
|
96
|
+
let id: string | null = null;
|
|
97
|
+
if (YOUTUBE_SHORT_HOSTS.has(host)) id = segments[0] ?? null;
|
|
98
|
+
else if (YOUTUBE_HOSTS.has(host)) {
|
|
99
|
+
if (segments[0] === 'watch') id = url.searchParams.get('v');
|
|
100
|
+
else if (YOUTUBE_PATH_PREFIXES.includes(segments[0] ?? '')) id = segments[1] ?? null;
|
|
101
|
+
} else return null;
|
|
102
|
+
return id && YOUTUBE_ID.test(id) ? { source: VideoSource.YouTube, id } : null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function parseVimeo(url: URL): VideoRef | null {
|
|
106
|
+
if (!VIMEO_HOSTS.has(url.hostname.toLowerCase())) return null;
|
|
107
|
+
const segments = url.pathname.split('/').filter(Boolean);
|
|
108
|
+
// player.vimeo.com/video/<id>, vimeo.com/<id>[/<hash>], vimeo.com/channels/<c>/<id>, vimeo.com/groups/<g>/videos/<id>
|
|
109
|
+
const index = segments.findIndex(s => VIMEO_ID.test(s));
|
|
110
|
+
if (index < 0) return null;
|
|
111
|
+
const id = segments[index];
|
|
112
|
+
const hash = url.searchParams.get('h') ?? segments[index + 1];
|
|
113
|
+
return { source: VideoSource.Vimeo, id, ...(hash && VIMEO_HASH.test(hash) ? { hash } : {}) };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The embed a pasted address names — a YouTube or Vimeo watch, share, embed or short link — or null. */
|
|
117
|
+
export function parseVideoUrl(raw: string | null | undefined): VideoRef | null {
|
|
118
|
+
const url = typeof raw === 'string' ? toUrl(raw) : null;
|
|
119
|
+
if (!url || !/^https?:$/.test(url.protocol)) return null;
|
|
120
|
+
return parseYouTube(url) ?? parseVimeo(url);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The source a node plays: its declared source when it is one, else what its address is. */
|
|
124
|
+
export function videoSourceOf(src: unknown, declared?: unknown): VideoSource {
|
|
125
|
+
if (declared === VideoSource.YouTube || declared === VideoSource.Vimeo || declared === VideoSource.File) return declared;
|
|
126
|
+
return parseVideoUrl(typeof src === 'string' ? src : '')?.source ?? VideoSource.File;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The player's address — loaded only after a click and a grant. Autoplays (the visitor just pressed play). */
|
|
130
|
+
export function videoEmbedUrl(ref: VideoRef): string {
|
|
131
|
+
if (ref.source === VideoSource.YouTube) {
|
|
132
|
+
return `https://www.youtube-nocookie.com/embed/${encodeURIComponent(ref.id)}?autoplay=1&rel=0&playsinline=1`;
|
|
133
|
+
}
|
|
134
|
+
return `https://player.vimeo.com/video/${encodeURIComponent(ref.id)}?autoplay=1&dnt=1${ref.hash ? `&h=${encodeURIComponent(ref.hash)}` : ''}`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Where the SERVER fetches an embed's thumbnail to store as the studio's own poster (never the visitor). */
|
|
138
|
+
export interface VideoThumbnailRequest {
|
|
139
|
+
/** `image`: the url IS the picture; `oembed`: the url answers JSON whose `thumbnail_url` is the picture. */
|
|
140
|
+
kind: 'image' | 'oembed';
|
|
141
|
+
url: string;
|
|
142
|
+
/** The only hosts the picture itself may come from (the server refuses anything else). */
|
|
143
|
+
imageHosts: ReadonlyArray<string>;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function videoThumbnailRequest(ref: VideoRef): VideoThumbnailRequest {
|
|
147
|
+
if (ref.source === VideoSource.YouTube) {
|
|
148
|
+
return { kind: 'image', url: `https://i.ytimg.com/vi/${encodeURIComponent(ref.id)}/hqdefault.jpg`, imageHosts: ['i.ytimg.com'] };
|
|
149
|
+
}
|
|
150
|
+
const page = `https://vimeo.com/${ref.id}${ref.hash ? `/${ref.hash}` : ''}`;
|
|
151
|
+
return { kind: 'oembed', url: `https://vimeo.com/api/oembed.json?url=${encodeURIComponent(page)}&width=1280`, imageHosts: ['i.vimeocdn.com'] };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** A stored poster's object name — one per video, so fetching it again overwrites rather than piles up. */
|
|
155
|
+
export function videoPosterFileName(ref: VideoRef): string {
|
|
156
|
+
return `video-poster-${ref.source}-${ref.id}.jpg`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ── the node, in either shape (saved props / builder traits) ───────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
export interface VideoNodeLike {
|
|
162
|
+
type?: string;
|
|
163
|
+
props?: Record<string, unknown> | null;
|
|
164
|
+
traits?: ReadonlyArray<{ name?: string; default?: unknown }> | null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** A content value of a Video node, saved (`props`) or builder (`traits[].default`) shape. */
|
|
168
|
+
export function videoProp(node: VideoNodeLike | null | undefined, name: VideoTrait | string): unknown {
|
|
169
|
+
if (!node) return undefined;
|
|
170
|
+
if (node.props && Object.prototype.hasOwnProperty.call(node.props, name)) return node.props[name];
|
|
171
|
+
return node.traits?.find(t => t?.name === name)?.default;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const str = (v: unknown): string => (typeof v === 'string' ? v.trim() : '');
|
|
175
|
+
const truthy = (v: unknown): boolean => v === true || v === 'true';
|
|
176
|
+
|
|
177
|
+
/** The consent category an embed waits for — Marketing by default (D5); an uploaded file waits for none. */
|
|
178
|
+
export function videoConsentCategory(node: VideoNodeLike | null | undefined, source: VideoSource = videoSourceOf(videoProp(node, VideoTrait.Src), videoProp(node, VideoTrait.Source))): ConsentCategory | null {
|
|
179
|
+
if (source === VideoSource.File) return null;
|
|
180
|
+
const declared = videoProp(node, VideoTrait.Consent);
|
|
181
|
+
return declared === ConsentCategory.Analytics ? ConsentCategory.Analytics : ConsentCategory.Marketing;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export interface VideoPlan {
|
|
185
|
+
kind: VideoKind;
|
|
186
|
+
source: VideoSource;
|
|
187
|
+
/** An embed's provider and id (Facade). */
|
|
188
|
+
ref: VideoRef | null;
|
|
189
|
+
/** An uploaded file's address (File). */
|
|
190
|
+
src: string;
|
|
191
|
+
/** The studio's own poster ('' = the token blank). Never a provider's thumbnail URL. */
|
|
192
|
+
poster: string;
|
|
193
|
+
/** The video's name — the play button's accessible name. */
|
|
194
|
+
title: string;
|
|
195
|
+
mode: VideoMode;
|
|
196
|
+
background: boolean;
|
|
197
|
+
/** Muted autoplay when motion is allowed (the runtime asks; the server never writes `autoplay`). */
|
|
198
|
+
autoplay: boolean;
|
|
199
|
+
muted: boolean;
|
|
200
|
+
loop: boolean;
|
|
201
|
+
controls: boolean;
|
|
202
|
+
/** A `.vtt` file ('' = none). */
|
|
203
|
+
captions: string;
|
|
204
|
+
captionsLang: string;
|
|
205
|
+
/** What the facade waits for (null for a file). */
|
|
206
|
+
consentCategory: ConsentCategory | null;
|
|
207
|
+
/** The visitor's decision grants it. */
|
|
208
|
+
granted: boolean;
|
|
209
|
+
/** The player's address — only when granted (the runtime loads it on a click). */
|
|
210
|
+
embedUrl: string | null;
|
|
211
|
+
/** "YouTube" / "Vimeo" ('' for a file). */
|
|
212
|
+
providerLabel: string;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* The ONE decision for a Video node and a visitor's consent (null = none recorded — the server always passes null,
|
|
217
|
+
* so its HTML is always the facade). A poster that is a provider's thumbnail URL is dropped: the poster is the
|
|
218
|
+
* studio's own image (or the thumbnail the server fetched and stored for it), never a request to a third party.
|
|
219
|
+
*/
|
|
220
|
+
export function planVideo(node: VideoNodeLike | null | undefined, consent: ConsentDecision | null): VideoPlan {
|
|
221
|
+
const rawSrc = str(videoProp(node, VideoTrait.Src));
|
|
222
|
+
const source = videoSourceOf(rawSrc, videoProp(node, VideoTrait.Source));
|
|
223
|
+
const ref = source === VideoSource.File ? null : parseVideoUrl(rawSrc);
|
|
224
|
+
const background = source === VideoSource.File && videoProp(node, VideoTrait.Mode) === VideoMode.Background;
|
|
225
|
+
const consentCategory = videoConsentCategory(node, source);
|
|
226
|
+
const granted = consentCategory ? hasConsent(consent, consentCategory) : true;
|
|
227
|
+
const kind = source === VideoSource.File ? (rawSrc ? VideoKind.File : VideoKind.Empty) : (ref ? VideoKind.Facade : VideoKind.Empty);
|
|
228
|
+
const poster = str(videoProp(node, VideoTrait.Poster));
|
|
229
|
+
return {
|
|
230
|
+
kind,
|
|
231
|
+
source,
|
|
232
|
+
ref,
|
|
233
|
+
src: source === VideoSource.File ? rawSrc : '',
|
|
234
|
+
poster: isThirdPartyPoster(poster) ? '' : poster,
|
|
235
|
+
title: str(videoProp(node, VideoTrait.Title)),
|
|
236
|
+
mode: background ? VideoMode.Background : VideoMode.Inline,
|
|
237
|
+
background,
|
|
238
|
+
autoplay: background || (source === VideoSource.File && truthy(videoProp(node, VideoTrait.Autoplay))),
|
|
239
|
+
muted: background || truthy(videoProp(node, VideoTrait.Autoplay)),
|
|
240
|
+
loop: background,
|
|
241
|
+
controls: !background,
|
|
242
|
+
captions: background ? '' : str(videoProp(node, VideoTrait.Captions)),
|
|
243
|
+
captionsLang: str(videoProp(node, VideoTrait.CaptionsLang)) || 'en',
|
|
244
|
+
consentCategory,
|
|
245
|
+
granted,
|
|
246
|
+
embedUrl: kind === VideoKind.Facade && granted && ref ? videoEmbedUrl(ref) : null,
|
|
247
|
+
providerLabel: ref ? VIDEO_PROVIDER_LABEL[ref.source] : '',
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const THIRD_PARTY_POSTER_HOSTS = ['ytimg.com', 'youtube.com', 'vimeocdn.com', 'vimeo.com', 'googleusercontent.com'];
|
|
252
|
+
|
|
253
|
+
/** A poster served by the video's provider — a request to a third party before consent, so it never renders. */
|
|
254
|
+
export function isThirdPartyPoster(url: string): boolean {
|
|
255
|
+
const parsed = url ? toUrl(url) : null;
|
|
256
|
+
if (!parsed || !/^https?:$/.test(parsed.protocol)) return false;
|
|
257
|
+
const host = parsed.hostname.toLowerCase();
|
|
258
|
+
return THIRD_PARTY_POSTER_HOSTS.some(h => host === h || host.endsWith(`.${h}`));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ── words ───────────────────────────────────────────────────────────────────────────────────────────
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* The words the renderer draws (board 5; Q7: the consent prompt is NOT an editable part — its words are the
|
|
265
|
+
* consent vocabulary's, one source, translated). English and Hebrew; the page's `lang` picks.
|
|
266
|
+
*/
|
|
267
|
+
export interface VideoWords {
|
|
268
|
+
/** The play button's name: the video's title and its provider. */
|
|
269
|
+
play(title: string, provider: string): string;
|
|
270
|
+
/** The play button's name with no title. */
|
|
271
|
+
playUntitled(provider: string): string;
|
|
272
|
+
pauseBackground: string;
|
|
273
|
+
playBackground: string;
|
|
274
|
+
/** The consent prompt (a group): its name, its lead and its body. */
|
|
275
|
+
promptLabel: string;
|
|
276
|
+
promptLead(provider: string): string;
|
|
277
|
+
promptBody(company: string): string;
|
|
278
|
+
/** The same prompt where the player is narrow (board 5's phone): one shorter sentence, its lead in bold. */
|
|
279
|
+
promptCompactLead(provider: string): string;
|
|
280
|
+
promptCompactBody: string;
|
|
281
|
+
allow: string;
|
|
282
|
+
settings: string;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export const VIDEO_WORDS: Readonly<Record<'en' | 'he', VideoWords>> = {
|
|
286
|
+
en: {
|
|
287
|
+
play: (title, provider) => (provider ? `Play: ${title} (${provider})` : `Play: ${title}`),
|
|
288
|
+
playUntitled: provider => (provider ? `Play video (${provider})` : 'Play video'),
|
|
289
|
+
pauseBackground: 'Pause background video',
|
|
290
|
+
playBackground: 'Play background video',
|
|
291
|
+
promptLabel: 'This video needs your permission',
|
|
292
|
+
promptLead: provider => `This video is hosted by ${provider}.`,
|
|
293
|
+
promptBody: company => `Playing it lets ${company} set cookies and see your IP address. Nothing has loaded yet.`,
|
|
294
|
+
promptCompactLead: provider => `Plays from ${provider},`,
|
|
295
|
+
promptCompactBody: 'which sets cookies and sees your IP address. Nothing has loaded yet.',
|
|
296
|
+
allow: 'Allow and play',
|
|
297
|
+
settings: 'Privacy settings',
|
|
298
|
+
},
|
|
299
|
+
he: {
|
|
300
|
+
play: (title, provider) => (provider ? `הפעלה: ${title} (${provider})` : `הפעלה: ${title}`),
|
|
301
|
+
playUntitled: provider => (provider ? `הפעלת הסרטון (${provider})` : 'הפעלת הסרטון'),
|
|
302
|
+
pauseBackground: 'השהיית סרטון הרקע',
|
|
303
|
+
playBackground: 'הפעלת סרטון הרקע',
|
|
304
|
+
promptLabel: 'הסרטון הזה צריך את רשותך',
|
|
305
|
+
promptLead: provider => `הסרטון מתארח ב-${provider}.`,
|
|
306
|
+
promptBody: company => `ההפעלה מאפשרת ל-${company} להגדיר קובצי Cookie ולראות את כתובת ה-IP שלך. עדיין לא נטען דבר.`,
|
|
307
|
+
promptCompactLead: provider => `מתנגן מ-${provider},`,
|
|
308
|
+
promptCompactBody: 'שמגדיר קובצי Cookie ורואה את כתובת ה-IP שלך. עדיין לא נטען דבר.',
|
|
309
|
+
allow: 'לאשר ולהפעיל',
|
|
310
|
+
settings: 'הגדרות פרטיות',
|
|
311
|
+
},
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
/** The words for a page language (`he`, `he-IL` → Hebrew; anything else → English). */
|
|
315
|
+
export function videoWords(lang?: string | null): VideoWords {
|
|
316
|
+
return (lang ?? '').toLowerCase().startsWith('he') ? VIDEO_WORDS.he : VIDEO_WORDS.en;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** The play button's accessible name. */
|
|
320
|
+
export function videoPlayLabel(plan: VideoPlan, words: VideoWords = VIDEO_WORDS.en): string {
|
|
321
|
+
return plan.title ? words.play(plan.title, plan.providerLabel) : words.playUntitled(plan.providerLabel);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ── markup (one builder for both engine paths and the canvas) ────────────────────────────────────────
|
|
325
|
+
|
|
326
|
+
export enum VideoClass {
|
|
327
|
+
Root = 'iox-video',
|
|
328
|
+
Poster = 'iox-video__poster',
|
|
329
|
+
Play = 'iox-video__play',
|
|
330
|
+
Badge = 'iox-video__badge',
|
|
331
|
+
Media = 'iox-video__media',
|
|
332
|
+
Frame = 'iox-video__frame',
|
|
333
|
+
Toggle = 'iox-video__toggle',
|
|
334
|
+
Consent = 'iox-video__consent',
|
|
335
|
+
ConsentText = 'iox-video__consent-text',
|
|
336
|
+
/** The prompt's text on a wide player / on a narrow one (a container query shows one). */
|
|
337
|
+
ConsentWide = 'iox-video__consent-wide',
|
|
338
|
+
ConsentCompact = 'iox-video__consent-compact',
|
|
339
|
+
ConsentRow = 'iox-video__consent-row',
|
|
340
|
+
Allow = 'iox-video__allow',
|
|
341
|
+
Settings = 'iox-video__settings',
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export enum VideoAttribute {
|
|
345
|
+
/** On the node element: its kind (`file` · `facade` · `empty`) — what the runtime attaches to. */
|
|
346
|
+
Root = 'data-iox-video',
|
|
347
|
+
Source = 'data-iox-video-source',
|
|
348
|
+
Id = 'data-iox-video-id',
|
|
349
|
+
Hash = 'data-iox-video-hash',
|
|
350
|
+
Mode = 'data-iox-video-mode',
|
|
351
|
+
Autoplay = 'data-iox-video-autoplay',
|
|
352
|
+
Consent = 'data-iox-video-consent',
|
|
353
|
+
Title = 'data-iox-video-title',
|
|
354
|
+
/** The poster is the token blank (no image): the shared CSS fills it with `surface-2`. */
|
|
355
|
+
PosterEmpty = 'data-iox-video-poster-empty',
|
|
356
|
+
/** The player is loaded (after the click and the grant). */
|
|
357
|
+
Playing = 'data-iox-video-playing',
|
|
358
|
+
/** The consent prompt is showing. */
|
|
359
|
+
Asking = 'data-iox-video-asking',
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export enum VideoIcon {
|
|
363
|
+
Play = 'ph-fill ph-play',
|
|
364
|
+
Pause = 'ph-fill ph-pause',
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** The attributes the node's own element carries (both engine paths, the canvas). */
|
|
368
|
+
export function videoRootAttributes(plan: VideoPlan): Record<string, string> {
|
|
369
|
+
const attrs: Record<string, string> = { [VideoAttribute.Root]: plan.kind, [VideoAttribute.Source]: plan.source };
|
|
370
|
+
if (plan.ref) {
|
|
371
|
+
attrs[VideoAttribute.Id] = plan.ref.id;
|
|
372
|
+
if (plan.ref.hash) attrs[VideoAttribute.Hash] = plan.ref.hash;
|
|
373
|
+
}
|
|
374
|
+
if (plan.background) attrs[VideoAttribute.Mode] = VideoMode.Background;
|
|
375
|
+
if (plan.autoplay) attrs[VideoAttribute.Autoplay] = '';
|
|
376
|
+
if (plan.consentCategory) attrs[VideoAttribute.Consent] = plan.consentCategory;
|
|
377
|
+
if (plan.title) attrs[VideoAttribute.Title] = plan.title;
|
|
378
|
+
if (!plan.poster) attrs[VideoAttribute.PosterEmpty] = '';
|
|
379
|
+
return attrs;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export interface VideoMarkupOptions {
|
|
383
|
+
/** HTML-escape an attribute or text value. */
|
|
384
|
+
esc: (value: string) => string;
|
|
385
|
+
/** The builder canvas: the poster ONLY — no `<video>`, no player, nothing that loads or takes a click. */
|
|
386
|
+
canvas?: boolean;
|
|
387
|
+
words?: VideoWords;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const iconHtml = (cls: string) => `<i class="${cls}" aria-hidden="true"></i>`;
|
|
391
|
+
|
|
392
|
+
/** The element's inner markup. The server's HTML for a facade holds no `<iframe>` and requests nothing from
|
|
393
|
+
* the provider: only the studio's poster, the play button and the (hidden) consent prompt. */
|
|
394
|
+
export function videoInnerHtml(plan: VideoPlan, options: VideoMarkupOptions): string {
|
|
395
|
+
const { esc } = options;
|
|
396
|
+
const words = options.words ?? VIDEO_WORDS.en;
|
|
397
|
+
const poster = plan.poster ? `<img class="${VideoClass.Poster}" src="${esc(plan.poster)}" alt="" loading="lazy" decoding="async">` : '';
|
|
398
|
+
const label = esc(videoPlayLabel(plan, words));
|
|
399
|
+
const playSpan = `<span class="${VideoClass.Play}" aria-hidden="true">${iconHtml(VideoIcon.Play)}</span>`;
|
|
400
|
+
const badge = plan.providerLabel ? `<span class="${VideoClass.Badge}">${iconHtml(VideoIcon.Play)}${esc(plan.providerLabel)}</span>` : '';
|
|
401
|
+
|
|
402
|
+
if (options.canvas || plan.kind === VideoKind.Empty) {
|
|
403
|
+
return `${poster}${plan.kind === VideoKind.Empty && !options.canvas ? '' : playSpan}${badge}`;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
if (plan.kind === VideoKind.File) {
|
|
407
|
+
const media = [
|
|
408
|
+
`<video class="${VideoClass.Media}" src="${esc(plan.src)}" preload="metadata" playsinline`,
|
|
409
|
+
plan.poster ? ` poster="${esc(plan.poster)}"` : '',
|
|
410
|
+
// A captions file is fetched with CORS (a <track> from another origin loads only so): the studio's bucket
|
|
411
|
+
// answers CORS for every object, the video included.
|
|
412
|
+
plan.captions ? ' crossorigin="anonymous"' : '',
|
|
413
|
+
plan.controls ? ' controls' : '',
|
|
414
|
+
plan.muted ? ' muted' : '',
|
|
415
|
+
plan.loop ? ' loop' : '',
|
|
416
|
+
plan.background ? ' aria-hidden="true" tabindex="-1"' : (plan.title ? ` aria-label="${esc(plan.title)}"` : ''),
|
|
417
|
+
'>',
|
|
418
|
+
plan.captions ? `<track kind="captions" src="${esc(plan.captions)}" srclang="${esc(plan.captionsLang)}" label="${esc(plan.captionsLang)}" default>` : '',
|
|
419
|
+
'</video>',
|
|
420
|
+
].join('');
|
|
421
|
+
if (plan.background) {
|
|
422
|
+
// Rendered paused (the server cannot know the visitor's motion setting): the runtime starts it when motion
|
|
423
|
+
// is allowed and turns this into the PAUSE button. Always visible (WCAG 2.2.2).
|
|
424
|
+
return `${media}<button type="button" class="${VideoClass.Toggle}" aria-label="${esc(words.playBackground)}" aria-pressed="true">${iconHtml(VideoIcon.Play)}</button>`;
|
|
425
|
+
}
|
|
426
|
+
return `${media}<button type="button" class="${VideoClass.Play}" aria-label="${label}">${iconHtml(VideoIcon.Play)}</button>`;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const company = plan.ref ? VIDEO_PROVIDER_COMPANY[plan.ref.source] : '';
|
|
430
|
+
const prompt = `<div class="${VideoClass.Consent}" role="group" aria-label="${esc(words.promptLabel)}" hidden>` +
|
|
431
|
+
`<p class="${VideoClass.ConsentText} ${VideoClass.ConsentWide}"><b>${esc(words.promptLead(plan.providerLabel))}</b> ${esc(words.promptBody(company))}</p>` +
|
|
432
|
+
`<p class="${VideoClass.ConsentText} ${VideoClass.ConsentCompact}"><b>${esc(words.promptCompactLead(plan.providerLabel))}</b> ${esc(words.promptCompactBody)}</p>` +
|
|
433
|
+
`<div class="${VideoClass.ConsentRow}">` +
|
|
434
|
+
`<button type="button" class="${VideoClass.Allow}">${esc(words.allow)}</button>` +
|
|
435
|
+
`<button type="button" class="${VideoClass.Settings}">${esc(words.settings)}</button>` +
|
|
436
|
+
`</div></div>`;
|
|
437
|
+
return `${poster}<button type="button" class="${VideoClass.Play}" aria-label="${label}">${iconHtml(VideoIcon.Play)}</button>${badge}${prompt}`;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** Serialise attributes for a string renderer (a present boolean attribute is written bare). */
|
|
441
|
+
export function serializeVideoAttributes(attrs: Readonly<Record<string, string>>, esc: (value: string) => string): string {
|
|
442
|
+
return Object.entries(attrs).map(([name, value]) => (value === '' ? ` ${name}` : ` ${name}="${esc(value)}"`)).join('');
|
|
443
|
+
}
|
package/styles/iox-design.css
CHANGED
|
@@ -237,3 +237,115 @@ dialog[data-iox-dialog]:not([open]) {
|
|
|
237
237
|
@media (prefers-reduced-motion: reduce) {
|
|
238
238
|
.iox-accordion-track { transition: none; }
|
|
239
239
|
}
|
|
240
|
+
|
|
241
|
+
/* ── CC4a: Tabs (complex-components boards 3 and 9; composite K2) ────────────────────────────────────
|
|
242
|
+
The renderers write ONE markup (composite-core tabs.ts): the list is role=tablist, each tab a <button role=tab>, each
|
|
243
|
+
panel role=tabpanel. Structural only — the look is the parts' element styles (tab-list, tab), on their own rules. */
|
|
244
|
+
|
|
245
|
+
/* A tab is a real button: its UA look is reset at zero specificity, so the node's own rule (the `tab` style) wins. */
|
|
246
|
+
:where(.iox-role-tabs-trigger) {
|
|
247
|
+
appearance: none; -webkit-appearance: none; background: none; border: 0 solid transparent; border-radius: 0; margin: 0;
|
|
248
|
+
font: inherit; color: inherit; cursor: pointer; box-sizing: border-box;
|
|
249
|
+
}
|
|
250
|
+
/* The ONE hiding rule for Tabs (board 9: "Tabs keep display:none"): a panel that is not on takes no space and leaves the
|
|
251
|
+
Tab order. `!important` because a panel is any Container: the author's display (its own rule, a breakpoint layer, a
|
|
252
|
+
state — each at (0,1,0) or (0,2,0), emitted after this sheet) must never bring an inactive panel back. */
|
|
253
|
+
.iox-role-tabs-panel[hidden] { display: none !important; }
|
|
254
|
+
/* A panel is a Tab stop of its own (APG): its focus ring is the look's. */
|
|
255
|
+
.iox-role-tabs-panel:focus-visible { outline: none; box-shadow: var(--iox-focus-ring); }
|
|
256
|
+
|
|
257
|
+
/* ── CC3: Video (complex-components plan §7b, board 5) ────────────────────────────────────────────────
|
|
258
|
+
One markup from media-core `videoInnerHtml` (both engine paths, the canvas). The frame is the node (it wears
|
|
259
|
+
`media`: radius, fit); the poster, the play button, the provider badge and the consent prompt are drawn by the
|
|
260
|
+
renderer from tokens — not nodes (board 9 Q7). Zero specificity on the frame, so the node's own size wins. */
|
|
261
|
+
:where(.iox-video) {
|
|
262
|
+
position: relative; overflow: hidden; display: grid; place-items: center; isolation: isolate;
|
|
263
|
+
container-type: inline-size;
|
|
264
|
+
}
|
|
265
|
+
/* The poster blank (DS3): no poster yet = the token fill, never a request to the provider. */
|
|
266
|
+
.iox-video[data-iox-video-poster-empty]:not([data-iox-video-playing]) { background-color: var(--iox-surface-2); }
|
|
267
|
+
.iox-video[data-iox-video-playing] { background-color: var(--iox-inverse-bg); }
|
|
268
|
+
.iox-video > :is(.iox-video__poster, .iox-video__media, .iox-video__frame) {
|
|
269
|
+
position: absolute; inset: 0; width: 100%; height: 100%; border: 0; margin: 0; display: block; object-fit: cover;
|
|
270
|
+
}
|
|
271
|
+
.iox-video > .iox-video__poster { z-index: -1; }
|
|
272
|
+
.iox-video > .iox-video__media { object-fit: contain; background-color: var(--iox-inverse-bg); }
|
|
273
|
+
.iox-video[data-iox-video-mode='background'] > .iox-video__media { object-fit: cover; }
|
|
274
|
+
.iox-video__play {
|
|
275
|
+
position: relative; display: grid; place-items: center; width: var(--iox-video-play-size); height: var(--iox-video-play-size);
|
|
276
|
+
padding: 0; margin: 0; border: 0; border-radius: var(--iox-radius-pill, 999px); cursor: pointer;
|
|
277
|
+
background: var(--iox-video-play-bg); color: var(--iox-video-play-ink); box-shadow: var(--iox-shadow-md);
|
|
278
|
+
font-size: calc(var(--iox-video-play-size) * 0.44); line-height: 1;
|
|
279
|
+
}
|
|
280
|
+
span.iox-video__play { pointer-events: none; }
|
|
281
|
+
.iox-video__play[hidden] { display: none; }
|
|
282
|
+
.iox-video__badge {
|
|
283
|
+
position: absolute; top: 10px; inset-inline-start: 10px; display: inline-flex; align-items: center; gap: 6px; padding: 2px 8px;
|
|
284
|
+
border-radius: var(--iox-radius-pill, 999px); background: color-mix(in srgb, var(--iox-surface) 88%, transparent); color: var(--iox-text);
|
|
285
|
+
font-family: var(--iox-font-label); font-size: var(--iox-size-label); font-weight: var(--iox-weight-label); line-height: 1.5; pointer-events: none;
|
|
286
|
+
}
|
|
287
|
+
.iox-video__consent {
|
|
288
|
+
position: absolute; inset: auto 12px 12px 12px; display: grid; gap: 10px; padding: 14px 16px; text-align: start;
|
|
289
|
+
border-radius: var(--iox-radius-md); background: var(--iox-surface-glass-strong, var(--iox-surface)); color: var(--iox-text); box-shadow: var(--iox-shadow-md);
|
|
290
|
+
-webkit-backdrop-filter: var(--iox-blur-surface, none); backdrop-filter: var(--iox-blur-surface, none);
|
|
291
|
+
}
|
|
292
|
+
.iox-video__consent[hidden] { display: none; }
|
|
293
|
+
.iox-video__consent-text { margin: 0; font-family: var(--iox-font-body); font-size: var(--iox-size-small); line-height: 1.45; }
|
|
294
|
+
.iox-video__consent-row { display: flex; flex-wrap: wrap; gap: 8px; }
|
|
295
|
+
.iox-video__consent-compact { display: none; }
|
|
296
|
+
.iox-video__allow, .iox-video__settings {
|
|
297
|
+
cursor: pointer; margin: 0; border-style: solid; border-width: var(--iox-button-border-width); border-radius: var(--iox-button-radius); padding: var(--iox-button-pad);
|
|
298
|
+
font-family: var(--iox-button-font); font-size: var(--iox-button-size); font-weight: var(--iox-button-weight); line-height: var(--iox-button-leading);
|
|
299
|
+
letter-spacing: var(--iox-button-tracking); text-transform: var(--iox-button-case);
|
|
300
|
+
}
|
|
301
|
+
.iox-video__allow { background: var(--iox-button-primary-bg); color: var(--iox-button-primary-ink); border-color: var(--iox-button-primary-border); }
|
|
302
|
+
.iox-video__settings { background: transparent; color: var(--iox-button-ghost-ink); border-color: transparent; text-decoration: var(--iox-button-ghost-decoration); }
|
|
303
|
+
/* Background mode's pause / play: always there (WCAG 2.2.2), the secondary button's look, top end. */
|
|
304
|
+
.iox-video__toggle {
|
|
305
|
+
position: absolute; top: 14px; inset-inline-end: 14px; display: grid; place-items: center; width: 44px; height: 44px; padding: 0; margin: 0; cursor: pointer;
|
|
306
|
+
border-radius: var(--iox-radius-pill, 999px); border: var(--iox-button-border-width) solid var(--iox-button-secondary-border);
|
|
307
|
+
background: var(--iox-button-secondary-bg); color: var(--iox-button-secondary-ink); font-size: 18px; line-height: 1;
|
|
308
|
+
}
|
|
309
|
+
:is(.iox-video__play, .iox-video__allow, .iox-video__settings, .iox-video__toggle):focus-visible { outline: none; box-shadow: var(--iox-focus-ring); }
|
|
310
|
+
/* A narrow video (a phone): the prompt fills the frame (board 5, phone). */
|
|
311
|
+
@container (max-width: 440px) {
|
|
312
|
+
.iox-video__consent { inset: 8px; align-content: center; padding: 10px 12px; gap: 8px; }
|
|
313
|
+
.iox-video__consent-text { font-size: 13px; line-height: 1.35; }
|
|
314
|
+
.iox-video__consent-wide { display: none; }
|
|
315
|
+
.iox-video__consent-compact { display: block; }
|
|
316
|
+
.iox-video__allow, .iox-video__settings { padding: 8px 12px; font-size: 13px; }
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/* ── CC3: the Slider's control PARTS (complex-components board 6; board 9 Q13) ────────────────────────
|
|
320
|
+
Previous, Next, Pause and Pagination are nodes inside `.swiper` (beside `.swiper-wrapper`). By default they sit
|
|
321
|
+
over the slides like Swiper's own controls — at ZERO specificity, so a part's own styles move it anywhere; the
|
|
322
|
+
edge is LOGICAL, so a carousel reads the same way in RTL. Their look is their element style. */
|
|
323
|
+
:where(.swiper) :where(.iox-role-slider-prev) { inset-inline-start: 12px; }
|
|
324
|
+
:where(.swiper) :where(.iox-role-slider-next) { inset-inline-end: 12px; }
|
|
325
|
+
/* The pause control shares the dots' line at the bottom (its own corner): it never meets an arrow, whatever the
|
|
326
|
+
carousel's height. */
|
|
327
|
+
:where(.swiper) :where(.iox-role-slider-pause) { bottom: 12px; inset-inline-start: 12px; }
|
|
328
|
+
:where(.swiper) :where(.iox-role-slider-pagination) { bottom: 12px; inset-inline: 0; }
|
|
329
|
+
/* In a row (a part set back in flow — the testimonial carousel's controls) the buttons line up by their middles. */
|
|
330
|
+
.iox-role-slider-pause, .iox-role-slider-prev, .iox-role-slider-next { vertical-align: middle; }
|
|
331
|
+
/* The arrows point the reading way. */
|
|
332
|
+
.iox-role-slider-prev:dir(rtl) > i, .iox-role-slider-next:dir(rtl) > i { scale: -1 1; }
|
|
333
|
+
.iox-role-slider-prev[disabled], .iox-role-slider-next[disabled], :is(.iox-role-slider-prev, .iox-role-slider-next).swiper-button-disabled { opacity: 0.45; cursor: default; }
|
|
334
|
+
/* The dots (Swiper draws them into the part): a 24px target around an 8px dot; the current one is a pill in the
|
|
335
|
+
part's own colour (`color`: the accent by default, the author's colour after a migration). */
|
|
336
|
+
/* The dots' strip spans the carousel (the part centres them); only the dots themselves take a click — the strip
|
|
337
|
+
never covers the slide under it. On the canvas the strip is selectable (data-iox-canvas is not needed: a dot's click
|
|
338
|
+
selects its part). */
|
|
339
|
+
.iox-role-slider-pagination { font-family: var(--iox-font-label); font-size: var(--iox-size-label); pointer-events: none; }
|
|
340
|
+
.iox-role-slider-pagination > * { pointer-events: auto; }
|
|
341
|
+
.iox-role-slider-pagination .swiper-pagination-bullet {
|
|
342
|
+
display: inline-grid; place-items: center; width: 24px; height: 24px; margin: 0; padding: 0; border: 0; border-radius: var(--iox-radius-pill, 999px);
|
|
343
|
+
background: none; opacity: 1; cursor: pointer; color: inherit;
|
|
344
|
+
}
|
|
345
|
+
.iox-role-slider-pagination .swiper-pagination-bullet::after {
|
|
346
|
+
content: ''; display: block; width: 8px; height: 8px; border-radius: var(--iox-radius-pill, 999px); background: var(--iox-line-strong);
|
|
347
|
+
transition: width var(--iox-dur-fast) var(--iox-ease-standard);
|
|
348
|
+
}
|
|
349
|
+
.iox-role-slider-pagination .swiper-pagination-bullet-active { width: 34px; }
|
|
350
|
+
.iox-role-slider-pagination .swiper-pagination-bullet-active::after { width: 26px; background: currentColor; }
|
|
351
|
+
.iox-role-slider-pagination .swiper-pagination-bullet:focus-visible { outline: none; box-shadow: var(--iox-focus-ring); }
|