@vectoriox/iox-ui 5.37.0 → 5.38.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 +294 -14
- package/design-core/index.d.ts +130 -3
- package/fesm2022/vectoriox-iox-ui-actions-core.mjs +9 -3
- package/fesm2022/vectoriox-iox-ui-actions-core.mjs.map +1 -1
- package/fesm2022/vectoriox-iox-ui-composite-core.mjs +694 -59
- package/fesm2022/vectoriox-iox-ui-composite-core.mjs.map +1 -1
- package/fesm2022/vectoriox-iox-ui-design-core.mjs +172 -3
- package/fesm2022/vectoriox-iox-ui-design-core.mjs.map +1 -1
- package/fesm2022/vectoriox-iox-ui-generation-core.mjs +410 -10
- 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 +478 -118
- package/fesm2022/vectoriox-iox-ui.mjs.map +1 -1
- package/generation-core/index.d.ts +167 -4
- package/index.d.ts +206 -24
- 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 +9 -2
- package/src/composite-core/composite-node.ts +4 -0
- package/src/composite-core/lightbox.ts +192 -0
- package/src/composite-core/public-api.ts +3 -0
- package/src/composite-core/slider-runtime.ts +180 -0
- package/src/composite-core/slider.ts +378 -0
- package/src/composite-core/structure.ts +17 -4
- package/src/design-core/element-styles.ts +27 -0
- package/src/design-core/flavor-spec.generated.ts +146 -2
- package/src/generation-core/baseline-roles.ts +2 -0
- package/src/generation-core/compose-site.ts +80 -4
- package/src/generation-core/node-baselines.generated.ts +148 -4
- package/src/generation-core/patterns.ts +17 -0
- package/src/generation-core/site-spec.generated.ts +164 -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 +96 -0
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import { ConsentCategory, ConsentDecision } 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
|
+
declare const VIDEO_NODE_TYPE = "Video";
|
|
15
|
+
declare enum VideoSource {
|
|
16
|
+
File = "file",
|
|
17
|
+
YouTube = "youtube",
|
|
18
|
+
Vimeo = "vimeo"
|
|
19
|
+
}
|
|
20
|
+
declare enum VideoMode {
|
|
21
|
+
/** In the page, with the player's controls. */
|
|
22
|
+
Inline = "inline",
|
|
23
|
+
/** Muted, looped, no controls, an always-visible pause button (WCAG 2.2.2) — an uploaded file only. */
|
|
24
|
+
Background = "background"
|
|
25
|
+
}
|
|
26
|
+
/** The node's traits (Bindings ▸ Media · Behaviour · Accessibility · Privacy). */
|
|
27
|
+
declare enum VideoTrait {
|
|
28
|
+
/** Not `source`: across the builder `source` names a DATA source (a Repeater's, a Slider's). */
|
|
29
|
+
Source = "videoSource",
|
|
30
|
+
Src = "src",
|
|
31
|
+
Poster = "poster",
|
|
32
|
+
Mode = "mode",
|
|
33
|
+
Autoplay = "autoplay",
|
|
34
|
+
Title = "title",
|
|
35
|
+
Captions = "captions",
|
|
36
|
+
CaptionsLang = "captionsLang",
|
|
37
|
+
Consent = "consentCategory"
|
|
38
|
+
}
|
|
39
|
+
/** What a plan renders. */
|
|
40
|
+
declare enum VideoKind {
|
|
41
|
+
/** An uploaded file: a real `<video>`. */
|
|
42
|
+
File = "file",
|
|
43
|
+
/** YouTube / Vimeo: poster + play, the player loaded on a click after consent. */
|
|
44
|
+
Facade = "facade",
|
|
45
|
+
/** No address yet: the poster blank (DS3) and nothing to play. */
|
|
46
|
+
Empty = "empty"
|
|
47
|
+
}
|
|
48
|
+
/** A recognised embed: which provider, its video id and (Vimeo, unlisted) its privacy hash. */
|
|
49
|
+
interface VideoRef {
|
|
50
|
+
source: VideoSource.YouTube | VideoSource.Vimeo;
|
|
51
|
+
id: string;
|
|
52
|
+
hash?: string;
|
|
53
|
+
}
|
|
54
|
+
declare const VIDEO_PROVIDER_LABEL: Readonly<Record<VideoRef['source'], string>>;
|
|
55
|
+
/** Who the visitor's IP address and cookies go to, named in the consent prompt. */
|
|
56
|
+
declare const VIDEO_PROVIDER_COMPANY: Readonly<Record<VideoRef['source'], string>>;
|
|
57
|
+
/** The two hosts a player ever loads from — youtube-nocookie and Vimeo's player (with Do Not Track). */
|
|
58
|
+
declare const VIDEO_EMBED_ORIGINS: ReadonlyArray<string>;
|
|
59
|
+
/** The embed a pasted address names — a YouTube or Vimeo watch, share, embed or short link — or null. */
|
|
60
|
+
declare function parseVideoUrl(raw: string | null | undefined): VideoRef | null;
|
|
61
|
+
/** The source a node plays: its declared source when it is one, else what its address is. */
|
|
62
|
+
declare function videoSourceOf(src: unknown, declared?: unknown): VideoSource;
|
|
63
|
+
/** The player's address — loaded only after a click and a grant. Autoplays (the visitor just pressed play). */
|
|
64
|
+
declare function videoEmbedUrl(ref: VideoRef): string;
|
|
65
|
+
/** Where the SERVER fetches an embed's thumbnail to store as the studio's own poster (never the visitor). */
|
|
66
|
+
interface VideoThumbnailRequest {
|
|
67
|
+
/** `image`: the url IS the picture; `oembed`: the url answers JSON whose `thumbnail_url` is the picture. */
|
|
68
|
+
kind: 'image' | 'oembed';
|
|
69
|
+
url: string;
|
|
70
|
+
/** The only hosts the picture itself may come from (the server refuses anything else). */
|
|
71
|
+
imageHosts: ReadonlyArray<string>;
|
|
72
|
+
}
|
|
73
|
+
declare function videoThumbnailRequest(ref: VideoRef): VideoThumbnailRequest;
|
|
74
|
+
/** A stored poster's object name — one per video, so fetching it again overwrites rather than piles up. */
|
|
75
|
+
declare function videoPosterFileName(ref: VideoRef): string;
|
|
76
|
+
interface VideoNodeLike {
|
|
77
|
+
type?: string;
|
|
78
|
+
props?: Record<string, unknown> | null;
|
|
79
|
+
traits?: ReadonlyArray<{
|
|
80
|
+
name?: string;
|
|
81
|
+
default?: unknown;
|
|
82
|
+
}> | null;
|
|
83
|
+
}
|
|
84
|
+
/** A content value of a Video node, saved (`props`) or builder (`traits[].default`) shape. */
|
|
85
|
+
declare function videoProp(node: VideoNodeLike | null | undefined, name: VideoTrait | string): unknown;
|
|
86
|
+
/** The consent category an embed waits for — Marketing by default (D5); an uploaded file waits for none. */
|
|
87
|
+
declare function videoConsentCategory(node: VideoNodeLike | null | undefined, source?: VideoSource): ConsentCategory | null;
|
|
88
|
+
interface VideoPlan {
|
|
89
|
+
kind: VideoKind;
|
|
90
|
+
source: VideoSource;
|
|
91
|
+
/** An embed's provider and id (Facade). */
|
|
92
|
+
ref: VideoRef | null;
|
|
93
|
+
/** An uploaded file's address (File). */
|
|
94
|
+
src: string;
|
|
95
|
+
/** The studio's own poster ('' = the token blank). Never a provider's thumbnail URL. */
|
|
96
|
+
poster: string;
|
|
97
|
+
/** The video's name — the play button's accessible name. */
|
|
98
|
+
title: string;
|
|
99
|
+
mode: VideoMode;
|
|
100
|
+
background: boolean;
|
|
101
|
+
/** Muted autoplay when motion is allowed (the runtime asks; the server never writes `autoplay`). */
|
|
102
|
+
autoplay: boolean;
|
|
103
|
+
muted: boolean;
|
|
104
|
+
loop: boolean;
|
|
105
|
+
controls: boolean;
|
|
106
|
+
/** A `.vtt` file ('' = none). */
|
|
107
|
+
captions: string;
|
|
108
|
+
captionsLang: string;
|
|
109
|
+
/** What the facade waits for (null for a file). */
|
|
110
|
+
consentCategory: ConsentCategory | null;
|
|
111
|
+
/** The visitor's decision grants it. */
|
|
112
|
+
granted: boolean;
|
|
113
|
+
/** The player's address — only when granted (the runtime loads it on a click). */
|
|
114
|
+
embedUrl: string | null;
|
|
115
|
+
/** "YouTube" / "Vimeo" ('' for a file). */
|
|
116
|
+
providerLabel: string;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* The ONE decision for a Video node and a visitor's consent (null = none recorded — the server always passes null,
|
|
120
|
+
* so its HTML is always the facade). A poster that is a provider's thumbnail URL is dropped: the poster is the
|
|
121
|
+
* studio's own image (or the thumbnail the server fetched and stored for it), never a request to a third party.
|
|
122
|
+
*/
|
|
123
|
+
declare function planVideo(node: VideoNodeLike | null | undefined, consent: ConsentDecision | null): VideoPlan;
|
|
124
|
+
/** A poster served by the video's provider — a request to a third party before consent, so it never renders. */
|
|
125
|
+
declare function isThirdPartyPoster(url: string): boolean;
|
|
126
|
+
/**
|
|
127
|
+
* The words the renderer draws (board 5; Q7: the consent prompt is NOT an editable part — its words are the
|
|
128
|
+
* consent vocabulary's, one source, translated). English and Hebrew; the page's `lang` picks.
|
|
129
|
+
*/
|
|
130
|
+
interface VideoWords {
|
|
131
|
+
/** The play button's name: the video's title and its provider. */
|
|
132
|
+
play(title: string, provider: string): string;
|
|
133
|
+
/** The play button's name with no title. */
|
|
134
|
+
playUntitled(provider: string): string;
|
|
135
|
+
pauseBackground: string;
|
|
136
|
+
playBackground: string;
|
|
137
|
+
/** The consent prompt (a group): its name, its lead and its body. */
|
|
138
|
+
promptLabel: string;
|
|
139
|
+
promptLead(provider: string): string;
|
|
140
|
+
promptBody(company: string): string;
|
|
141
|
+
/** The same prompt where the player is narrow (board 5's phone): one shorter sentence, its lead in bold. */
|
|
142
|
+
promptCompactLead(provider: string): string;
|
|
143
|
+
promptCompactBody: string;
|
|
144
|
+
allow: string;
|
|
145
|
+
settings: string;
|
|
146
|
+
}
|
|
147
|
+
declare const VIDEO_WORDS: Readonly<Record<'en' | 'he', VideoWords>>;
|
|
148
|
+
/** The words for a page language (`he`, `he-IL` → Hebrew; anything else → English). */
|
|
149
|
+
declare function videoWords(lang?: string | null): VideoWords;
|
|
150
|
+
/** The play button's accessible name. */
|
|
151
|
+
declare function videoPlayLabel(plan: VideoPlan, words?: VideoWords): string;
|
|
152
|
+
declare enum VideoClass {
|
|
153
|
+
Root = "iox-video",
|
|
154
|
+
Poster = "iox-video__poster",
|
|
155
|
+
Play = "iox-video__play",
|
|
156
|
+
Badge = "iox-video__badge",
|
|
157
|
+
Media = "iox-video__media",
|
|
158
|
+
Frame = "iox-video__frame",
|
|
159
|
+
Toggle = "iox-video__toggle",
|
|
160
|
+
Consent = "iox-video__consent",
|
|
161
|
+
ConsentText = "iox-video__consent-text",
|
|
162
|
+
/** The prompt's text on a wide player / on a narrow one (a container query shows one). */
|
|
163
|
+
ConsentWide = "iox-video__consent-wide",
|
|
164
|
+
ConsentCompact = "iox-video__consent-compact",
|
|
165
|
+
ConsentRow = "iox-video__consent-row",
|
|
166
|
+
Allow = "iox-video__allow",
|
|
167
|
+
Settings = "iox-video__settings"
|
|
168
|
+
}
|
|
169
|
+
declare enum VideoAttribute {
|
|
170
|
+
/** On the node element: its kind (`file` · `facade` · `empty`) — what the runtime attaches to. */
|
|
171
|
+
Root = "data-iox-video",
|
|
172
|
+
Source = "data-iox-video-source",
|
|
173
|
+
Id = "data-iox-video-id",
|
|
174
|
+
Hash = "data-iox-video-hash",
|
|
175
|
+
Mode = "data-iox-video-mode",
|
|
176
|
+
Autoplay = "data-iox-video-autoplay",
|
|
177
|
+
Consent = "data-iox-video-consent",
|
|
178
|
+
Title = "data-iox-video-title",
|
|
179
|
+
/** The poster is the token blank (no image): the shared CSS fills it with `surface-2`. */
|
|
180
|
+
PosterEmpty = "data-iox-video-poster-empty",
|
|
181
|
+
/** The player is loaded (after the click and the grant). */
|
|
182
|
+
Playing = "data-iox-video-playing",
|
|
183
|
+
/** The consent prompt is showing. */
|
|
184
|
+
Asking = "data-iox-video-asking"
|
|
185
|
+
}
|
|
186
|
+
declare enum VideoIcon {
|
|
187
|
+
Play = "ph-fill ph-play",
|
|
188
|
+
Pause = "ph-fill ph-pause"
|
|
189
|
+
}
|
|
190
|
+
/** The attributes the node's own element carries (both engine paths, the canvas). */
|
|
191
|
+
declare function videoRootAttributes(plan: VideoPlan): Record<string, string>;
|
|
192
|
+
interface VideoMarkupOptions {
|
|
193
|
+
/** HTML-escape an attribute or text value. */
|
|
194
|
+
esc: (value: string) => string;
|
|
195
|
+
/** The builder canvas: the poster ONLY — no `<video>`, no player, nothing that loads or takes a click. */
|
|
196
|
+
canvas?: boolean;
|
|
197
|
+
words?: VideoWords;
|
|
198
|
+
}
|
|
199
|
+
/** The element's inner markup. The server's HTML for a facade holds no `<iframe>` and requests nothing from
|
|
200
|
+
* the provider: only the studio's poster, the play button and the (hidden) consent prompt. */
|
|
201
|
+
declare function videoInnerHtml(plan: VideoPlan, options: VideoMarkupOptions): string;
|
|
202
|
+
/** Serialise attributes for a string renderer (a present boolean attribute is written bare). */
|
|
203
|
+
declare function serializeVideoAttributes(attrs: Readonly<Record<string, string>>, esc: (value: string) => string): string;
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The Video's RUNTIME (CC3, board 5) — DOM only, ONE copy for both engine paths (the component path attaches it from
|
|
207
|
+
* its component, the string path from the page's interaction service; `attachVideoAt` is idempotent). It reads the
|
|
208
|
+
* markup `videoInnerHtml` writes. The builder canvas never attaches it (the canvas shows the poster only).
|
|
209
|
+
*
|
|
210
|
+
* - FACADE: play → the visitor's consent grants the category → the player's `<iframe>` replaces the poster, focused,
|
|
211
|
+
* autoplaying (the visitor just pressed play). Not granted → the consent prompt: "Allow and play" grants the
|
|
212
|
+
* category in the site's ONE consent record (the banner and its preferences read the same cookie) and plays;
|
|
213
|
+
* "Privacy settings" opens the site's own preferences dialog.
|
|
214
|
+
* - FILE: the play button starts the `<video>`; muted autoplay only when motion is allowed.
|
|
215
|
+
* - BACKGROUND: muted and looped; it plays only when motion is allowed (never under reduced motion), and its
|
|
216
|
+
* pause button is always there (WCAG 2.2.2).
|
|
217
|
+
*/
|
|
218
|
+
/** Ask the page to record a category the visitor granted (cancelable: the site's consent service handles it). */
|
|
219
|
+
declare const CONSENT_GRANT_EVENT = "iox-consent-grant";
|
|
220
|
+
/** Ask the page to open its consent preferences (cancelable: handled = a dialog opened). */
|
|
221
|
+
declare const CONSENT_PREFERENCES_EVENT = "iox-consent-preferences";
|
|
222
|
+
interface ConsentGrantDetail {
|
|
223
|
+
category: ConsentCategory;
|
|
224
|
+
}
|
|
225
|
+
/** How the runtime reads and writes the visitor's ONE consent record — the page's transport. */
|
|
226
|
+
interface ConsentTransport {
|
|
227
|
+
read(): ConsentDecision | null;
|
|
228
|
+
/** Record the category as granted (synchronously, before the player loads). */
|
|
229
|
+
grant(category: ConsentCategory): void;
|
|
230
|
+
/** Open the site's privacy preferences; false when the page has none. */
|
|
231
|
+
openPreferences(): boolean;
|
|
232
|
+
}
|
|
233
|
+
/** The cookie the consent record lives in, read from a document. */
|
|
234
|
+
declare function readConsentRecord(doc: Document): ConsentDecision | null;
|
|
235
|
+
/**
|
|
236
|
+
* The default transport: the page's own consent service answers the events (the engine records the decision, tells
|
|
237
|
+
* Consent Mode, re-syncs the banner's switches); a page with no such service (the admin's Preview) records the cookie
|
|
238
|
+
* itself, through the same consent-core decision, so the record is the same shape either way.
|
|
239
|
+
*/
|
|
240
|
+
declare function documentConsentTransport(doc: Document): ConsentTransport;
|
|
241
|
+
interface VideoRuntimeOptions {
|
|
242
|
+
consent?: ConsentTransport;
|
|
243
|
+
/** True to stop motion (default: the visitor's reduced-motion setting). */
|
|
244
|
+
reducedMotion?: () => boolean;
|
|
245
|
+
words?: VideoWords;
|
|
246
|
+
}
|
|
247
|
+
/** The embed a facade root names (its provider, id and hash attributes). */
|
|
248
|
+
declare function videoRefOf(root: Element): VideoRef | null;
|
|
249
|
+
declare class VideoRuntime {
|
|
250
|
+
readonly root: HTMLElement;
|
|
251
|
+
private readonly cleanups;
|
|
252
|
+
private readonly consent;
|
|
253
|
+
private readonly reduced;
|
|
254
|
+
private readonly words;
|
|
255
|
+
private readonly doc;
|
|
256
|
+
constructor(root: HTMLElement, options?: VideoRuntimeOptions);
|
|
257
|
+
destroy(): void;
|
|
258
|
+
/** The category a facade waits for (Marketing unless the node named another). */
|
|
259
|
+
private get category();
|
|
260
|
+
private on;
|
|
261
|
+
private attachFacade;
|
|
262
|
+
/** Play pressed: the player when the category is granted, else the consent prompt. */
|
|
263
|
+
requestPlay(): void;
|
|
264
|
+
/** The player — created only now, after the click and the grant. */
|
|
265
|
+
loadPlayer(): void;
|
|
266
|
+
private attachFile;
|
|
267
|
+
private attachBackground;
|
|
268
|
+
}
|
|
269
|
+
/** Run the video at `el` (a `[data-iox-video]` element) once; null when it is not one or already runs. */
|
|
270
|
+
declare function attachVideoAt(el: HTMLElement | null | undefined, options?: VideoRuntimeOptions): VideoRuntime | null;
|
|
271
|
+
/** Every video under `container` that is not running yet (the string path's globals and pages). */
|
|
272
|
+
declare function attachVideos(container: ParentNode, options?: VideoRuntimeOptions): VideoRuntime[];
|
|
273
|
+
/** The runtime already running at `el`, if any. */
|
|
274
|
+
declare function videoRuntimeOf(el: Element | null | undefined): VideoRuntime | null;
|
|
275
|
+
|
|
276
|
+
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 };
|
|
277
|
+
export type { ConsentGrantDetail, ConsentTransport, VideoMarkupOptions, VideoNodeLike, VideoPlan, VideoRef, VideoRuntimeOptions, VideoThumbnailRequest, VideoWords };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vectoriox/iox-ui",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.38.0",
|
|
4
4
|
"peerDependencies": {
|
|
5
5
|
"@angular/common": ">=20.0.0",
|
|
6
6
|
"@angular/core": ">=20.0.0",
|
|
@@ -43,6 +43,10 @@
|
|
|
43
43
|
"types": "./generation-core/index.d.ts",
|
|
44
44
|
"default": "./fesm2022/vectoriox-iox-ui-generation-core.mjs"
|
|
45
45
|
},
|
|
46
|
+
"./media-core": {
|
|
47
|
+
"types": "./media-core/index.d.ts",
|
|
48
|
+
"default": "./fesm2022/vectoriox-iox-ui-media-core.mjs"
|
|
49
|
+
},
|
|
46
50
|
"./seo-core": {
|
|
47
51
|
"types": "./seo-core/index.d.ts",
|
|
48
52
|
"default": "./fesm2022/vectoriox-iox-ui-seo-core.mjs"
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { AccordionRole, CompositeItemAction, DisclosureRole } from '@vectoriox/iox-ui/composite-core';
|
|
1
|
+
import { AccordionRole, CompositeItemAction, DisclosureRole, SLIDE_TO_ROW } from '@vectoriox/iox-ui/composite-core';
|
|
2
2
|
|
|
3
3
|
import { ActionDescriptor } from './action.model';
|
|
4
4
|
|
|
@@ -45,8 +45,10 @@ export const ELEMENT_ACTION_DESCRIPTORS: readonly ActionDescriptor[] = Object.fr
|
|
|
45
45
|
target: { required: false } },
|
|
46
46
|
|
|
47
47
|
// ── Dialog (A1 — needs the Dialog component, A1b) ───────────────────────
|
|
48
|
+
// CC3 (board 6): "Open at the clicked item" — `slideTo: 'row'` opens a lightbox at the clicked thumbnail's slide.
|
|
48
49
|
{ type: 'openDialog', labelKey: 'builder.actions.openDialog', kind: 'instant', group: 'dialog', builderBehaviour: 'run',
|
|
49
|
-
target: { required: true, types: ['Dialog'] }
|
|
50
|
+
target: { required: true, types: ['Dialog'] },
|
|
51
|
+
params: [{ name: 'slideTo', type: 'string', label: 'builder.actions.params.slideTo', options: [{ value: SLIDE_TO_ROW, labelKey: 'builder.actions.params.slideToRow' }] }] },
|
|
50
52
|
{ type: 'closeDialog', labelKey: 'builder.actions.closeDialog', kind: 'instant', group: 'dialog', builderBehaviour: 'run',
|
|
51
53
|
target: { required: true, types: ['Dialog'] } },
|
|
52
54
|
{ type: 'toggleDialog', labelKey: 'builder.actions.toggleDialog', kind: 'instant', group: 'dialog', builderBehaviour: 'run',
|
|
@@ -136,6 +138,11 @@ export const ITEM_ACTION_TYPES: Readonly<Record<string, CompositeItemAction>> =
|
|
|
136
138
|
});
|
|
137
139
|
|
|
138
140
|
/** The item action an action type runs, or null when it is not one. */
|
|
141
|
+
/** True when an `openDialog` action opens its dialog at the clicked row's slide (CC3 "Open at the clicked item"). */
|
|
142
|
+
export function opensAtClickedItem(action: { type?: string; params?: Record<string, unknown> | null } | null | undefined): boolean {
|
|
143
|
+
return action?.type === 'openDialog' && action.params?.['slideTo'] === SLIDE_TO_ROW;
|
|
144
|
+
}
|
|
145
|
+
|
|
139
146
|
export function itemActionOf(type: string | null | undefined): CompositeItemAction | null {
|
|
140
147
|
return (type && ITEM_ACTION_TYPES[type]) || null;
|
|
141
148
|
}
|
|
@@ -37,6 +37,10 @@ export interface CompositeNode extends ElementStyleTarget {
|
|
|
37
37
|
* (the builder's `createConfig`, generation-core's `createSerializedNode`), with these props and this
|
|
38
38
|
* element style. `key` is stable per recipe node — a consumer that needs deterministic ids builds them
|
|
39
39
|
* from it (generation-core: `<root id>-<key>`). */
|
|
40
|
+
/** `elementStyle: NO_ELEMENT_STYLE` asks for a node that wears NO element style (undefined / null = the type's default,
|
|
41
|
+
* as both node factories have always read it): a migrated slider's bare chevrons, a lightbox's slide (CC3). */
|
|
42
|
+
export const NO_ELEMENT_STYLE = '';
|
|
43
|
+
|
|
40
44
|
export interface CompositeNodeSpec {
|
|
41
45
|
key: string;
|
|
42
46
|
type: string;
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { setColorScheme } from '@vectoriox/iox-ui/design-core';
|
|
2
|
+
|
|
3
|
+
import { CompositeNode, CompositeNodeFactory, makeCompositePart, NO_ELEMENT_STYLE, readProp, writeProp, writeStyles } from './composite-node';
|
|
4
|
+
import { buildSliderPart, SLIDE_TYPE, SLIDER_TYPE, SliderRole } from './slider';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* THE LIGHTBOX (CC3 — complex-components plan §7d, board 6). Not a component: a Dialog holding a Slider bound to the
|
|
8
|
+
* SAME source as the grid of thumbnails. The one new thing is `openDialog`'s "Open at the clicked item"
|
|
9
|
+
* (`slideTo: 'row'`): a thumbnail in a repeater row opens the lightbox at ITS slide.
|
|
10
|
+
*
|
|
11
|
+
* Dialog `dialog` · centre · full viewport · Inverse scheme "Lightbox" (a free-standing Dialog: board A4)
|
|
12
|
+
* ├─ ButtonBlock `button-ghost` "Close" › Icon × click → closeDialog
|
|
13
|
+
* └─ SliderContainer one slide in view · keyboard · the source's rows
|
|
14
|
+
* ├─ Previous · Next (slider.prev / slider.next, button-secondary) hidden on a phone (swipe)
|
|
15
|
+
* ├─ SliderSlide › Image `media` (contain, keeps its alt) + Text `small` caption
|
|
16
|
+
* └─ Pagination (slider.pagination) — the counter "3 / 6"
|
|
17
|
+
*
|
|
18
|
+
* Keyboard ← → are the Slider's Keyboard module; Esc and the focus trap are the `<dialog>`'s; focus returns to the
|
|
19
|
+
* thumbnail that opened it (the dialog opener's own rule).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export const SLIDE_TO_ROW = 'row';
|
|
23
|
+
/** The attribute a renderer writes on every repeated row (the context-state provenance, both engine paths, the canvas). */
|
|
24
|
+
export const ROW_INDEX_ATTRIBUTE = 'data-iox-row-idx';
|
|
25
|
+
export const LIGHTBOX_LABEL = 'Lightbox';
|
|
26
|
+
const DIALOG = 'Dialog';
|
|
27
|
+
const REPEATER = 'Repeater';
|
|
28
|
+
const SOURCE = 'source';
|
|
29
|
+
const SOURCE_PATH = 'sourcePath';
|
|
30
|
+
|
|
31
|
+
export interface LightboxOptions {
|
|
32
|
+
/** The list the slides come from (the grid's own source). Empty = three manual slides. */
|
|
33
|
+
source?: string;
|
|
34
|
+
sourcePath?: string;
|
|
35
|
+
/** The row field each slide's image and caption read (`item` = the row itself, a bare URL). */
|
|
36
|
+
imageField?: string;
|
|
37
|
+
captionField?: string;
|
|
38
|
+
/** The image's alt binding; '' = decorative (a bare URL row has no words of its own). */
|
|
39
|
+
altField?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const ZERO = '0px 0px 0px 0px';
|
|
43
|
+
const DIALOG_BASE: Readonly<Record<string, string>> = {
|
|
44
|
+
width: '100vw', maxWidth: '100vw', height: '100dvh', maxHeight: '100dvh', borderRadius: '0px', padding: '18px 18px 18px 18px',
|
|
45
|
+
display: 'flex', flexDirection: 'column', gap: '0px',
|
|
46
|
+
};
|
|
47
|
+
/** The top row (board 6): the counter at its start, Close at its end — Close sits above the slider, which rises into
|
|
48
|
+
* the row so its counter shares it. */
|
|
49
|
+
const CLOSE_BASE: Readonly<Record<string, string>> = {
|
|
50
|
+
display: 'flex', alignItems: 'center', justifyContent: 'center', width: '44px', height: '44px', minWidth: '44px',
|
|
51
|
+
padding: ZERO, margin: ZERO, borderRadius: '999px', alignSelf: 'flex-end', flexShrink: '0', position: 'relative', zIndex: '20',
|
|
52
|
+
};
|
|
53
|
+
const SLIDER_BASE: Readonly<Record<string, string>> = { width: '100%', height: '100%', minHeight: '0px', flexGrow: '1', margin: '-44px 0px 0px 0px' };
|
|
54
|
+
const SLIDE_BASE: Readonly<Record<string, string>> = {
|
|
55
|
+
width: '100%', height: '100%', aspectRatio: 'auto', display: 'flex', flexDirection: 'column', alignItems: 'center',
|
|
56
|
+
justifyContent: 'center', gap: '12px', padding: '56px 64px 16px 64px',
|
|
57
|
+
};
|
|
58
|
+
const IMAGE_BASE: Readonly<Record<string, string>> = { width: '100%', height: 'auto', maxHeight: '72dvh', minHeight: '0px', objectFit: 'contain', aspectRatio: '4 / 3', flexShrink: '1' };
|
|
59
|
+
const CAPTION_BASE: Readonly<Record<string, string>> = { textAlign: 'center', margin: ZERO, padding: ZERO, minHeight: 'auto', width: 'auto' };
|
|
60
|
+
/** The counter "3 / 6" sits at the START of the top row, Close at its end (board 6). */
|
|
61
|
+
const COUNTER_BASE: Readonly<Record<string, string>> = { color: 'var(--iox-text-muted)', top: '12px', alignItems: 'flex-start', justifyContent: 'flex-start' };
|
|
62
|
+
/** On a phone the arrows give way to a swipe (board 6, "the arrows hide on touch-only widths"). */
|
|
63
|
+
export const LIGHTBOX_ARROWS_HIDE_AT = 'mobile';
|
|
64
|
+
|
|
65
|
+
interface WithInteractions { interactions?: unknown[] }
|
|
66
|
+
|
|
67
|
+
/** A click on `node` runs one instant action on `target`. */
|
|
68
|
+
function onClick(node: CompositeNode, type: string, target: string, params?: Record<string, unknown>): void {
|
|
69
|
+
(node as WithInteractions).interactions = [{
|
|
70
|
+
id: `${node.id ?? 'node'}-click`, trigger: 'click',
|
|
71
|
+
actions: [{ type, target, duration: 0, delay: 0, easing: 'ease', ...(params ? { params } : {}) }],
|
|
72
|
+
}];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** A binding of `prop` to the row field `field` of `source` (`item` = the row itself). */
|
|
76
|
+
function bindRow(node: CompositeNode, prop: string, source: string, field: string): void {
|
|
77
|
+
const bindings = ((node as { bindings?: unknown[] }).bindings ?? []).filter((b) => (b as { prop?: string }).prop !== prop);
|
|
78
|
+
bindings.push({ prop, source, path: field === 'item' ? '' : field });
|
|
79
|
+
(node as { bindings?: unknown[] }).bindings = bindings;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function buildSlide(create: CompositeNodeFactory, key: string, options: LightboxOptions, rowAlias: string | null): CompositeNode {
|
|
83
|
+
const slide = makeCompositePart(create, { key, type: SLIDE_TYPE, props: {}, elementStyle: NO_ELEMENT_STYLE }, null, 'Slide', SLIDE_BASE);
|
|
84
|
+
const image = makeCompositePart(create, { key: `${key}-image`, type: 'Image', props: { src: '', alt: '' }, elementStyle: 'media' }, null, 'Image', IMAGE_BASE);
|
|
85
|
+
const caption = makeCompositePart(create, { key: `${key}-caption`, type: 'Text', props: { content: 'Caption', tag: 'p' }, elementStyle: 'small' }, null, 'Caption', CAPTION_BASE);
|
|
86
|
+
if (rowAlias) {
|
|
87
|
+
bindRow(image, 'src', rowAlias, options.imageField ?? 'item');
|
|
88
|
+
if (options.altField) bindRow(image, 'alt', rowAlias, options.altField);
|
|
89
|
+
if (options.captionField) bindRow(caption, 'content', rowAlias, options.captionField);
|
|
90
|
+
}
|
|
91
|
+
slide.children = options.captionField || !rowAlias ? [image, caption] : [image];
|
|
92
|
+
return slide;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** A fresh lightbox: the Dialog, its Close part and the Slider (bound to `source`, or three manual slides). */
|
|
96
|
+
export function buildLightboxRecipe(create: CompositeNodeFactory, options: LightboxOptions = {}): CompositeNode {
|
|
97
|
+
const dialog = makeCompositePart(create, { key: 'lightbox', type: DIALOG, props: {}, elementStyle: 'dialog' }, null, LIGHTBOX_LABEL, DIALOG_BASE);
|
|
98
|
+
writeProp(dialog, 'placement', 'center');
|
|
99
|
+
writeProp(dialog, 'closeOnBackdrop', true);
|
|
100
|
+
writeProp(dialog, 'showCloseButton', false);
|
|
101
|
+
writeProp(dialog, 'ariaLabel', 'Gallery');
|
|
102
|
+
setColorScheme(dialog, 'inverse');
|
|
103
|
+
|
|
104
|
+
const close = makeCompositePart(create, { key: 'close', type: 'ButtonBlock', props: { tag: 'button', type: 'button', ariaLabel: 'Close' }, elementStyle: 'button-ghost' }, null, 'Close', CLOSE_BASE);
|
|
105
|
+
close.children = [makeCompositePart(create, { key: 'close-icon', type: 'Icon', props: { iconClass: 'ph ph-x' }, elementStyle: 'icon' }, null, 'Icon', { fontSize: '22px', color: 'inherit' })];
|
|
106
|
+
onClick(close, 'closeDialog', dialog.id ?? '');
|
|
107
|
+
|
|
108
|
+
const slider = makeCompositePart(create, { key: 'slider', type: SLIDER_TYPE, props: {}, elementStyle: NO_ELEMENT_STYLE }, null, 'Slider', SLIDER_BASE);
|
|
109
|
+
for (const [name, value] of Object.entries({
|
|
110
|
+
slidesPerView: 1, spaceBetween: 0, loop: 'off', enableNavigation: true, enablePagination: true, paginationType: 'fraction',
|
|
111
|
+
enableKeyboard: true, ariaLabel: 'Gallery', limit: 50,
|
|
112
|
+
})) writeProp(slider, name, value);
|
|
113
|
+
const rowAlias = options.source ? lastSegment(options.sourcePath) || options.source : null;
|
|
114
|
+
if (options.source) {
|
|
115
|
+
writeProp(slider, SOURCE, options.source);
|
|
116
|
+
if (options.sourcePath) writeProp(slider, SOURCE_PATH, options.sourcePath);
|
|
117
|
+
}
|
|
118
|
+
const prev = buildSliderPart(create, SliderRole.Prev);
|
|
119
|
+
const next = buildSliderPart(create, SliderRole.Next);
|
|
120
|
+
for (const arrow of [prev, next]) {
|
|
121
|
+
const layers = { ...(arrow.responsive ?? {}) };
|
|
122
|
+
layers[LIGHTBOX_ARROWS_HIDE_AT] = { styleProps: { display: 'none' } };
|
|
123
|
+
arrow.responsive = layers;
|
|
124
|
+
}
|
|
125
|
+
const counter = buildSliderPart(create, SliderRole.Pagination);
|
|
126
|
+
counter.label = 'Counter';
|
|
127
|
+
writeStyles(counter, COUNTER_BASE);
|
|
128
|
+
const slides = rowAlias ? [buildSlide(create, 'slide', options, rowAlias)] : [1, 2, 3].map(n => buildSlide(create, `slide-${n}`, options, null));
|
|
129
|
+
slider.children = [prev, next, ...slides, counter];
|
|
130
|
+
dialog.children = [close, slider];
|
|
131
|
+
return dialog;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const lastSegment = (path: string | undefined): string => (path ? String(path).split('.').filter(Boolean).pop() ?? '' : '');
|
|
135
|
+
|
|
136
|
+
// ── "Open at the clicked item" ─────────────────────────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
export interface RepeatSource {
|
|
139
|
+
source: string;
|
|
140
|
+
path: string;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** What a Repeater (its `source` binding) or a bound Slider (its `source` / `sourcePath` traits) repeats over. */
|
|
144
|
+
export function repeatSourceOf(node: CompositeNode | null | undefined): RepeatSource | null {
|
|
145
|
+
if (!node || (node.type !== REPEATER && node.type !== SLIDER_TYPE)) return null;
|
|
146
|
+
const binding = ((node as { bindings?: Array<{ prop?: string; source?: string; path?: string }> }).bindings ?? []).find(b => b?.prop === SOURCE);
|
|
147
|
+
if (binding?.source) return { source: binding.source, path: binding.path ?? '' };
|
|
148
|
+
const source = readProp(node, SOURCE);
|
|
149
|
+
if (typeof source !== 'string' || !source) return null;
|
|
150
|
+
const path = readProp(node, SOURCE_PATH);
|
|
151
|
+
return { source, path: typeof path === 'string' ? path : '' };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function sameRepeatSource(a: RepeatSource | null, b: RepeatSource | null): boolean {
|
|
155
|
+
return !!a && !!b && a.source === b.source && (a.path ?? '') === (b.path ?? '');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The Slider a lightbox Dialog holds (the first one, depth first), or null. */
|
|
159
|
+
export function lightboxSliderOf(dialog: CompositeNode | null | undefined): CompositeNode | null {
|
|
160
|
+
for (const child of dialog?.children ?? []) {
|
|
161
|
+
if (child.type === SLIDER_TYPE) return child;
|
|
162
|
+
const below = lightboxSliderOf(child);
|
|
163
|
+
if (below) return below;
|
|
164
|
+
}
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** The repeated rows the element is in: the nearest Repeater or bound Slider among its ancestors (`path` = root → the
|
|
169
|
+
* element, inclusive). */
|
|
170
|
+
export function rowSourceOf(path: ReadonlyArray<CompositeNode>): RepeatSource | null {
|
|
171
|
+
for (let i = path.length - 2; i >= 0; i--) {
|
|
172
|
+
const source = repeatSourceOf(path[i]);
|
|
173
|
+
if (source) return source;
|
|
174
|
+
}
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Interactions ▸ openDialog ▸ "Open at the clicked item" is offered only when it can work (board 9): the clicked
|
|
180
|
+
* element sits in a repeater row AND the target Dialog holds a Slider bound to the same source.
|
|
181
|
+
*/
|
|
182
|
+
export function canOpenAtClickedItem(ownerPath: ReadonlyArray<CompositeNode>, dialog: CompositeNode | null | undefined): boolean {
|
|
183
|
+
if (!dialog || dialog.type !== DIALOG) return false;
|
|
184
|
+
return sameRepeatSource(rowSourceOf(ownerPath), repeatSourceOf(lightboxSliderOf(dialog)));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** The repeated row an element sits in (its row index), 0 when it is in none (the canvas' template row). */
|
|
188
|
+
export function rowIndexOf(el: Element | null | undefined): number {
|
|
189
|
+
const row = el?.closest?.(`[${ROW_INDEX_ATTRIBUTE}]`);
|
|
190
|
+
const index = Number(row?.getAttribute(ROW_INDEX_ATTRIBUTE));
|
|
191
|
+
return Number.isInteger(index) && index >= 0 ? index : 0;
|
|
192
|
+
}
|