@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,628 @@
1
+ import { elementStyleById } from '@vectoriox/iox-ui/design-core';
2
+
3
+ import {
4
+ AccordionDomIds, accordionDomIds, AccordionKeyAction, CompositeBinding, compositeScopeId, ContentFieldLike, COMPOSITE_ATTRIBUTE, DomAttributes,
5
+ ITEM_KEY_ATTRIBUTE, ITEM_OPEN_STATE, ON_ATTRIBUTE, rowItemKey, SCOPE_ATTRIBUTE,
6
+ } from './accordion';
7
+ import { CARD_STACK_BREAKPOINT } from './card';
8
+ import { CompositeKind, TabsRole } from './composite-kinds';
9
+ import {
10
+ clearLayerStyles, CompositeNode, CompositeNodeFactory, makeCompositePart, readProp, walkComposite, writeLayerStyles, writeProp, writeStyles,
11
+ } from './composite-node';
12
+
13
+ /**
14
+ * TABS (CC4a — composite K2 T17–T20; complex-components boards 3 and 9, APPROVED 2026-10-20). Not a component: a
15
+ * recipe of ordinary nodes, each part carrying a role, so every tab and every panel is selectable on the canvas, in
16
+ * the layer tree and the breadcrumb, and wears its own element style (R2).
17
+ *
18
+ * Container `box` role tabs traits activateOn · orientation · ariaLabel (the list's name)
19
+ * ├─ Container `tab-list` role tabs.list → role=tablist (locked) — or a Repeater (From content)
20
+ * │ └─ ButtonBlock `tab` ×3 role tabs.trigger itemKey k1… → <button role=tab> › Text `button-label`
21
+ * └─ Container `box` role tabs.panels (locked) — or a Repeater (From content)
22
+ * └─ Container `box` ×3 role tabs.panel the SAME itemKey as its tab → role=tabpanel, hidden while off
23
+ * ├─ Image `media` 4 / 3
24
+ * └─ Text `body`
25
+ *
26
+ * A tab and its panel are a PAIR by `itemKey` — never by index, so a tab moved to the end still shows its own panel.
27
+ * The behaviour is BUILT IN (composite plan D1 = B): exactly one item is on (`exclusive`), the APG tabs pattern with
28
+ * AUTOMATIC activation (arrows move focus and activate, roving tabindex) — one runtime (`exclusive-runtime.ts`) on the
29
+ * builder canvas and both engine paths, from the attribute planners below.
30
+ */
31
+
32
+ export enum TabsBehaviourTrait {
33
+ /** Which pointer gesture turns a tab on: a click (always) or also a hover. */
34
+ ActivateOn = 'activateOn',
35
+ /** The tab list across the top, or beside the panels. */
36
+ Orientation = 'orientation',
37
+ /** The tab list's accessible name (`aria-label` on the role=tablist element) — the root's, edited in Accessibility. */
38
+ Label = 'ariaLabel',
39
+ }
40
+
41
+ export enum TabsActivateOn {
42
+ Click = 'click',
43
+ Hover = 'hover',
44
+ }
45
+
46
+ export enum TabsOrientation {
47
+ Horizontal = 'horizontal',
48
+ Vertical = 'vertical',
49
+ }
50
+
51
+ export const TABS_ACTIVATE_ONS: ReadonlyArray<TabsActivateOn> = [TabsActivateOn.Click, TabsActivateOn.Hover];
52
+ export const TABS_ORIENTATIONS: ReadonlyArray<TabsOrientation> = [TabsOrientation.Horizontal, TabsOrientation.Vertical];
53
+
54
+ /** Tabs from content default to six rows: more tabs do not fit a row (board 9 Q13). */
55
+ export const TABS_CONTENT_LIMIT = 6;
56
+
57
+ /** The breakpoint whose layer stacks a VERTICAL tabs (the list above the panels) and a panel's picture above its words. */
58
+ export const TABS_STACK_BREAKPOINT = CARD_STACK_BREAKPOINT;
59
+
60
+ export interface TabWords {
61
+ label: string;
62
+ text: string;
63
+ }
64
+
65
+ /** The words a fresh Tabs starts with — generic, the author's to replace (Card v2.1: never invented facts). */
66
+ export const TABS_WORDS: ReadonlyArray<TabWords> = [
67
+ { label: 'First tab', text: 'Say what this tab is about in a sentence or two. Its picture sits beside it.' },
68
+ { label: 'Second tab', text: 'Say what this tab is about in a sentence or two. Its picture sits beside it.' },
69
+ { label: 'Third tab', text: 'Say what this tab is about in a sentence or two. Its picture sits beside it.' },
70
+ ];
71
+ export const TABS_NEW_TAB_WORDS: Readonly<TabWords> = { label: 'New tab', text: 'Say what this tab is about in a sentence or two. Its picture sits beside it.' };
72
+
73
+ enum Type {
74
+ Container = 'Container',
75
+ ButtonBlock = 'ButtonBlock',
76
+ Text = 'Text',
77
+ Image = 'Image',
78
+ Repeater = 'Repeater',
79
+ }
80
+
81
+ enum Style {
82
+ Root = 'box',
83
+ List = 'tab-list',
84
+ Tab = 'tab',
85
+ Label = 'button-label',
86
+ Panels = 'box',
87
+ Panel = 'box',
88
+ Media = 'media',
89
+ Body = 'body',
90
+ }
91
+
92
+ const ZERO_BOX = '0px 0px 0px 0px';
93
+ /** The list's hairline, as its element style writes it — read from the contract, never retyped. */
94
+ const LIST_EDGE = String(elementStyleById(Style.List)?.base['borderWidth'] ?? ZERO_BOX);
95
+
96
+ /** What each part holds — layout only; its LOOK is its element style. The list WRAPS at narrow widths (board 3). */
97
+ const PART_BASE: Readonly<Record<string, Readonly<Record<string, string>>>> = {
98
+ [TabsRole.Tabs]: { display: 'flex', width: '100%', minHeight: 'auto', height: 'auto' },
99
+ [TabsRole.List]: { display: 'flex', gap: '4px', minHeight: 'auto', height: 'auto' },
100
+ [TabsRole.Trigger]: { width: 'auto', minHeight: 'auto', height: 'auto', cursor: 'pointer' },
101
+ [TabsRole.Panels]: { display: 'block', width: '100%', minWidth: '0px', minHeight: 'auto', height: 'auto' },
102
+ // Board 3: a panel is any nodes at all — the recipe's is a picture beside its words.
103
+ [TabsRole.Panel]: { display: 'grid', gridTemplateColumns: '1fr 1.2fr', gap: '24px', alignItems: 'center', width: '100%', minHeight: 'auto', height: 'auto' },
104
+ };
105
+ const LABEL_BASE: Readonly<Record<string, string>> = { width: 'auto', minHeight: 'auto', padding: ZERO_BOX, margin: ZERO_BOX };
106
+ const IMAGE_BASE: Readonly<Record<string, string>> = { display: 'block', width: '100%', maxWidth: '100%', aspectRatio: '4 / 3', height: 'auto', minHeight: 'auto' };
107
+ const BODY_BASE: Readonly<Record<string, string>> = { margin: ZERO_BOX, padding: ZERO_BOX, minHeight: 'auto' };
108
+ /** On a phone a panel's picture sits above its words. */
109
+ const PANEL_STACK: Readonly<Record<string, string>> = { gridTemplateColumns: '1fr' };
110
+
111
+ /**
112
+ * Behaviour ▸ Orientation is a PRESET over the root's and the list's layout (the Card layout precedent): every
113
+ * orientation writes the same keys, so switching is clean and detection exact. Vertical puts the list beside the
114
+ * panels (a column, no divider — the active tab keeps its mark) and, on a phone, stacks it above them.
115
+ */
116
+ interface OrientationLayout {
117
+ root: Readonly<Record<string, string>>;
118
+ list: Readonly<Record<string, string>>;
119
+ /** The phone layer (TABS_STACK_BREAKPOINT); Horizontal clears these keys from it. */
120
+ rootStack?: Readonly<Record<string, string>>;
121
+ listStack?: Readonly<Record<string, string>>;
122
+ }
123
+ const ORIENTATION_LAYOUT: Readonly<Record<TabsOrientation, OrientationLayout>> = {
124
+ [TabsOrientation.Horizontal]: {
125
+ root: { flexDirection: 'column', alignItems: 'stretch', gap: '20px' },
126
+ list: { flexDirection: 'row', flexWrap: 'wrap', width: '100%', flexShrink: '1', borderWidth: LIST_EDGE },
127
+ },
128
+ [TabsOrientation.Vertical]: {
129
+ root: { flexDirection: 'row', alignItems: 'flex-start', gap: '24px' },
130
+ list: { flexDirection: 'column', flexWrap: 'nowrap', width: 'auto', flexShrink: '0', borderWidth: ZERO_BOX },
131
+ rootStack: { flexDirection: 'column', alignItems: 'stretch', gap: '20px' },
132
+ listStack: { width: '100%' },
133
+ },
134
+ };
135
+ const ROOT_STACK_KEYS = ['flexDirection', 'alignItems', 'gap'];
136
+ const LIST_STACK_KEYS = ['width'];
137
+
138
+ // ── recipe ──────────────────────────────────────────────────────────────────────────────────────
139
+
140
+ export interface TabsOptions {
141
+ tabs?: ReadonlyArray<TabWords>;
142
+ activateOn?: TabsActivateOn;
143
+ orientation?: TabsOrientation;
144
+ /** The tab list's accessible name (empty: unnamed — a section heading usually names the section). */
145
+ label?: string;
146
+ }
147
+
148
+ function make(create: CompositeNodeFactory, key: string, type: string, role: TabsRole | null, style: string,
149
+ props: Record<string, unknown> = {}, label?: string): CompositeNode {
150
+ return makeCompositePart(create, { key, type, props, elementStyle: style }, role, label, role ? PART_BASE[role] : undefined);
151
+ }
152
+
153
+ export interface TabPair {
154
+ tab: CompositeNode;
155
+ panel: CompositeNode;
156
+ }
157
+
158
+ /** One tab and its panel, sharing `itemKey`. */
159
+ export function buildTabPair(create: CompositeNodeFactory, words: TabWords, itemKey: string): TabPair {
160
+ const tab = make(create, `${itemKey}-tab`, Type.ButtonBlock, TabsRole.Trigger, Style.Tab, {}, 'Tab');
161
+ tab.itemKey = itemKey;
162
+ const label = make(create, `${itemKey}-label`, Type.Text, null, Style.Label, { content: words.label, tag: 'span' }, 'Text');
163
+ writeStyles(label, LABEL_BASE);
164
+ tab.children = [label];
165
+ const panel = make(create, `${itemKey}-panel`, Type.Container, TabsRole.Panel, Style.Panel, {}, 'Panel');
166
+ panel.itemKey = itemKey;
167
+ writeLayerStyles(panel, TABS_STACK_BREAKPOINT, PANEL_STACK);
168
+ const image = make(create, `${itemKey}-image`, Type.Image, null, Style.Media, { src: '', alt: '' }, 'Image');
169
+ writeStyles(image, IMAGE_BASE);
170
+ const text = make(create, `${itemKey}-text`, Type.Text, null, Style.Body, { content: words.text, tag: 'p' }, 'Text');
171
+ writeStyles(text, BODY_BASE);
172
+ panel.children = [image, text];
173
+ return { tab, panel };
174
+ }
175
+
176
+ /** A fresh Tabs: three tabs, the first on, activated by a click, the list across the top (board 3). */
177
+ export function buildTabsRecipe(create: CompositeNodeFactory, options: TabsOptions = {}): CompositeNode {
178
+ const root = make(create, 'tabs', Type.Container, TabsRole.Tabs, Style.Root, {}, 'Tabs');
179
+ writeProp(root, TabsBehaviourTrait.ActivateOn, options.activateOn ?? TabsActivateOn.Click);
180
+ writeProp(root, TabsBehaviourTrait.Orientation, options.orientation ?? TabsOrientation.Horizontal);
181
+ if (options.label) writeProp(root, TabsBehaviourTrait.Label, options.label);
182
+ const list = make(create, 'list', Type.Container, TabsRole.List, Style.List, {}, 'Tab list');
183
+ const panels = make(create, 'panels', Type.Container, TabsRole.Panels, Style.Panels, {}, 'Panels');
184
+ const pairs = (options.tabs ?? TABS_WORDS).map((w, i) => buildTabPair(create, w, `k${i + 1}`));
185
+ list.children = pairs.map(p => p.tab);
186
+ panels.children = pairs.map(p => p.panel);
187
+ root.children = [list, panels];
188
+ applyOrientation(root, options.orientation ?? TabsOrientation.Horizontal);
189
+ return root;
190
+ }
191
+
192
+ // ── reading a tree ──────────────────────────────────────────────────────────────────────────────
193
+
194
+ export function isTabsRoot(node: { role?: string | null } | null | undefined): boolean {
195
+ return node?.role === TabsRole.Tabs;
196
+ }
197
+
198
+ /** The tab list (the holder of the triggers). */
199
+ export function tabsListOf(root: CompositeNode): CompositeNode | undefined {
200
+ return (root.children ?? []).find(c => c.role === TabsRole.List);
201
+ }
202
+
203
+ /** The holder of the panels. */
204
+ export function tabsPanelsOf(root: CompositeNode): CompositeNode | undefined {
205
+ return (root.children ?? []).find(c => c.role === TabsRole.Panels);
206
+ }
207
+
208
+ /** The tabs in order — every tab (Manual), or the one template tab (From content). */
209
+ export function tabsTriggers(root: CompositeNode): CompositeNode[] {
210
+ return (tabsListOf(root)?.children ?? []).filter(c => c.role === TabsRole.Trigger);
211
+ }
212
+
213
+ /** The panels in their holder's order (their order does not pair them — their key does). */
214
+ export function tabsPanels(root: CompositeNode): CompositeNode[] {
215
+ return (tabsPanelsOf(root)?.children ?? []).filter(c => c.role === TabsRole.Panel);
216
+ }
217
+
218
+ /** True when the Tabs takes its tabs from content (its list is a Repeater, R3). */
219
+ export function isTabsFromContent(root: CompositeNode | null | undefined): boolean {
220
+ return !!root && tabsListOf(root)?.type === Type.Repeater;
221
+ }
222
+
223
+ /** The panel paired with `tab` (the same key), or undefined. From content the one template panel pairs the template tab. */
224
+ export function tabPanelOf(root: CompositeNode, tab: CompositeNode): CompositeNode | undefined {
225
+ if (isTabsFromContent(root)) return tabsPanels(root)[0];
226
+ return tab.itemKey ? tabsPanels(root).find(p => p.itemKey === tab.itemKey) : undefined;
227
+ }
228
+
229
+ /** The tab paired with `panel` (the same key), or undefined. */
230
+ export function tabTriggerOf(root: CompositeNode, panel: CompositeNode): CompositeNode | undefined {
231
+ if (isTabsFromContent(root)) return tabsTriggers(root)[0];
232
+ return panel.itemKey ? tabsTriggers(root).find(t => t.itemKey === panel.itemKey) : undefined;
233
+ }
234
+
235
+ /** The other half of a tab ⇄ panel pair, or undefined for any other node. */
236
+ export function tabsPartnerOf(root: CompositeNode, node: CompositeNode): CompositeNode | undefined {
237
+ if (node.role === TabsRole.Trigger) return tabPanelOf(root, node);
238
+ if (node.role === TabsRole.Panel) return tabTriggerOf(root, node);
239
+ return undefined;
240
+ }
241
+
242
+ /** The words a tab shows: its first Text's. */
243
+ export function tabLabelNodeOf(tab: CompositeNode): CompositeNode | undefined {
244
+ let hit: CompositeNode | undefined;
245
+ walkComposite(tab, n => { if (!hit && n !== tab && n.type === Type.Text) hit = n; });
246
+ return hit;
247
+ }
248
+
249
+ const stripTags = (v: unknown) => String(v ?? '').replace(/<[^>]*>/g, '').trim();
250
+
251
+ /** A tab's name in the Items list, the layer tree and the item-action picker: its words. */
252
+ export function tabLabel(tab: CompositeNode): string {
253
+ const label = tabLabelNodeOf(tab);
254
+ return label ? stripTags(readProp(label, 'content')) : '';
255
+ }
256
+
257
+ /** Items ▸ rename: the tab's words. */
258
+ export function renameTab(tab: CompositeNode, words: string): boolean {
259
+ const label = tabLabelNodeOf(tab);
260
+ return !!label && writeProp(label, 'content', words);
261
+ }
262
+
263
+ /** The first Text inside a panel (the words From content binds to). */
264
+ export function tabPanelTextOf(panel: CompositeNode): CompositeNode | undefined {
265
+ let hit: CompositeNode | undefined;
266
+ walkComposite(panel, n => { if (!hit && n !== panel && n.type === Type.Text) hit = n; });
267
+ return hit;
268
+ }
269
+
270
+ /** The first Image inside a panel. */
271
+ export function tabPanelImageOf(panel: CompositeNode): CompositeNode | undefined {
272
+ let hit: CompositeNode | undefined;
273
+ walkComposite(panel, n => { if (!hit && n.type === Type.Image) hit = n; });
274
+ return hit;
275
+ }
276
+
277
+ export interface TabsBehaviour {
278
+ activateOn: TabsActivateOn;
279
+ orientation: TabsOrientation;
280
+ }
281
+
282
+ /** The root's behaviour (an unset trait reads its default: a click, the list across the top). */
283
+ export function tabsBehaviour(root: CompositeNode | null | undefined): TabsBehaviour {
284
+ const on = root ? readProp(root, TabsBehaviourTrait.ActivateOn) : undefined;
285
+ const orientation = root ? readProp(root, TabsBehaviourTrait.Orientation) : undefined;
286
+ return {
287
+ activateOn: TABS_ACTIVATE_ONS.includes(on as TabsActivateOn) ? on as TabsActivateOn : TabsActivateOn.Click,
288
+ orientation: TABS_ORIENTATIONS.includes(orientation as TabsOrientation) ? orientation as TabsOrientation : TabsOrientation.Horizontal,
289
+ };
290
+ }
291
+
292
+ /** The tab list's accessible name (empty when unnamed). */
293
+ export function tabsLabel(root: CompositeNode | null | undefined): string {
294
+ return root ? String(readProp(root, TabsBehaviourTrait.Label) ?? '').trim() : '';
295
+ }
296
+
297
+ /** Write the orientation preset into the root's and the list's layout (both orientations write the same keys). */
298
+ function applyOrientation(root: CompositeNode, orientation: TabsOrientation): void {
299
+ const spec = ORIENTATION_LAYOUT[orientation];
300
+ const list = tabsListOf(root);
301
+ writeStyles(root, spec.root);
302
+ if (list) writeStyles(list, spec.list);
303
+ if (spec.rootStack) writeLayerStyles(root, TABS_STACK_BREAKPOINT, spec.rootStack);
304
+ else clearLayerStyles(root, TABS_STACK_BREAKPOINT, ROOT_STACK_KEYS);
305
+ if (list) {
306
+ if (spec.listStack) writeLayerStyles(list, TABS_STACK_BREAKPOINT, spec.listStack);
307
+ else clearLayerStyles(list, TABS_STACK_BREAKPOINT, LIST_STACK_KEYS);
308
+ }
309
+ }
310
+
311
+ /** Behaviour ▸ Activate on / Orientation (and Accessibility ▸ the list's name). False when the root has no such trait. */
312
+ export function setTabsBehaviour(root: CompositeNode, patch: Partial<TabsBehaviour> & { label?: string }): boolean {
313
+ let ok = true;
314
+ if (patch.activateOn !== undefined) ok = writeProp(root, TabsBehaviourTrait.ActivateOn, patch.activateOn) && ok;
315
+ if (patch.orientation !== undefined) {
316
+ ok = writeProp(root, TabsBehaviourTrait.Orientation, patch.orientation) && ok;
317
+ applyOrientation(root, patch.orientation);
318
+ }
319
+ if (patch.label !== undefined) ok = writeProp(root, TabsBehaviourTrait.Label, patch.label) && ok;
320
+ return ok;
321
+ }
322
+
323
+ // ── editing (Items list, selection toolbar ▸ Add tab, the structure guard) ──────────────────────
324
+
325
+ /** A key no tab of `root` holds yet (`k4`…). */
326
+ export function nextTabKey(root: CompositeNode): string {
327
+ const taken = new Set([...tabsTriggers(root), ...tabsPanels(root)].map(n => n.itemKey).filter(Boolean));
328
+ let n = tabsTriggers(root).length + 1;
329
+ while (taken.has(`k${n}`)) n++;
330
+ return `k${n}`;
331
+ }
332
+
333
+ /** Items ▸ Add tab (and the toolbar's "Add tab"): a fresh tab after `after` (or at the end) AND its panel. */
334
+ export function addTabPair(root: CompositeNode, create: CompositeNodeFactory, after?: CompositeNode | null, words: TabWords = TABS_NEW_TAB_WORDS): TabPair | null {
335
+ const list = tabsListOf(root);
336
+ const panels = tabsPanelsOf(root);
337
+ if (!list || !panels || isTabsFromContent(root)) return null;
338
+ const pair = buildTabPair(create, words, nextTabKey(root));
339
+ const tabs = [...(list.children ?? [])];
340
+ const afterTab = after ? (after.role === TabsRole.Panel ? tabTriggerOf(root, after) : after) : null;
341
+ const at = afterTab ? tabs.indexOf(afterTab) : -1;
342
+ tabs.splice(at < 0 ? tabs.length : at + 1, 0, pair.tab);
343
+ list.children = tabs;
344
+ const afterPanel = afterTab ? tabPanelOf(root, afterTab) : null;
345
+ const held = [...(panels.children ?? [])];
346
+ const pAt = afterPanel ? held.indexOf(afterPanel) : -1;
347
+ held.splice(pAt < 0 ? held.length : pAt + 1, 0, pair.panel);
348
+ panels.children = held;
349
+ return pair;
350
+ }
351
+
352
+ function moveIn(holder: CompositeNode, node: CompositeNode, toIndex: number, role: TabsRole): void {
353
+ const children = [...(holder.children ?? [])];
354
+ const from = children.indexOf(node);
355
+ if (from < 0) return;
356
+ children.splice(from, 1);
357
+ const same = children.filter(c => c.role === role);
358
+ const anchor = same[Math.max(0, Math.min(toIndex, same.length))];
359
+ children.splice(anchor ? children.indexOf(anchor) : children.length, 0, node);
360
+ holder.children = children;
361
+ }
362
+
363
+ /** Items ▸ drag to reorder: move a tab — and its panel, so the layer tree reads in the same order — to `toIndex`.
364
+ * The pairing never depended on the order. False when `tab` is not one of the root's tabs. */
365
+ export function moveTabPair(root: CompositeNode, tab: CompositeNode, toIndex: number): boolean {
366
+ const list = tabsListOf(root);
367
+ const panels = tabsPanelsOf(root);
368
+ if (!list || tab.role !== TabsRole.Trigger || !(list.children ?? []).includes(tab)) return false;
369
+ const panel = tabPanelOf(root, tab);
370
+ moveIn(list, tab, toIndex, TabsRole.Trigger);
371
+ if (panel && panels) moveIn(panels, panel, toIndex, TabsRole.Panel);
372
+ return true;
373
+ }
374
+
375
+ /** Remove a tab and its panel (the pair) — what "Delete tab and its panel" does. True when the tab was removed. */
376
+ export function removeTabPair(root: CompositeNode, node: CompositeNode): boolean {
377
+ const tab = node.role === TabsRole.Panel ? tabTriggerOf(root, node) : node;
378
+ const list = tabsListOf(root);
379
+ if (!tab || !list || tab.role !== TabsRole.Trigger) return false;
380
+ const panel = tabPanelOf(root, tab);
381
+ list.children = (list.children ?? []).filter(c => c !== tab);
382
+ const panels = tabsPanelsOf(root);
383
+ if (panel && panels) panels.children = (panels.children ?? []).filter(c => c !== panel);
384
+ return true;
385
+ }
386
+
387
+ export enum TabsEditRefusal {
388
+ /** A Tabs keeps at least one tab (composite plan T18). */
389
+ LastTab = 'last-tab',
390
+ }
391
+
392
+ /** Why removing `node` (a tab or a panel) from `root` is refused, or null. */
393
+ export function tabsRemovalRefusal(root: CompositeNode | null | undefined, node: CompositeNode | null | undefined): TabsEditRefusal | null {
394
+ if (!root || (node?.role !== TabsRole.Trigger && node?.role !== TabsRole.Panel)) return null;
395
+ return tabsTriggers(root).length <= 1 ? TabsEditRefusal.LastTab : null;
396
+ }
397
+
398
+ /** Every "Item open" (`trigger-on`) state inside a tab or a panel points at ITS tab or panel — the part that carries
399
+ * `data-iox-on` while its item is on. */
400
+ export function tabsItemTriggerIds(root: CompositeNode): void {
401
+ for (const part of [...tabsTriggers(root), ...tabsPanels(root)]) {
402
+ if (!part.id) continue;
403
+ walkComposite(part, n => {
404
+ if (n === part || !n.stateStyles?.[ITEM_OPEN_STATE]) return;
405
+ n.stateTriggers = { ...(n.stateTriggers ?? {}), [ITEM_OPEN_STATE]: part.id! };
406
+ });
407
+ }
408
+ }
409
+
410
+ // ── state, ids and attributes (both renderers, SSR included) ───────────────────────────────────
411
+
412
+ /** The render scope of one Tabs: its scope id, behaviour, every item key in order and the one that is on. */
413
+ export interface TabsScope {
414
+ rootId: string;
415
+ behaviour: TabsBehaviour;
416
+ keys: ReadonlyArray<string>;
417
+ /** The key on at first paint: the first tab (null when there is none). */
418
+ active: string | null;
419
+ label: string;
420
+ }
421
+
422
+ export function tabsScope(rootId: string, behaviour: TabsBehaviour, keys: ReadonlyArray<string>, label = ''): TabsScope {
423
+ return { rootId, behaviour, keys, active: keys[0] ?? null, label };
424
+ }
425
+
426
+ /** The item keys a manual Tabs pairs by — its tabs' own keys, in order (a tab with none by its index). */
427
+ export function manualTabKeys(root: CompositeNode): string[] {
428
+ return tabsTriggers(root).map((t, i) => t.itemKey || `item-${i}`);
429
+ }
430
+
431
+ export interface TabsItemState {
432
+ key: string;
433
+ on: boolean;
434
+ ids: AccordionDomIds;
435
+ }
436
+
437
+ /** One item's render state: its key, whether it is on, and its ARIA ids (`iox-tab-<scope>-<key>-h` / `-p`). */
438
+ export function tabsItemState(scope: TabsScope, key: string): TabsItemState {
439
+ return { key, on: scope.active === key, ids: accordionDomIds(scope.rootId, key, CompositeKind.Tabs) };
440
+ }
441
+
442
+ export const ACTIVATE_ON_ATTRIBUTE = 'data-iox-activate-on';
443
+ export const ORIENTATION_ATTRIBUTE = 'data-iox-orientation';
444
+
445
+ export function tabsRootAttributes(behaviour: TabsBehaviour, scopeId?: string): DomAttributes {
446
+ return {
447
+ [COMPOSITE_ATTRIBUTE]: CompositeKind.Tabs,
448
+ [ACTIVATE_ON_ATTRIBUTE]: behaviour.activateOn,
449
+ [ORIENTATION_ATTRIBUTE]: behaviour.orientation,
450
+ ...(scopeId ? { [SCOPE_ATTRIBUTE]: scopeId } : {}),
451
+ };
452
+ }
453
+
454
+ /** The tab list: `role=tablist`, vertical said aloud, named by the root's label. */
455
+ export function tabListAttributes(behaviour: TabsBehaviour, label = ''): DomAttributes {
456
+ return {
457
+ role: 'tablist',
458
+ ...(behaviour.orientation === TabsOrientation.Vertical ? { 'aria-orientation': 'vertical' } : {}),
459
+ ...(label ? { 'aria-label': label } : {}),
460
+ };
461
+ }
462
+
463
+ /** A tab: a real button that selects its panel — selected = the one tab in the Tab order (roving tabindex). */
464
+ export function tabTriggerAttributes(state: TabsItemState): DomAttributes {
465
+ return {
466
+ type: 'button',
467
+ role: 'tab',
468
+ id: state.ids.trigger,
469
+ 'aria-selected': state.on ? 'true' : 'false',
470
+ 'aria-controls': state.ids.panel,
471
+ tabindex: state.on ? '0' : '-1',
472
+ [ITEM_KEY_ATTRIBUTE]: state.key,
473
+ ...(state.on ? { [ON_ATTRIBUTE]: '' } : {}),
474
+ };
475
+ }
476
+
477
+ /** A panel: `role=tabpanel`, named by its tab, a Tab stop of its own, and `hidden` (display:none) while off. */
478
+ export function tabPanelAttributes(state: TabsItemState): DomAttributes {
479
+ return {
480
+ role: 'tabpanel',
481
+ id: state.ids.panel,
482
+ 'aria-labelledby': state.ids.trigger,
483
+ tabindex: '0',
484
+ [ITEM_KEY_ATTRIBUTE]: state.key,
485
+ ...(state.on ? { [ON_ATTRIBUTE]: '' } : { hidden: '' }),
486
+ };
487
+ }
488
+
489
+ /**
490
+ * The render scope of one rendered Tabs — ONE decision for both engine paths (and the composer's checks): its scope id
491
+ * (its node id + the rows around it, vectoriox/iox-ui#20), its behaviour, its list's name and every item key in order —
492
+ * a manual tab's own `itemKey`, or, From content, each ROW's own id (R3: never its index — a reorder in the CMS keeps
493
+ * every tab with its own panel). `rows` = the rows the tab list's Repeater resolved (null for a manual Tabs).
494
+ */
495
+ export function tabsScopeFor(root: CompositeNode, rootCssId: string, rows: ReadonlyArray<unknown> | null, rowKeys: ReadonlyArray<string> = []): TabsScope {
496
+ const keys = rows ? rows.map((row, i) => rowItemKey(row, i)) : manualTabKeys(root);
497
+ return tabsScope(compositeScopeId(rootCssId, rowKeys), tabsBehaviour(root), keys, tabsLabel(root));
498
+ }
499
+
500
+ /** The key a manual tab or panel renders with: its own key, else its index among its kind. */
501
+ export function tabPartKey(node: CompositeNode, index: number): string {
502
+ return node.itemKey || `item-${index}`;
503
+ }
504
+
505
+ /**
506
+ * The attributes a part of a Tabs writes on its own element (both engine paths, SSR included): the root's data
507
+ * attributes (its scope id when it sits in repeated rows), the list's role and name, a tab's and a panel's state by
508
+ * `key` (its own key, or — From content — its row's). Anything else writes nothing.
509
+ */
510
+ export function tabsPartAttributes(node: { role?: string | null }, scope: TabsScope, key: string | null, rowScoped = false): DomAttributes {
511
+ switch (node.role) {
512
+ case TabsRole.Tabs: return tabsRootAttributes(scope.behaviour, rowScoped ? scope.rootId : undefined);
513
+ case TabsRole.List: return tabListAttributes(scope.behaviour, scope.label);
514
+ case TabsRole.Trigger: return key ? tabTriggerAttributes(tabsItemState(scope, key)) : {};
515
+ case TabsRole.Panel: return key ? tabPanelAttributes(tabsItemState(scope, key)) : {};
516
+ default: return {};
517
+ }
518
+ }
519
+
520
+ /**
521
+ * The APG tabs keys on a tab (automatic activation): → / ← move along a horizontal list — mirrored under RTL — and ↓ /
522
+ * ↑ along a vertical one; Home / End jump. Every move wraps. Enter / Space are the button's own.
523
+ */
524
+ export function tabsKeyAction(key: string, orientation: TabsOrientation, rtl = false): AccordionKeyAction | null {
525
+ if (key === 'Home') return AccordionKeyAction.First;
526
+ if (key === 'End') return AccordionKeyAction.Last;
527
+ if (orientation === TabsOrientation.Vertical) {
528
+ if (key === 'ArrowDown') return AccordionKeyAction.Next;
529
+ if (key === 'ArrowUp') return AccordionKeyAction.Previous;
530
+ return null;
531
+ }
532
+ if (key === 'ArrowRight') return rtl ? AccordionKeyAction.Previous : AccordionKeyAction.Next;
533
+ if (key === 'ArrowLeft') return rtl ? AccordionKeyAction.Next : AccordionKeyAction.Previous;
534
+ return null;
535
+ }
536
+
537
+ // ── Items from content (R3, composite T39) ──────────────────────────────────────────────────────
538
+
539
+ const SHORT_TEXT = new Set(['text']);
540
+ const LONG_TEXT = new Set(['textarea', 'richText', 'editor']);
541
+ const IMAGE_CONTROLLERS = new Set(['image', 'media', 'file']);
542
+
543
+ export interface TabsAutoBinds {
544
+ /** The first short-text field: the tab's words. */
545
+ label: string | null;
546
+ /** The first long-text field: the panel's words. */
547
+ text: string | null;
548
+ /** The first image field: the panel's picture. */
549
+ image: string | null;
550
+ }
551
+
552
+ /** R3 (board 3 "Tabs from content"): switching to From content binds the tab's words to the first short-text field,
553
+ * the panel's words to the first long-text field and its picture to the first image field. ONE rule for the builder's
554
+ * switch and the composer. */
555
+ export function pickTabsAutoBinds(fields: ReadonlyArray<ContentFieldLike>): TabsAutoBinds {
556
+ const isString = (f: ContentFieldLike) => !f.type || f.type === 'string';
557
+ const label = fields.find(f => isString(f) && SHORT_TEXT.has(String(f.controller ?? 'text')))?.name ?? null;
558
+ const text = fields.find(f => isString(f) && LONG_TEXT.has(String(f.controller ?? '')) && f.name !== label)?.name ?? null;
559
+ const image = fields.find(f => IMAGE_CONTROLLERS.has(String(f.controller ?? '')) && f.name !== label && f.name !== text)?.name ?? null;
560
+ return { label, text, image };
561
+ }
562
+
563
+ /** The bound props of a tab's words, a panel's words and its picture. */
564
+ export const TABS_LABEL_BIND_PROP = 'content';
565
+ export const TABS_TEXT_BIND_PROP = 'content';
566
+ export const TABS_IMAGE_BIND_PROP = 'src';
567
+
568
+ /**
569
+ * Manual → From content (board 9 "Items: Manual · From content"): the list becomes `listRepeater` and the panels
570
+ * holder `panelsRepeater` (Repeater nodes the caller created with the same ids, roles, names, element styles and
571
+ * styles — a type change is the consumer's). The FIRST tab and ITS panel become the templates (their parts, styles
572
+ * and interactions kept; their keys dropped — a row's key is its id); every other pair is removed. Returns the removed
573
+ * nodes.
574
+ */
575
+ export function tabsToContent(root: CompositeNode, listRepeater: CompositeNode, panelsRepeater: CompositeNode): CompositeNode[] {
576
+ const [tab, ...restTabs] = tabsTriggers(root);
577
+ const panel = tab ? tabPanelOf(root, tab) : undefined;
578
+ const restPanels = tabsPanels(root).filter(p => p !== panel);
579
+ if (tab) delete tab.itemKey;
580
+ if (panel) delete panel.itemKey;
581
+ listRepeater.role = TabsRole.List;
582
+ panelsRepeater.role = TabsRole.Panels;
583
+ listRepeater.children = tab ? [tab] : [];
584
+ panelsRepeater.children = panel ? [panel] : [];
585
+ root.children = (root.children ?? []).map(c => (c.role === TabsRole.List ? listRepeater : c.role === TabsRole.Panels ? panelsRepeater : c));
586
+ return [...restTabs, ...restPanels];
587
+ }
588
+
589
+ interface BoundNode extends CompositeNode {
590
+ bindings?: CompositeBinding[];
591
+ }
592
+
593
+ function writeRowIn(node: CompositeNode, row: unknown, resolve: (row: unknown, binding: CompositeBinding) => unknown): void {
594
+ walkComposite(node, n => {
595
+ const bindings = (n as BoundNode).bindings ?? [];
596
+ for (const b of bindings) {
597
+ const value = resolve(row, b);
598
+ if (value !== undefined && value !== null) writeProp(n, b.prop, value);
599
+ }
600
+ if (bindings.length) (n as BoundNode).bindings = [];
601
+ });
602
+ }
603
+
604
+ /**
605
+ * From content → Manual: the rows come in as static pairs — each a copy of the template tab and panel (`clone`, which
606
+ * gives the copy fresh ids) whose bound words are written in as its own and whose bindings are dropped. `list` and
607
+ * `panels` are the manual holders the caller created. Returns the new pairs.
608
+ */
609
+ export function tabsToManual(root: CompositeNode, list: CompositeNode, panels: CompositeNode, rows: ReadonlyArray<unknown>,
610
+ clone: (template: CompositeNode) => CompositeNode, resolve: (row: unknown, binding: CompositeBinding) => unknown): TabPair[] {
611
+ const [tabTemplate] = tabsTriggers(root);
612
+ const panelTemplate = tabsPanels(root)[0];
613
+ list.role = TabsRole.List;
614
+ panels.role = TabsRole.Panels;
615
+ const pairs = tabTemplate && panelTemplate ? rows.map((row, i) => {
616
+ const tab = clone(tabTemplate);
617
+ const panel = clone(panelTemplate);
618
+ tab.itemKey = panel.itemKey = `k${i + 1}`;
619
+ writeRowIn(tab, row, resolve);
620
+ writeRowIn(panel, row, resolve);
621
+ return { tab, panel };
622
+ }) : [];
623
+ list.children = pairs.map(p => p.tab);
624
+ panels.children = pairs.map(p => p.panel);
625
+ root.children = (root.children ?? []).map(c => (c.role === TabsRole.List ? list : c.role === TabsRole.Panels ? panels : c));
626
+ return pairs;
627
+ }
628
+
@@ -150,6 +150,8 @@ export function auditDesignContrast(rows: ReadonlyArray<DesignTokenLike>, mode:
150
150
  ['base', ownStyle(e)],
151
151
  ...(e.hover ? [['hover', { ...ownStyle(e), ...e.hover }] as [string, StyleMap]] : []),
152
152
  ...(e.current ? [['current', { ...ownStyle(e), ...e.current }] as [string, StyleMap]] : []),
153
+ // `on` (CC4a): the active tab, on every ground it can sit on.
154
+ ...(e.on ? [['on', { ...ownStyle(e), ...e.on }] as [string, StyleMap]] : []),
153
155
  ];
154
156
  const glyph = e.group === GLYPH_GROUP;
155
157
  for (const [state, s] of states) {
@@ -157,6 +159,9 @@ export function auditDesignContrast(rows: ReadonlyArray<DesignTokenLike>, mode:
157
159
  const under = fill.length && opaque(fill) ? overAll(fill, bgs) : bgs;
158
160
  const ink = s['color'] ? parseCssColour(resolveIn(values, s['color']), boxInk) : boxInk;
159
161
  check(e.id, state === 'base' ? (glyph ? 'glyph' : 'text') : `${state} text`, ink, under, glyph ? WCAG_AA_UI : WCAG_AA_TEXT);
162
+ // The active tab's mark is the selected state's indicator (WCAG 1.4.11) — the contract's audit, 1:1.
163
+ const mark = state === 'on' ? (e.on as StyleMap | undefined)?.['borderColor'] : undefined;
164
+ if (mark) check(e.id, 'on mark', parseCssColour(resolveIn(values, mark), ink), bgs, WCAG_AA_UI);
160
165
  // What a filled element HOLDS (inkScopes): a button's or a badge's icon reads its ink.
161
166
  if (state === 'base' && INK_SCOPES.some(sc => sc.styles.includes(e.id))) {
162
167
  check(e.id, 'icon', parseCssColour(resolveIn(inkScoped(values, e.id), iconColour), ink), under, WCAG_AA_UI);