@vectoriox/iox-ui 5.35.0 → 5.37.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 (36) hide show
  1. package/actions-core/index.d.ts +12 -3
  2. package/composite-core/index.d.ts +500 -2
  3. package/design-core/index.d.ts +417 -0
  4. package/fesm2022/vectoriox-iox-ui-actions-core.mjs +26 -2
  5. package/fesm2022/vectoriox-iox-ui-actions-core.mjs.map +1 -1
  6. package/fesm2022/vectoriox-iox-ui-composite-core.mjs +1205 -2
  7. package/fesm2022/vectoriox-iox-ui-composite-core.mjs.map +1 -1
  8. package/fesm2022/vectoriox-iox-ui-design-core.mjs +489 -0
  9. package/fesm2022/vectoriox-iox-ui-design-core.mjs.map +1 -1
  10. package/fesm2022/vectoriox-iox-ui-forms-core.mjs +2 -1
  11. package/fesm2022/vectoriox-iox-ui-forms-core.mjs.map +1 -1
  12. package/fesm2022/vectoriox-iox-ui-generation-core.mjs +113 -8
  13. package/fesm2022/vectoriox-iox-ui-generation-core.mjs.map +1 -1
  14. package/fesm2022/vectoriox-iox-ui.mjs +94 -19
  15. package/fesm2022/vectoriox-iox-ui.mjs.map +1 -1
  16. package/generation-core/index.d.ts +53 -3
  17. package/index.d.ts +60 -10
  18. package/package.json +1 -1
  19. package/src/actions-core/action.model.ts +4 -2
  20. package/src/actions-core/element-actions.ts +29 -1
  21. package/src/composite-core/accordion-runtime.ts +443 -0
  22. package/src/composite-core/accordion.ts +694 -0
  23. package/src/composite-core/composite-kinds.ts +110 -0
  24. package/src/composite-core/composite-node.ts +16 -0
  25. package/src/composite-core/disclosure.ts +125 -0
  26. package/src/composite-core/public-api.ts +7 -1
  27. package/src/composite-core/structure.ts +14 -0
  28. package/src/design-core/flavor-spec.generated.ts +489 -0
  29. package/src/forms-core/nesting.ts +2 -1
  30. package/src/generation-core/compose-site.ts +49 -1
  31. package/src/generation-core/node-baselines.generated.ts +6 -2
  32. package/src/generation-core/patterns.ts +10 -0
  33. package/src/generation-core/serialized-node.ts +4 -0
  34. package/src/generation-core/site-spec.generated.ts +46 -1
  35. package/src/generation-core/validate-site-plan.ts +5 -4
  36. package/styles/iox-design.css +30 -0
@@ -0,0 +1,694 @@
1
+ import { readOwnedTrait } from '@vectoriox/iox-ui/design-core';
2
+
3
+ import {
4
+ AccordionRole, COMPOSITE_KIND_ROLES, CompositeKind, compositeKindOfRole,
5
+ } from './composite-kinds';
6
+ import {
7
+ CompositeNode, CompositeNodeFactory, findByRole, makeCompositePart, readProp, removeFromComposite, walkComposite, writeProp, writeStyles,
8
+ } from './composite-node';
9
+
10
+ /**
11
+ * ACCORDION (CC2 — composite K1; complex-components boards 3, 4 and 9, APPROVED 2026-10-20). Not a
12
+ * component: a recipe of ordinary nodes, each part carrying a role, so every part is selectable on the
13
+ * canvas, in the layer tree and the breadcrumb, and wears its own element style (R2).
14
+ *
15
+ * Container `box` role accordion traits multiple · defaultOn (Manual)
16
+ * — or a Repeater bound to a list role accordion ONE template item (From content, R3)
17
+ * └─ Container `accordion-item` role accordion.item itemKey k1 … (Manual) · the row's id (From content)
18
+ * ├─ LinkedContainer `accordion-trigger` role accordion.trigger → <hN><button> (the question's level)
19
+ * │ ├─ Heading `h3` the question (rendered as phrasing inside the button)
20
+ * │ └─ Icon `icon` role accordion.icon Item open → rotate
21
+ * └─ Container `accordion-panel` role accordion.panel inside a hidden="until-found" region
22
+ * └─ Text `body` the answer
23
+ *
24
+ * The behaviour is BUILT IN (composite plan D1 = B): which items are open is one state machine
25
+ * (`nextAccordionState`), the ARIA and DOM state one planner (`accordionItemState` and the attribute
26
+ * functions), the keyboard one key map — read by the builder canvas, both engine paths and the composer.
27
+ */
28
+
29
+ /** The behaviour options a composite root stores as ordinary TRAITS (composite plan §3). */
30
+ export enum CompositeBehaviourTrait {
31
+ /** Several items may be open at once ("Allow several open"). */
32
+ Multiple = 'multiple',
33
+ /** Which items open by default: first · none · all. */
34
+ DefaultOn = 'defaultOn',
35
+ }
36
+
37
+ export enum AccordionDefaultOn {
38
+ First = 'first',
39
+ None = 'none',
40
+ All = 'all',
41
+ }
42
+
43
+ export const ACCORDION_DEFAULT_ONS: ReadonlyArray<AccordionDefaultOn> = [AccordionDefaultOn.First, AccordionDefaultOn.None, AccordionDefaultOn.All];
44
+
45
+ /** Behaviour ▸ Icon — a preset over the items' icons, never a stored trait (detected from the icons). */
46
+ export enum AccordionIcon {
47
+ Chevron = 'chevron',
48
+ Plus = 'plus',
49
+ None = 'none',
50
+ }
51
+
52
+ export const ACCORDION_ICONS: ReadonlyArray<AccordionIcon> = [AccordionIcon.Chevron, AccordionIcon.Plus, AccordionIcon.None];
53
+
54
+ /** Each icon's glyph and how far it turns when its item opens (board 4: a chevron flips, a plus becomes ×). */
55
+ export const ACCORDION_ICON_GLYPH: Readonly<Record<Exclude<AccordionIcon, AccordionIcon.None>, { iconClass: string; turn: string }>> = {
56
+ [AccordionIcon.Chevron]: { iconClass: 'ph ph-caret-down', turn: '180deg' },
57
+ [AccordionIcon.Plus]: { iconClass: 'ph ph-plus', turn: '45deg' },
58
+ };
59
+
60
+ /** The item-open style state (a part INSIDE an item, styled by its item being open — board 9 "Item open"). */
61
+ export const ITEM_OPEN_STATE = 'trigger-on';
62
+ /** A part that IS on (an item, its header, its panel — board 9 "Open"). */
63
+ export const ON_STATE = 'on';
64
+
65
+ /** Items from content default to twenty rows (board 9 Q13). */
66
+ export const ACCORDION_CONTENT_LIMIT = 20;
67
+
68
+ export interface AccordionWords {
69
+ question: string;
70
+ answer: string;
71
+ }
72
+
73
+ /** The words a fresh accordion starts with — generic, the author's to replace (as Card v2's are). */
74
+ export const ACCORDION_WORDS: ReadonlyArray<AccordionWords> = [
75
+ { question: 'Write the first question people ask', answer: 'Answer it plainly, in two or three short sentences.' },
76
+ { question: 'Write the second question', answer: 'Answer it plainly, in two or three short sentences.' },
77
+ { question: 'Write the third question', answer: 'Answer it plainly, in two or three short sentences.' },
78
+ ];
79
+ export const ACCORDION_NEW_ITEM_WORDS: Readonly<AccordionWords> = { question: 'Write a new question', answer: 'Answer it plainly, in two or three short sentences.' };
80
+
81
+ enum Type {
82
+ Container = 'Container',
83
+ LinkedContainer = 'LinkedContainer',
84
+ Heading = 'Heading',
85
+ Text = 'Text',
86
+ Icon = 'Icon',
87
+ Repeater = 'Repeater',
88
+ }
89
+
90
+ enum Style {
91
+ Root = 'box',
92
+ Item = 'accordion-item',
93
+ Trigger = 'accordion-trigger',
94
+ Question = 'h3',
95
+ Icon = 'icon',
96
+ Panel = 'accordion-panel',
97
+ Answer = 'body',
98
+ }
99
+
100
+ const ZERO_BOX = '0px 0px 0px 0px';
101
+ const HEADING_LEVELS = ['h2', 'h3', 'h4', 'h5', 'h6'];
102
+ const DEFAULT_LEVEL = 'h3';
103
+ /** A linked trait the header must never carry: it is a button, not a link. */
104
+ const LINK_TYPE = 'linkType';
105
+
106
+ /** What each part holds in the recipe — layout only; its LOOK is its element style. */
107
+ const PART_BASE: Readonly<Record<string, Readonly<Record<string, string>>>> = {
108
+ // The items are a column a token apart (`accordion-gap`: 0 for a divider list, 12px for cards).
109
+ [AccordionRole.Accordion]: { display: 'flex', flexDirection: 'column', gap: 'var(--iox-accordion-gap)', width: '100%', minHeight: 'auto', height: 'auto' },
110
+ [AccordionRole.Item]: { display: 'block', width: '100%', minHeight: 'auto', height: 'auto' },
111
+ // The header is the whole row: the question takes the line, the icon sits at its end (board 4).
112
+ [AccordionRole.Trigger]: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '16px', width: '100%', minHeight: 'auto', height: 'auto', textAlign: 'start', cursor: 'pointer' },
113
+ [AccordionRole.Icon]: { flexShrink: '0', fontSize: '22px', transitionDuration: 'var(--iox-dur-base)', transitionTimingFunction: 'var(--iox-ease-standard)' },
114
+ [AccordionRole.Panel]: { display: 'block', width: '100%', minHeight: 'auto', height: 'auto' },
115
+ };
116
+ /** The question INHERITS the header's ink (board 4's wears table: the Question reads no colour token), so the
117
+ * header's hover ink reaches the words; and it sits in the row with no margin of its own. */
118
+ const QUESTION_BASE: Readonly<Record<string, string>> = { color: 'inherit', margin: ZERO_BOX, flexGrow: '1', minWidth: '0px' };
119
+ const ANSWER_BASE: Readonly<Record<string, string>> = { margin: ZERO_BOX, padding: ZERO_BOX, minHeight: 'auto' };
120
+
121
+ // ── recipe ──────────────────────────────────────────────────────────────────────────────────────
122
+
123
+ export interface AccordionOptions {
124
+ /** The items' words (three generic items by default). */
125
+ items?: ReadonlyArray<AccordionWords>;
126
+ multiple?: boolean;
127
+ defaultOn?: AccordionDefaultOn;
128
+ icon?: AccordionIcon;
129
+ }
130
+
131
+ function make(create: CompositeNodeFactory, key: string, type: string, role: AccordionRole | null, style: string,
132
+ props: Record<string, unknown> = {}, label?: string): CompositeNode {
133
+ return makeCompositePart(create, { key, type, props, elementStyle: style }, role, label, role ? PART_BASE[role] : undefined);
134
+ }
135
+
136
+ /** The icon of an item (an accordion's, or a disclosure's — `role`); its Item-open state (the turn) is driven by
137
+ * ITS item (`stateTriggers`). ONE builder for both kinds. */
138
+ export function buildCompositeIcon(create: CompositeNodeFactory, key: string, icon: Exclude<AccordionIcon, AccordionIcon.None>, itemId?: string,
139
+ role: string = AccordionRole.Icon): CompositeNode {
140
+ const glyph = ACCORDION_ICON_GLYPH[icon];
141
+ const node = makeCompositePart(create, { key, type: Type.Icon, props: { iconClass: glyph.iconClass }, elementStyle: Style.Icon }, role, 'Icon', PART_BASE[AccordionRole.Icon]);
142
+ node.stateStyles = { ...(node.stateStyles ?? {}), [ITEM_OPEN_STATE]: { rotate: glyph.turn } };
143
+ if (itemId) node.stateTriggers = { ...(node.stateTriggers ?? {}), [ITEM_OPEN_STATE]: itemId };
144
+ return node;
145
+ }
146
+
147
+ /** One item: header (question + icon) and panel (answer). `key` makes every part's recipe key unique. */
148
+ export function buildAccordionItem(create: CompositeNodeFactory, words: AccordionWords, itemKey: string, icon: AccordionIcon = AccordionIcon.Chevron): CompositeNode {
149
+ const item = make(create, `${itemKey}`, Type.Container, AccordionRole.Item, Style.Item, {}, 'Item');
150
+ item.itemKey = itemKey;
151
+ const trigger = make(create, `${itemKey}-trigger`, Type.LinkedContainer, AccordionRole.Trigger, Style.Trigger, {}, 'Header');
152
+ const question = make(create, `${itemKey}-question`, Type.Heading, null, Style.Question, { content: words.question, level: DEFAULT_LEVEL }, 'Heading');
153
+ writeStyles(question, QUESTION_BASE);
154
+ trigger.children = [question, ...(icon === AccordionIcon.None ? [] : [buildCompositeIcon(create, `${itemKey}-icon`, icon, item.id)])];
155
+ const panel = make(create, `${itemKey}-panel`, Type.Container, AccordionRole.Panel, Style.Panel, {}, 'Panel');
156
+ const answer = make(create, `${itemKey}-answer`, Type.Text, null, Style.Answer, { content: words.answer, tag: 'p' }, 'Text');
157
+ writeStyles(answer, ANSWER_BASE);
158
+ panel.children = [answer];
159
+ item.children = [trigger, panel];
160
+ return item;
161
+ }
162
+
163
+ /** A fresh Accordion: three items, one open at a time, the first open by default (board 4). */
164
+ export function buildAccordionRecipe(create: CompositeNodeFactory, options: AccordionOptions = {}): CompositeNode {
165
+ const root = make(create, 'accordion', Type.Container, AccordionRole.Accordion, Style.Root, {}, 'Accordion');
166
+ writeProp(root, CompositeBehaviourTrait.Multiple, options.multiple ?? false);
167
+ writeProp(root, CompositeBehaviourTrait.DefaultOn, options.defaultOn ?? AccordionDefaultOn.First);
168
+ const words = options.items ?? ACCORDION_WORDS;
169
+ root.children = words.map((w, i) => buildAccordionItem(create, w, `k${i + 1}`, options.icon ?? AccordionIcon.Chevron));
170
+ return root;
171
+ }
172
+
173
+ // ── reading a tree ──────────────────────────────────────────────────────────────────────────────
174
+
175
+ export function isAccordionRoot(node: { role?: string | null } | null | undefined): boolean {
176
+ return node?.role === AccordionRole.Accordion;
177
+ }
178
+
179
+ /** True for any part of an accordion (its root included). */
180
+ export function isAccordionPart(node: { role?: string | null } | null | undefined): boolean {
181
+ return !!node?.role && (node.role === AccordionRole.Accordion || node.role.startsWith(`${AccordionRole.Accordion}.`));
182
+ }
183
+
184
+ /** The accordion a node belongs to: the nearest ancestor (or the node itself) playing `accordion`.
185
+ * `path` = the node's ancestors, root first. */
186
+ export function accordionRootOf<T extends { role?: string | null }>(node: T, path: ReadonlyArray<T>): T | null {
187
+ if (isAccordionRoot(node)) return node;
188
+ for (let i = path.length - 1; i >= 0; i--) if (isAccordionRoot(path[i])) return path[i];
189
+ return null;
190
+ }
191
+
192
+ /** The item a node sits in (the node itself when it is one), or null. */
193
+ export function accordionItemOf<T extends { role?: string | null }>(node: T, path: ReadonlyArray<T>): T | null {
194
+ if (node.role === AccordionRole.Item) return node;
195
+ for (let i = path.length - 1; i >= 0; i--) {
196
+ if (path[i].role === AccordionRole.Item) return path[i];
197
+ if (isAccordionRoot(path[i])) return null;
198
+ }
199
+ return null;
200
+ }
201
+
202
+ /** True when the root takes its items from content (a Repeater bound to a list, R3). */
203
+ export function isAccordionFromContent(root: CompositeNode | null | undefined): boolean {
204
+ return !!root && root.type === Type.Repeater;
205
+ }
206
+
207
+ /** The root's items in order — every item (Manual), or the one template item (From content). */
208
+ export function accordionItems(root: CompositeNode): CompositeNode[] {
209
+ return (root.children ?? []).filter(c => c.role === AccordionRole.Item);
210
+ }
211
+
212
+ export interface AccordionBehaviour {
213
+ multiple: boolean;
214
+ defaultOn: AccordionDefaultOn;
215
+ }
216
+
217
+ const asBool = (v: unknown) => v === true || v === 'true';
218
+
219
+ /** The root's behaviour (an unset trait reads its default: one at a time, the first open). */
220
+ export function accordionBehaviour(root: CompositeNode | null | undefined): AccordionBehaviour {
221
+ const raw = root ? readProp(root, CompositeBehaviourTrait.DefaultOn) : undefined;
222
+ const defaultOn = ACCORDION_DEFAULT_ONS.includes(raw as AccordionDefaultOn) ? raw as AccordionDefaultOn : AccordionDefaultOn.First;
223
+ return { multiple: root ? asBool(readProp(root, CompositeBehaviourTrait.Multiple)) : false, defaultOn };
224
+ }
225
+
226
+ /** Behaviour ▸ Allow several open / Open by default. False when the node has no such trait. */
227
+ export function setAccordionBehaviour(root: CompositeNode, patch: Partial<AccordionBehaviour>): boolean {
228
+ let ok = true;
229
+ if (patch.multiple !== undefined) ok = writeProp(root, CompositeBehaviourTrait.Multiple, patch.multiple) && ok;
230
+ if (patch.defaultOn !== undefined) ok = writeProp(root, CompositeBehaviourTrait.DefaultOn, patch.defaultOn) && ok;
231
+ return ok;
232
+ }
233
+
234
+ /** The roles of the kind an item belongs to (an accordion item, or a disclosure — its own item). */
235
+ function kindRolesOf(node: CompositeNode) {
236
+ return COMPOSITE_KIND_ROLES[compositeKindOfRole(node.role) ?? CompositeKind.Accordion];
237
+ }
238
+
239
+ /** The header of an item (either kind). */
240
+ export function accordionTriggerOf(item: CompositeNode): CompositeNode | undefined {
241
+ return findByRole(item, kindRolesOf(item).trigger);
242
+ }
243
+
244
+ /** The panel of an item (either kind). */
245
+ export function accordionPanelOf(item: CompositeNode): CompositeNode | undefined {
246
+ return findByRole(item, kindRolesOf(item).panel);
247
+ }
248
+
249
+ /** A root's items — an accordion's (`accordionItems`), or a disclosure, which is its own one item. */
250
+ export function compositeItems(root: CompositeNode): CompositeNode[] {
251
+ return compositeKindOfRole(root.role) === CompositeKind.Disclosure ? [root] : accordionItems(root);
252
+ }
253
+
254
+ /** The question: the first Heading inside the header — a disclosure's toggle holds no heading, so its first word
255
+ * (the Text shown while closed, "Read more"). */
256
+ export function accordionQuestionOf(item: CompositeNode): CompositeNode | undefined {
257
+ const trigger = accordionTriggerOf(item);
258
+ let hit: CompositeNode | undefined;
259
+ let text: CompositeNode | undefined;
260
+ if (trigger) walkComposite(trigger, n => {
261
+ if (!hit && n.type === Type.Heading) hit = n;
262
+ if (!text && n.type === Type.Text) text = n;
263
+ });
264
+ return hit ?? text;
265
+ }
266
+
267
+ /** The answer: the first Text inside the panel. */
268
+ export function accordionAnswerOf(item: CompositeNode): CompositeNode | undefined {
269
+ const panel = accordionPanelOf(item);
270
+ let hit: CompositeNode | undefined;
271
+ if (panel) walkComposite(panel, n => { if (!hit && n.type === Type.Text) hit = n; });
272
+ return hit;
273
+ }
274
+
275
+ const stripTags = (v: unknown) => String(v ?? '').replace(/<[^>]*>/g, '').trim();
276
+
277
+ /** An item's name in the Items list and the layer tree: its question's words. */
278
+ export function accordionItemLabel(item: CompositeNode): string {
279
+ const q = accordionQuestionOf(item);
280
+ return q ? stripTags(readProp(q, 'content')) : '';
281
+ }
282
+
283
+ /** Items ▸ rename: the question's words. */
284
+ export function renameAccordionItem(item: CompositeNode, question: string): boolean {
285
+ const q = accordionQuestionOf(item);
286
+ return !!q && writeProp(q, 'content', question);
287
+ }
288
+
289
+ /** The heading level the header's wrapper renders (`<h3><button>`): its question's level. */
290
+ export function accordionHeadingLevel(trigger: { children?: ReadonlyArray<CompositeNode> | null } | null | undefined): string {
291
+ let level: unknown;
292
+ const visit = (n: CompositeNode) => {
293
+ if (level !== undefined) return;
294
+ if (n.type === Type.Heading) { level = readProp(n, 'level'); return; }
295
+ for (const c of n.children ?? []) visit(c);
296
+ };
297
+ for (const c of trigger?.children ?? []) visit(c);
298
+ return HEADING_LEVELS.includes(level as string) ? level as string : DEFAULT_LEVEL;
299
+ }
300
+
301
+ // ── editing (Items list, selection toolbar, Behaviour ▸ Icon) ──────────────────────────────────
302
+
303
+ /** A key no item of `root` holds yet (`k4`…). */
304
+ export function nextItemKey(root: CompositeNode): string {
305
+ const taken = new Set(accordionItems(root).map(i => i.itemKey).filter(Boolean));
306
+ let n = taken.size + 1;
307
+ while (taken.has(`k${n}`)) n++;
308
+ return `k${n}`;
309
+ }
310
+
311
+ /** Items ▸ Add item (and the toolbar's "Add item"): a fresh item after `after` (or at the end), wearing the
312
+ * accordion's current icon. Returns the new item. */
313
+ export function addAccordionItem(root: CompositeNode, create: CompositeNodeFactory, after?: CompositeNode | null, words: AccordionWords = ACCORDION_NEW_ITEM_WORDS): CompositeNode {
314
+ const key = nextItemKey(root);
315
+ const item = buildAccordionItem(create, words, key, detectAccordionIcon(root));
316
+ const children = [...(root.children ?? [])];
317
+ const at = after ? children.indexOf(after) : -1;
318
+ children.splice(at < 0 ? children.length : at + 1, 0, item);
319
+ root.children = children;
320
+ return item;
321
+ }
322
+
323
+ /** Items ▸ drag to reorder: move an item to `toIndex` among the items. False when it is not an item of `root`. */
324
+ export function moveAccordionItem(root: CompositeNode, item: CompositeNode, toIndex: number): boolean {
325
+ const children = [...(root.children ?? [])];
326
+ const from = children.indexOf(item);
327
+ if (from < 0 || item.role !== AccordionRole.Item) return false;
328
+ children.splice(from, 1);
329
+ const items = children.filter(c => c.role === AccordionRole.Item);
330
+ const anchor = items[Math.max(0, Math.min(toIndex, items.length))];
331
+ const at = anchor ? children.indexOf(anchor) : children.length;
332
+ children.splice(at, 0, item);
333
+ root.children = children;
334
+ return true;
335
+ }
336
+
337
+ export enum AccordionEditRefusal {
338
+ /** An accordion keeps at least one item (composite plan T11). */
339
+ LastItem = 'last-item',
340
+ }
341
+
342
+ /** Why removing `item` from `root` is refused, or null. */
343
+ export function accordionRemovalRefusal(root: CompositeNode | null | undefined, item: CompositeNode | null | undefined): AccordionEditRefusal | null {
344
+ if (!root || item?.role !== AccordionRole.Item) return null;
345
+ return accordionItems(root).length <= 1 ? AccordionEditRefusal.LastItem : null;
346
+ }
347
+
348
+ /** The icon the composite's items wear now (the first item's; None when it has no icon). Either kind. */
349
+ export function detectAccordionIcon(root: CompositeNode): AccordionIcon {
350
+ const first = compositeItems(root)[0];
351
+ const icon = first ? findByRole(first, kindRolesOf(root).icon) : undefined;
352
+ if (!icon) return first ? AccordionIcon.None : AccordionIcon.Chevron;
353
+ const cls = String(readProp(icon, 'iconClass') ?? '');
354
+ return cls.includes(ACCORDION_ICON_GLYPH[AccordionIcon.Plus].iconClass.split(' ')[1]) ? AccordionIcon.Plus : AccordionIcon.Chevron;
355
+ }
356
+
357
+ /**
358
+ * Behaviour ▸ Icon: every item's header shows the chevron, the plus, or no icon. A switch between glyphs
359
+ * keeps the icon NODE (its styles and interactions) and changes its glyph and turn; None removes it, and a
360
+ * glyph after None stamps it back from the recipe. Returns how many items changed.
361
+ */
362
+ export function setAccordionIcon(root: CompositeNode, icon: AccordionIcon, create?: CompositeNodeFactory): number {
363
+ let changed = 0;
364
+ const iconRole = kindRolesOf(root).icon;
365
+ for (const item of compositeItems(root)) {
366
+ const trigger = accordionTriggerOf(item);
367
+ if (!trigger) continue;
368
+ const existing = findByRole(trigger, iconRole);
369
+ if (icon === AccordionIcon.None) {
370
+ if (existing && removeFromComposite(trigger, existing)) changed++;
371
+ continue;
372
+ }
373
+ const glyph = ACCORDION_ICON_GLYPH[icon];
374
+ if (existing) {
375
+ writeProp(existing, 'iconClass', glyph.iconClass);
376
+ existing.stateStyles = { ...(existing.stateStyles ?? {}), [ITEM_OPEN_STATE]: { ...(existing.stateStyles?.[ITEM_OPEN_STATE] ?? {}), rotate: glyph.turn } };
377
+ changed++;
378
+ } else if (create) {
379
+ trigger.children = [...(trigger.children ?? []), buildCompositeIcon(create, `${item.itemKey ?? 'item'}-icon`, icon, item.id, iconRole)];
380
+ changed++;
381
+ }
382
+ }
383
+ return changed;
384
+ }
385
+
386
+ /** The node id every `trigger-on` state inside an item points at: its ITEM (`stateTriggers['trigger-on']`). */
387
+ export function itemOpenTriggerIds(root: CompositeNode): void {
388
+ for (const item of accordionItems(root)) {
389
+ if (!item.id) continue;
390
+ walkComposite(item, n => {
391
+ if (n === item || !n.stateStyles?.[ITEM_OPEN_STATE]) return;
392
+ n.stateTriggers = { ...(n.stateTriggers ?? {}), [ITEM_OPEN_STATE]: item.id! };
393
+ });
394
+ }
395
+ }
396
+
397
+ // ── state machine ───────────────────────────────────────────────────────────────────────────────
398
+
399
+ /** The keys open on first render: the first item, none, or all (From content: resolved against the rows). */
400
+ export function initialAccordionOpen(keys: ReadonlyArray<string>, defaultOn: AccordionDefaultOn): Set<string> {
401
+ if (defaultOn === AccordionDefaultOn.None || !keys.length) return new Set();
402
+ if (defaultOn === AccordionDefaultOn.All) return new Set(keys);
403
+ return new Set([keys[0]]);
404
+ }
405
+
406
+ /** The open keys after toggling `key`: one at a time closes the others; several leaves them. */
407
+ export function nextAccordionState(open: ReadonlySet<string>, key: string, behaviour: Pick<AccordionBehaviour, 'multiple'>): Set<string> {
408
+ if (open.has(key)) {
409
+ const next = new Set(open);
410
+ next.delete(key);
411
+ return next;
412
+ }
413
+ return behaviour.multiple ? new Set([...open, key]) : new Set([key]);
414
+ }
415
+
416
+ /** The open keys after OPENING `key` (find-in-page, selecting into a closed panel, the `openItem` action). */
417
+ export function openAccordionItem(open: ReadonlySet<string>, key: string, behaviour: Pick<AccordionBehaviour, 'multiple'>): Set<string> {
418
+ return open.has(key) ? new Set(open) : nextAccordionState(open, key, behaviour);
419
+ }
420
+
421
+ /** The open keys after CLOSING `key` (the `closeItem` action). */
422
+ export function closeAccordionItem(open: ReadonlySet<string>, key: string): Set<string> {
423
+ const next = new Set(open);
424
+ next.delete(key);
425
+ return next;
426
+ }
427
+
428
+ export enum AccordionKeyAction {
429
+ Next = 'next',
430
+ Previous = 'previous',
431
+ First = 'first',
432
+ Last = 'last',
433
+ }
434
+
435
+ /** The APG accordion's keys on a header: ↓ ↑ move (and wrap), Home / End jump. Enter and Space are the
436
+ * button's own. */
437
+ const KEY_ACTIONS: Readonly<Record<string, AccordionKeyAction>> = {
438
+ ArrowDown: AccordionKeyAction.Next,
439
+ ArrowUp: AccordionKeyAction.Previous,
440
+ Home: AccordionKeyAction.First,
441
+ End: AccordionKeyAction.Last,
442
+ };
443
+
444
+ export function accordionKeyAction(key: string): AccordionKeyAction | null {
445
+ return KEY_ACTIONS[key] ?? null;
446
+ }
447
+
448
+ /** The header index `action` moves focus to, from `current` among `count` headers (↓ ↑ wrap). */
449
+ export function accordionFocusIndex(action: AccordionKeyAction, current: number, count: number): number {
450
+ if (count <= 0) return -1;
451
+ switch (action) {
452
+ case AccordionKeyAction.Next: return (current + 1) % count;
453
+ case AccordionKeyAction.Previous: return (current - 1 + count) % count;
454
+ case AccordionKeyAction.First: return 0;
455
+ case AccordionKeyAction.Last: return count - 1;
456
+ }
457
+ }
458
+
459
+ // ── identity, ids and attributes (both renderers, SSR included) ────────────────────────────────
460
+
461
+ /** A key safe in an element id. */
462
+ export function domSafeKey(raw: unknown): string {
463
+ return String(raw ?? '').replace(/[^A-Za-z0-9_-]/g, '-');
464
+ }
465
+
466
+ /** A content row's identity: its own id (never its index — a reorder in the CMS must never pair a header with
467
+ * another row's panel, R3); the index only for a row that has none. */
468
+ export function rowItemKey(row: unknown, index: number): string {
469
+ const r = row as { _id?: unknown; id?: unknown } | null;
470
+ const id = r && typeof r === 'object' ? (r._id ?? r.id) : undefined;
471
+ return id !== undefined && id !== null && id !== '' ? domSafeKey(id) : `row-${index}`;
472
+ }
473
+
474
+ export interface AccordionDomIds {
475
+ trigger: string;
476
+ panel: string;
477
+ }
478
+
479
+ const DOM_ID_PREFIX: Readonly<Record<CompositeKind, string>> = {
480
+ [CompositeKind.Accordion]: 'iox-acc',
481
+ [CompositeKind.Disclosure]: 'iox-dis',
482
+ };
483
+
484
+ /** The ARIA pairing ids of an item: unique per composite (its SCOPE id — `compositeScopeId`) and per item (its key). */
485
+ export function accordionDomIds(rootId: string, itemKey: string, kind: CompositeKind = CompositeKind.Accordion): AccordionDomIds {
486
+ const base = `${DOM_ID_PREFIX[kind]}-${domSafeKey(rootId)}-${domSafeKey(itemKey)}`;
487
+ return { trigger: `${base}-h`, panel: `${base}-p` };
488
+ }
489
+
490
+ /**
491
+ * The id a rendered composite's ARIA ids are built from: its node id, and — when it sits inside repeated rows —
492
+ * every enclosing row's identity, outermost first (vectoriox/iox-ui#20: an accordion repeated per card used to
493
+ * pair every row's header with the FIRST row's panel). The engine folds each row's `rowItemKey`; the builder
494
+ * canvas its preview row's index. Unrepeated, it is the node id unchanged.
495
+ */
496
+ export function compositeScopeId(rootId: string, rowKeys: ReadonlyArray<string> = []): string {
497
+ return rowKeys.length ? [rootId, ...rowKeys.map(domSafeKey)].join('-') : rootId;
498
+ }
499
+
500
+ /** One item's render state: its identity, whether it is open, and its ARIA ids. */
501
+ export interface AccordionItemState {
502
+ kind: CompositeKind;
503
+ key: string;
504
+ index: number;
505
+ on: boolean;
506
+ ids: AccordionDomIds;
507
+ }
508
+
509
+ /** The render scope of one composite: its kind, scope id, behaviour and every item key in order. */
510
+ export interface AccordionScope {
511
+ kind: CompositeKind;
512
+ rootId: string;
513
+ behaviour: AccordionBehaviour;
514
+ keys: ReadonlyArray<string>;
515
+ open: ReadonlySet<string>;
516
+ }
517
+
518
+ export function accordionScope(rootId: string, behaviour: AccordionBehaviour, keys: ReadonlyArray<string>, kind: CompositeKind = CompositeKind.Accordion): AccordionScope {
519
+ return { kind, rootId, behaviour, keys, open: initialAccordionOpen(keys, behaviour.defaultOn) };
520
+ }
521
+
522
+ export function accordionItemState(scope: AccordionScope, index: number): AccordionItemState {
523
+ const key = scope.keys[index] ?? `item-${index}`;
524
+ return { kind: scope.kind, key, index, on: scope.open.has(key), ids: accordionDomIds(scope.rootId, key, scope.kind) };
525
+ }
526
+
527
+ /** Attribute values: a string, or '' for a present boolean attribute. Absent attributes are left out. */
528
+ export type DomAttributes = Record<string, string>;
529
+
530
+ /** `data-iox-on` — the ONE attribute every "on" style keys on (Open, Item open). */
531
+ export const ON_ATTRIBUTE = 'data-iox-on';
532
+ export const ITEM_KEY_ATTRIBUTE = 'data-iox-item-key';
533
+ export const COMPOSITE_ATTRIBUTE = 'data-iox-composite';
534
+ export const MULTIPLE_ATTRIBUTE = 'data-iox-multiple';
535
+ export const DEFAULT_ON_ATTRIBUTE = 'data-iox-default-on';
536
+ /** The scope id the ARIA ids are built from (`compositeScopeId`), written by the engine so the runtime re-stamps
537
+ * exactly the ids the server rendered. */
538
+ export const SCOPE_ATTRIBUTE = 'data-iox-scope';
539
+ /** The value a closed panel's region carries: find-in-page can still reveal it (D9, board 4). */
540
+ export const UNTIL_FOUND = 'until-found';
541
+
542
+ /** The structural classes the renderers write around an accordion's parts (no node owns them). */
543
+ export enum AccordionClass {
544
+ Heading = 'iox-accordion-heading',
545
+ Button = 'iox-accordion-button',
546
+ Track = 'iox-accordion-track',
547
+ Region = 'iox-accordion-region',
548
+ }
549
+
550
+ export function accordionRootAttributes(behaviour: AccordionBehaviour, kind: CompositeKind = CompositeKind.Accordion, scopeId?: string): DomAttributes {
551
+ return {
552
+ [COMPOSITE_ATTRIBUTE]: kind,
553
+ [DEFAULT_ON_ATTRIBUTE]: behaviour.defaultOn,
554
+ ...(behaviour.multiple && kind === CompositeKind.Accordion ? { [MULTIPLE_ATTRIBUTE]: '' } : {}),
555
+ ...(scopeId ? { [SCOPE_ATTRIBUTE]: scopeId } : {}),
556
+ };
557
+ }
558
+
559
+ export function accordionItemAttributes(state: AccordionItemState): DomAttributes {
560
+ return { [ITEM_KEY_ATTRIBUTE]: state.key, ...(state.on ? { [ON_ATTRIBUTE]: '' } : {}) };
561
+ }
562
+
563
+ export function accordionTriggerAttributes(state: AccordionItemState): DomAttributes {
564
+ return {
565
+ type: 'button',
566
+ id: state.ids.trigger,
567
+ 'aria-expanded': state.on ? 'true' : 'false',
568
+ 'aria-controls': state.ids.panel,
569
+ ...(state.on ? { [ON_ATTRIBUTE]: '' } : {}),
570
+ };
571
+ }
572
+
573
+ /** The track wrapping a panel: its row collapses to 0 while closed (the ONE hiding rule, iox-design.css). */
574
+ export function accordionTrackAttributes(state: AccordionItemState): DomAttributes {
575
+ return state.on ? { [ON_ATTRIBUTE]: '' } : {};
576
+ }
577
+
578
+ /** The region the panel sits in: `hidden="until-found"` while closed, so its words stay in the page. An accordion's
579
+ * is a labelled `region` (APG accordion); a disclosure's is a plain block (APG disclosure: the button names it). */
580
+ export function accordionRegionAttributes(state: AccordionItemState): DomAttributes {
581
+ const labelled = hasLabelledRegion(state.kind);
582
+ return {
583
+ ...(labelled ? { role: 'region' } : {}),
584
+ id: state.ids.panel,
585
+ ...(labelled ? { 'aria-labelledby': state.ids.trigger } : {}),
586
+ ...(state.on ? {} : { hidden: UNTIL_FOUND }),
587
+ };
588
+ }
589
+
590
+ /** True when a region of this kind is a labelled `role=region`. */
591
+ export function hasLabelledRegion(kind: CompositeKind): boolean {
592
+ return kind === CompositeKind.Accordion;
593
+ }
594
+
595
+ export function accordionPanelAttributes(state: AccordionItemState): DomAttributes {
596
+ return state.on ? { [ON_ATTRIBUTE]: '' } : {};
597
+ }
598
+
599
+ /** `attrs` as an HTML attribute string (` a="b" c`), values escaped by `esc`. */
600
+ export function serializeDomAttributes(attrs: DomAttributes, esc: (v: string) => string): string {
601
+ return Object.entries(attrs).map(([k, v]) => (v === '' ? ` ${k}` : ` ${k}="${esc(v)}"`)).join('');
602
+ }
603
+
604
+ // ── Items from content (R3) ─────────────────────────────────────────────────────────────────────
605
+
606
+ /** A field of a content type, as both the CMS schema and a site plan describe it. */
607
+ export interface ContentFieldLike {
608
+ name: string;
609
+ type?: string;
610
+ controller?: string;
611
+ }
612
+
613
+ const SHORT_TEXT = new Set(['text']);
614
+ const LONG_TEXT = new Set(['textarea', 'richText', 'editor']);
615
+
616
+ export interface AccordionAutoBinds {
617
+ /** The first short-text field: the question. */
618
+ question: string | null;
619
+ /** The first long-text field: the answer. */
620
+ answer: string | null;
621
+ }
622
+
623
+ /** R3 (board 9 Q19): switching to From content binds the question to the first short-text field and the
624
+ * answer to the first long-text field. ONE rule for the builder's switch and the composer. */
625
+ export function pickAccordionAutoBinds(fields: ReadonlyArray<ContentFieldLike>): AccordionAutoBinds {
626
+ const isString = (f: ContentFieldLike) => !f.type || f.type === 'string';
627
+ const question = fields.find(f => isString(f) && SHORT_TEXT.has(String(f.controller ?? 'text')))?.name ?? null;
628
+ const answer = fields.find(f => isString(f) && LONG_TEXT.has(String(f.controller ?? '')) && f.name !== question)?.name ?? null;
629
+ return { question, answer };
630
+ }
631
+
632
+ /** The bound props of the question and the answer (each a content trait). */
633
+ export const ACCORDION_BIND_PROP = 'content';
634
+
635
+ /**
636
+ * Manual → From content (board 9 "Items: Manual · From content"): the root becomes `repeater` (a Repeater node
637
+ * the caller created with the same id, role, label, element style and styles — a type change is the
638
+ * consumer's), the FIRST item becomes the one template (its parts, styles and interactions kept; its key
639
+ * dropped — a row's key is its id) and the others are removed. Returns the removed items.
640
+ */
641
+ export function accordionToContent(root: CompositeNode, repeater: CompositeNode): CompositeNode[] {
642
+ const items = accordionItems(root);
643
+ const [template, ...rest] = items;
644
+ if (template) delete template.itemKey;
645
+ repeater.role = AccordionRole.Accordion;
646
+ repeater.children = template ? [template] : [];
647
+ writeProp(repeater, CompositeBehaviourTrait.Multiple, accordionBehaviour(root).multiple);
648
+ writeProp(repeater, CompositeBehaviourTrait.DefaultOn, accordionBehaviour(root).defaultOn);
649
+ return rest;
650
+ }
651
+
652
+ /**
653
+ * From content → Manual: the rows come in as static items — each a copy of the template (`cloneItem`, which
654
+ * gives the copy fresh ids) whose bound words are written in as its own and whose bindings are dropped.
655
+ * `container` is the manual root the caller created. Returns the new items.
656
+ */
657
+ export function accordionToManual(root: CompositeNode, container: CompositeNode, rows: ReadonlyArray<unknown>,
658
+ cloneItem: (template: CompositeNode) => CompositeNode, resolve: (row: unknown, binding: CompositeBinding) => unknown): CompositeNode[] {
659
+ const template = accordionItems(root)[0];
660
+ container.role = AccordionRole.Accordion;
661
+ writeProp(container, CompositeBehaviourTrait.Multiple, accordionBehaviour(root).multiple);
662
+ writeProp(container, CompositeBehaviourTrait.DefaultOn, accordionBehaviour(root).defaultOn);
663
+ const items = template ? rows.map((row, i) => {
664
+ const item = cloneItem(template);
665
+ item.itemKey = `k${i + 1}`;
666
+ walkComposite(item, n => {
667
+ const bindings = (n as BoundNode).bindings ?? [];
668
+ for (const b of bindings) {
669
+ const value = resolve(row, b);
670
+ if (value !== undefined && value !== null) writeProp(n, b.prop, value);
671
+ }
672
+ if (bindings.length) (n as BoundNode).bindings = [];
673
+ });
674
+ return item;
675
+ }) : [];
676
+ container.children = items;
677
+ return items;
678
+ }
679
+
680
+ export interface CompositeBinding {
681
+ prop: string;
682
+ source: string;
683
+ path?: string;
684
+ pipes?: unknown[];
685
+ }
686
+
687
+ interface BoundNode extends CompositeNode {
688
+ bindings?: CompositeBinding[];
689
+ }
690
+
691
+ /** The style a part holds for `name` (both node shapes) — exported for the consumers' detectors. */
692
+ export function accordionPartStyle(node: CompositeNode, name: string): unknown {
693
+ return readOwnedTrait(node, { trait: name });
694
+ }