@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.
- package/actions-core/index.d.ts +12 -3
- package/composite-core/index.d.ts +500 -2
- package/design-core/index.d.ts +417 -0
- package/fesm2022/vectoriox-iox-ui-actions-core.mjs +26 -2
- package/fesm2022/vectoriox-iox-ui-actions-core.mjs.map +1 -1
- package/fesm2022/vectoriox-iox-ui-composite-core.mjs +1205 -2
- package/fesm2022/vectoriox-iox-ui-composite-core.mjs.map +1 -1
- package/fesm2022/vectoriox-iox-ui-design-core.mjs +489 -0
- package/fesm2022/vectoriox-iox-ui-design-core.mjs.map +1 -1
- package/fesm2022/vectoriox-iox-ui-forms-core.mjs +2 -1
- package/fesm2022/vectoriox-iox-ui-forms-core.mjs.map +1 -1
- package/fesm2022/vectoriox-iox-ui-generation-core.mjs +113 -8
- package/fesm2022/vectoriox-iox-ui-generation-core.mjs.map +1 -1
- package/fesm2022/vectoriox-iox-ui.mjs +94 -19
- package/fesm2022/vectoriox-iox-ui.mjs.map +1 -1
- package/generation-core/index.d.ts +53 -3
- package/index.d.ts +60 -10
- package/package.json +1 -1
- package/src/actions-core/action.model.ts +4 -2
- package/src/actions-core/element-actions.ts +29 -1
- package/src/composite-core/accordion-runtime.ts +443 -0
- package/src/composite-core/accordion.ts +694 -0
- package/src/composite-core/composite-kinds.ts +110 -0
- package/src/composite-core/composite-node.ts +16 -0
- package/src/composite-core/disclosure.ts +125 -0
- package/src/composite-core/public-api.ts +7 -1
- package/src/composite-core/structure.ts +14 -0
- package/src/design-core/flavor-spec.generated.ts +489 -0
- package/src/forms-core/nesting.ts +2 -1
- package/src/generation-core/compose-site.ts +49 -1
- package/src/generation-core/node-baselines.generated.ts +6 -2
- package/src/generation-core/patterns.ts +10 -0
- package/src/generation-core/serialized-node.ts +4 -0
- package/src/generation-core/site-spec.generated.ts +46 -1
- package/src/generation-core/validate-site-plan.ts +5 -4
- 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
|
+
}
|