@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,239 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CONSENT_COOKIE_NAME, ConsentCategory, ConsentDecision, cookieAttributes, decode, encode, hasConsent, setCategoryDecision,
|
|
3
|
+
} from '@vectoriox/iox-ui/consent-core';
|
|
4
|
+
|
|
5
|
+
import { VIDEO_PROVIDER_LABEL, VideoAttribute, VideoClass, VideoIcon, VideoKind, VideoMode, VideoRef, VideoSource, videoEmbedUrl, VideoWords, videoWords } from './video';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The Video's RUNTIME (CC3, board 5) — DOM only, ONE copy for both engine paths (the component path attaches it from
|
|
9
|
+
* its component, the string path from the page's interaction service; `attachVideoAt` is idempotent). It reads the
|
|
10
|
+
* markup `videoInnerHtml` writes. The builder canvas never attaches it (the canvas shows the poster only).
|
|
11
|
+
*
|
|
12
|
+
* - FACADE: play → the visitor's consent grants the category → the player's `<iframe>` replaces the poster, focused,
|
|
13
|
+
* autoplaying (the visitor just pressed play). Not granted → the consent prompt: "Allow and play" grants the
|
|
14
|
+
* category in the site's ONE consent record (the banner and its preferences read the same cookie) and plays;
|
|
15
|
+
* "Privacy settings" opens the site's own preferences dialog.
|
|
16
|
+
* - FILE: the play button starts the `<video>`; muted autoplay only when motion is allowed.
|
|
17
|
+
* - BACKGROUND: muted and looped; it plays only when motion is allowed (never under reduced motion), and its
|
|
18
|
+
* pause button is always there (WCAG 2.2.2).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** Ask the page to record a category the visitor granted (cancelable: the site's consent service handles it). */
|
|
22
|
+
export const CONSENT_GRANT_EVENT = 'iox-consent-grant';
|
|
23
|
+
/** Ask the page to open its consent preferences (cancelable: handled = a dialog opened). */
|
|
24
|
+
export const CONSENT_PREFERENCES_EVENT = 'iox-consent-preferences';
|
|
25
|
+
|
|
26
|
+
export interface ConsentGrantDetail { category: ConsentCategory }
|
|
27
|
+
|
|
28
|
+
/** How the runtime reads and writes the visitor's ONE consent record — the page's transport. */
|
|
29
|
+
export interface ConsentTransport {
|
|
30
|
+
read(): ConsentDecision | null;
|
|
31
|
+
/** Record the category as granted (synchronously, before the player loads). */
|
|
32
|
+
grant(category: ConsentCategory): void;
|
|
33
|
+
/** Open the site's privacy preferences; false when the page has none. */
|
|
34
|
+
openPreferences(): boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const REDUCED_MOTION = '(prefers-reduced-motion: reduce)';
|
|
38
|
+
const RUNTIME_KEY = '__ioxVideo';
|
|
39
|
+
const IFRAME_ALLOW = 'autoplay; encrypted-media; picture-in-picture; fullscreen';
|
|
40
|
+
|
|
41
|
+
/** The cookie the consent record lives in, read from a document. */
|
|
42
|
+
export function readConsentRecord(doc: Document): ConsentDecision | null {
|
|
43
|
+
const raw = (doc.cookie ?? '').split('; ').find(c => c.startsWith(`${CONSENT_COOKIE_NAME}=`));
|
|
44
|
+
return decode(raw ? decodeURIComponent(raw.slice(CONSENT_COOKIE_NAME.length + 1)) : null);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The default transport: the page's own consent service answers the events (the engine records the decision, tells
|
|
49
|
+
* Consent Mode, re-syncs the banner's switches); a page with no such service (the admin's Preview) records the cookie
|
|
50
|
+
* itself, through the same consent-core decision, so the record is the same shape either way.
|
|
51
|
+
*/
|
|
52
|
+
export function documentConsentTransport(doc: Document): ConsentTransport {
|
|
53
|
+
return {
|
|
54
|
+
read: () => readConsentRecord(doc),
|
|
55
|
+
grant: category => {
|
|
56
|
+
const event = new CustomEvent<ConsentGrantDetail>(CONSENT_GRANT_EVENT, { detail: { category }, cancelable: true });
|
|
57
|
+
doc.dispatchEvent(event);
|
|
58
|
+
if (event.defaultPrevented) return;
|
|
59
|
+
const previous = readConsentRecord(doc);
|
|
60
|
+
const anonymousId = previous?.anonymousId ?? randomId();
|
|
61
|
+
const decision = setCategoryDecision(previous, category, true, anonymousId, previous?.policyVersion ?? '1');
|
|
62
|
+
const secure = doc.location?.protocol === 'https:';
|
|
63
|
+
doc.cookie = `${CONSENT_COOKIE_NAME}=${encodeURIComponent(encode(decision))}; ${cookieAttributes(12, secure)}`;
|
|
64
|
+
},
|
|
65
|
+
openPreferences: () => {
|
|
66
|
+
const event = new CustomEvent(CONSENT_PREFERENCES_EVENT, { cancelable: true });
|
|
67
|
+
doc.dispatchEvent(event);
|
|
68
|
+
return event.defaultPrevented;
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function randomId(): string {
|
|
74
|
+
const c = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto;
|
|
75
|
+
return c?.randomUUID ? c.randomUUID() : Math.random().toString(36).slice(2);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface VideoRuntimeOptions {
|
|
79
|
+
consent?: ConsentTransport;
|
|
80
|
+
/** True to stop motion (default: the visitor's reduced-motion setting). */
|
|
81
|
+
reducedMotion?: () => boolean;
|
|
82
|
+
words?: VideoWords;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The embed a facade root names (its provider, id and hash attributes). */
|
|
86
|
+
export function videoRefOf(root: Element): VideoRef | null {
|
|
87
|
+
const source = root.getAttribute(VideoAttribute.Source);
|
|
88
|
+
const id = root.getAttribute(VideoAttribute.Id);
|
|
89
|
+
if ((source !== VideoSource.YouTube && source !== VideoSource.Vimeo) || !id) return null;
|
|
90
|
+
const hash = root.getAttribute(VideoAttribute.Hash);
|
|
91
|
+
return { source, id, ...(hash ? { hash } : {}) };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export class VideoRuntime {
|
|
95
|
+
private readonly cleanups: Array<() => void> = [];
|
|
96
|
+
private readonly consent: ConsentTransport;
|
|
97
|
+
private readonly reduced: () => boolean;
|
|
98
|
+
private readonly words: VideoWords;
|
|
99
|
+
private readonly doc: Document;
|
|
100
|
+
|
|
101
|
+
constructor(readonly root: HTMLElement, options: VideoRuntimeOptions = {}) {
|
|
102
|
+
this.doc = root.ownerDocument;
|
|
103
|
+
this.consent = options.consent ?? documentConsentTransport(this.doc);
|
|
104
|
+
const view = this.doc.defaultView;
|
|
105
|
+
this.reduced = options.reducedMotion ?? (() => !!view?.matchMedia?.(REDUCED_MOTION)?.matches);
|
|
106
|
+
this.words = options.words ?? videoWords(this.doc.documentElement?.getAttribute('lang'));
|
|
107
|
+
const kind = root.getAttribute(VideoAttribute.Root);
|
|
108
|
+
if (kind === VideoKind.Facade) this.attachFacade();
|
|
109
|
+
else if (kind === VideoKind.File) this.attachFile();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
destroy(): void {
|
|
113
|
+
this.cleanups.splice(0).forEach(fn => fn());
|
|
114
|
+
delete (this.root as unknown as Record<string, unknown>)[RUNTIME_KEY];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The category a facade waits for (Marketing unless the node named another). */
|
|
118
|
+
private get category(): ConsentCategory {
|
|
119
|
+
const declared = this.root.getAttribute(VideoAttribute.Consent);
|
|
120
|
+
return declared === ConsentCategory.Analytics ? ConsentCategory.Analytics : ConsentCategory.Marketing;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private on(el: Element | null, type: string, fn: (e: Event) => void): void {
|
|
124
|
+
if (!el) return;
|
|
125
|
+
el.addEventListener(type, fn);
|
|
126
|
+
this.cleanups.push(() => el.removeEventListener(type, fn));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── facade ────────────────────────────────────────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
private attachFacade(): void {
|
|
132
|
+
const play = this.root.querySelector<HTMLButtonElement>(`button.${VideoClass.Play}`);
|
|
133
|
+
const allow = this.root.querySelector<HTMLButtonElement>(`.${VideoClass.Allow}`);
|
|
134
|
+
const settings = this.root.querySelector<HTMLButtonElement>(`.${VideoClass.Settings}`);
|
|
135
|
+
this.on(play, 'click', () => this.requestPlay());
|
|
136
|
+
this.on(allow, 'click', () => {
|
|
137
|
+
this.consent.grant(this.category);
|
|
138
|
+
this.loadPlayer();
|
|
139
|
+
});
|
|
140
|
+
this.on(settings, 'click', () => { this.consent.openPreferences(); });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Play pressed: the player when the category is granted, else the consent prompt. */
|
|
144
|
+
requestPlay(): void {
|
|
145
|
+
if (hasConsent(this.consent.read(), this.category)) {
|
|
146
|
+
this.loadPlayer();
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const prompt = this.root.querySelector<HTMLElement>(`.${VideoClass.Consent}`);
|
|
150
|
+
if (!prompt) return;
|
|
151
|
+
prompt.hidden = false;
|
|
152
|
+
this.root.setAttribute(VideoAttribute.Asking, '');
|
|
153
|
+
const play = this.root.querySelector<HTMLElement>(`button.${VideoClass.Play}`);
|
|
154
|
+
if (play) play.hidden = true;
|
|
155
|
+
this.root.querySelector<HTMLElement>(`.${VideoClass.Allow}`)?.focus();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The player — created only now, after the click and the grant. */
|
|
159
|
+
loadPlayer(): void {
|
|
160
|
+
const ref = videoRefOf(this.root);
|
|
161
|
+
if (!ref || this.root.hasAttribute(VideoAttribute.Playing)) return;
|
|
162
|
+
const frame = this.doc.createElement('iframe');
|
|
163
|
+
frame.className = VideoClass.Frame;
|
|
164
|
+
frame.src = videoEmbedUrl(ref);
|
|
165
|
+
frame.title = this.root.getAttribute(VideoAttribute.Title) || this.words.playUntitled(VIDEO_PROVIDER_LABEL[ref.source]);
|
|
166
|
+
frame.setAttribute('allow', IFRAME_ALLOW);
|
|
167
|
+
frame.setAttribute('allowfullscreen', '');
|
|
168
|
+
frame.setAttribute('referrerpolicy', 'strict-origin-when-cross-origin');
|
|
169
|
+
this.root.replaceChildren(frame);
|
|
170
|
+
this.root.removeAttribute(VideoAttribute.Asking);
|
|
171
|
+
this.root.setAttribute(VideoAttribute.Playing, '');
|
|
172
|
+
frame.focus();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ── uploaded file ─────────────────────────────────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
private attachFile(): void {
|
|
178
|
+
const video = this.root.querySelector<HTMLVideoElement>(`video.${VideoClass.Media}`);
|
|
179
|
+
if (!video) return;
|
|
180
|
+
if (this.root.getAttribute(VideoAttribute.Mode) === VideoMode.Background) {
|
|
181
|
+
this.attachBackground(video);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const play = this.root.querySelector<HTMLButtonElement>(`button.${VideoClass.Play}`);
|
|
185
|
+
const hidePlay = () => { if (play) play.hidden = true; };
|
|
186
|
+
this.on(play, 'click', () => { hidePlay(); void video.play()?.catch?.(() => undefined); video.focus(); });
|
|
187
|
+
this.on(video, 'play', hidePlay);
|
|
188
|
+
// Autoplay (muted) only while motion is allowed — never under reduced motion.
|
|
189
|
+
if (this.root.hasAttribute(VideoAttribute.Autoplay) && !this.reduced()) {
|
|
190
|
+
video.muted = true;
|
|
191
|
+
void video.play()?.catch?.(() => undefined);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private attachBackground(video: HTMLVideoElement): void {
|
|
196
|
+
const toggle = this.root.querySelector<HTMLButtonElement>(`.${VideoClass.Toggle}`);
|
|
197
|
+
video.muted = true;
|
|
198
|
+
const sync = () => {
|
|
199
|
+
const paused = video.paused;
|
|
200
|
+
if (!toggle) return;
|
|
201
|
+
toggle.setAttribute('aria-pressed', paused ? 'true' : 'false');
|
|
202
|
+
toggle.setAttribute('aria-label', paused ? this.words.playBackground : this.words.pauseBackground);
|
|
203
|
+
const icon = toggle.querySelector('i');
|
|
204
|
+
if (icon) icon.className = paused ? VideoIcon.Play : VideoIcon.Pause;
|
|
205
|
+
};
|
|
206
|
+
this.on(video, 'play', sync);
|
|
207
|
+
this.on(video, 'pause', sync);
|
|
208
|
+
this.on(toggle, 'click', () => {
|
|
209
|
+
if (video.paused) void video.play()?.catch?.(() => undefined);
|
|
210
|
+
else video.pause();
|
|
211
|
+
sync();
|
|
212
|
+
});
|
|
213
|
+
// It moves only when motion is allowed; under reduced motion it rests on its poster, paused.
|
|
214
|
+
if (!this.reduced()) void video.play()?.catch?.(() => sync());
|
|
215
|
+
sync();
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Run the video at `el` (a `[data-iox-video]` element) once; null when it is not one or already runs. */
|
|
220
|
+
export function attachVideoAt(el: HTMLElement | null | undefined, options: VideoRuntimeOptions = {}): VideoRuntime | null {
|
|
221
|
+
if (!el?.hasAttribute?.(VideoAttribute.Root)) return null;
|
|
222
|
+
const host = el as unknown as Record<string, unknown>;
|
|
223
|
+
if (host[RUNTIME_KEY]) return null;
|
|
224
|
+
const runtime = new VideoRuntime(el, options);
|
|
225
|
+
host[RUNTIME_KEY] = runtime;
|
|
226
|
+
return runtime;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Every video under `container` that is not running yet (the string path's globals and pages). */
|
|
230
|
+
export function attachVideos(container: ParentNode, options: VideoRuntimeOptions = {}): VideoRuntime[] {
|
|
231
|
+
return Array.from(container.querySelectorAll<HTMLElement>(`[${VideoAttribute.Root}]`))
|
|
232
|
+
.map(el => attachVideoAt(el, options))
|
|
233
|
+
.filter((r): r is VideoRuntime => !!r);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** The runtime already running at `el`, if any. */
|
|
237
|
+
export function videoRuntimeOf(el: Element | null | undefined): VideoRuntime | null {
|
|
238
|
+
return ((el as unknown as Record<string, unknown> | null)?.[RUNTIME_KEY] as VideoRuntime | undefined) ?? null;
|
|
239
|
+
}
|