@vectoriox/iox-ui 5.36.0 → 5.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/actions-core/index.d.ts +17 -3
  2. package/composite-core/index.d.ts +463 -31
  3. package/design-core/index.d.ts +130 -3
  4. package/fesm2022/vectoriox-iox-ui-actions-core.mjs +33 -3
  5. package/fesm2022/vectoriox-iox-ui-actions-core.mjs.map +1 -1
  6. package/fesm2022/vectoriox-iox-ui-composite-core.mjs +1062 -121
  7. package/fesm2022/vectoriox-iox-ui-composite-core.mjs.map +1 -1
  8. package/fesm2022/vectoriox-iox-ui-design-core.mjs +172 -3
  9. package/fesm2022/vectoriox-iox-ui-design-core.mjs.map +1 -1
  10. package/fesm2022/vectoriox-iox-ui-generation-core.mjs +410 -10
  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 +482 -120
  15. package/fesm2022/vectoriox-iox-ui.mjs.map +1 -1
  16. package/generation-core/index.d.ts +167 -4
  17. package/index.d.ts +209 -25
  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/action.model.ts +4 -2
  22. package/src/actions-core/element-actions.ts +37 -2
  23. package/src/composite-core/accordion-runtime.ts +117 -28
  24. package/src/composite-core/accordion.ts +89 -45
  25. package/src/composite-core/composite-kinds.ts +110 -0
  26. package/src/composite-core/composite-node.ts +15 -0
  27. package/src/composite-core/disclosure.ts +125 -0
  28. package/src/composite-core/lightbox.ts +192 -0
  29. package/src/composite-core/public-api.ts +7 -1
  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 +25 -5
  33. package/src/design-core/element-styles.ts +27 -0
  34. package/src/design-core/flavor-spec.generated.ts +146 -2
  35. package/src/generation-core/baseline-roles.ts +2 -0
  36. package/src/generation-core/compose-site.ts +80 -4
  37. package/src/generation-core/node-baselines.generated.ts +148 -4
  38. package/src/generation-core/patterns.ts +17 -0
  39. package/src/generation-core/site-spec.generated.ts +164 -0
  40. package/src/media-core/public-api.ts +9 -0
  41. package/src/media-core/video-runtime.ts +239 -0
  42. package/src/media-core/video.ts +443 -0
  43. package/styles/iox-design.css +96 -0
@@ -1,21 +1,26 @@
1
1
  import {
2
- AccordionBehaviour, AccordionClass, AccordionDefaultOn, accordionDomIds, accordionFocusIndex, accordionKeyAction, AccordionRole,
3
- ACCORDION_DEFAULT_ONS, COMPOSITE_ATTRIBUTE, DEFAULT_ON_ATTRIBUTE, initialAccordionOpen, ITEM_KEY_ATTRIBUTE, MULTIPLE_ATTRIBUTE,
4
- nextAccordionState, ON_ATTRIBUTE, openAccordionItem, UNTIL_FOUND,
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
5
  } from './accordion';
6
+ import { CompositeKind } from './composite-kinds';
6
7
 
7
8
  /**
8
9
  * The Accordion's RUNTIME (CC2) — ONE copy, DOM only, for the builder canvas and the live site (both engine
9
10
  * paths render the same markup). It reads the markup the renderers write (iox-design.css + accordion.ts's
10
11
  * attribute planners), so the server's first paint and every later state agree:
11
12
  *
12
- * [root] data-iox-composite="accordion" data-iox-default-on [data-iox-multiple] (the node's own element —
13
- * its role class may sit on a wrapper host, as the engine's component-path Repeater draws it)
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)
14
15
  * .iox-role-accordion-item data-iox-item-key [data-iox-on]
15
16
  * hN.iox-accordion-heading > button.iox-accordion-button id aria-expanded aria-controls
16
17
  * .iox-accordion-track [data-iox-on] > .iox-accordion-region role=region [hidden=until-found]
17
18
  * .iox-role-accordion-panel
18
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
+ *
19
24
  * It owns: a click on a header toggles its item; ↓ ↑ Home End move between the headers of THIS accordion
20
25
  * (a nested one keeps its own); find-in-page (`beforematch`) opens the item that holds the match; a closed
21
26
  * panel gets `hidden="until-found"` after its track has collapsed (at once under reduced motion). Every
@@ -27,9 +32,12 @@ import {
27
32
  export const ITEM_ON_EVENT = 'iox-item-on';
28
33
  export const ITEM_OFF_EVENT = 'iox-item-off';
29
34
 
30
- const ROOT_SELECTOR = `[${COMPOSITE_ATTRIBUTE}="${AccordionRole.Accordion}"]`;
31
- const ITEM_SELECTOR = '.iox-role-accordion-item';
32
- const PANEL_SELECTOR = '.iox-role-accordion-panel';
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';
33
41
  const BUTTON_SELECTOR = `.${AccordionClass.Button}`;
34
42
  const TRACK_SELECTOR = `.${AccordionClass.Track}`;
35
43
  const REGION_SELECTOR = `.${AccordionClass.Region}`;
@@ -38,8 +46,11 @@ const MAX_CLOSE_MS = 600;
38
46
  const REDUCED_MOTION = '(prefers-reduced-motion: reduce)';
39
47
 
40
48
  export interface AccordionRuntimeOptions {
41
- /** The root's node id — the ARIA ids' prefix (else read from the root's `iox-node-<id>` class). */
42
- rootId?: string;
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;
43
54
  /** The behaviour (else read from the root's data attributes). */
44
55
  behaviour?: AccordionBehaviour;
45
56
  /** Keys forced open on top of the live state (the builder's design state). */
@@ -50,16 +61,24 @@ export interface AccordionRuntimeOptions {
50
61
  reducedMotion?: () => boolean;
51
62
  }
52
63
 
53
- /** The accordion an element belongs to (the element itself when it is a root). */
64
+ /** The composite (accordion or disclosure) an element belongs to (the element itself when it is a root). */
54
65
  export function closestAccordion(el: Element | null | undefined): HTMLElement | null {
55
66
  return (el?.closest?.(ROOT_SELECTOR) as HTMLElement | null) ?? null;
56
67
  }
57
68
 
58
- /** The item an element sits in (itself when it is one). */
69
+ /** The item an element sits in (itself when it is one) — an accordion item, or a disclosure. */
59
70
  export function closestAccordionItem(el: Element | null | undefined): HTMLElement | null {
60
71
  return (el?.closest?.(ITEM_SELECTOR) as HTMLElement | null) ?? null;
61
72
  }
62
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
+
63
82
  /** Subscribe to an item turning on / off — what the `itemOn` / `itemOff` interaction triggers listen to. The
64
83
  * item is the element's own item (the element itself when it is one). Returns the unsubscribe. */
65
84
  export function onAccordionItemState(el: Element, on: boolean, handler: () => void): () => void {
@@ -103,10 +122,13 @@ export class AccordionRuntime {
103
122
  private behaviourValue: AccordionBehaviour;
104
123
  /** The builder attaches a root before its items render: the saved default is applied once they do. */
105
124
  private awaitingItems = false;
125
+ readonly kind: CompositeKind;
106
126
 
107
127
  constructor(readonly root: HTMLElement, private readonly options: AccordionRuntimeOptions = {}) {
128
+ this.kind = options.kind ?? kindOf(root);
108
129
  this.behaviourValue = options.behaviour ?? behaviourOf(root);
109
- root.setAttribute(COMPOSITE_ATTRIBUTE, AccordionRole.Accordion);
130
+ root.setAttribute(COMPOSITE_ATTRIBUTE, this.kind);
131
+ RUNTIMES.set(root, this);
110
132
  // The server rendered the first state; with no server state the root's default decides.
111
133
  // (The builder passes the behaviour and starts from the saved default; on the live site the server
112
134
  // stamped every item's key and state, and that is the truth.)
@@ -144,9 +166,10 @@ export class AccordionRuntime {
144
166
  else this.stamp();
145
167
  }
146
168
 
147
- /** THIS accordion's items, in document order (a nested accordion's items are its own). */
169
+ /** THIS composite's items, in document order (a nested composite's items are its own); a disclosure's is itself. */
148
170
  items(): HTMLElement[] {
149
- return Array.from(this.root.querySelectorAll<HTMLElement>(ITEM_SELECTOR)).filter(i => closestAccordion(i.parentElement) === this.root);
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);
150
173
  }
151
174
 
152
175
  /** The open keys the live state holds (before anything forced). */
@@ -173,6 +196,19 @@ export class AccordionRuntime {
173
196
  this.stamp();
174
197
  }
175
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
+
176
212
  /** Back to the saved default ("Showing on canvas ▸ As saved"). */
177
213
  resetLive(): void {
178
214
  const items = this.items();
@@ -186,6 +222,7 @@ export class AccordionRuntime {
186
222
  }
187
223
 
188
224
  destroy(): void {
225
+ if (RUNTIMES.get(this.root) === this) RUNTIMES.delete(this.root);
189
226
  this.root.removeEventListener('click', this.onClick, true);
190
227
  this.root.removeEventListener('keydown', this.onKeydown);
191
228
  this.root.removeEventListener('beforematch', this.onBeforeMatch, true);
@@ -217,7 +254,8 @@ export class AccordionRuntime {
217
254
  first = true;
218
255
  }
219
256
  const forced = this.options.forced?.() ?? new Set<string>();
220
- const rootId = this.options.rootId ?? nodeIdOf(this.root);
257
+ const own = this.options.rootId;
258
+ const rootId = (typeof own === 'function' ? own() : own) ?? this.root.getAttribute(SCOPE_ATTRIBUTE) ?? nodeIdOf(this.root);
221
259
  const next = new Set<string>();
222
260
  items.forEach((item, index) => {
223
261
  const key = this.keyOf(item, items);
@@ -238,7 +276,7 @@ export class AccordionRuntime {
238
276
  }
239
277
 
240
278
  private stampItem(item: HTMLElement, key: string, index: number, on: boolean, rootId: string, first: boolean): void {
241
- const ids = accordionDomIds(rootId, key);
279
+ const ids = accordionDomIds(rootId, key, this.kind);
242
280
  if (!item.hasAttribute(ITEM_KEY_ATTRIBUTE)) item.setAttribute(ITEM_KEY_ATTRIBUTE, key);
243
281
  setAttr(item, ON_ATTRIBUTE, on ? '' : null);
244
282
  const button = this.own(item, BUTTON_SELECTOR);
@@ -252,9 +290,10 @@ export class AccordionRuntime {
252
290
  setAttr(button, ON_ATTRIBUTE, on ? '' : null);
253
291
  setAttr(track, ON_ATTRIBUTE, on ? '' : null);
254
292
  setAttr(panel, ON_ATTRIBUTE, on ? '' : null);
255
- setAttr(region, 'role', 'region');
293
+ const labelled = hasLabelledRegion(this.kind);
294
+ setAttr(region, 'role', labelled ? 'region' : null);
256
295
  setAttr(region, 'id', ids.panel);
257
- setAttr(region, 'aria-labelledby', ids.trigger);
296
+ setAttr(region, 'aria-labelledby', labelled ? ids.trigger : null);
258
297
  if (!region) return;
259
298
  const pending = this.closing.get(key);
260
299
  if (on) {
@@ -301,6 +340,8 @@ export class AccordionRuntime {
301
340
  };
302
341
 
303
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;
304
345
  const e = event as KeyboardEvent;
305
346
  const action = accordionKeyAction(e.key);
306
347
  const button = (e.target as Element | null)?.closest?.(BUTTON_SELECTOR);
@@ -316,8 +357,9 @@ export class AccordionRuntime {
316
357
  private readonly onBeforeMatch = (event: Event): void => {
317
358
  const region = (event.target as Element | null)?.closest?.(REGION_SELECTOR);
318
359
  const item = closestAccordionItem(region);
319
- if (!item || closestAccordion(item.parentElement) !== this.root) return;
320
- this.open(this.keyOf(item));
360
+ const items = this.items();
361
+ if (!item || !items.includes(item)) return;
362
+ this.open(this.keyOf(item, items));
321
363
  };
322
364
  }
323
365
 
@@ -326,13 +368,11 @@ function dispatch(el: Element, type: string): void {
326
368
  if (Ctor) el.dispatchEvent(new Ctor(type, { bubbles: false }));
327
369
  }
328
370
 
329
- /** Attach the runtime to one rendered accordion root, unless it already runs one. Returns the NEW runtime, or
371
+ /** Attach the runtime to one rendered composite root, unless it already runs one. Returns the NEW runtime, or
330
372
  * null when the root already had one (so a caller destroys only what it created). */
331
373
  export function attachAccordionAt(root: HTMLElement, options: Omit<AccordionRuntimeOptions, 'rootId' | 'behaviour'> = {}): AccordionRuntime | null {
332
- if (ATTACHED.has(root)) return null;
333
- const runtime = new AccordionRuntime(root, options);
334
- ATTACHED.set(root, runtime);
335
- return runtime;
374
+ if (RUNTIMES.has(root)) return null;
375
+ return new AccordionRuntime(root, options);
336
376
  }
337
377
 
338
378
  /** Attach the runtime to every accordion under `container` that has none yet. Returns the new runtimes. */
@@ -348,7 +388,56 @@ export function attachAccordions(container: ParentNode, options: Omit<AccordionR
348
388
  /** Detach a runtime `attachAccordions` created. */
349
389
  export function detachAccordion(runtime: AccordionRuntime): void {
350
390
  runtime.destroy();
351
- ATTACHED.delete(runtime.root);
352
391
  }
353
392
 
354
- const ATTACHED = new WeakMap<Element, AccordionRuntime>();
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
+ }
@@ -1,7 +1,10 @@
1
1
  import { readOwnedTrait } from '@vectoriox/iox-ui/design-core';
2
2
 
3
3
  import {
4
- CompositeNode, CompositeNodeFactory, findByRole, readProp, removeFromComposite, walkComposite, writeProp, writeStyles,
4
+ AccordionRole, COMPOSITE_KIND_ROLES, CompositeKind, compositeKindOfRole,
5
+ } from './composite-kinds';
6
+ import {
7
+ CompositeNode, CompositeNodeFactory, findByRole, makeCompositePart, readProp, removeFromComposite, walkComposite, writeProp, writeStyles,
5
8
  } from './composite-node';
6
9
 
7
10
  /**
@@ -23,14 +26,6 @@ import {
23
26
  * functions), the keyboard one key map — read by the builder canvas, both engine paths and the composer.
24
27
  */
25
28
 
26
- export enum AccordionRole {
27
- Accordion = 'accordion',
28
- Item = 'accordion.item',
29
- Trigger = 'accordion.trigger',
30
- Icon = 'accordion.icon',
31
- Panel = 'accordion.panel',
32
- }
33
-
34
29
  /** The behaviour options a composite root stores as ordinary TRAITS (composite plan §3). */
35
30
  export enum CompositeBehaviourTrait {
36
31
  /** Several items may be open at once ("Allow several open"). */
@@ -135,17 +130,15 @@ export interface AccordionOptions {
135
130
 
136
131
  function make(create: CompositeNodeFactory, key: string, type: string, role: AccordionRole | null, style: string,
137
132
  props: Record<string, unknown> = {}, label?: string): CompositeNode {
138
- const node = create({ key, type, props, elementStyle: style });
139
- if (role) node.role = role;
140
- if (label) node.label = label;
141
- if (role && PART_BASE[role]) writeStyles(node, PART_BASE[role]);
142
- return node;
133
+ return makeCompositePart(create, { key, type, props, elementStyle: style }, role, label, role ? PART_BASE[role] : undefined);
143
134
  }
144
135
 
145
- /** The item's icon; its Item-open state (the turn) is driven by ITS item (`stateTriggers`). */
146
- function buildIcon(create: CompositeNodeFactory, key: string, icon: Exclude<AccordionIcon, AccordionIcon.None>, itemId?: string): CompositeNode {
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 {
147
140
  const glyph = ACCORDION_ICON_GLYPH[icon];
148
- const node = make(create, key, Type.Icon, AccordionRole.Icon, Style.Icon, { iconClass: glyph.iconClass }, 'Icon');
141
+ const node = makeCompositePart(create, { key, type: Type.Icon, props: { iconClass: glyph.iconClass }, elementStyle: Style.Icon }, role, 'Icon', PART_BASE[AccordionRole.Icon]);
149
142
  node.stateStyles = { ...(node.stateStyles ?? {}), [ITEM_OPEN_STATE]: { rotate: glyph.turn } };
150
143
  if (itemId) node.stateTriggers = { ...(node.stateTriggers ?? {}), [ITEM_OPEN_STATE]: itemId };
151
144
  return node;
@@ -158,7 +151,7 @@ export function buildAccordionItem(create: CompositeNodeFactory, words: Accordio
158
151
  const trigger = make(create, `${itemKey}-trigger`, Type.LinkedContainer, AccordionRole.Trigger, Style.Trigger, {}, 'Header');
159
152
  const question = make(create, `${itemKey}-question`, Type.Heading, null, Style.Question, { content: words.question, level: DEFAULT_LEVEL }, 'Heading');
160
153
  writeStyles(question, QUESTION_BASE);
161
- trigger.children = [question, ...(icon === AccordionIcon.None ? [] : [buildIcon(create, `${itemKey}-icon`, icon, item.id)])];
154
+ trigger.children = [question, ...(icon === AccordionIcon.None ? [] : [buildCompositeIcon(create, `${itemKey}-icon`, icon, item.id)])];
162
155
  const panel = make(create, `${itemKey}-panel`, Type.Container, AccordionRole.Panel, Style.Panel, {}, 'Panel');
163
156
  const answer = make(create, `${itemKey}-answer`, Type.Text, null, Style.Answer, { content: words.answer, tag: 'p' }, 'Text');
164
157
  writeStyles(answer, ANSWER_BASE);
@@ -238,22 +231,37 @@ export function setAccordionBehaviour(root: CompositeNode, patch: Partial<Accord
238
231
  return ok;
239
232
  }
240
233
 
241
- /** The header of an item. */
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). */
242
240
  export function accordionTriggerOf(item: CompositeNode): CompositeNode | undefined {
243
- return findByRole(item, AccordionRole.Trigger);
241
+ return findByRole(item, kindRolesOf(item).trigger);
244
242
  }
245
243
 
246
- /** The panel of an item. */
244
+ /** The panel of an item (either kind). */
247
245
  export function accordionPanelOf(item: CompositeNode): CompositeNode | undefined {
248
- return findByRole(item, AccordionRole.Panel);
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);
249
252
  }
250
253
 
251
- /** The question: the first Heading inside the header. */
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"). */
252
256
  export function accordionQuestionOf(item: CompositeNode): CompositeNode | undefined {
253
257
  const trigger = accordionTriggerOf(item);
254
258
  let hit: CompositeNode | undefined;
255
- if (trigger) walkComposite(trigger, n => { if (!hit && n.type === Type.Heading) hit = n; });
256
- return hit;
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;
257
265
  }
258
266
 
259
267
  /** The answer: the first Text inside the panel. */
@@ -337,10 +345,10 @@ export function accordionRemovalRefusal(root: CompositeNode | null | undefined,
337
345
  return accordionItems(root).length <= 1 ? AccordionEditRefusal.LastItem : null;
338
346
  }
339
347
 
340
- /** The icon the accordion's items wear now (the first item's; None when it has no icon). */
348
+ /** The icon the composite's items wear now (the first item's; None when it has no icon). Either kind. */
341
349
  export function detectAccordionIcon(root: CompositeNode): AccordionIcon {
342
- const first = accordionItems(root)[0];
343
- const icon = first ? findByRole(first, AccordionRole.Icon) : undefined;
350
+ const first = compositeItems(root)[0];
351
+ const icon = first ? findByRole(first, kindRolesOf(root).icon) : undefined;
344
352
  if (!icon) return first ? AccordionIcon.None : AccordionIcon.Chevron;
345
353
  const cls = String(readProp(icon, 'iconClass') ?? '');
346
354
  return cls.includes(ACCORDION_ICON_GLYPH[AccordionIcon.Plus].iconClass.split(' ')[1]) ? AccordionIcon.Plus : AccordionIcon.Chevron;
@@ -353,10 +361,11 @@ export function detectAccordionIcon(root: CompositeNode): AccordionIcon {
353
361
  */
354
362
  export function setAccordionIcon(root: CompositeNode, icon: AccordionIcon, create?: CompositeNodeFactory): number {
355
363
  let changed = 0;
356
- for (const item of accordionItems(root)) {
364
+ const iconRole = kindRolesOf(root).icon;
365
+ for (const item of compositeItems(root)) {
357
366
  const trigger = accordionTriggerOf(item);
358
367
  if (!trigger) continue;
359
- const existing = findByRole(trigger, AccordionRole.Icon);
368
+ const existing = findByRole(trigger, iconRole);
360
369
  if (icon === AccordionIcon.None) {
361
370
  if (existing && removeFromComposite(trigger, existing)) changed++;
362
371
  continue;
@@ -367,7 +376,7 @@ export function setAccordionIcon(root: CompositeNode, icon: AccordionIcon, creat
367
376
  existing.stateStyles = { ...(existing.stateStyles ?? {}), [ITEM_OPEN_STATE]: { ...(existing.stateStyles?.[ITEM_OPEN_STATE] ?? {}), rotate: glyph.turn } };
368
377
  changed++;
369
378
  } else if (create) {
370
- trigger.children = [...(trigger.children ?? []), buildIcon(create, `${item.itemKey ?? 'item'}-icon`, icon, item.id)];
379
+ trigger.children = [...(trigger.children ?? []), buildCompositeIcon(create, `${item.itemKey ?? 'item'}-icon`, icon, item.id, iconRole)];
371
380
  changed++;
372
381
  }
373
382
  }
@@ -404,11 +413,18 @@ export function nextAccordionState(open: ReadonlySet<string>, key: string, behav
404
413
  return behaviour.multiple ? new Set([...open, key]) : new Set([key]);
405
414
  }
406
415
 
407
- /** The open keys after OPENING `key` (find-in-page, selecting into a closed panel). */
416
+ /** The open keys after OPENING `key` (find-in-page, selecting into a closed panel, the `openItem` action). */
408
417
  export function openAccordionItem(open: ReadonlySet<string>, key: string, behaviour: Pick<AccordionBehaviour, 'multiple'>): Set<string> {
409
418
  return open.has(key) ? new Set(open) : nextAccordionState(open, key, behaviour);
410
419
  }
411
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
+
412
428
  export enum AccordionKeyAction {
413
429
  Next = 'next',
414
430
  Previous = 'previous',
@@ -460,35 +476,52 @@ export interface AccordionDomIds {
460
476
  panel: string;
461
477
  }
462
478
 
463
- /** The ARIA pairing ids of an item: unique per accordion (its root's node id) and per item (its key). */
464
- export function accordionDomIds(rootId: string, itemKey: string): AccordionDomIds {
465
- const base = `iox-acc-${domSafeKey(rootId)}-${domSafeKey(itemKey)}`;
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)}`;
466
487
  return { trigger: `${base}-h`, panel: `${base}-p` };
467
488
  }
468
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
+
469
500
  /** One item's render state: its identity, whether it is open, and its ARIA ids. */
470
501
  export interface AccordionItemState {
502
+ kind: CompositeKind;
471
503
  key: string;
472
504
  index: number;
473
505
  on: boolean;
474
506
  ids: AccordionDomIds;
475
507
  }
476
508
 
477
- /** The render scope of one accordion: its root id, behaviour and every item key in order. */
509
+ /** The render scope of one composite: its kind, scope id, behaviour and every item key in order. */
478
510
  export interface AccordionScope {
511
+ kind: CompositeKind;
479
512
  rootId: string;
480
513
  behaviour: AccordionBehaviour;
481
514
  keys: ReadonlyArray<string>;
482
515
  open: ReadonlySet<string>;
483
516
  }
484
517
 
485
- export function accordionScope(rootId: string, behaviour: AccordionBehaviour, keys: ReadonlyArray<string>): AccordionScope {
486
- return { rootId, behaviour, keys, open: initialAccordionOpen(keys, behaviour.defaultOn) };
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) };
487
520
  }
488
521
 
489
522
  export function accordionItemState(scope: AccordionScope, index: number): AccordionItemState {
490
523
  const key = scope.keys[index] ?? `item-${index}`;
491
- return { key, index, on: scope.open.has(key), ids: accordionDomIds(scope.rootId, key) };
524
+ return { kind: scope.kind, key, index, on: scope.open.has(key), ids: accordionDomIds(scope.rootId, key, scope.kind) };
492
525
  }
493
526
 
494
527
  /** Attribute values: a string, or '' for a present boolean attribute. Absent attributes are left out. */
@@ -500,6 +533,9 @@ export const ITEM_KEY_ATTRIBUTE = 'data-iox-item-key';
500
533
  export const COMPOSITE_ATTRIBUTE = 'data-iox-composite';
501
534
  export const MULTIPLE_ATTRIBUTE = 'data-iox-multiple';
502
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';
503
539
  /** The value a closed panel's region carries: find-in-page can still reveal it (D9, board 4). */
504
540
  export const UNTIL_FOUND = 'until-found';
505
541
 
@@ -511,11 +547,12 @@ export enum AccordionClass {
511
547
  Region = 'iox-accordion-region',
512
548
  }
513
549
 
514
- export function accordionRootAttributes(behaviour: AccordionBehaviour): DomAttributes {
550
+ export function accordionRootAttributes(behaviour: AccordionBehaviour, kind: CompositeKind = CompositeKind.Accordion, scopeId?: string): DomAttributes {
515
551
  return {
516
- [COMPOSITE_ATTRIBUTE]: AccordionRole.Accordion,
552
+ [COMPOSITE_ATTRIBUTE]: kind,
517
553
  [DEFAULT_ON_ATTRIBUTE]: behaviour.defaultOn,
518
- ...(behaviour.multiple ? { [MULTIPLE_ATTRIBUTE]: '' } : {}),
554
+ ...(behaviour.multiple && kind === CompositeKind.Accordion ? { [MULTIPLE_ATTRIBUTE]: '' } : {}),
555
+ ...(scopeId ? { [SCOPE_ATTRIBUTE]: scopeId } : {}),
519
556
  };
520
557
  }
521
558
 
@@ -538,16 +575,23 @@ export function accordionTrackAttributes(state: AccordionItemState): DomAttribut
538
575
  return state.on ? { [ON_ATTRIBUTE]: '' } : {};
539
576
  }
540
577
 
541
- /** The region the panel sits in: `hidden="until-found"` while closed, so its words stay in the page. */
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). */
542
580
  export function accordionRegionAttributes(state: AccordionItemState): DomAttributes {
581
+ const labelled = hasLabelledRegion(state.kind);
543
582
  return {
544
- role: 'region',
583
+ ...(labelled ? { role: 'region' } : {}),
545
584
  id: state.ids.panel,
546
- 'aria-labelledby': state.ids.trigger,
585
+ ...(labelled ? { 'aria-labelledby': state.ids.trigger } : {}),
547
586
  ...(state.on ? {} : { hidden: UNTIL_FOUND }),
548
587
  };
549
588
  }
550
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
+
551
595
  export function accordionPanelAttributes(state: AccordionItemState): DomAttributes {
552
596
  return state.on ? { [ON_ATTRIBUTE]: '' } : {};
553
597
  }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * The open/close composites' kinds and roles (CC2 Accordion, CC2.1 Disclosure — composite plan T9 / T21). ONE
3
+ * family: a Disclosure is a one-item accordion whose ROOT is its item, so both run the same state machine, the
4
+ * same attribute planners and the same runtime (`accordion-runtime.ts`). Every consumer that treats "an item",
5
+ * "a header" or "a panel" alike asks these predicates, never a role literal.
6
+ */
7
+
8
+ export enum CompositeKind {
9
+ Accordion = 'accordion',
10
+ Disclosure = 'disclosure',
11
+ }
12
+
13
+ export enum AccordionRole {
14
+ Accordion = 'accordion',
15
+ Item = 'accordion.item',
16
+ Trigger = 'accordion.trigger',
17
+ Icon = 'accordion.icon',
18
+ Panel = 'accordion.panel',
19
+ }
20
+
21
+ /**
22
+ * DISCLOSURE ("Read more") — one trigger, one panel. The root IS its item (it carries the item key and
23
+ * `data-iox-on`); the trigger holds two words, the one shown while closed (`more`) and the one shown while open
24
+ * (`less`), swapped by the Item open state — never by script.
25
+ */
26
+ export enum DisclosureRole {
27
+ Disclosure = 'disclosure',
28
+ Trigger = 'disclosure.trigger',
29
+ More = 'disclosure.more',
30
+ Less = 'disclosure.less',
31
+ Icon = 'disclosure.icon',
32
+ Panel = 'disclosure.panel',
33
+ }
34
+
35
+ interface RoleLike { role?: string | null }
36
+
37
+ /** The roles each kind plays in the shared runtime. */
38
+ export interface CompositeKindRoles {
39
+ root: string;
40
+ item: string;
41
+ trigger: string;
42
+ panel: string;
43
+ icon: string;
44
+ }
45
+
46
+ export const COMPOSITE_KIND_ROLES: Readonly<Record<CompositeKind, CompositeKindRoles>> = {
47
+ [CompositeKind.Accordion]: { root: AccordionRole.Accordion, item: AccordionRole.Item, trigger: AccordionRole.Trigger, panel: AccordionRole.Panel, icon: AccordionRole.Icon },
48
+ [CompositeKind.Disclosure]: { root: DisclosureRole.Disclosure, item: DisclosureRole.Disclosure, trigger: DisclosureRole.Trigger, panel: DisclosureRole.Panel, icon: DisclosureRole.Icon },
49
+ };
50
+
51
+ const KINDS = Object.values(CompositeKind);
52
+ const rolesOf = (pick: keyof CompositeKindRoles): ReadonlySet<string> => new Set(KINDS.map(k => COMPOSITE_KIND_ROLES[k][pick]));
53
+ const ROOT_ROLES = rolesOf('root');
54
+ const ITEM_ROLES = rolesOf('item');
55
+ const TRIGGER_ROLES = rolesOf('trigger');
56
+ const PANEL_ROLES = rolesOf('panel');
57
+
58
+ /** The kind a role belongs to (`disclosure.panel` → Disclosure), or null for any other role. */
59
+ export function compositeKindOfRole(role: string | null | undefined): CompositeKind | null {
60
+ const head = role?.split('.')[0];
61
+ return (KINDS as string[]).includes(head ?? '') ? head as CompositeKind : null;
62
+ }
63
+
64
+ /** A root that runs the open/close runtime (an Accordion or a Disclosure). */
65
+ export function isCompositeRoot(node: RoleLike | null | undefined): boolean {
66
+ return !!node?.role && ROOT_ROLES.has(node.role);
67
+ }
68
+
69
+ /** An ITEM — an accordion's item, or a disclosure (its own item). */
70
+ export function isCompositeItem(node: RoleLike | null | undefined): boolean {
71
+ return !!node?.role && ITEM_ROLES.has(node.role);
72
+ }
73
+
74
+ /** A trigger — rendered as a `<button>` that opens its item, never a link. */
75
+ export function isCompositeTrigger(node: RoleLike | null | undefined): boolean {
76
+ return !!node?.role && TRIGGER_ROLES.has(node.role);
77
+ }
78
+
79
+ /** A panel — rendered inside the track + region the one hiding rule collapses. */
80
+ export function isCompositePanel(node: RoleLike | null | undefined): boolean {
81
+ return !!node?.role && PANEL_ROLES.has(node.role);
82
+ }
83
+
84
+ export function isDisclosureRoot(node: RoleLike | null | undefined): boolean {
85
+ return node?.role === DisclosureRole.Disclosure;
86
+ }
87
+
88
+ /** True for any part of a disclosure (its root included). */
89
+ export function isDisclosurePart(node: RoleLike | null | undefined): boolean {
90
+ return compositeKindOfRole(node?.role) === CompositeKind.Disclosure;
91
+ }
92
+
93
+ /** The composite root a node belongs to: the nearest ancestor (or the node itself) that is one. `path` = the
94
+ * node's ancestors, root first. */
95
+ export function compositeRootOf<T extends RoleLike>(node: T, path: ReadonlyArray<T>): T | null {
96
+ if (isCompositeRoot(node)) return node;
97
+ for (let i = path.length - 1; i >= 0; i--) if (isCompositeRoot(path[i])) return path[i];
98
+ return null;
99
+ }
100
+
101
+ /** The item a node sits in — the nearest item of either kind (itself when it is one) — or null. An accordion
102
+ * root ends the walk (its items are below it). */
103
+ export function compositeItemOf<T extends RoleLike>(node: T, path: ReadonlyArray<T>): T | null {
104
+ if (isCompositeItem(node)) return node;
105
+ for (let i = path.length - 1; i >= 0; i--) {
106
+ if (isCompositeItem(path[i])) return path[i];
107
+ if (isCompositeRoot(path[i])) return null;
108
+ }
109
+ return null;
110
+ }