@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.
Files changed (45) hide show
  1. package/actions-core/index.d.ts +6 -1
  2. package/composite-core/index.d.ts +684 -28
  3. package/design-core/index.d.ts +452 -6
  4. package/fesm2022/vectoriox-iox-ui-actions-core.mjs +12 -4
  5. package/fesm2022/vectoriox-iox-ui-actions-core.mjs.map +1 -1
  6. package/fesm2022/vectoriox-iox-ui-composite-core.mjs +1761 -143
  7. package/fesm2022/vectoriox-iox-ui-composite-core.mjs.map +1 -1
  8. package/fesm2022/vectoriox-iox-ui-design-core.mjs +555 -6
  9. package/fesm2022/vectoriox-iox-ui-design-core.mjs.map +1 -1
  10. package/fesm2022/vectoriox-iox-ui-generation-core.mjs +534 -12
  11. package/fesm2022/vectoriox-iox-ui-generation-core.mjs.map +1 -1
  12. package/fesm2022/vectoriox-iox-ui-media-core.mjs +571 -0
  13. package/fesm2022/vectoriox-iox-ui-media-core.mjs.map +1 -0
  14. package/fesm2022/vectoriox-iox-ui.mjs +494 -127
  15. package/fesm2022/vectoriox-iox-ui.mjs.map +1 -1
  16. package/generation-core/index.d.ts +210 -4
  17. package/index.d.ts +217 -30
  18. package/media-core/index.d.ts +277 -0
  19. package/media-core/package.json +3 -0
  20. package/package.json +5 -1
  21. package/src/actions-core/element-actions.ts +12 -3
  22. package/src/composite-core/accordion-runtime.ts +3 -15
  23. package/src/composite-core/accordion.ts +4 -2
  24. package/src/composite-core/composite-kinds.ts +121 -7
  25. package/src/composite-core/composite-node.ts +4 -0
  26. package/src/composite-core/composite-runtime.ts +68 -0
  27. package/src/composite-core/exclusive-runtime.ts +358 -0
  28. package/src/composite-core/lightbox.ts +192 -0
  29. package/src/composite-core/public-api.ts +8 -2
  30. package/src/composite-core/slider-runtime.ts +180 -0
  31. package/src/composite-core/slider.ts +378 -0
  32. package/src/composite-core/structure.ts +38 -5
  33. package/src/composite-core/tabs.ts +628 -0
  34. package/src/design-core/contrast-audit.ts +5 -0
  35. package/src/design-core/element-styles.ts +34 -3
  36. package/src/design-core/flavor-spec.generated.ts +517 -2
  37. package/src/generation-core/baseline-roles.ts +2 -0
  38. package/src/generation-core/compose-site.ts +147 -4
  39. package/src/generation-core/node-baselines.generated.ts +156 -6
  40. package/src/generation-core/patterns.ts +20 -0
  41. package/src/generation-core/site-spec.generated.ts +210 -0
  42. package/src/media-core/public-api.ts +9 -0
  43. package/src/media-core/video-runtime.ts +239 -0
  44. package/src/media-core/video.ts +443 -0
  45. package/styles/iox-design.css +112 -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 };
@@ -0,0 +1,3 @@
1
+ {
2
+ "module": "../fesm2022/vectoriox-iox-ui-media-core.mjs"
3
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vectoriox/iox-ui",
3
- "version": "5.37.0",
3
+ "version": "5.39.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,9 +1,11 @@
1
- import { AccordionRole, CompositeItemAction, DisclosureRole } from '@vectoriox/iox-ui/composite-core';
1
+ import { AccordionRole, CompositeItemAction, DisclosureRole, SLIDE_TO_ROW, TabsRole } from '@vectoriox/iox-ui/composite-core';
2
2
 
3
3
  import { ActionDescriptor } from './action.model';
4
4
 
5
5
  /** The items an item action may target: an accordion's item, or a disclosure (its own one item). */
6
6
  const ITEM_TARGET_ROLES = [AccordionRole.Item, DisclosureRole.Disclosure];
7
+ /** Open also takes a TAB (CC4a): it turns it on. A Tabs always shows one, so Close and Toggle never aim at one. */
8
+ const OPEN_TARGET_ROLES = [...ITEM_TARGET_ROLES, TabsRole.Trigger];
7
9
 
8
10
  /**
9
11
  * The ELEMENT action catalog (actions plan A1) — what a node can DO, as data. One list feeds the
@@ -45,8 +47,10 @@ export const ELEMENT_ACTION_DESCRIPTORS: readonly ActionDescriptor[] = Object.fr
45
47
  target: { required: false } },
46
48
 
47
49
  // ── Dialog (A1 — needs the Dialog component, A1b) ───────────────────────
50
+ // CC3 (board 6): "Open at the clicked item" — `slideTo: 'row'` opens a lightbox at the clicked thumbnail's slide.
48
51
  { type: 'openDialog', labelKey: 'builder.actions.openDialog', kind: 'instant', group: 'dialog', builderBehaviour: 'run',
49
- target: { required: true, types: ['Dialog'] } },
52
+ target: { required: true, types: ['Dialog'] },
53
+ params: [{ name: 'slideTo', type: 'string', label: 'builder.actions.params.slideTo', options: [{ value: SLIDE_TO_ROW, labelKey: 'builder.actions.params.slideToRow' }] }] },
50
54
  { type: 'closeDialog', labelKey: 'builder.actions.closeDialog', kind: 'instant', group: 'dialog', builderBehaviour: 'run',
51
55
  target: { required: true, types: ['Dialog'] } },
52
56
  { type: 'toggleDialog', labelKey: 'builder.actions.toggleDialog', kind: 'instant', group: 'dialog', builderBehaviour: 'run',
@@ -56,7 +60,7 @@ export const ELEMENT_ACTION_DESCRIPTORS: readonly ActionDescriptor[] = Object.fr
56
60
  // opens FAQ item 3). The canvas RUNS them, like the dialog actions: it is the same runtime, and an item you
57
61
  // cannot open from the button is a button you cannot design.
58
62
  { type: 'openItem', labelKey: 'builder.actions.openItem', kind: 'instant', group: 'item', builderBehaviour: 'run',
59
- target: { required: true, roles: ITEM_TARGET_ROLES } },
63
+ target: { required: true, roles: OPEN_TARGET_ROLES } },
60
64
  { type: 'closeItem', labelKey: 'builder.actions.closeItem', kind: 'instant', group: 'item', builderBehaviour: 'run',
61
65
  target: { required: true, roles: ITEM_TARGET_ROLES } },
62
66
  { type: 'toggleItem', labelKey: 'builder.actions.toggleItem', kind: 'instant', group: 'item', builderBehaviour: 'run',
@@ -136,6 +140,11 @@ export const ITEM_ACTION_TYPES: Readonly<Record<string, CompositeItemAction>> =
136
140
  });
137
141
 
138
142
  /** The item action an action type runs, or null when it is not one. */
143
+ /** True when an `openDialog` action opens its dialog at the clicked row's slide (CC3 "Open at the clicked item"). */
144
+ export function opensAtClickedItem(action: { type?: string; params?: Record<string, unknown> | null } | null | undefined): boolean {
145
+ return action?.type === 'openDialog' && action.params?.['slideTo'] === SLIDE_TO_ROW;
146
+ }
147
+
139
148
  export function itemActionOf(type: string | null | undefined): CompositeItemAction | null {
140
149
  return (type && ITEM_ACTION_TYPES[type]) || null;
141
150
  }
@@ -3,7 +3,7 @@ import {
3
3
  ACCORDION_DEFAULT_ONS, closeAccordionItem, COMPOSITE_ATTRIBUTE, DEFAULT_ON_ATTRIBUTE, hasLabelledRegion, initialAccordionOpen, ITEM_KEY_ATTRIBUTE,
4
4
  MULTIPLE_ATTRIBUTE, nextAccordionState, ON_ATTRIBUTE, openAccordionItem, SCOPE_ATTRIBUTE, UNTIL_FOUND,
5
5
  } from './accordion';
6
- import { CompositeKind } from './composite-kinds';
6
+ import { COMPOSITE_KIND_ROLES, CompositeKind, ToggleCompositeKind } from './composite-kinds';
7
7
 
8
8
  /**
9
9
  * The Accordion's RUNTIME (CC2) — ONE copy, DOM only, for the builder canvas and the live site (both engine
@@ -33,7 +33,7 @@ export const ITEM_ON_EVENT = 'iox-item-on';
33
33
  export const ITEM_OFF_EVENT = 'iox-item-off';
34
34
 
35
35
  const KIND_ROOT = (kind: CompositeKind) => `[${COMPOSITE_ATTRIBUTE}="${kind}"]`;
36
- const ROOT_SELECTOR = Object.values(CompositeKind).map(KIND_ROOT).join(', ');
36
+ const ROOT_SELECTOR = (Object.keys(COMPOSITE_KIND_ROLES) as ToggleCompositeKind[]).map(KIND_ROOT).join(', ');
37
37
  /** An accordion's items are its `.iox-role-accordion-item` children; a disclosure is its own item. */
38
38
  const ACCORDION_ITEM_SELECTOR = '.iox-role-accordion-item';
39
39
  const ITEM_SELECTOR = `${ACCORDION_ITEM_SELECTOR}, ${KIND_ROOT(CompositeKind.Disclosure)}`;
@@ -75,7 +75,7 @@ const kindOf = (root: Element): CompositeKind =>
75
75
  root.getAttribute(COMPOSITE_ATTRIBUTE) === CompositeKind.Disclosure ? CompositeKind.Disclosure : CompositeKind.Accordion;
76
76
 
77
77
  /** The composite root whose items an item belongs to: a disclosure is its own root. */
78
- function rootOfItem(item: HTMLElement): HTMLElement | null {
78
+ export function accordionRootOfItem(item: HTMLElement): HTMLElement | null {
79
79
  return kindOf(item) === CompositeKind.Disclosure && item.matches(KIND_ROOT(CompositeKind.Disclosure)) ? item : closestAccordion(item.parentElement);
80
80
  }
81
81
 
@@ -421,18 +421,6 @@ export enum CompositeItemAction {
421
421
  Toggle = 'toggle',
422
422
  }
423
423
 
424
- /**
425
- * Run an item action on the element of an ITEM (an accordion item, or a disclosure) — the `openItem` /
426
- * `closeItem` / `toggleItem` interaction actions, on the canvas and on the live site through this one function.
427
- * Returns whether the item shows open afterwards, or null when `el` is in no running composite.
428
- */
429
- export function runCompositeItemAction(el: Element | null | undefined, action: CompositeItemAction): boolean | null {
430
- const item = closestAccordionItem(el);
431
- const runtime = item ? accordionRuntimeOf(rootOfItem(item)) : null;
432
- if (!item || !runtime) return null;
433
- return runtime.run(action, runtime.keyOf(item));
434
- }
435
-
436
424
  /** How much of an opened item must already be on screen for the action NOT to scroll to it (its header row). */
437
425
  const VISIBLE_HEAD_PX = 48;
438
426
 
@@ -1,7 +1,7 @@
1
1
  import { readOwnedTrait } from '@vectoriox/iox-ui/design-core';
2
2
 
3
3
  import {
4
- AccordionRole, COMPOSITE_KIND_ROLES, CompositeKind, compositeKindOfRole,
4
+ AccordionRole, COMPOSITE_KIND_ROLES, CompositeKind, compositeKindOfRole, isToggleKind,
5
5
  } from './composite-kinds';
6
6
  import {
7
7
  CompositeNode, CompositeNodeFactory, findByRole, makeCompositePart, readProp, removeFromComposite, walkComposite, writeProp, writeStyles,
@@ -233,7 +233,8 @@ export function setAccordionBehaviour(root: CompositeNode, patch: Partial<Accord
233
233
 
234
234
  /** The roles of the kind an item belongs to (an accordion item, or a disclosure — its own item). */
235
235
  function kindRolesOf(node: CompositeNode) {
236
- return COMPOSITE_KIND_ROLES[compositeKindOfRole(node.role) ?? CompositeKind.Accordion];
236
+ const kind = compositeKindOfRole(node.role);
237
+ return COMPOSITE_KIND_ROLES[isToggleKind(kind) ? kind : CompositeKind.Accordion];
237
238
  }
238
239
 
239
240
  /** The header of an item (either kind). */
@@ -479,6 +480,7 @@ export interface AccordionDomIds {
479
480
  const DOM_ID_PREFIX: Readonly<Record<CompositeKind, string>> = {
480
481
  [CompositeKind.Accordion]: 'iox-acc',
481
482
  [CompositeKind.Disclosure]: 'iox-dis',
483
+ [CompositeKind.Tabs]: 'iox-tab',
482
484
  };
483
485
 
484
486
  /** The ARIA pairing ids of an item: unique per composite (its SCOPE id — `compositeScopeId`) and per item (its key). */
@@ -1,15 +1,23 @@
1
1
  /**
2
- * The open/close composites' kinds and roles (CC2 Accordion, CC2.1 Disclosure — composite plan T9 / T21). ONE
3
- * family: a Disclosure is a one-item accordion whose ROOT is its item, so both run the same state machine, the
4
- * same attribute planners and the same runtime (`accordion-runtime.ts`). Every consumer that treats "an item",
5
- * "a header" or "a panel" alike asks these predicates, never a role literal.
2
+ * The composites' kinds and roles. Two families:
3
+ * - OPEN/CLOSE (CC2 Accordion, CC2.1 Disclosure — composite plan T9 / T21): a Disclosure is a one-item accordion whose
4
+ * ROOT is its item, so both run the same state machine, attribute planners and runtime (`accordion-runtime.ts`);
5
+ * - EXCLUSIVE (CC4 Tabs — composite T17; the Segmented control joins in CC4b): exactly one item is on, a trigger and
6
+ * its panel paired by `itemKey` (`tabs.ts`, `exclusive-runtime.ts`).
7
+ * Every consumer that treats "an item", "a trigger" or "a panel" alike asks these predicates, never a role literal.
6
8
  */
7
9
 
8
10
  export enum CompositeKind {
9
11
  Accordion = 'accordion',
10
12
  Disclosure = 'disclosure',
13
+ Tabs = 'tabs',
11
14
  }
12
15
 
16
+ /** The kinds the open/close runtime runs (`AccordionRuntime`). */
17
+ export type ToggleCompositeKind = CompositeKind.Accordion | CompositeKind.Disclosure;
18
+ /** The kinds the exclusive runtime runs (`ExclusiveRuntime`): one item on at a time, always one. */
19
+ export type ExclusiveCompositeKind = CompositeKind.Tabs;
20
+
13
21
  export enum AccordionRole {
14
22
  Accordion = 'accordion',
15
23
  Item = 'accordion.item',
@@ -32,9 +40,22 @@ export enum DisclosureRole {
32
40
  Panel = 'disclosure.panel',
33
41
  }
34
42
 
43
+ /**
44
+ * TABS (CC4a — composite T17). The tab LIST holds the triggers, the PANELS holder holds the panels; a tab and its panel
45
+ * share an `itemKey` (never their index — moving a tab keeps its own panel). From content (R3) the list and the panels
46
+ * holder are both Repeaters over the SAME source, one row giving its tab AND its panel (paired by the row's id).
47
+ */
48
+ export enum TabsRole {
49
+ Tabs = 'tabs',
50
+ List = 'tabs.list',
51
+ Trigger = 'tabs.trigger',
52
+ Panels = 'tabs.panels',
53
+ Panel = 'tabs.panel',
54
+ }
55
+
35
56
  interface RoleLike { role?: string | null }
36
57
 
37
- /** The roles each kind plays in the shared runtime. */
58
+ /** The roles each open/close kind plays in the shared runtime. */
38
59
  export interface CompositeKindRoles {
39
60
  root: string;
40
61
  item: string;
@@ -43,17 +64,40 @@ export interface CompositeKindRoles {
43
64
  icon: string;
44
65
  }
45
66
 
46
- export const COMPOSITE_KIND_ROLES: Readonly<Record<CompositeKind, CompositeKindRoles>> = {
67
+ export const COMPOSITE_KIND_ROLES: Readonly<Record<ToggleCompositeKind, CompositeKindRoles>> = {
47
68
  [CompositeKind.Accordion]: { root: AccordionRole.Accordion, item: AccordionRole.Item, trigger: AccordionRole.Trigger, panel: AccordionRole.Panel, icon: AccordionRole.Icon },
48
69
  [CompositeKind.Disclosure]: { root: DisclosureRole.Disclosure, item: DisclosureRole.Disclosure, trigger: DisclosureRole.Trigger, panel: DisclosureRole.Panel, icon: DisclosureRole.Icon },
49
70
  };
50
71
 
72
+ /** The roles each EXCLUSIVE kind plays: its root, the holder of its triggers, its triggers, and — when its items show
73
+ * a panel (Tabs; a Segmented control has none) — the holder of its panels and the panels. */
74
+ export interface ExclusiveKindRoles {
75
+ root: string;
76
+ list: string;
77
+ trigger: string;
78
+ panels: string | null;
79
+ panel: string | null;
80
+ }
81
+
82
+ export const EXCLUSIVE_KIND_ROLES: Readonly<Record<ExclusiveCompositeKind, ExclusiveKindRoles>> = {
83
+ [CompositeKind.Tabs]: { root: TabsRole.Tabs, list: TabsRole.List, trigger: TabsRole.Trigger, panels: TabsRole.Panels, panel: TabsRole.Panel },
84
+ };
85
+
51
86
  const KINDS = Object.values(CompositeKind);
52
- const rolesOf = (pick: keyof CompositeKindRoles): ReadonlySet<string> => new Set(KINDS.map(k => COMPOSITE_KIND_ROLES[k][pick]));
87
+ const TOGGLE_KINDS = Object.keys(COMPOSITE_KIND_ROLES) as ToggleCompositeKind[];
88
+ const EXCLUSIVE_KINDS = Object.keys(EXCLUSIVE_KIND_ROLES) as ExclusiveCompositeKind[];
89
+ const rolesOf = (pick: keyof CompositeKindRoles): ReadonlySet<string> => new Set(TOGGLE_KINDS.map(k => COMPOSITE_KIND_ROLES[k][pick]));
90
+ const exclusiveRolesOf = (pick: keyof ExclusiveKindRoles): ReadonlySet<string> =>
91
+ new Set(EXCLUSIVE_KINDS.map(k => EXCLUSIVE_KIND_ROLES[k][pick]).filter((r): r is string => !!r));
53
92
  const ROOT_ROLES = rolesOf('root');
54
93
  const ITEM_ROLES = rolesOf('item');
55
94
  const TRIGGER_ROLES = rolesOf('trigger');
56
95
  const PANEL_ROLES = rolesOf('panel');
96
+ const EXCLUSIVE_ROOT_ROLES = exclusiveRolesOf('root');
97
+ const EXCLUSIVE_LIST_ROLES = exclusiveRolesOf('list');
98
+ const EXCLUSIVE_TRIGGER_ROLES = exclusiveRolesOf('trigger');
99
+ const EXCLUSIVE_PANELS_ROLES = exclusiveRolesOf('panels');
100
+ const EXCLUSIVE_PANEL_ROLES = exclusiveRolesOf('panel');
57
101
 
58
102
  /** The kind a role belongs to (`disclosure.panel` → Disclosure), or null for any other role. */
59
103
  export function compositeKindOfRole(role: string | null | undefined): CompositeKind | null {
@@ -61,6 +105,16 @@ export function compositeKindOfRole(role: string | null | undefined): CompositeK
61
105
  return (KINDS as string[]).includes(head ?? '') ? head as CompositeKind : null;
62
106
  }
63
107
 
108
+ /** True for a kind the open/close runtime runs. */
109
+ export function isToggleKind(kind: CompositeKind | null | undefined): kind is ToggleCompositeKind {
110
+ return !!kind && (TOGGLE_KINDS as string[]).includes(kind);
111
+ }
112
+
113
+ /** True for a kind the exclusive runtime runs. */
114
+ export function isExclusiveKind(kind: CompositeKind | null | undefined): kind is ExclusiveCompositeKind {
115
+ return !!kind && (EXCLUSIVE_KINDS as string[]).includes(kind);
116
+ }
117
+
64
118
  /** A root that runs the open/close runtime (an Accordion or a Disclosure). */
65
119
  export function isCompositeRoot(node: RoleLike | null | undefined): boolean {
66
120
  return !!node?.role && ROOT_ROLES.has(node.role);
@@ -108,3 +162,63 @@ export function compositeItemOf<T extends RoleLike>(node: T, path: ReadonlyArray
108
162
  }
109
163
  return null;
110
164
  }
165
+
166
+ // ── the exclusive family (CC4a Tabs) ───────────────────────────────────────────────────────────
167
+
168
+ /** A root that runs the exclusive runtime (Tabs). */
169
+ export function isExclusiveRoot(node: RoleLike | null | undefined): boolean {
170
+ return !!node?.role && EXCLUSIVE_ROOT_ROLES.has(node.role);
171
+ }
172
+
173
+ /** The holder of an exclusive composite's triggers (a tab list): it takes only triggers. */
174
+ export function isExclusiveList(node: RoleLike | null | undefined): boolean {
175
+ return !!node?.role && EXCLUSIVE_LIST_ROLES.has(node.role);
176
+ }
177
+
178
+ /** One of an exclusive composite's triggers (a tab): pressing it turns its item on. */
179
+ export function isExclusiveTrigger(node: RoleLike | null | undefined): boolean {
180
+ return !!node?.role && EXCLUSIVE_TRIGGER_ROLES.has(node.role);
181
+ }
182
+
183
+ /** The holder of an exclusive composite's panels: it takes only panels. */
184
+ export function isExclusivePanels(node: RoleLike | null | undefined): boolean {
185
+ return !!node?.role && EXCLUSIVE_PANELS_ROLES.has(node.role);
186
+ }
187
+
188
+ /** One of an exclusive composite's panels (a tab panel): shown only while its item is on. */
189
+ export function isExclusivePanel(node: RoleLike | null | undefined): boolean {
190
+ return !!node?.role && EXCLUSIVE_PANEL_ROLES.has(node.role);
191
+ }
192
+
193
+ /** True for a trigger or a panel — a part that IS one item of an exclusive composite (it carries the `itemKey`). */
194
+ export function isExclusiveItemPart(node: RoleLike | null | undefined): boolean {
195
+ return isExclusiveTrigger(node) || isExclusivePanel(node);
196
+ }
197
+
198
+ /** True for any part of a Tabs (its root included). */
199
+ export function isTabsPart(node: RoleLike | null | undefined): boolean {
200
+ return compositeKindOfRole(node?.role) === CompositeKind.Tabs;
201
+ }
202
+
203
+ /** A root of either family — anything that runs a composite runtime. */
204
+ export function isAnyCompositeRoot(node: RoleLike | null | undefined): boolean {
205
+ return isCompositeRoot(node) || isExclusiveRoot(node);
206
+ }
207
+
208
+ /** The exclusive root a node belongs to (the node itself when it is one), or null. `path` = its ancestors, root first. */
209
+ export function exclusiveRootOf<T extends RoleLike>(node: T, path: ReadonlyArray<T>): T | null {
210
+ if (isExclusiveRoot(node)) return node;
211
+ for (let i = path.length - 1; i >= 0; i--) if (isExclusiveRoot(path[i])) return path[i];
212
+ return null;
213
+ }
214
+
215
+ /** The trigger or panel a node sits in (itself when it is one) inside an exclusive composite, or null. A nested
216
+ * exclusive root ends the walk. */
217
+ export function exclusiveItemPartOf<T extends RoleLike>(node: T, path: ReadonlyArray<T>): T | null {
218
+ if (isExclusiveItemPart(node)) return node;
219
+ for (let i = path.length - 1; i >= 0; i--) {
220
+ if (isExclusiveItemPart(path[i])) return path[i];
221
+ if (isExclusiveRoot(path[i])) return null;
222
+ }
223
+ return null;
224
+ }
@@ -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;