@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,443 @@
1
+ import {
2
+ AccordionBehaviour, AccordionClass, AccordionDefaultOn, accordionDomIds, accordionFocusIndex, accordionKeyAction,
3
+ ACCORDION_DEFAULT_ONS, closeAccordionItem, COMPOSITE_ATTRIBUTE, DEFAULT_ON_ATTRIBUTE, hasLabelledRegion, initialAccordionOpen, ITEM_KEY_ATTRIBUTE,
4
+ MULTIPLE_ATTRIBUTE, nextAccordionState, ON_ATTRIBUTE, openAccordionItem, SCOPE_ATTRIBUTE, UNTIL_FOUND,
5
+ } from './accordion';
6
+ import { CompositeKind } from './composite-kinds';
7
+
8
+ /**
9
+ * The Accordion's RUNTIME (CC2) — ONE copy, DOM only, for the builder canvas and the live site (both engine
10
+ * paths render the same markup). It reads the markup the renderers write (iox-design.css + accordion.ts's
11
+ * attribute planners), so the server's first paint and every later state agree:
12
+ *
13
+ * [root] data-iox-composite="accordion" data-iox-default-on [data-iox-multiple] [data-iox-scope] (the node's
14
+ * own element — its role class may sit on a wrapper host, as the engine's component-path Repeater draws it)
15
+ * .iox-role-accordion-item data-iox-item-key [data-iox-on]
16
+ * hN.iox-accordion-heading > button.iox-accordion-button id aria-expanded aria-controls
17
+ * .iox-accordion-track [data-iox-on] > .iox-accordion-region role=region [hidden=until-found]
18
+ * .iox-role-accordion-panel
19
+ *
20
+ * The DISCLOSURE (CC2.1) runs on this same runtime: `data-iox-composite="disclosure"` on a root that IS its one
21
+ * item (it carries the key and `data-iox-on`), its button straight inside it (no heading), its panel in the same
22
+ * track + region (a plain block — no `role=region`), and no arrow keys (one button).
23
+ *
24
+ * It owns: a click on a header toggles its item; ↓ ↑ Home End move between the headers of THIS accordion
25
+ * (a nested one keeps its own); find-in-page (`beforematch`) opens the item that holds the match; a closed
26
+ * panel gets `hidden="until-found"` after its track has collapsed (at once under reduced motion). Every
27
+ * change dispatches `iox-item-on` / `iox-item-off` on the item, the `itemOn` / `itemOff` interaction
28
+ * triggers. The open set is LIVE ∪ FORCED: the builder forces items open (a selection inside a closed panel,
29
+ * "All open"); the live site forces nothing.
30
+ */
31
+
32
+ export const ITEM_ON_EVENT = 'iox-item-on';
33
+ export const ITEM_OFF_EVENT = 'iox-item-off';
34
+
35
+ const KIND_ROOT = (kind: CompositeKind) => `[${COMPOSITE_ATTRIBUTE}="${kind}"]`;
36
+ const ROOT_SELECTOR = Object.values(CompositeKind).map(KIND_ROOT).join(', ');
37
+ /** An accordion's items are its `.iox-role-accordion-item` children; a disclosure is its own item. */
38
+ const ACCORDION_ITEM_SELECTOR = '.iox-role-accordion-item';
39
+ const ITEM_SELECTOR = `${ACCORDION_ITEM_SELECTOR}, ${KIND_ROOT(CompositeKind.Disclosure)}`;
40
+ const PANEL_SELECTOR = '.iox-role-accordion-panel, .iox-role-disclosure-panel';
41
+ const BUTTON_SELECTOR = `.${AccordionClass.Button}`;
42
+ const TRACK_SELECTOR = `.${AccordionClass.Track}`;
43
+ const REGION_SELECTOR = `.${AccordionClass.Region}`;
44
+ /** How long a closing track may take before its region is hidden (dur-base is 200–320ms in every pack). */
45
+ const MAX_CLOSE_MS = 600;
46
+ const REDUCED_MOTION = '(prefers-reduced-motion: reduce)';
47
+
48
+ export interface AccordionRuntimeOptions {
49
+ /** The composite's scope id — the ARIA ids' prefix (else the root's `data-iox-scope`, else its `iox-node-<id>`). A
50
+ * function is read at every stamp (the builder canvas: a preview row's index is known once it is in the page). */
51
+ rootId?: string | (() => string);
52
+ /** Accordion or Disclosure (else read from the root's `data-iox-composite`). */
53
+ kind?: CompositeKind;
54
+ /** The behaviour (else read from the root's data attributes). */
55
+ behaviour?: AccordionBehaviour;
56
+ /** Keys forced open on top of the live state (the builder's design state). */
57
+ forced?: () => ReadonlySet<string>;
58
+ /** Re-stamp when the markup under the root changes (the builder canvas re-renders parts). */
59
+ observe?: boolean;
60
+ /** True to stop motion (default: the visitor's reduced-motion setting). */
61
+ reducedMotion?: () => boolean;
62
+ }
63
+
64
+ /** The composite (accordion or disclosure) an element belongs to (the element itself when it is a root). */
65
+ export function closestAccordion(el: Element | null | undefined): HTMLElement | null {
66
+ return (el?.closest?.(ROOT_SELECTOR) as HTMLElement | null) ?? null;
67
+ }
68
+
69
+ /** The item an element sits in (itself when it is one) — an accordion item, or a disclosure. */
70
+ export function closestAccordionItem(el: Element | null | undefined): HTMLElement | null {
71
+ return (el?.closest?.(ITEM_SELECTOR) as HTMLElement | null) ?? null;
72
+ }
73
+
74
+ const kindOf = (root: Element): CompositeKind =>
75
+ root.getAttribute(COMPOSITE_ATTRIBUTE) === CompositeKind.Disclosure ? CompositeKind.Disclosure : CompositeKind.Accordion;
76
+
77
+ /** The composite root whose items an item belongs to: a disclosure is its own root. */
78
+ function rootOfItem(item: HTMLElement): HTMLElement | null {
79
+ return kindOf(item) === CompositeKind.Disclosure && item.matches(KIND_ROOT(CompositeKind.Disclosure)) ? item : closestAccordion(item.parentElement);
80
+ }
81
+
82
+ /** Subscribe to an item turning on / off — what the `itemOn` / `itemOff` interaction triggers listen to. The
83
+ * item is the element's own item (the element itself when it is one). Returns the unsubscribe. */
84
+ export function onAccordionItemState(el: Element, on: boolean, handler: () => void): () => void {
85
+ const item = closestAccordionItem(el);
86
+ if (!item) return () => undefined;
87
+ const type = on ? ITEM_ON_EVENT : ITEM_OFF_EVENT;
88
+ item.addEventListener(type, handler);
89
+ return () => item.removeEventListener(type, handler);
90
+ }
91
+
92
+ /** True when the element's item is open now. */
93
+ export function isAccordionItemOn(el: Element): boolean {
94
+ return !!closestAccordionItem(el)?.hasAttribute(ON_ATTRIBUTE);
95
+ }
96
+
97
+ const nodeIdOf = (el: Element): string => {
98
+ for (const c of Array.from(el.classList)) if (c.startsWith('iox-node-')) return c.slice('iox-node-'.length);
99
+ return '';
100
+ };
101
+
102
+ function behaviourOf(root: Element): AccordionBehaviour {
103
+ const raw = root.getAttribute(DEFAULT_ON_ATTRIBUTE);
104
+ return {
105
+ multiple: root.hasAttribute(MULTIPLE_ATTRIBUTE),
106
+ defaultOn: ACCORDION_DEFAULT_ONS.includes(raw as AccordionDefaultOn) ? raw as AccordionDefaultOn : AccordionDefaultOn.First,
107
+ };
108
+ }
109
+
110
+ function setAttr(el: Element | null | undefined, name: string, value: string | null): void {
111
+ if (!el) return;
112
+ if (value === null) { if (el.hasAttribute(name)) el.removeAttribute(name); }
113
+ else if (el.getAttribute(name) !== value) el.setAttribute(name, value);
114
+ }
115
+
116
+ export class AccordionRuntime {
117
+ private live: Set<string>;
118
+ private shown = new Set<string>();
119
+ private readonly closing = new Map<string, ReturnType<typeof setTimeout>>();
120
+ private observer: MutationObserver | null = null;
121
+ private stampQueued = false;
122
+ private behaviourValue: AccordionBehaviour;
123
+ /** The builder attaches a root before its items render: the saved default is applied once they do. */
124
+ private awaitingItems = false;
125
+ readonly kind: CompositeKind;
126
+
127
+ constructor(readonly root: HTMLElement, private readonly options: AccordionRuntimeOptions = {}) {
128
+ this.kind = options.kind ?? kindOf(root);
129
+ this.behaviourValue = options.behaviour ?? behaviourOf(root);
130
+ root.setAttribute(COMPOSITE_ATTRIBUTE, this.kind);
131
+ RUNTIMES.set(root, this);
132
+ // The server rendered the first state; with no server state the root's default decides.
133
+ // (The builder passes the behaviour and starts from the saved default; on the live site the server
134
+ // stamped every item's key and state, and that is the truth.)
135
+ const items = this.items();
136
+ const serverStamped = !options.behaviour && items.some(i => i.hasAttribute(ITEM_KEY_ATTRIBUTE));
137
+ this.live = serverStamped
138
+ ? new Set(items.filter(i => i.hasAttribute(ON_ATTRIBUTE)).map(i => this.keyOf(i, items)))
139
+ : initialAccordionOpen(items.map(i => this.keyOf(i, items)), this.behaviourValue.defaultOn);
140
+ this.awaitingItems = !serverStamped && !items.length;
141
+ this.shown = new Set(this.live);
142
+ // Capture: on the builder canvas every part's own listener stops the click (it selects that part), and
143
+ // the header must still toggle — the canvas is a live runtime.
144
+ root.addEventListener('click', this.onClick, true);
145
+ root.addEventListener('keydown', this.onKeydown);
146
+ root.addEventListener('beforematch', this.onBeforeMatch, true);
147
+ if (options.observe && typeof MutationObserver !== 'undefined') {
148
+ this.observer = new MutationObserver(() => this.queueStamp());
149
+ this.observer.observe(root, { childList: true, subtree: true });
150
+ }
151
+ this.stamp(true);
152
+ }
153
+
154
+ get behaviour(): AccordionBehaviour {
155
+ return this.behaviourValue;
156
+ }
157
+
158
+ /** The behaviour changed in the panel: re-read, and start from the new default. */
159
+ setBehaviour(behaviour: AccordionBehaviour): void {
160
+ const changedDefault = behaviour.defaultOn !== this.behaviourValue.defaultOn;
161
+ this.behaviourValue = behaviour;
162
+ if (behaviour.multiple) this.root.setAttribute(MULTIPLE_ATTRIBUTE, '');
163
+ else this.root.removeAttribute(MULTIPLE_ATTRIBUTE);
164
+ this.root.setAttribute(DEFAULT_ON_ATTRIBUTE, behaviour.defaultOn);
165
+ if (changedDefault) this.resetLive();
166
+ else this.stamp();
167
+ }
168
+
169
+ /** THIS composite's items, in document order (a nested composite's items are its own); a disclosure's is itself. */
170
+ items(): HTMLElement[] {
171
+ if (this.kind === CompositeKind.Disclosure) return [this.root];
172
+ return Array.from(this.root.querySelectorAll<HTMLElement>(ACCORDION_ITEM_SELECTOR)).filter(i => closestAccordion(i.parentElement) === this.root);
173
+ }
174
+
175
+ /** The open keys the live state holds (before anything forced). */
176
+ get liveKeys(): ReadonlySet<string> {
177
+ return this.live;
178
+ }
179
+
180
+ /** The keys showing open right now. */
181
+ get openKeys(): ReadonlySet<string> {
182
+ return this.shown;
183
+ }
184
+
185
+ keyOf(item: Element, items: HTMLElement[] = this.items()): string {
186
+ return item.getAttribute(ITEM_KEY_ATTRIBUTE) || `item-${items.indexOf(item as HTMLElement)}`;
187
+ }
188
+
189
+ toggle(key: string): void {
190
+ this.live = nextAccordionState(this.live, key, this.behaviourValue);
191
+ this.stamp();
192
+ }
193
+
194
+ open(key: string): void {
195
+ this.live = openAccordionItem(this.live, key, this.behaviourValue);
196
+ this.stamp();
197
+ }
198
+
199
+ close(key: string): void {
200
+ this.live = closeAccordionItem(this.live, key);
201
+ this.stamp();
202
+ }
203
+
204
+ /** Run an item action (`openItem` / `closeItem` / `toggleItem`, T14) on `key`. True when it shows open after. */
205
+ run(action: CompositeItemAction, key: string): boolean {
206
+ if (action === CompositeItemAction.Open) this.open(key);
207
+ else if (action === CompositeItemAction.Close) this.close(key);
208
+ else this.toggle(key);
209
+ return this.shown.has(key);
210
+ }
211
+
212
+ /** Back to the saved default ("Showing on canvas ▸ As saved"). */
213
+ resetLive(): void {
214
+ const items = this.items();
215
+ this.live = initialAccordionOpen(items.map(i => this.keyOf(i, items)), this.behaviourValue.defaultOn);
216
+ this.stamp();
217
+ }
218
+
219
+ /** The forced set changed (builder design state): show it. */
220
+ refresh(): void {
221
+ this.stamp();
222
+ }
223
+
224
+ destroy(): void {
225
+ if (RUNTIMES.get(this.root) === this) RUNTIMES.delete(this.root);
226
+ this.root.removeEventListener('click', this.onClick, true);
227
+ this.root.removeEventListener('keydown', this.onKeydown);
228
+ this.root.removeEventListener('beforematch', this.onBeforeMatch, true);
229
+ this.observer?.disconnect();
230
+ this.observer = null;
231
+ for (const t of this.closing.values()) clearTimeout(t);
232
+ this.closing.clear();
233
+ }
234
+
235
+ private queueStamp(): void {
236
+ if (this.stampQueued) return;
237
+ this.stampQueued = true;
238
+ const run = () => { this.stampQueued = false; this.stamp(); };
239
+ if (typeof queueMicrotask === 'function') queueMicrotask(run); else Promise.resolve().then(run);
240
+ }
241
+
242
+ private reduced(): boolean {
243
+ if (this.options.reducedMotion) return this.options.reducedMotion();
244
+ try { return typeof matchMedia === 'function' && matchMedia(REDUCED_MOTION).matches; } catch { return false; }
245
+ }
246
+
247
+ /** Write every item's state into its markup; announce what changed. */
248
+ private stamp(first = false): void {
249
+ // Our own writes must not wake the observer (they are attributes; it watches the child lists only).
250
+ const items = this.items();
251
+ if (this.awaitingItems && items.length) {
252
+ this.awaitingItems = false;
253
+ this.live = initialAccordionOpen(items.map(i => this.keyOf(i, items)), this.behaviourValue.defaultOn);
254
+ first = true;
255
+ }
256
+ const forced = this.options.forced?.() ?? new Set<string>();
257
+ const own = this.options.rootId;
258
+ const rootId = (typeof own === 'function' ? own() : own) ?? this.root.getAttribute(SCOPE_ATTRIBUTE) ?? nodeIdOf(this.root);
259
+ const next = new Set<string>();
260
+ items.forEach((item, index) => {
261
+ const key = this.keyOf(item, items);
262
+ const on = this.live.has(key) || forced.has(key);
263
+ if (on) next.add(key);
264
+ this.stampItem(item, key, index, on, rootId, first);
265
+ });
266
+ const was = first ? new Set<string>() : this.shown;
267
+ this.shown = next;
268
+ // On the first stamp an item open by default HAS opened: its `itemOn` listeners attached so far hear it
269
+ // (the builder renders an item's parts, and attaches their interactions, before its root's runtime; the
270
+ // live engine attaches the runtime first and its interactions run an already-open item's at once).
271
+ for (const item of items) {
272
+ const key = this.keyOf(item, items);
273
+ if (next.has(key) && !was.has(key)) dispatch(item, ITEM_ON_EVENT);
274
+ else if (!next.has(key) && was.has(key)) dispatch(item, ITEM_OFF_EVENT);
275
+ }
276
+ }
277
+
278
+ private stampItem(item: HTMLElement, key: string, index: number, on: boolean, rootId: string, first: boolean): void {
279
+ const ids = accordionDomIds(rootId, key, this.kind);
280
+ if (!item.hasAttribute(ITEM_KEY_ATTRIBUTE)) item.setAttribute(ITEM_KEY_ATTRIBUTE, key);
281
+ setAttr(item, ON_ATTRIBUTE, on ? '' : null);
282
+ const button = this.own(item, BUTTON_SELECTOR);
283
+ const track = this.own(item, TRACK_SELECTOR);
284
+ const region = this.own(item, REGION_SELECTOR);
285
+ const panel = this.own(item, PANEL_SELECTOR);
286
+ setAttr(button, 'type', 'button');
287
+ setAttr(button, 'id', ids.trigger);
288
+ setAttr(button, 'aria-expanded', on ? 'true' : 'false');
289
+ setAttr(button, 'aria-controls', ids.panel);
290
+ setAttr(button, ON_ATTRIBUTE, on ? '' : null);
291
+ setAttr(track, ON_ATTRIBUTE, on ? '' : null);
292
+ setAttr(panel, ON_ATTRIBUTE, on ? '' : null);
293
+ const labelled = hasLabelledRegion(this.kind);
294
+ setAttr(region, 'role', labelled ? 'region' : null);
295
+ setAttr(region, 'id', ids.panel);
296
+ setAttr(region, 'aria-labelledby', labelled ? ids.trigger : null);
297
+ if (!region) return;
298
+ const pending = this.closing.get(key);
299
+ if (on) {
300
+ if (pending) { clearTimeout(pending); this.closing.delete(key); }
301
+ setAttr(region, 'hidden', null);
302
+ return;
303
+ }
304
+ if (region.hasAttribute('hidden') || pending) return;
305
+ // Closing: the track collapses first (its transition), THEN the region is hidden — hiding it at once
306
+ // would collapse it with no motion. At first paint, or under reduced motion, it is hidden at once.
307
+ if (first || this.reduced()) { setAttr(region, 'hidden', UNTIL_FOUND); return; }
308
+ const hide = () => {
309
+ this.closing.delete(key);
310
+ if (!item.hasAttribute(ON_ATTRIBUTE)) setAttr(region, 'hidden', UNTIL_FOUND);
311
+ };
312
+ this.closing.set(key, setTimeout(hide, this.closeDelay(track)));
313
+ }
314
+
315
+ private closeDelay(track: Element | null): number {
316
+ try {
317
+ const view = track?.ownerDocument?.defaultView;
318
+ const raw = track && view ? view.getComputedStyle(track).transitionDuration : '';
319
+ const ms = Math.max(0, ...String(raw).split(',').map(s => (s.trim().endsWith('ms') ? parseFloat(s) : parseFloat(s) * 1000)).filter(n => !isNaN(n)));
320
+ return Math.min(MAX_CLOSE_MS, ms + 20);
321
+ } catch { return 0; }
322
+ }
323
+
324
+ /** The element `selector` matches inside `item` that belongs to it, not to an item nested in it. */
325
+ private own(item: HTMLElement, selector: string): HTMLElement | null {
326
+ return Array.from(item.querySelectorAll<HTMLElement>(selector)).find(el => closestAccordionItem(el) === item) ?? null;
327
+ }
328
+
329
+ private buttons(): HTMLElement[] {
330
+ return this.items().map(i => this.own(i, BUTTON_SELECTOR)).filter((b): b is HTMLElement => !!b);
331
+ }
332
+
333
+ private readonly onClick = (event: Event): void => {
334
+ const button = (event.target as Element | null)?.closest?.(BUTTON_SELECTOR);
335
+ if (!button || closestAccordion(button) !== this.root) return;
336
+ const item = closestAccordionItem(button);
337
+ if (!item) return;
338
+ event.preventDefault();
339
+ this.toggle(this.keyOf(item));
340
+ };
341
+
342
+ private readonly onKeydown = (event: Event): void => {
343
+ // A disclosure has one button: Enter / Space are the button's own, and there is nowhere to move.
344
+ if (this.kind !== CompositeKind.Accordion) return;
345
+ const e = event as KeyboardEvent;
346
+ const action = accordionKeyAction(e.key);
347
+ const button = (e.target as Element | null)?.closest?.(BUTTON_SELECTOR);
348
+ if (!action || !button || closestAccordion(button) !== this.root) return;
349
+ const buttons = this.buttons();
350
+ const at = buttons.indexOf(button as HTMLElement);
351
+ const target = buttons[accordionFocusIndex(action, at, buttons.length)];
352
+ if (!target) return;
353
+ e.preventDefault();
354
+ target.focus();
355
+ };
356
+
357
+ private readonly onBeforeMatch = (event: Event): void => {
358
+ const region = (event.target as Element | null)?.closest?.(REGION_SELECTOR);
359
+ const item = closestAccordionItem(region);
360
+ const items = this.items();
361
+ if (!item || !items.includes(item)) return;
362
+ this.open(this.keyOf(item, items));
363
+ };
364
+ }
365
+
366
+ function dispatch(el: Element, type: string): void {
367
+ const Ctor = (globalThis as { CustomEvent?: typeof CustomEvent }).CustomEvent;
368
+ if (Ctor) el.dispatchEvent(new Ctor(type, { bubbles: false }));
369
+ }
370
+
371
+ /** Attach the runtime to one rendered composite root, unless it already runs one. Returns the NEW runtime, or
372
+ * null when the root already had one (so a caller destroys only what it created). */
373
+ export function attachAccordionAt(root: HTMLElement, options: Omit<AccordionRuntimeOptions, 'rootId' | 'behaviour'> = {}): AccordionRuntime | null {
374
+ if (RUNTIMES.has(root)) return null;
375
+ return new AccordionRuntime(root, options);
376
+ }
377
+
378
+ /** Attach the runtime to every accordion under `container` that has none yet. Returns the new runtimes. */
379
+ export function attachAccordions(container: ParentNode, options: Omit<AccordionRuntimeOptions, 'rootId' | 'behaviour'> = {}): AccordionRuntime[] {
380
+ const out: AccordionRuntime[] = [];
381
+ container.querySelectorAll<HTMLElement>(ROOT_SELECTOR).forEach(root => {
382
+ const runtime = attachAccordionAt(root, options);
383
+ if (runtime) out.push(runtime);
384
+ });
385
+ return out;
386
+ }
387
+
388
+ /** Detach a runtime `attachAccordions` created. */
389
+ export function detachAccordion(runtime: AccordionRuntime): void {
390
+ runtime.destroy();
391
+ }
392
+
393
+ /** Every live runtime by its root — the canvas's and the site's alike (a runtime registers itself and leaves on
394
+ * destroy), so an item action finds the runtime of whatever item it targets. */
395
+ const RUNTIMES = new WeakMap<Element, AccordionRuntime>();
396
+
397
+ const ROW_INDEX_ATTRIBUTE = 'data-iox-row-idx';
398
+
399
+ /** The repeated rows an element sits in, outermost first — each row's index as its row wrappers stamp it
400
+ * (`data-iox-row-idx`: the builder's Repeater rows, the engine's component path). The builder folds them into a
401
+ * composite's scope id (`compositeScopeId`, vectoriox/iox-ui#20); the element itself is not counted. */
402
+ export function compositeRowKeys(el: Element | null | undefined): string[] {
403
+ const keys: string[] = [];
404
+ for (let at = el?.parentElement?.closest?.(`[${ROW_INDEX_ATTRIBUTE}]`); at; at = at.parentElement?.closest(`[${ROW_INDEX_ATTRIBUTE}]`)) {
405
+ keys.unshift(at.getAttribute(ROW_INDEX_ATTRIBUTE) ?? '');
406
+ }
407
+ return keys;
408
+ }
409
+
410
+ /** The runtime running a composite root, or null. */
411
+ export function accordionRuntimeOf(root: Element | null | undefined): AccordionRuntime | null {
412
+ return (root && RUNTIMES.get(root)) || null;
413
+ }
414
+
415
+ // ── T14: open / close / toggle an item from anywhere ─────────────────────────────────────────────
416
+
417
+ /** What an item action does to its target item (actions-core `openItem` / `closeItem` / `toggleItem`). */
418
+ export enum CompositeItemAction {
419
+ Open = 'open',
420
+ Close = 'close',
421
+ Toggle = 'toggle',
422
+ }
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
+ /** How much of an opened item must already be on screen for the action NOT to scroll to it (its header row). */
437
+ const VISIBLE_HEAD_PX = 48;
438
+
439
+ /** True when an item an action just opened is out of view and the page should scroll to it (T14: "opens that item
440
+ * and scrolls to it") — never when its head is already on screen, so a button beside it does not jolt the page. */
441
+ export function shouldScrollToItem(top: number, viewportHeight: number): boolean {
442
+ return top < 0 || top + VISIBLE_HEAD_PX > viewportHeight;
443
+ }