@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
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
import { readOwnedTrait, writeOwnedTrait, elementStyleById, switchElementStyle, LinkType, setColorScheme, COLOR_SCHEME_TRAIT } from '@vectoriox/iox-ui/design-core';
|
|
2
2
|
|
|
3
|
+
/** A recipe part: a NEW node (`create`) playing `role` under `label`, with the layout `base` styles its recipe
|
|
4
|
+
* gives it — its LOOK is its element style. Shared by every recipe (Accordion, Disclosure). */
|
|
5
|
+
function makeCompositePart(create, spec, role, label, base) {
|
|
6
|
+
const node = create(spec);
|
|
7
|
+
if (role)
|
|
8
|
+
node.role = role;
|
|
9
|
+
if (label)
|
|
10
|
+
node.label = label;
|
|
11
|
+
if (base)
|
|
12
|
+
writeStyles(node, base);
|
|
13
|
+
return node;
|
|
14
|
+
}
|
|
3
15
|
const usesTraits = (node) => Array.isArray(node.traits);
|
|
4
16
|
/** A content trait's value — `traits[].default` on a builder node, `props[name]` on a saved one. */
|
|
5
17
|
function readProp(node, name) {
|
|
@@ -105,6 +117,1182 @@ function removeFromComposite(root, node) {
|
|
|
105
117
|
return true;
|
|
106
118
|
}
|
|
107
119
|
|
|
120
|
+
/**
|
|
121
|
+
* The open/close composites' kinds and roles (CC2 Accordion, CC2.1 Disclosure — composite plan T9 / T21). ONE
|
|
122
|
+
* family: a Disclosure is a one-item accordion whose ROOT is its item, so both run the same state machine, the
|
|
123
|
+
* same attribute planners and the same runtime (`accordion-runtime.ts`). Every consumer that treats "an item",
|
|
124
|
+
* "a header" or "a panel" alike asks these predicates, never a role literal.
|
|
125
|
+
*/
|
|
126
|
+
var CompositeKind;
|
|
127
|
+
(function (CompositeKind) {
|
|
128
|
+
CompositeKind["Accordion"] = "accordion";
|
|
129
|
+
CompositeKind["Disclosure"] = "disclosure";
|
|
130
|
+
})(CompositeKind || (CompositeKind = {}));
|
|
131
|
+
var AccordionRole;
|
|
132
|
+
(function (AccordionRole) {
|
|
133
|
+
AccordionRole["Accordion"] = "accordion";
|
|
134
|
+
AccordionRole["Item"] = "accordion.item";
|
|
135
|
+
AccordionRole["Trigger"] = "accordion.trigger";
|
|
136
|
+
AccordionRole["Icon"] = "accordion.icon";
|
|
137
|
+
AccordionRole["Panel"] = "accordion.panel";
|
|
138
|
+
})(AccordionRole || (AccordionRole = {}));
|
|
139
|
+
/**
|
|
140
|
+
* DISCLOSURE ("Read more") — one trigger, one panel. The root IS its item (it carries the item key and
|
|
141
|
+
* `data-iox-on`); the trigger holds two words, the one shown while closed (`more`) and the one shown while open
|
|
142
|
+
* (`less`), swapped by the Item open state — never by script.
|
|
143
|
+
*/
|
|
144
|
+
var DisclosureRole;
|
|
145
|
+
(function (DisclosureRole) {
|
|
146
|
+
DisclosureRole["Disclosure"] = "disclosure";
|
|
147
|
+
DisclosureRole["Trigger"] = "disclosure.trigger";
|
|
148
|
+
DisclosureRole["More"] = "disclosure.more";
|
|
149
|
+
DisclosureRole["Less"] = "disclosure.less";
|
|
150
|
+
DisclosureRole["Icon"] = "disclosure.icon";
|
|
151
|
+
DisclosureRole["Panel"] = "disclosure.panel";
|
|
152
|
+
})(DisclosureRole || (DisclosureRole = {}));
|
|
153
|
+
const COMPOSITE_KIND_ROLES = {
|
|
154
|
+
[CompositeKind.Accordion]: { root: AccordionRole.Accordion, item: AccordionRole.Item, trigger: AccordionRole.Trigger, panel: AccordionRole.Panel, icon: AccordionRole.Icon },
|
|
155
|
+
[CompositeKind.Disclosure]: { root: DisclosureRole.Disclosure, item: DisclosureRole.Disclosure, trigger: DisclosureRole.Trigger, panel: DisclosureRole.Panel, icon: DisclosureRole.Icon },
|
|
156
|
+
};
|
|
157
|
+
const KINDS = Object.values(CompositeKind);
|
|
158
|
+
const rolesOf = (pick) => new Set(KINDS.map(k => COMPOSITE_KIND_ROLES[k][pick]));
|
|
159
|
+
const ROOT_ROLES = rolesOf('root');
|
|
160
|
+
const ITEM_ROLES = rolesOf('item');
|
|
161
|
+
const TRIGGER_ROLES = rolesOf('trigger');
|
|
162
|
+
const PANEL_ROLES = rolesOf('panel');
|
|
163
|
+
/** The kind a role belongs to (`disclosure.panel` → Disclosure), or null for any other role. */
|
|
164
|
+
function compositeKindOfRole(role) {
|
|
165
|
+
const head = role?.split('.')[0];
|
|
166
|
+
return KINDS.includes(head ?? '') ? head : null;
|
|
167
|
+
}
|
|
168
|
+
/** A root that runs the open/close runtime (an Accordion or a Disclosure). */
|
|
169
|
+
function isCompositeRoot(node) {
|
|
170
|
+
return !!node?.role && ROOT_ROLES.has(node.role);
|
|
171
|
+
}
|
|
172
|
+
/** An ITEM — an accordion's item, or a disclosure (its own item). */
|
|
173
|
+
function isCompositeItem(node) {
|
|
174
|
+
return !!node?.role && ITEM_ROLES.has(node.role);
|
|
175
|
+
}
|
|
176
|
+
/** A trigger — rendered as a `<button>` that opens its item, never a link. */
|
|
177
|
+
function isCompositeTrigger(node) {
|
|
178
|
+
return !!node?.role && TRIGGER_ROLES.has(node.role);
|
|
179
|
+
}
|
|
180
|
+
/** A panel — rendered inside the track + region the one hiding rule collapses. */
|
|
181
|
+
function isCompositePanel(node) {
|
|
182
|
+
return !!node?.role && PANEL_ROLES.has(node.role);
|
|
183
|
+
}
|
|
184
|
+
function isDisclosureRoot(node) {
|
|
185
|
+
return node?.role === DisclosureRole.Disclosure;
|
|
186
|
+
}
|
|
187
|
+
/** True for any part of a disclosure (its root included). */
|
|
188
|
+
function isDisclosurePart(node) {
|
|
189
|
+
return compositeKindOfRole(node?.role) === CompositeKind.Disclosure;
|
|
190
|
+
}
|
|
191
|
+
/** The composite root a node belongs to: the nearest ancestor (or the node itself) that is one. `path` = the
|
|
192
|
+
* node's ancestors, root first. */
|
|
193
|
+
function compositeRootOf(node, path) {
|
|
194
|
+
if (isCompositeRoot(node))
|
|
195
|
+
return node;
|
|
196
|
+
for (let i = path.length - 1; i >= 0; i--)
|
|
197
|
+
if (isCompositeRoot(path[i]))
|
|
198
|
+
return path[i];
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
/** The item a node sits in — the nearest item of either kind (itself when it is one) — or null. An accordion
|
|
202
|
+
* root ends the walk (its items are below it). */
|
|
203
|
+
function compositeItemOf(node, path) {
|
|
204
|
+
if (isCompositeItem(node))
|
|
205
|
+
return node;
|
|
206
|
+
for (let i = path.length - 1; i >= 0; i--) {
|
|
207
|
+
if (isCompositeItem(path[i]))
|
|
208
|
+
return path[i];
|
|
209
|
+
if (isCompositeRoot(path[i]))
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* ACCORDION (CC2 — composite K1; complex-components boards 3, 4 and 9, APPROVED 2026-10-20). Not a
|
|
217
|
+
* component: a recipe of ordinary nodes, each part carrying a role, so every part is selectable on the
|
|
218
|
+
* canvas, in the layer tree and the breadcrumb, and wears its own element style (R2).
|
|
219
|
+
*
|
|
220
|
+
* Container `box` role accordion traits multiple · defaultOn (Manual)
|
|
221
|
+
* — or a Repeater bound to a list role accordion ONE template item (From content, R3)
|
|
222
|
+
* └─ Container `accordion-item` role accordion.item itemKey k1 … (Manual) · the row's id (From content)
|
|
223
|
+
* ├─ LinkedContainer `accordion-trigger` role accordion.trigger → <hN><button> (the question's level)
|
|
224
|
+
* │ ├─ Heading `h3` the question (rendered as phrasing inside the button)
|
|
225
|
+
* │ └─ Icon `icon` role accordion.icon Item open → rotate
|
|
226
|
+
* └─ Container `accordion-panel` role accordion.panel inside a hidden="until-found" region
|
|
227
|
+
* └─ Text `body` the answer
|
|
228
|
+
*
|
|
229
|
+
* The behaviour is BUILT IN (composite plan D1 = B): which items are open is one state machine
|
|
230
|
+
* (`nextAccordionState`), the ARIA and DOM state one planner (`accordionItemState` and the attribute
|
|
231
|
+
* functions), the keyboard one key map — read by the builder canvas, both engine paths and the composer.
|
|
232
|
+
*/
|
|
233
|
+
/** The behaviour options a composite root stores as ordinary TRAITS (composite plan §3). */
|
|
234
|
+
var CompositeBehaviourTrait;
|
|
235
|
+
(function (CompositeBehaviourTrait) {
|
|
236
|
+
/** Several items may be open at once ("Allow several open"). */
|
|
237
|
+
CompositeBehaviourTrait["Multiple"] = "multiple";
|
|
238
|
+
/** Which items open by default: first · none · all. */
|
|
239
|
+
CompositeBehaviourTrait["DefaultOn"] = "defaultOn";
|
|
240
|
+
})(CompositeBehaviourTrait || (CompositeBehaviourTrait = {}));
|
|
241
|
+
var AccordionDefaultOn;
|
|
242
|
+
(function (AccordionDefaultOn) {
|
|
243
|
+
AccordionDefaultOn["First"] = "first";
|
|
244
|
+
AccordionDefaultOn["None"] = "none";
|
|
245
|
+
AccordionDefaultOn["All"] = "all";
|
|
246
|
+
})(AccordionDefaultOn || (AccordionDefaultOn = {}));
|
|
247
|
+
const ACCORDION_DEFAULT_ONS = [AccordionDefaultOn.First, AccordionDefaultOn.None, AccordionDefaultOn.All];
|
|
248
|
+
/** Behaviour ▸ Icon — a preset over the items' icons, never a stored trait (detected from the icons). */
|
|
249
|
+
var AccordionIcon;
|
|
250
|
+
(function (AccordionIcon) {
|
|
251
|
+
AccordionIcon["Chevron"] = "chevron";
|
|
252
|
+
AccordionIcon["Plus"] = "plus";
|
|
253
|
+
AccordionIcon["None"] = "none";
|
|
254
|
+
})(AccordionIcon || (AccordionIcon = {}));
|
|
255
|
+
const ACCORDION_ICONS = [AccordionIcon.Chevron, AccordionIcon.Plus, AccordionIcon.None];
|
|
256
|
+
/** Each icon's glyph and how far it turns when its item opens (board 4: a chevron flips, a plus becomes ×). */
|
|
257
|
+
const ACCORDION_ICON_GLYPH = {
|
|
258
|
+
[AccordionIcon.Chevron]: { iconClass: 'ph ph-caret-down', turn: '180deg' },
|
|
259
|
+
[AccordionIcon.Plus]: { iconClass: 'ph ph-plus', turn: '45deg' },
|
|
260
|
+
};
|
|
261
|
+
/** The item-open style state (a part INSIDE an item, styled by its item being open — board 9 "Item open"). */
|
|
262
|
+
const ITEM_OPEN_STATE = 'trigger-on';
|
|
263
|
+
/** A part that IS on (an item, its header, its panel — board 9 "Open"). */
|
|
264
|
+
const ON_STATE = 'on';
|
|
265
|
+
/** Items from content default to twenty rows (board 9 Q13). */
|
|
266
|
+
const ACCORDION_CONTENT_LIMIT = 20;
|
|
267
|
+
/** The words a fresh accordion starts with — generic, the author's to replace (as Card v2's are). */
|
|
268
|
+
const ACCORDION_WORDS = [
|
|
269
|
+
{ question: 'Write the first question people ask', answer: 'Answer it plainly, in two or three short sentences.' },
|
|
270
|
+
{ question: 'Write the second question', answer: 'Answer it plainly, in two or three short sentences.' },
|
|
271
|
+
{ question: 'Write the third question', answer: 'Answer it plainly, in two or three short sentences.' },
|
|
272
|
+
];
|
|
273
|
+
const ACCORDION_NEW_ITEM_WORDS = { question: 'Write a new question', answer: 'Answer it plainly, in two or three short sentences.' };
|
|
274
|
+
var Type$2;
|
|
275
|
+
(function (Type) {
|
|
276
|
+
Type["Container"] = "Container";
|
|
277
|
+
Type["LinkedContainer"] = "LinkedContainer";
|
|
278
|
+
Type["Heading"] = "Heading";
|
|
279
|
+
Type["Text"] = "Text";
|
|
280
|
+
Type["Icon"] = "Icon";
|
|
281
|
+
Type["Repeater"] = "Repeater";
|
|
282
|
+
})(Type$2 || (Type$2 = {}));
|
|
283
|
+
var Style$2;
|
|
284
|
+
(function (Style) {
|
|
285
|
+
Style["Root"] = "box";
|
|
286
|
+
Style["Item"] = "accordion-item";
|
|
287
|
+
Style["Trigger"] = "accordion-trigger";
|
|
288
|
+
Style["Question"] = "h3";
|
|
289
|
+
Style["Icon"] = "icon";
|
|
290
|
+
Style["Panel"] = "accordion-panel";
|
|
291
|
+
Style["Answer"] = "body";
|
|
292
|
+
})(Style$2 || (Style$2 = {}));
|
|
293
|
+
const ZERO_BOX$2 = '0px 0px 0px 0px';
|
|
294
|
+
const HEADING_LEVELS = ['h2', 'h3', 'h4', 'h5', 'h6'];
|
|
295
|
+
const DEFAULT_LEVEL = 'h3';
|
|
296
|
+
/** A linked trait the header must never carry: it is a button, not a link. */
|
|
297
|
+
const LINK_TYPE$1 = 'linkType';
|
|
298
|
+
/** What each part holds in the recipe — layout only; its LOOK is its element style. */
|
|
299
|
+
const PART_BASE$1 = {
|
|
300
|
+
// The items are a column a token apart (`accordion-gap`: 0 for a divider list, 12px for cards).
|
|
301
|
+
[AccordionRole.Accordion]: { display: 'flex', flexDirection: 'column', gap: 'var(--iox-accordion-gap)', width: '100%', minHeight: 'auto', height: 'auto' },
|
|
302
|
+
[AccordionRole.Item]: { display: 'block', width: '100%', minHeight: 'auto', height: 'auto' },
|
|
303
|
+
// The header is the whole row: the question takes the line, the icon sits at its end (board 4).
|
|
304
|
+
[AccordionRole.Trigger]: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '16px', width: '100%', minHeight: 'auto', height: 'auto', textAlign: 'start', cursor: 'pointer' },
|
|
305
|
+
[AccordionRole.Icon]: { flexShrink: '0', fontSize: '22px', transitionDuration: 'var(--iox-dur-base)', transitionTimingFunction: 'var(--iox-ease-standard)' },
|
|
306
|
+
[AccordionRole.Panel]: { display: 'block', width: '100%', minHeight: 'auto', height: 'auto' },
|
|
307
|
+
};
|
|
308
|
+
/** The question INHERITS the header's ink (board 4's wears table: the Question reads no colour token), so the
|
|
309
|
+
* header's hover ink reaches the words; and it sits in the row with no margin of its own. */
|
|
310
|
+
const QUESTION_BASE = { color: 'inherit', margin: ZERO_BOX$2, flexGrow: '1', minWidth: '0px' };
|
|
311
|
+
const ANSWER_BASE = { margin: ZERO_BOX$2, padding: ZERO_BOX$2, minHeight: 'auto' };
|
|
312
|
+
function make$2(create, key, type, role, style, props = {}, label) {
|
|
313
|
+
return makeCompositePart(create, { key, type, props, elementStyle: style }, role, label, role ? PART_BASE$1[role] : undefined);
|
|
314
|
+
}
|
|
315
|
+
/** The icon of an item (an accordion's, or a disclosure's — `role`); its Item-open state (the turn) is driven by
|
|
316
|
+
* ITS item (`stateTriggers`). ONE builder for both kinds. */
|
|
317
|
+
function buildCompositeIcon(create, key, icon, itemId, role = AccordionRole.Icon) {
|
|
318
|
+
const glyph = ACCORDION_ICON_GLYPH[icon];
|
|
319
|
+
const node = makeCompositePart(create, { key, type: Type$2.Icon, props: { iconClass: glyph.iconClass }, elementStyle: Style$2.Icon }, role, 'Icon', PART_BASE$1[AccordionRole.Icon]);
|
|
320
|
+
node.stateStyles = { ...(node.stateStyles ?? {}), [ITEM_OPEN_STATE]: { rotate: glyph.turn } };
|
|
321
|
+
if (itemId)
|
|
322
|
+
node.stateTriggers = { ...(node.stateTriggers ?? {}), [ITEM_OPEN_STATE]: itemId };
|
|
323
|
+
return node;
|
|
324
|
+
}
|
|
325
|
+
/** One item: header (question + icon) and panel (answer). `key` makes every part's recipe key unique. */
|
|
326
|
+
function buildAccordionItem(create, words, itemKey, icon = AccordionIcon.Chevron) {
|
|
327
|
+
const item = make$2(create, `${itemKey}`, Type$2.Container, AccordionRole.Item, Style$2.Item, {}, 'Item');
|
|
328
|
+
item.itemKey = itemKey;
|
|
329
|
+
const trigger = make$2(create, `${itemKey}-trigger`, Type$2.LinkedContainer, AccordionRole.Trigger, Style$2.Trigger, {}, 'Header');
|
|
330
|
+
const question = make$2(create, `${itemKey}-question`, Type$2.Heading, null, Style$2.Question, { content: words.question, level: DEFAULT_LEVEL }, 'Heading');
|
|
331
|
+
writeStyles(question, QUESTION_BASE);
|
|
332
|
+
trigger.children = [question, ...(icon === AccordionIcon.None ? [] : [buildCompositeIcon(create, `${itemKey}-icon`, icon, item.id)])];
|
|
333
|
+
const panel = make$2(create, `${itemKey}-panel`, Type$2.Container, AccordionRole.Panel, Style$2.Panel, {}, 'Panel');
|
|
334
|
+
const answer = make$2(create, `${itemKey}-answer`, Type$2.Text, null, Style$2.Answer, { content: words.answer, tag: 'p' }, 'Text');
|
|
335
|
+
writeStyles(answer, ANSWER_BASE);
|
|
336
|
+
panel.children = [answer];
|
|
337
|
+
item.children = [trigger, panel];
|
|
338
|
+
return item;
|
|
339
|
+
}
|
|
340
|
+
/** A fresh Accordion: three items, one open at a time, the first open by default (board 4). */
|
|
341
|
+
function buildAccordionRecipe(create, options = {}) {
|
|
342
|
+
const root = make$2(create, 'accordion', Type$2.Container, AccordionRole.Accordion, Style$2.Root, {}, 'Accordion');
|
|
343
|
+
writeProp(root, CompositeBehaviourTrait.Multiple, options.multiple ?? false);
|
|
344
|
+
writeProp(root, CompositeBehaviourTrait.DefaultOn, options.defaultOn ?? AccordionDefaultOn.First);
|
|
345
|
+
const words = options.items ?? ACCORDION_WORDS;
|
|
346
|
+
root.children = words.map((w, i) => buildAccordionItem(create, w, `k${i + 1}`, options.icon ?? AccordionIcon.Chevron));
|
|
347
|
+
return root;
|
|
348
|
+
}
|
|
349
|
+
// ── reading a tree ──────────────────────────────────────────────────────────────────────────────
|
|
350
|
+
function isAccordionRoot(node) {
|
|
351
|
+
return node?.role === AccordionRole.Accordion;
|
|
352
|
+
}
|
|
353
|
+
/** True for any part of an accordion (its root included). */
|
|
354
|
+
function isAccordionPart(node) {
|
|
355
|
+
return !!node?.role && (node.role === AccordionRole.Accordion || node.role.startsWith(`${AccordionRole.Accordion}.`));
|
|
356
|
+
}
|
|
357
|
+
/** The accordion a node belongs to: the nearest ancestor (or the node itself) playing `accordion`.
|
|
358
|
+
* `path` = the node's ancestors, root first. */
|
|
359
|
+
function accordionRootOf(node, path) {
|
|
360
|
+
if (isAccordionRoot(node))
|
|
361
|
+
return node;
|
|
362
|
+
for (let i = path.length - 1; i >= 0; i--)
|
|
363
|
+
if (isAccordionRoot(path[i]))
|
|
364
|
+
return path[i];
|
|
365
|
+
return null;
|
|
366
|
+
}
|
|
367
|
+
/** The item a node sits in (the node itself when it is one), or null. */
|
|
368
|
+
function accordionItemOf(node, path) {
|
|
369
|
+
if (node.role === AccordionRole.Item)
|
|
370
|
+
return node;
|
|
371
|
+
for (let i = path.length - 1; i >= 0; i--) {
|
|
372
|
+
if (path[i].role === AccordionRole.Item)
|
|
373
|
+
return path[i];
|
|
374
|
+
if (isAccordionRoot(path[i]))
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
/** True when the root takes its items from content (a Repeater bound to a list, R3). */
|
|
380
|
+
function isAccordionFromContent(root) {
|
|
381
|
+
return !!root && root.type === Type$2.Repeater;
|
|
382
|
+
}
|
|
383
|
+
/** The root's items in order — every item (Manual), or the one template item (From content). */
|
|
384
|
+
function accordionItems(root) {
|
|
385
|
+
return (root.children ?? []).filter(c => c.role === AccordionRole.Item);
|
|
386
|
+
}
|
|
387
|
+
const asBool = (v) => v === true || v === 'true';
|
|
388
|
+
/** The root's behaviour (an unset trait reads its default: one at a time, the first open). */
|
|
389
|
+
function accordionBehaviour(root) {
|
|
390
|
+
const raw = root ? readProp(root, CompositeBehaviourTrait.DefaultOn) : undefined;
|
|
391
|
+
const defaultOn = ACCORDION_DEFAULT_ONS.includes(raw) ? raw : AccordionDefaultOn.First;
|
|
392
|
+
return { multiple: root ? asBool(readProp(root, CompositeBehaviourTrait.Multiple)) : false, defaultOn };
|
|
393
|
+
}
|
|
394
|
+
/** Behaviour ▸ Allow several open / Open by default. False when the node has no such trait. */
|
|
395
|
+
function setAccordionBehaviour(root, patch) {
|
|
396
|
+
let ok = true;
|
|
397
|
+
if (patch.multiple !== undefined)
|
|
398
|
+
ok = writeProp(root, CompositeBehaviourTrait.Multiple, patch.multiple) && ok;
|
|
399
|
+
if (patch.defaultOn !== undefined)
|
|
400
|
+
ok = writeProp(root, CompositeBehaviourTrait.DefaultOn, patch.defaultOn) && ok;
|
|
401
|
+
return ok;
|
|
402
|
+
}
|
|
403
|
+
/** The roles of the kind an item belongs to (an accordion item, or a disclosure — its own item). */
|
|
404
|
+
function kindRolesOf(node) {
|
|
405
|
+
return COMPOSITE_KIND_ROLES[compositeKindOfRole(node.role) ?? CompositeKind.Accordion];
|
|
406
|
+
}
|
|
407
|
+
/** The header of an item (either kind). */
|
|
408
|
+
function accordionTriggerOf(item) {
|
|
409
|
+
return findByRole(item, kindRolesOf(item).trigger);
|
|
410
|
+
}
|
|
411
|
+
/** The panel of an item (either kind). */
|
|
412
|
+
function accordionPanelOf(item) {
|
|
413
|
+
return findByRole(item, kindRolesOf(item).panel);
|
|
414
|
+
}
|
|
415
|
+
/** A root's items — an accordion's (`accordionItems`), or a disclosure, which is its own one item. */
|
|
416
|
+
function compositeItems(root) {
|
|
417
|
+
return compositeKindOfRole(root.role) === CompositeKind.Disclosure ? [root] : accordionItems(root);
|
|
418
|
+
}
|
|
419
|
+
/** The question: the first Heading inside the header — a disclosure's toggle holds no heading, so its first word
|
|
420
|
+
* (the Text shown while closed, "Read more"). */
|
|
421
|
+
function accordionQuestionOf(item) {
|
|
422
|
+
const trigger = accordionTriggerOf(item);
|
|
423
|
+
let hit;
|
|
424
|
+
let text;
|
|
425
|
+
if (trigger)
|
|
426
|
+
walkComposite(trigger, n => {
|
|
427
|
+
if (!hit && n.type === Type$2.Heading)
|
|
428
|
+
hit = n;
|
|
429
|
+
if (!text && n.type === Type$2.Text)
|
|
430
|
+
text = n;
|
|
431
|
+
});
|
|
432
|
+
return hit ?? text;
|
|
433
|
+
}
|
|
434
|
+
/** The answer: the first Text inside the panel. */
|
|
435
|
+
function accordionAnswerOf(item) {
|
|
436
|
+
const panel = accordionPanelOf(item);
|
|
437
|
+
let hit;
|
|
438
|
+
if (panel)
|
|
439
|
+
walkComposite(panel, n => { if (!hit && n.type === Type$2.Text)
|
|
440
|
+
hit = n; });
|
|
441
|
+
return hit;
|
|
442
|
+
}
|
|
443
|
+
const stripTags = (v) => String(v ?? '').replace(/<[^>]*>/g, '').trim();
|
|
444
|
+
/** An item's name in the Items list and the layer tree: its question's words. */
|
|
445
|
+
function accordionItemLabel(item) {
|
|
446
|
+
const q = accordionQuestionOf(item);
|
|
447
|
+
return q ? stripTags(readProp(q, 'content')) : '';
|
|
448
|
+
}
|
|
449
|
+
/** Items ▸ rename: the question's words. */
|
|
450
|
+
function renameAccordionItem(item, question) {
|
|
451
|
+
const q = accordionQuestionOf(item);
|
|
452
|
+
return !!q && writeProp(q, 'content', question);
|
|
453
|
+
}
|
|
454
|
+
/** The heading level the header's wrapper renders (`<h3><button>`): its question's level. */
|
|
455
|
+
function accordionHeadingLevel(trigger) {
|
|
456
|
+
let level;
|
|
457
|
+
const visit = (n) => {
|
|
458
|
+
if (level !== undefined)
|
|
459
|
+
return;
|
|
460
|
+
if (n.type === Type$2.Heading) {
|
|
461
|
+
level = readProp(n, 'level');
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
for (const c of n.children ?? [])
|
|
465
|
+
visit(c);
|
|
466
|
+
};
|
|
467
|
+
for (const c of trigger?.children ?? [])
|
|
468
|
+
visit(c);
|
|
469
|
+
return HEADING_LEVELS.includes(level) ? level : DEFAULT_LEVEL;
|
|
470
|
+
}
|
|
471
|
+
// ── editing (Items list, selection toolbar, Behaviour ▸ Icon) ──────────────────────────────────
|
|
472
|
+
/** A key no item of `root` holds yet (`k4`…). */
|
|
473
|
+
function nextItemKey(root) {
|
|
474
|
+
const taken = new Set(accordionItems(root).map(i => i.itemKey).filter(Boolean));
|
|
475
|
+
let n = taken.size + 1;
|
|
476
|
+
while (taken.has(`k${n}`))
|
|
477
|
+
n++;
|
|
478
|
+
return `k${n}`;
|
|
479
|
+
}
|
|
480
|
+
/** Items ▸ Add item (and the toolbar's "Add item"): a fresh item after `after` (or at the end), wearing the
|
|
481
|
+
* accordion's current icon. Returns the new item. */
|
|
482
|
+
function addAccordionItem(root, create, after, words = ACCORDION_NEW_ITEM_WORDS) {
|
|
483
|
+
const key = nextItemKey(root);
|
|
484
|
+
const item = buildAccordionItem(create, words, key, detectAccordionIcon(root));
|
|
485
|
+
const children = [...(root.children ?? [])];
|
|
486
|
+
const at = after ? children.indexOf(after) : -1;
|
|
487
|
+
children.splice(at < 0 ? children.length : at + 1, 0, item);
|
|
488
|
+
root.children = children;
|
|
489
|
+
return item;
|
|
490
|
+
}
|
|
491
|
+
/** Items ▸ drag to reorder: move an item to `toIndex` among the items. False when it is not an item of `root`. */
|
|
492
|
+
function moveAccordionItem(root, item, toIndex) {
|
|
493
|
+
const children = [...(root.children ?? [])];
|
|
494
|
+
const from = children.indexOf(item);
|
|
495
|
+
if (from < 0 || item.role !== AccordionRole.Item)
|
|
496
|
+
return false;
|
|
497
|
+
children.splice(from, 1);
|
|
498
|
+
const items = children.filter(c => c.role === AccordionRole.Item);
|
|
499
|
+
const anchor = items[Math.max(0, Math.min(toIndex, items.length))];
|
|
500
|
+
const at = anchor ? children.indexOf(anchor) : children.length;
|
|
501
|
+
children.splice(at, 0, item);
|
|
502
|
+
root.children = children;
|
|
503
|
+
return true;
|
|
504
|
+
}
|
|
505
|
+
var AccordionEditRefusal;
|
|
506
|
+
(function (AccordionEditRefusal) {
|
|
507
|
+
/** An accordion keeps at least one item (composite plan T11). */
|
|
508
|
+
AccordionEditRefusal["LastItem"] = "last-item";
|
|
509
|
+
})(AccordionEditRefusal || (AccordionEditRefusal = {}));
|
|
510
|
+
/** Why removing `item` from `root` is refused, or null. */
|
|
511
|
+
function accordionRemovalRefusal(root, item) {
|
|
512
|
+
if (!root || item?.role !== AccordionRole.Item)
|
|
513
|
+
return null;
|
|
514
|
+
return accordionItems(root).length <= 1 ? AccordionEditRefusal.LastItem : null;
|
|
515
|
+
}
|
|
516
|
+
/** The icon the composite's items wear now (the first item's; None when it has no icon). Either kind. */
|
|
517
|
+
function detectAccordionIcon(root) {
|
|
518
|
+
const first = compositeItems(root)[0];
|
|
519
|
+
const icon = first ? findByRole(first, kindRolesOf(root).icon) : undefined;
|
|
520
|
+
if (!icon)
|
|
521
|
+
return first ? AccordionIcon.None : AccordionIcon.Chevron;
|
|
522
|
+
const cls = String(readProp(icon, 'iconClass') ?? '');
|
|
523
|
+
return cls.includes(ACCORDION_ICON_GLYPH[AccordionIcon.Plus].iconClass.split(' ')[1]) ? AccordionIcon.Plus : AccordionIcon.Chevron;
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* Behaviour ▸ Icon: every item's header shows the chevron, the plus, or no icon. A switch between glyphs
|
|
527
|
+
* keeps the icon NODE (its styles and interactions) and changes its glyph and turn; None removes it, and a
|
|
528
|
+
* glyph after None stamps it back from the recipe. Returns how many items changed.
|
|
529
|
+
*/
|
|
530
|
+
function setAccordionIcon(root, icon, create) {
|
|
531
|
+
let changed = 0;
|
|
532
|
+
const iconRole = kindRolesOf(root).icon;
|
|
533
|
+
for (const item of compositeItems(root)) {
|
|
534
|
+
const trigger = accordionTriggerOf(item);
|
|
535
|
+
if (!trigger)
|
|
536
|
+
continue;
|
|
537
|
+
const existing = findByRole(trigger, iconRole);
|
|
538
|
+
if (icon === AccordionIcon.None) {
|
|
539
|
+
if (existing && removeFromComposite(trigger, existing))
|
|
540
|
+
changed++;
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
const glyph = ACCORDION_ICON_GLYPH[icon];
|
|
544
|
+
if (existing) {
|
|
545
|
+
writeProp(existing, 'iconClass', glyph.iconClass);
|
|
546
|
+
existing.stateStyles = { ...(existing.stateStyles ?? {}), [ITEM_OPEN_STATE]: { ...(existing.stateStyles?.[ITEM_OPEN_STATE] ?? {}), rotate: glyph.turn } };
|
|
547
|
+
changed++;
|
|
548
|
+
}
|
|
549
|
+
else if (create) {
|
|
550
|
+
trigger.children = [...(trigger.children ?? []), buildCompositeIcon(create, `${item.itemKey ?? 'item'}-icon`, icon, item.id, iconRole)];
|
|
551
|
+
changed++;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
return changed;
|
|
555
|
+
}
|
|
556
|
+
/** The node id every `trigger-on` state inside an item points at: its ITEM (`stateTriggers['trigger-on']`). */
|
|
557
|
+
function itemOpenTriggerIds(root) {
|
|
558
|
+
for (const item of accordionItems(root)) {
|
|
559
|
+
if (!item.id)
|
|
560
|
+
continue;
|
|
561
|
+
walkComposite(item, n => {
|
|
562
|
+
if (n === item || !n.stateStyles?.[ITEM_OPEN_STATE])
|
|
563
|
+
return;
|
|
564
|
+
n.stateTriggers = { ...(n.stateTriggers ?? {}), [ITEM_OPEN_STATE]: item.id };
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
// ── state machine ───────────────────────────────────────────────────────────────────────────────
|
|
569
|
+
/** The keys open on first render: the first item, none, or all (From content: resolved against the rows). */
|
|
570
|
+
function initialAccordionOpen(keys, defaultOn) {
|
|
571
|
+
if (defaultOn === AccordionDefaultOn.None || !keys.length)
|
|
572
|
+
return new Set();
|
|
573
|
+
if (defaultOn === AccordionDefaultOn.All)
|
|
574
|
+
return new Set(keys);
|
|
575
|
+
return new Set([keys[0]]);
|
|
576
|
+
}
|
|
577
|
+
/** The open keys after toggling `key`: one at a time closes the others; several leaves them. */
|
|
578
|
+
function nextAccordionState(open, key, behaviour) {
|
|
579
|
+
if (open.has(key)) {
|
|
580
|
+
const next = new Set(open);
|
|
581
|
+
next.delete(key);
|
|
582
|
+
return next;
|
|
583
|
+
}
|
|
584
|
+
return behaviour.multiple ? new Set([...open, key]) : new Set([key]);
|
|
585
|
+
}
|
|
586
|
+
/** The open keys after OPENING `key` (find-in-page, selecting into a closed panel, the `openItem` action). */
|
|
587
|
+
function openAccordionItem(open, key, behaviour) {
|
|
588
|
+
return open.has(key) ? new Set(open) : nextAccordionState(open, key, behaviour);
|
|
589
|
+
}
|
|
590
|
+
/** The open keys after CLOSING `key` (the `closeItem` action). */
|
|
591
|
+
function closeAccordionItem(open, key) {
|
|
592
|
+
const next = new Set(open);
|
|
593
|
+
next.delete(key);
|
|
594
|
+
return next;
|
|
595
|
+
}
|
|
596
|
+
var AccordionKeyAction;
|
|
597
|
+
(function (AccordionKeyAction) {
|
|
598
|
+
AccordionKeyAction["Next"] = "next";
|
|
599
|
+
AccordionKeyAction["Previous"] = "previous";
|
|
600
|
+
AccordionKeyAction["First"] = "first";
|
|
601
|
+
AccordionKeyAction["Last"] = "last";
|
|
602
|
+
})(AccordionKeyAction || (AccordionKeyAction = {}));
|
|
603
|
+
/** The APG accordion's keys on a header: ↓ ↑ move (and wrap), Home / End jump. Enter and Space are the
|
|
604
|
+
* button's own. */
|
|
605
|
+
const KEY_ACTIONS = {
|
|
606
|
+
ArrowDown: AccordionKeyAction.Next,
|
|
607
|
+
ArrowUp: AccordionKeyAction.Previous,
|
|
608
|
+
Home: AccordionKeyAction.First,
|
|
609
|
+
End: AccordionKeyAction.Last,
|
|
610
|
+
};
|
|
611
|
+
function accordionKeyAction(key) {
|
|
612
|
+
return KEY_ACTIONS[key] ?? null;
|
|
613
|
+
}
|
|
614
|
+
/** The header index `action` moves focus to, from `current` among `count` headers (↓ ↑ wrap). */
|
|
615
|
+
function accordionFocusIndex(action, current, count) {
|
|
616
|
+
if (count <= 0)
|
|
617
|
+
return -1;
|
|
618
|
+
switch (action) {
|
|
619
|
+
case AccordionKeyAction.Next: return (current + 1) % count;
|
|
620
|
+
case AccordionKeyAction.Previous: return (current - 1 + count) % count;
|
|
621
|
+
case AccordionKeyAction.First: return 0;
|
|
622
|
+
case AccordionKeyAction.Last: return count - 1;
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
// ── identity, ids and attributes (both renderers, SSR included) ────────────────────────────────
|
|
626
|
+
/** A key safe in an element id. */
|
|
627
|
+
function domSafeKey(raw) {
|
|
628
|
+
return String(raw ?? '').replace(/[^A-Za-z0-9_-]/g, '-');
|
|
629
|
+
}
|
|
630
|
+
/** A content row's identity: its own id (never its index — a reorder in the CMS must never pair a header with
|
|
631
|
+
* another row's panel, R3); the index only for a row that has none. */
|
|
632
|
+
function rowItemKey(row, index) {
|
|
633
|
+
const r = row;
|
|
634
|
+
const id = r && typeof r === 'object' ? (r._id ?? r.id) : undefined;
|
|
635
|
+
return id !== undefined && id !== null && id !== '' ? domSafeKey(id) : `row-${index}`;
|
|
636
|
+
}
|
|
637
|
+
const DOM_ID_PREFIX = {
|
|
638
|
+
[CompositeKind.Accordion]: 'iox-acc',
|
|
639
|
+
[CompositeKind.Disclosure]: 'iox-dis',
|
|
640
|
+
};
|
|
641
|
+
/** The ARIA pairing ids of an item: unique per composite (its SCOPE id — `compositeScopeId`) and per item (its key). */
|
|
642
|
+
function accordionDomIds(rootId, itemKey, kind = CompositeKind.Accordion) {
|
|
643
|
+
const base = `${DOM_ID_PREFIX[kind]}-${domSafeKey(rootId)}-${domSafeKey(itemKey)}`;
|
|
644
|
+
return { trigger: `${base}-h`, panel: `${base}-p` };
|
|
645
|
+
}
|
|
646
|
+
/**
|
|
647
|
+
* The id a rendered composite's ARIA ids are built from: its node id, and — when it sits inside repeated rows —
|
|
648
|
+
* every enclosing row's identity, outermost first (vectoriox/iox-ui#20: an accordion repeated per card used to
|
|
649
|
+
* pair every row's header with the FIRST row's panel). The engine folds each row's `rowItemKey`; the builder
|
|
650
|
+
* canvas its preview row's index. Unrepeated, it is the node id unchanged.
|
|
651
|
+
*/
|
|
652
|
+
function compositeScopeId(rootId, rowKeys = []) {
|
|
653
|
+
return rowKeys.length ? [rootId, ...rowKeys.map(domSafeKey)].join('-') : rootId;
|
|
654
|
+
}
|
|
655
|
+
function accordionScope(rootId, behaviour, keys, kind = CompositeKind.Accordion) {
|
|
656
|
+
return { kind, rootId, behaviour, keys, open: initialAccordionOpen(keys, behaviour.defaultOn) };
|
|
657
|
+
}
|
|
658
|
+
function accordionItemState(scope, index) {
|
|
659
|
+
const key = scope.keys[index] ?? `item-${index}`;
|
|
660
|
+
return { kind: scope.kind, key, index, on: scope.open.has(key), ids: accordionDomIds(scope.rootId, key, scope.kind) };
|
|
661
|
+
}
|
|
662
|
+
/** `data-iox-on` — the ONE attribute every "on" style keys on (Open, Item open). */
|
|
663
|
+
const ON_ATTRIBUTE = 'data-iox-on';
|
|
664
|
+
const ITEM_KEY_ATTRIBUTE = 'data-iox-item-key';
|
|
665
|
+
const COMPOSITE_ATTRIBUTE = 'data-iox-composite';
|
|
666
|
+
const MULTIPLE_ATTRIBUTE = 'data-iox-multiple';
|
|
667
|
+
const DEFAULT_ON_ATTRIBUTE = 'data-iox-default-on';
|
|
668
|
+
/** The scope id the ARIA ids are built from (`compositeScopeId`), written by the engine so the runtime re-stamps
|
|
669
|
+
* exactly the ids the server rendered. */
|
|
670
|
+
const SCOPE_ATTRIBUTE = 'data-iox-scope';
|
|
671
|
+
/** The value a closed panel's region carries: find-in-page can still reveal it (D9, board 4). */
|
|
672
|
+
const UNTIL_FOUND = 'until-found';
|
|
673
|
+
/** The structural classes the renderers write around an accordion's parts (no node owns them). */
|
|
674
|
+
var AccordionClass;
|
|
675
|
+
(function (AccordionClass) {
|
|
676
|
+
AccordionClass["Heading"] = "iox-accordion-heading";
|
|
677
|
+
AccordionClass["Button"] = "iox-accordion-button";
|
|
678
|
+
AccordionClass["Track"] = "iox-accordion-track";
|
|
679
|
+
AccordionClass["Region"] = "iox-accordion-region";
|
|
680
|
+
})(AccordionClass || (AccordionClass = {}));
|
|
681
|
+
function accordionRootAttributes(behaviour, kind = CompositeKind.Accordion, scopeId) {
|
|
682
|
+
return {
|
|
683
|
+
[COMPOSITE_ATTRIBUTE]: kind,
|
|
684
|
+
[DEFAULT_ON_ATTRIBUTE]: behaviour.defaultOn,
|
|
685
|
+
...(behaviour.multiple && kind === CompositeKind.Accordion ? { [MULTIPLE_ATTRIBUTE]: '' } : {}),
|
|
686
|
+
...(scopeId ? { [SCOPE_ATTRIBUTE]: scopeId } : {}),
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
function accordionItemAttributes(state) {
|
|
690
|
+
return { [ITEM_KEY_ATTRIBUTE]: state.key, ...(state.on ? { [ON_ATTRIBUTE]: '' } : {}) };
|
|
691
|
+
}
|
|
692
|
+
function accordionTriggerAttributes(state) {
|
|
693
|
+
return {
|
|
694
|
+
type: 'button',
|
|
695
|
+
id: state.ids.trigger,
|
|
696
|
+
'aria-expanded': state.on ? 'true' : 'false',
|
|
697
|
+
'aria-controls': state.ids.panel,
|
|
698
|
+
...(state.on ? { [ON_ATTRIBUTE]: '' } : {}),
|
|
699
|
+
};
|
|
700
|
+
}
|
|
701
|
+
/** The track wrapping a panel: its row collapses to 0 while closed (the ONE hiding rule, iox-design.css). */
|
|
702
|
+
function accordionTrackAttributes(state) {
|
|
703
|
+
return state.on ? { [ON_ATTRIBUTE]: '' } : {};
|
|
704
|
+
}
|
|
705
|
+
/** The region the panel sits in: `hidden="until-found"` while closed, so its words stay in the page. An accordion's
|
|
706
|
+
* is a labelled `region` (APG accordion); a disclosure's is a plain block (APG disclosure: the button names it). */
|
|
707
|
+
function accordionRegionAttributes(state) {
|
|
708
|
+
const labelled = hasLabelledRegion(state.kind);
|
|
709
|
+
return {
|
|
710
|
+
...(labelled ? { role: 'region' } : {}),
|
|
711
|
+
id: state.ids.panel,
|
|
712
|
+
...(labelled ? { 'aria-labelledby': state.ids.trigger } : {}),
|
|
713
|
+
...(state.on ? {} : { hidden: UNTIL_FOUND }),
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
/** True when a region of this kind is a labelled `role=region`. */
|
|
717
|
+
function hasLabelledRegion(kind) {
|
|
718
|
+
return kind === CompositeKind.Accordion;
|
|
719
|
+
}
|
|
720
|
+
function accordionPanelAttributes(state) {
|
|
721
|
+
return state.on ? { [ON_ATTRIBUTE]: '' } : {};
|
|
722
|
+
}
|
|
723
|
+
/** `attrs` as an HTML attribute string (` a="b" c`), values escaped by `esc`. */
|
|
724
|
+
function serializeDomAttributes(attrs, esc) {
|
|
725
|
+
return Object.entries(attrs).map(([k, v]) => (v === '' ? ` ${k}` : ` ${k}="${esc(v)}"`)).join('');
|
|
726
|
+
}
|
|
727
|
+
const SHORT_TEXT = new Set(['text']);
|
|
728
|
+
const LONG_TEXT = new Set(['textarea', 'richText', 'editor']);
|
|
729
|
+
/** R3 (board 9 Q19): switching to From content binds the question to the first short-text field and the
|
|
730
|
+
* answer to the first long-text field. ONE rule for the builder's switch and the composer. */
|
|
731
|
+
function pickAccordionAutoBinds(fields) {
|
|
732
|
+
const isString = (f) => !f.type || f.type === 'string';
|
|
733
|
+
const question = fields.find(f => isString(f) && SHORT_TEXT.has(String(f.controller ?? 'text')))?.name ?? null;
|
|
734
|
+
const answer = fields.find(f => isString(f) && LONG_TEXT.has(String(f.controller ?? '')) && f.name !== question)?.name ?? null;
|
|
735
|
+
return { question, answer };
|
|
736
|
+
}
|
|
737
|
+
/** The bound props of the question and the answer (each a content trait). */
|
|
738
|
+
const ACCORDION_BIND_PROP = 'content';
|
|
739
|
+
/**
|
|
740
|
+
* Manual → From content (board 9 "Items: Manual · From content"): the root becomes `repeater` (a Repeater node
|
|
741
|
+
* the caller created with the same id, role, label, element style and styles — a type change is the
|
|
742
|
+
* consumer's), the FIRST item becomes the one template (its parts, styles and interactions kept; its key
|
|
743
|
+
* dropped — a row's key is its id) and the others are removed. Returns the removed items.
|
|
744
|
+
*/
|
|
745
|
+
function accordionToContent(root, repeater) {
|
|
746
|
+
const items = accordionItems(root);
|
|
747
|
+
const [template, ...rest] = items;
|
|
748
|
+
if (template)
|
|
749
|
+
delete template.itemKey;
|
|
750
|
+
repeater.role = AccordionRole.Accordion;
|
|
751
|
+
repeater.children = template ? [template] : [];
|
|
752
|
+
writeProp(repeater, CompositeBehaviourTrait.Multiple, accordionBehaviour(root).multiple);
|
|
753
|
+
writeProp(repeater, CompositeBehaviourTrait.DefaultOn, accordionBehaviour(root).defaultOn);
|
|
754
|
+
return rest;
|
|
755
|
+
}
|
|
756
|
+
/**
|
|
757
|
+
* From content → Manual: the rows come in as static items — each a copy of the template (`cloneItem`, which
|
|
758
|
+
* gives the copy fresh ids) whose bound words are written in as its own and whose bindings are dropped.
|
|
759
|
+
* `container` is the manual root the caller created. Returns the new items.
|
|
760
|
+
*/
|
|
761
|
+
function accordionToManual(root, container, rows, cloneItem, resolve) {
|
|
762
|
+
const template = accordionItems(root)[0];
|
|
763
|
+
container.role = AccordionRole.Accordion;
|
|
764
|
+
writeProp(container, CompositeBehaviourTrait.Multiple, accordionBehaviour(root).multiple);
|
|
765
|
+
writeProp(container, CompositeBehaviourTrait.DefaultOn, accordionBehaviour(root).defaultOn);
|
|
766
|
+
const items = template ? rows.map((row, i) => {
|
|
767
|
+
const item = cloneItem(template);
|
|
768
|
+
item.itemKey = `k${i + 1}`;
|
|
769
|
+
walkComposite(item, n => {
|
|
770
|
+
const bindings = n.bindings ?? [];
|
|
771
|
+
for (const b of bindings) {
|
|
772
|
+
const value = resolve(row, b);
|
|
773
|
+
if (value !== undefined && value !== null)
|
|
774
|
+
writeProp(n, b.prop, value);
|
|
775
|
+
}
|
|
776
|
+
if (bindings.length)
|
|
777
|
+
n.bindings = [];
|
|
778
|
+
});
|
|
779
|
+
return item;
|
|
780
|
+
}) : [];
|
|
781
|
+
container.children = items;
|
|
782
|
+
return items;
|
|
783
|
+
}
|
|
784
|
+
/** The style a part holds for `name` (both node shapes) — exported for the consumers' detectors. */
|
|
785
|
+
function accordionPartStyle(node, name) {
|
|
786
|
+
return readOwnedTrait(node, { trait: name });
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/**
|
|
790
|
+
* The Accordion's RUNTIME (CC2) — ONE copy, DOM only, for the builder canvas and the live site (both engine
|
|
791
|
+
* paths render the same markup). It reads the markup the renderers write (iox-design.css + accordion.ts's
|
|
792
|
+
* attribute planners), so the server's first paint and every later state agree:
|
|
793
|
+
*
|
|
794
|
+
* [root] data-iox-composite="accordion" data-iox-default-on [data-iox-multiple] [data-iox-scope] (the node's
|
|
795
|
+
* own element — its role class may sit on a wrapper host, as the engine's component-path Repeater draws it)
|
|
796
|
+
* .iox-role-accordion-item data-iox-item-key [data-iox-on]
|
|
797
|
+
* hN.iox-accordion-heading > button.iox-accordion-button id aria-expanded aria-controls
|
|
798
|
+
* .iox-accordion-track [data-iox-on] > .iox-accordion-region role=region [hidden=until-found]
|
|
799
|
+
* .iox-role-accordion-panel
|
|
800
|
+
*
|
|
801
|
+
* The DISCLOSURE (CC2.1) runs on this same runtime: `data-iox-composite="disclosure"` on a root that IS its one
|
|
802
|
+
* item (it carries the key and `data-iox-on`), its button straight inside it (no heading), its panel in the same
|
|
803
|
+
* track + region (a plain block — no `role=region`), and no arrow keys (one button).
|
|
804
|
+
*
|
|
805
|
+
* It owns: a click on a header toggles its item; ↓ ↑ Home End move between the headers of THIS accordion
|
|
806
|
+
* (a nested one keeps its own); find-in-page (`beforematch`) opens the item that holds the match; a closed
|
|
807
|
+
* panel gets `hidden="until-found"` after its track has collapsed (at once under reduced motion). Every
|
|
808
|
+
* change dispatches `iox-item-on` / `iox-item-off` on the item, the `itemOn` / `itemOff` interaction
|
|
809
|
+
* triggers. The open set is LIVE ∪ FORCED: the builder forces items open (a selection inside a closed panel,
|
|
810
|
+
* "All open"); the live site forces nothing.
|
|
811
|
+
*/
|
|
812
|
+
const ITEM_ON_EVENT = 'iox-item-on';
|
|
813
|
+
const ITEM_OFF_EVENT = 'iox-item-off';
|
|
814
|
+
const KIND_ROOT = (kind) => `[${COMPOSITE_ATTRIBUTE}="${kind}"]`;
|
|
815
|
+
const ROOT_SELECTOR = Object.values(CompositeKind).map(KIND_ROOT).join(', ');
|
|
816
|
+
/** An accordion's items are its `.iox-role-accordion-item` children; a disclosure is its own item. */
|
|
817
|
+
const ACCORDION_ITEM_SELECTOR = '.iox-role-accordion-item';
|
|
818
|
+
const ITEM_SELECTOR = `${ACCORDION_ITEM_SELECTOR}, ${KIND_ROOT(CompositeKind.Disclosure)}`;
|
|
819
|
+
const PANEL_SELECTOR = '.iox-role-accordion-panel, .iox-role-disclosure-panel';
|
|
820
|
+
const BUTTON_SELECTOR = `.${AccordionClass.Button}`;
|
|
821
|
+
const TRACK_SELECTOR = `.${AccordionClass.Track}`;
|
|
822
|
+
const REGION_SELECTOR = `.${AccordionClass.Region}`;
|
|
823
|
+
/** How long a closing track may take before its region is hidden (dur-base is 200–320ms in every pack). */
|
|
824
|
+
const MAX_CLOSE_MS = 600;
|
|
825
|
+
const REDUCED_MOTION = '(prefers-reduced-motion: reduce)';
|
|
826
|
+
/** The composite (accordion or disclosure) an element belongs to (the element itself when it is a root). */
|
|
827
|
+
function closestAccordion(el) {
|
|
828
|
+
return el?.closest?.(ROOT_SELECTOR) ?? null;
|
|
829
|
+
}
|
|
830
|
+
/** The item an element sits in (itself when it is one) — an accordion item, or a disclosure. */
|
|
831
|
+
function closestAccordionItem(el) {
|
|
832
|
+
return el?.closest?.(ITEM_SELECTOR) ?? null;
|
|
833
|
+
}
|
|
834
|
+
const kindOf = (root) => root.getAttribute(COMPOSITE_ATTRIBUTE) === CompositeKind.Disclosure ? CompositeKind.Disclosure : CompositeKind.Accordion;
|
|
835
|
+
/** The composite root whose items an item belongs to: a disclosure is its own root. */
|
|
836
|
+
function rootOfItem(item) {
|
|
837
|
+
return kindOf(item) === CompositeKind.Disclosure && item.matches(KIND_ROOT(CompositeKind.Disclosure)) ? item : closestAccordion(item.parentElement);
|
|
838
|
+
}
|
|
839
|
+
/** Subscribe to an item turning on / off — what the `itemOn` / `itemOff` interaction triggers listen to. The
|
|
840
|
+
* item is the element's own item (the element itself when it is one). Returns the unsubscribe. */
|
|
841
|
+
function onAccordionItemState(el, on, handler) {
|
|
842
|
+
const item = closestAccordionItem(el);
|
|
843
|
+
if (!item)
|
|
844
|
+
return () => undefined;
|
|
845
|
+
const type = on ? ITEM_ON_EVENT : ITEM_OFF_EVENT;
|
|
846
|
+
item.addEventListener(type, handler);
|
|
847
|
+
return () => item.removeEventListener(type, handler);
|
|
848
|
+
}
|
|
849
|
+
/** True when the element's item is open now. */
|
|
850
|
+
function isAccordionItemOn(el) {
|
|
851
|
+
return !!closestAccordionItem(el)?.hasAttribute(ON_ATTRIBUTE);
|
|
852
|
+
}
|
|
853
|
+
const nodeIdOf = (el) => {
|
|
854
|
+
for (const c of Array.from(el.classList))
|
|
855
|
+
if (c.startsWith('iox-node-'))
|
|
856
|
+
return c.slice('iox-node-'.length);
|
|
857
|
+
return '';
|
|
858
|
+
};
|
|
859
|
+
function behaviourOf(root) {
|
|
860
|
+
const raw = root.getAttribute(DEFAULT_ON_ATTRIBUTE);
|
|
861
|
+
return {
|
|
862
|
+
multiple: root.hasAttribute(MULTIPLE_ATTRIBUTE),
|
|
863
|
+
defaultOn: ACCORDION_DEFAULT_ONS.includes(raw) ? raw : AccordionDefaultOn.First,
|
|
864
|
+
};
|
|
865
|
+
}
|
|
866
|
+
function setAttr(el, name, value) {
|
|
867
|
+
if (!el)
|
|
868
|
+
return;
|
|
869
|
+
if (value === null) {
|
|
870
|
+
if (el.hasAttribute(name))
|
|
871
|
+
el.removeAttribute(name);
|
|
872
|
+
}
|
|
873
|
+
else if (el.getAttribute(name) !== value)
|
|
874
|
+
el.setAttribute(name, value);
|
|
875
|
+
}
|
|
876
|
+
class AccordionRuntime {
|
|
877
|
+
constructor(root, options = {}) {
|
|
878
|
+
this.root = root;
|
|
879
|
+
this.options = options;
|
|
880
|
+
this.shown = new Set();
|
|
881
|
+
this.closing = new Map();
|
|
882
|
+
this.observer = null;
|
|
883
|
+
this.stampQueued = false;
|
|
884
|
+
/** The builder attaches a root before its items render: the saved default is applied once they do. */
|
|
885
|
+
this.awaitingItems = false;
|
|
886
|
+
this.onClick = (event) => {
|
|
887
|
+
const button = event.target?.closest?.(BUTTON_SELECTOR);
|
|
888
|
+
if (!button || closestAccordion(button) !== this.root)
|
|
889
|
+
return;
|
|
890
|
+
const item = closestAccordionItem(button);
|
|
891
|
+
if (!item)
|
|
892
|
+
return;
|
|
893
|
+
event.preventDefault();
|
|
894
|
+
this.toggle(this.keyOf(item));
|
|
895
|
+
};
|
|
896
|
+
this.onKeydown = (event) => {
|
|
897
|
+
// A disclosure has one button: Enter / Space are the button's own, and there is nowhere to move.
|
|
898
|
+
if (this.kind !== CompositeKind.Accordion)
|
|
899
|
+
return;
|
|
900
|
+
const e = event;
|
|
901
|
+
const action = accordionKeyAction(e.key);
|
|
902
|
+
const button = e.target?.closest?.(BUTTON_SELECTOR);
|
|
903
|
+
if (!action || !button || closestAccordion(button) !== this.root)
|
|
904
|
+
return;
|
|
905
|
+
const buttons = this.buttons();
|
|
906
|
+
const at = buttons.indexOf(button);
|
|
907
|
+
const target = buttons[accordionFocusIndex(action, at, buttons.length)];
|
|
908
|
+
if (!target)
|
|
909
|
+
return;
|
|
910
|
+
e.preventDefault();
|
|
911
|
+
target.focus();
|
|
912
|
+
};
|
|
913
|
+
this.onBeforeMatch = (event) => {
|
|
914
|
+
const region = event.target?.closest?.(REGION_SELECTOR);
|
|
915
|
+
const item = closestAccordionItem(region);
|
|
916
|
+
const items = this.items();
|
|
917
|
+
if (!item || !items.includes(item))
|
|
918
|
+
return;
|
|
919
|
+
this.open(this.keyOf(item, items));
|
|
920
|
+
};
|
|
921
|
+
this.kind = options.kind ?? kindOf(root);
|
|
922
|
+
this.behaviourValue = options.behaviour ?? behaviourOf(root);
|
|
923
|
+
root.setAttribute(COMPOSITE_ATTRIBUTE, this.kind);
|
|
924
|
+
RUNTIMES.set(root, this);
|
|
925
|
+
// The server rendered the first state; with no server state the root's default decides.
|
|
926
|
+
// (The builder passes the behaviour and starts from the saved default; on the live site the server
|
|
927
|
+
// stamped every item's key and state, and that is the truth.)
|
|
928
|
+
const items = this.items();
|
|
929
|
+
const serverStamped = !options.behaviour && items.some(i => i.hasAttribute(ITEM_KEY_ATTRIBUTE));
|
|
930
|
+
this.live = serverStamped
|
|
931
|
+
? new Set(items.filter(i => i.hasAttribute(ON_ATTRIBUTE)).map(i => this.keyOf(i, items)))
|
|
932
|
+
: initialAccordionOpen(items.map(i => this.keyOf(i, items)), this.behaviourValue.defaultOn);
|
|
933
|
+
this.awaitingItems = !serverStamped && !items.length;
|
|
934
|
+
this.shown = new Set(this.live);
|
|
935
|
+
// Capture: on the builder canvas every part's own listener stops the click (it selects that part), and
|
|
936
|
+
// the header must still toggle — the canvas is a live runtime.
|
|
937
|
+
root.addEventListener('click', this.onClick, true);
|
|
938
|
+
root.addEventListener('keydown', this.onKeydown);
|
|
939
|
+
root.addEventListener('beforematch', this.onBeforeMatch, true);
|
|
940
|
+
if (options.observe && typeof MutationObserver !== 'undefined') {
|
|
941
|
+
this.observer = new MutationObserver(() => this.queueStamp());
|
|
942
|
+
this.observer.observe(root, { childList: true, subtree: true });
|
|
943
|
+
}
|
|
944
|
+
this.stamp(true);
|
|
945
|
+
}
|
|
946
|
+
get behaviour() {
|
|
947
|
+
return this.behaviourValue;
|
|
948
|
+
}
|
|
949
|
+
/** The behaviour changed in the panel: re-read, and start from the new default. */
|
|
950
|
+
setBehaviour(behaviour) {
|
|
951
|
+
const changedDefault = behaviour.defaultOn !== this.behaviourValue.defaultOn;
|
|
952
|
+
this.behaviourValue = behaviour;
|
|
953
|
+
if (behaviour.multiple)
|
|
954
|
+
this.root.setAttribute(MULTIPLE_ATTRIBUTE, '');
|
|
955
|
+
else
|
|
956
|
+
this.root.removeAttribute(MULTIPLE_ATTRIBUTE);
|
|
957
|
+
this.root.setAttribute(DEFAULT_ON_ATTRIBUTE, behaviour.defaultOn);
|
|
958
|
+
if (changedDefault)
|
|
959
|
+
this.resetLive();
|
|
960
|
+
else
|
|
961
|
+
this.stamp();
|
|
962
|
+
}
|
|
963
|
+
/** THIS composite's items, in document order (a nested composite's items are its own); a disclosure's is itself. */
|
|
964
|
+
items() {
|
|
965
|
+
if (this.kind === CompositeKind.Disclosure)
|
|
966
|
+
return [this.root];
|
|
967
|
+
return Array.from(this.root.querySelectorAll(ACCORDION_ITEM_SELECTOR)).filter(i => closestAccordion(i.parentElement) === this.root);
|
|
968
|
+
}
|
|
969
|
+
/** The open keys the live state holds (before anything forced). */
|
|
970
|
+
get liveKeys() {
|
|
971
|
+
return this.live;
|
|
972
|
+
}
|
|
973
|
+
/** The keys showing open right now. */
|
|
974
|
+
get openKeys() {
|
|
975
|
+
return this.shown;
|
|
976
|
+
}
|
|
977
|
+
keyOf(item, items = this.items()) {
|
|
978
|
+
return item.getAttribute(ITEM_KEY_ATTRIBUTE) || `item-${items.indexOf(item)}`;
|
|
979
|
+
}
|
|
980
|
+
toggle(key) {
|
|
981
|
+
this.live = nextAccordionState(this.live, key, this.behaviourValue);
|
|
982
|
+
this.stamp();
|
|
983
|
+
}
|
|
984
|
+
open(key) {
|
|
985
|
+
this.live = openAccordionItem(this.live, key, this.behaviourValue);
|
|
986
|
+
this.stamp();
|
|
987
|
+
}
|
|
988
|
+
close(key) {
|
|
989
|
+
this.live = closeAccordionItem(this.live, key);
|
|
990
|
+
this.stamp();
|
|
991
|
+
}
|
|
992
|
+
/** Run an item action (`openItem` / `closeItem` / `toggleItem`, T14) on `key`. True when it shows open after. */
|
|
993
|
+
run(action, key) {
|
|
994
|
+
if (action === CompositeItemAction.Open)
|
|
995
|
+
this.open(key);
|
|
996
|
+
else if (action === CompositeItemAction.Close)
|
|
997
|
+
this.close(key);
|
|
998
|
+
else
|
|
999
|
+
this.toggle(key);
|
|
1000
|
+
return this.shown.has(key);
|
|
1001
|
+
}
|
|
1002
|
+
/** Back to the saved default ("Showing on canvas ▸ As saved"). */
|
|
1003
|
+
resetLive() {
|
|
1004
|
+
const items = this.items();
|
|
1005
|
+
this.live = initialAccordionOpen(items.map(i => this.keyOf(i, items)), this.behaviourValue.defaultOn);
|
|
1006
|
+
this.stamp();
|
|
1007
|
+
}
|
|
1008
|
+
/** The forced set changed (builder design state): show it. */
|
|
1009
|
+
refresh() {
|
|
1010
|
+
this.stamp();
|
|
1011
|
+
}
|
|
1012
|
+
destroy() {
|
|
1013
|
+
if (RUNTIMES.get(this.root) === this)
|
|
1014
|
+
RUNTIMES.delete(this.root);
|
|
1015
|
+
this.root.removeEventListener('click', this.onClick, true);
|
|
1016
|
+
this.root.removeEventListener('keydown', this.onKeydown);
|
|
1017
|
+
this.root.removeEventListener('beforematch', this.onBeforeMatch, true);
|
|
1018
|
+
this.observer?.disconnect();
|
|
1019
|
+
this.observer = null;
|
|
1020
|
+
for (const t of this.closing.values())
|
|
1021
|
+
clearTimeout(t);
|
|
1022
|
+
this.closing.clear();
|
|
1023
|
+
}
|
|
1024
|
+
queueStamp() {
|
|
1025
|
+
if (this.stampQueued)
|
|
1026
|
+
return;
|
|
1027
|
+
this.stampQueued = true;
|
|
1028
|
+
const run = () => { this.stampQueued = false; this.stamp(); };
|
|
1029
|
+
if (typeof queueMicrotask === 'function')
|
|
1030
|
+
queueMicrotask(run);
|
|
1031
|
+
else
|
|
1032
|
+
Promise.resolve().then(run);
|
|
1033
|
+
}
|
|
1034
|
+
reduced() {
|
|
1035
|
+
if (this.options.reducedMotion)
|
|
1036
|
+
return this.options.reducedMotion();
|
|
1037
|
+
try {
|
|
1038
|
+
return typeof matchMedia === 'function' && matchMedia(REDUCED_MOTION).matches;
|
|
1039
|
+
}
|
|
1040
|
+
catch {
|
|
1041
|
+
return false;
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
/** Write every item's state into its markup; announce what changed. */
|
|
1045
|
+
stamp(first = false) {
|
|
1046
|
+
// Our own writes must not wake the observer (they are attributes; it watches the child lists only).
|
|
1047
|
+
const items = this.items();
|
|
1048
|
+
if (this.awaitingItems && items.length) {
|
|
1049
|
+
this.awaitingItems = false;
|
|
1050
|
+
this.live = initialAccordionOpen(items.map(i => this.keyOf(i, items)), this.behaviourValue.defaultOn);
|
|
1051
|
+
first = true;
|
|
1052
|
+
}
|
|
1053
|
+
const forced = this.options.forced?.() ?? new Set();
|
|
1054
|
+
const own = this.options.rootId;
|
|
1055
|
+
const rootId = (typeof own === 'function' ? own() : own) ?? this.root.getAttribute(SCOPE_ATTRIBUTE) ?? nodeIdOf(this.root);
|
|
1056
|
+
const next = new Set();
|
|
1057
|
+
items.forEach((item, index) => {
|
|
1058
|
+
const key = this.keyOf(item, items);
|
|
1059
|
+
const on = this.live.has(key) || forced.has(key);
|
|
1060
|
+
if (on)
|
|
1061
|
+
next.add(key);
|
|
1062
|
+
this.stampItem(item, key, index, on, rootId, first);
|
|
1063
|
+
});
|
|
1064
|
+
const was = first ? new Set() : this.shown;
|
|
1065
|
+
this.shown = next;
|
|
1066
|
+
// On the first stamp an item open by default HAS opened: its `itemOn` listeners attached so far hear it
|
|
1067
|
+
// (the builder renders an item's parts, and attaches their interactions, before its root's runtime; the
|
|
1068
|
+
// live engine attaches the runtime first and its interactions run an already-open item's at once).
|
|
1069
|
+
for (const item of items) {
|
|
1070
|
+
const key = this.keyOf(item, items);
|
|
1071
|
+
if (next.has(key) && !was.has(key))
|
|
1072
|
+
dispatch(item, ITEM_ON_EVENT);
|
|
1073
|
+
else if (!next.has(key) && was.has(key))
|
|
1074
|
+
dispatch(item, ITEM_OFF_EVENT);
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
stampItem(item, key, index, on, rootId, first) {
|
|
1078
|
+
const ids = accordionDomIds(rootId, key, this.kind);
|
|
1079
|
+
if (!item.hasAttribute(ITEM_KEY_ATTRIBUTE))
|
|
1080
|
+
item.setAttribute(ITEM_KEY_ATTRIBUTE, key);
|
|
1081
|
+
setAttr(item, ON_ATTRIBUTE, on ? '' : null);
|
|
1082
|
+
const button = this.own(item, BUTTON_SELECTOR);
|
|
1083
|
+
const track = this.own(item, TRACK_SELECTOR);
|
|
1084
|
+
const region = this.own(item, REGION_SELECTOR);
|
|
1085
|
+
const panel = this.own(item, PANEL_SELECTOR);
|
|
1086
|
+
setAttr(button, 'type', 'button');
|
|
1087
|
+
setAttr(button, 'id', ids.trigger);
|
|
1088
|
+
setAttr(button, 'aria-expanded', on ? 'true' : 'false');
|
|
1089
|
+
setAttr(button, 'aria-controls', ids.panel);
|
|
1090
|
+
setAttr(button, ON_ATTRIBUTE, on ? '' : null);
|
|
1091
|
+
setAttr(track, ON_ATTRIBUTE, on ? '' : null);
|
|
1092
|
+
setAttr(panel, ON_ATTRIBUTE, on ? '' : null);
|
|
1093
|
+
const labelled = hasLabelledRegion(this.kind);
|
|
1094
|
+
setAttr(region, 'role', labelled ? 'region' : null);
|
|
1095
|
+
setAttr(region, 'id', ids.panel);
|
|
1096
|
+
setAttr(region, 'aria-labelledby', labelled ? ids.trigger : null);
|
|
1097
|
+
if (!region)
|
|
1098
|
+
return;
|
|
1099
|
+
const pending = this.closing.get(key);
|
|
1100
|
+
if (on) {
|
|
1101
|
+
if (pending) {
|
|
1102
|
+
clearTimeout(pending);
|
|
1103
|
+
this.closing.delete(key);
|
|
1104
|
+
}
|
|
1105
|
+
setAttr(region, 'hidden', null);
|
|
1106
|
+
return;
|
|
1107
|
+
}
|
|
1108
|
+
if (region.hasAttribute('hidden') || pending)
|
|
1109
|
+
return;
|
|
1110
|
+
// Closing: the track collapses first (its transition), THEN the region is hidden — hiding it at once
|
|
1111
|
+
// would collapse it with no motion. At first paint, or under reduced motion, it is hidden at once.
|
|
1112
|
+
if (first || this.reduced()) {
|
|
1113
|
+
setAttr(region, 'hidden', UNTIL_FOUND);
|
|
1114
|
+
return;
|
|
1115
|
+
}
|
|
1116
|
+
const hide = () => {
|
|
1117
|
+
this.closing.delete(key);
|
|
1118
|
+
if (!item.hasAttribute(ON_ATTRIBUTE))
|
|
1119
|
+
setAttr(region, 'hidden', UNTIL_FOUND);
|
|
1120
|
+
};
|
|
1121
|
+
this.closing.set(key, setTimeout(hide, this.closeDelay(track)));
|
|
1122
|
+
}
|
|
1123
|
+
closeDelay(track) {
|
|
1124
|
+
try {
|
|
1125
|
+
const view = track?.ownerDocument?.defaultView;
|
|
1126
|
+
const raw = track && view ? view.getComputedStyle(track).transitionDuration : '';
|
|
1127
|
+
const ms = Math.max(0, ...String(raw).split(',').map(s => (s.trim().endsWith('ms') ? parseFloat(s) : parseFloat(s) * 1000)).filter(n => !isNaN(n)));
|
|
1128
|
+
return Math.min(MAX_CLOSE_MS, ms + 20);
|
|
1129
|
+
}
|
|
1130
|
+
catch {
|
|
1131
|
+
return 0;
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
/** The element `selector` matches inside `item` that belongs to it, not to an item nested in it. */
|
|
1135
|
+
own(item, selector) {
|
|
1136
|
+
return Array.from(item.querySelectorAll(selector)).find(el => closestAccordionItem(el) === item) ?? null;
|
|
1137
|
+
}
|
|
1138
|
+
buttons() {
|
|
1139
|
+
return this.items().map(i => this.own(i, BUTTON_SELECTOR)).filter((b) => !!b);
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
function dispatch(el, type) {
|
|
1143
|
+
const Ctor = globalThis.CustomEvent;
|
|
1144
|
+
if (Ctor)
|
|
1145
|
+
el.dispatchEvent(new Ctor(type, { bubbles: false }));
|
|
1146
|
+
}
|
|
1147
|
+
/** Attach the runtime to one rendered composite root, unless it already runs one. Returns the NEW runtime, or
|
|
1148
|
+
* null when the root already had one (so a caller destroys only what it created). */
|
|
1149
|
+
function attachAccordionAt(root, options = {}) {
|
|
1150
|
+
if (RUNTIMES.has(root))
|
|
1151
|
+
return null;
|
|
1152
|
+
return new AccordionRuntime(root, options);
|
|
1153
|
+
}
|
|
1154
|
+
/** Attach the runtime to every accordion under `container` that has none yet. Returns the new runtimes. */
|
|
1155
|
+
function attachAccordions(container, options = {}) {
|
|
1156
|
+
const out = [];
|
|
1157
|
+
container.querySelectorAll(ROOT_SELECTOR).forEach(root => {
|
|
1158
|
+
const runtime = attachAccordionAt(root, options);
|
|
1159
|
+
if (runtime)
|
|
1160
|
+
out.push(runtime);
|
|
1161
|
+
});
|
|
1162
|
+
return out;
|
|
1163
|
+
}
|
|
1164
|
+
/** Detach a runtime `attachAccordions` created. */
|
|
1165
|
+
function detachAccordion(runtime) {
|
|
1166
|
+
runtime.destroy();
|
|
1167
|
+
}
|
|
1168
|
+
/** Every live runtime by its root — the canvas's and the site's alike (a runtime registers itself and leaves on
|
|
1169
|
+
* destroy), so an item action finds the runtime of whatever item it targets. */
|
|
1170
|
+
const RUNTIMES = new WeakMap();
|
|
1171
|
+
const ROW_INDEX_ATTRIBUTE = 'data-iox-row-idx';
|
|
1172
|
+
/** The repeated rows an element sits in, outermost first — each row's index as its row wrappers stamp it
|
|
1173
|
+
* (`data-iox-row-idx`: the builder's Repeater rows, the engine's component path). The builder folds them into a
|
|
1174
|
+
* composite's scope id (`compositeScopeId`, vectoriox/iox-ui#20); the element itself is not counted. */
|
|
1175
|
+
function compositeRowKeys(el) {
|
|
1176
|
+
const keys = [];
|
|
1177
|
+
for (let at = el?.parentElement?.closest?.(`[${ROW_INDEX_ATTRIBUTE}]`); at; at = at.parentElement?.closest(`[${ROW_INDEX_ATTRIBUTE}]`)) {
|
|
1178
|
+
keys.unshift(at.getAttribute(ROW_INDEX_ATTRIBUTE) ?? '');
|
|
1179
|
+
}
|
|
1180
|
+
return keys;
|
|
1181
|
+
}
|
|
1182
|
+
/** The runtime running a composite root, or null. */
|
|
1183
|
+
function accordionRuntimeOf(root) {
|
|
1184
|
+
return (root && RUNTIMES.get(root)) || null;
|
|
1185
|
+
}
|
|
1186
|
+
// ── T14: open / close / toggle an item from anywhere ─────────────────────────────────────────────
|
|
1187
|
+
/** What an item action does to its target item (actions-core `openItem` / `closeItem` / `toggleItem`). */
|
|
1188
|
+
var CompositeItemAction;
|
|
1189
|
+
(function (CompositeItemAction) {
|
|
1190
|
+
CompositeItemAction["Open"] = "open";
|
|
1191
|
+
CompositeItemAction["Close"] = "close";
|
|
1192
|
+
CompositeItemAction["Toggle"] = "toggle";
|
|
1193
|
+
})(CompositeItemAction || (CompositeItemAction = {}));
|
|
1194
|
+
/**
|
|
1195
|
+
* Run an item action on the element of an ITEM (an accordion item, or a disclosure) — the `openItem` /
|
|
1196
|
+
* `closeItem` / `toggleItem` interaction actions, on the canvas and on the live site through this one function.
|
|
1197
|
+
* Returns whether the item shows open afterwards, or null when `el` is in no running composite.
|
|
1198
|
+
*/
|
|
1199
|
+
function runCompositeItemAction(el, action) {
|
|
1200
|
+
const item = closestAccordionItem(el);
|
|
1201
|
+
const runtime = item ? accordionRuntimeOf(rootOfItem(item)) : null;
|
|
1202
|
+
if (!item || !runtime)
|
|
1203
|
+
return null;
|
|
1204
|
+
return runtime.run(action, runtime.keyOf(item));
|
|
1205
|
+
}
|
|
1206
|
+
/** How much of an opened item must already be on screen for the action NOT to scroll to it (its header row). */
|
|
1207
|
+
const VISIBLE_HEAD_PX = 48;
|
|
1208
|
+
/** True when an item an action just opened is out of view and the page should scroll to it (T14: "opens that item
|
|
1209
|
+
* and scrolls to it") — never when its head is already on screen, so a button beside it does not jolt the page. */
|
|
1210
|
+
function shouldScrollToItem(top, viewportHeight) {
|
|
1211
|
+
return top < 0 || top + VISIBLE_HEAD_PX > viewportHeight;
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
/** The words a fresh disclosure starts with — generic, the author's to replace. */
|
|
1215
|
+
const DISCLOSURE_WORDS = {
|
|
1216
|
+
more: 'Read more',
|
|
1217
|
+
less: 'Read less',
|
|
1218
|
+
body: 'Write the rest of the story here. It stays out of the way until someone asks for it.',
|
|
1219
|
+
};
|
|
1220
|
+
/** A disclosure's one item key — its root IS its item. */
|
|
1221
|
+
const DISCLOSURE_ITEM_KEY = 'k1';
|
|
1222
|
+
var Type$1;
|
|
1223
|
+
(function (Type) {
|
|
1224
|
+
Type["Container"] = "Container";
|
|
1225
|
+
Type["LinkedContainer"] = "LinkedContainer";
|
|
1226
|
+
Type["Text"] = "Text";
|
|
1227
|
+
})(Type$1 || (Type$1 = {}));
|
|
1228
|
+
var Style$1;
|
|
1229
|
+
(function (Style) {
|
|
1230
|
+
Style["Root"] = "box";
|
|
1231
|
+
Style["Trigger"] = "accordion-trigger";
|
|
1232
|
+
Style["Word"] = "body";
|
|
1233
|
+
Style["Panel"] = "accordion-panel";
|
|
1234
|
+
Style["Body"] = "body";
|
|
1235
|
+
})(Style$1 || (Style$1 = {}));
|
|
1236
|
+
const ZERO_BOX$1 = '0px 0px 0px 0px';
|
|
1237
|
+
const HIDDEN = 'none';
|
|
1238
|
+
const INHERIT = 'inherit';
|
|
1239
|
+
/** A word is a flex item of the toggle — `block` (never `inline`: the compiler drops a value equal to the CSS initial,
|
|
1240
|
+
* and the Text's own default would come back in its place). */
|
|
1241
|
+
const SHOWN = 'block';
|
|
1242
|
+
/** What each part holds — layout only; its LOOK is its element style. The trigger sizes to its words (a button in
|
|
1243
|
+
* the flow, not a full-width header row — `fit-content` keeps it from stretching) and the panel spans the root. The
|
|
1244
|
+
* trigger is `flex`, not `inline-flex`: it is a flex item of the root, which blockifies it on the site anyway. */
|
|
1245
|
+
const PART_BASE = {
|
|
1246
|
+
[DisclosureRole.Disclosure]: { display: 'flex', flexDirection: 'column', width: '100%', minHeight: 'auto', height: 'auto' },
|
|
1247
|
+
// The toggle reads the text around it (the page's body type, through the root) — a LinkedContainer is born with
|
|
1248
|
+
// its own literal font, which an accordion's heading masks and a disclosure's words would show.
|
|
1249
|
+
[DisclosureRole.Trigger]: {
|
|
1250
|
+
display: 'flex', alignItems: 'center', gap: '8px', width: 'fit-content', minHeight: 'auto', height: 'auto', textAlign: 'start', cursor: 'pointer',
|
|
1251
|
+
fontFamily: INHERIT, fontSize: INHERIT, fontWeight: INHERIT, lineHeight: INHERIT,
|
|
1252
|
+
},
|
|
1253
|
+
// A word is just its words: none of a Text's own box (its type's 10px padding, its full width) — the toggle's
|
|
1254
|
+
// padding is the click target.
|
|
1255
|
+
[DisclosureRole.More]: { display: SHOWN, margin: ZERO_BOX$1, padding: ZERO_BOX$1, width: 'auto' },
|
|
1256
|
+
[DisclosureRole.Less]: { display: HIDDEN, margin: ZERO_BOX$1, padding: ZERO_BOX$1, width: 'auto' },
|
|
1257
|
+
[DisclosureRole.Panel]: { display: 'block', width: '100%', minHeight: 'auto', height: 'auto' },
|
|
1258
|
+
};
|
|
1259
|
+
const BODY_BASE = { margin: ZERO_BOX$1, padding: ZERO_BOX$1, minHeight: 'auto' };
|
|
1260
|
+
function make$1(create, key, type, role, style, props, label) {
|
|
1261
|
+
return makeCompositePart(create, { key, type, props, elementStyle: style }, role, label, role ? PART_BASE[role] : undefined);
|
|
1262
|
+
}
|
|
1263
|
+
/** One of the trigger's two words: shown while the disclosure is closed (`more`) or open (`less`). */
|
|
1264
|
+
function buildWord(create, role, words, rootId) {
|
|
1265
|
+
const node = make$1(create, role === DisclosureRole.More ? 'more' : 'less', Type$1.Text, role, Style$1.Word, { content: words, tag: 'span' }, 'Text');
|
|
1266
|
+
node.stateStyles = { ...(node.stateStyles ?? {}), [ITEM_OPEN_STATE]: { display: role === DisclosureRole.More ? HIDDEN : SHOWN } };
|
|
1267
|
+
if (rootId)
|
|
1268
|
+
node.stateTriggers = { ...(node.stateTriggers ?? {}), [ITEM_OPEN_STATE]: rootId };
|
|
1269
|
+
return node;
|
|
1270
|
+
}
|
|
1271
|
+
/** A fresh Disclosure: "Read more" opens the text below and reads "Read less" while it is open; closed by default. */
|
|
1272
|
+
function buildDisclosureRecipe(create, options = {}) {
|
|
1273
|
+
const words = { ...DISCLOSURE_WORDS, ...(options.words ?? {}) };
|
|
1274
|
+
const root = make$1(create, 'disclosure', Type$1.Container, DisclosureRole.Disclosure, Style$1.Root, {}, 'Disclosure');
|
|
1275
|
+
root.itemKey = DISCLOSURE_ITEM_KEY;
|
|
1276
|
+
writeProp(root, CompositeBehaviourTrait.DefaultOn, options.open ? AccordionDefaultOn.First : AccordionDefaultOn.None);
|
|
1277
|
+
const icon = options.icon ?? AccordionIcon.Chevron;
|
|
1278
|
+
const trigger = make$1(create, 'trigger', Type$1.LinkedContainer, DisclosureRole.Trigger, Style$1.Trigger, {}, 'Toggle');
|
|
1279
|
+
trigger.children = [
|
|
1280
|
+
buildWord(create, DisclosureRole.More, words.more, root.id),
|
|
1281
|
+
buildWord(create, DisclosureRole.Less, words.less, root.id),
|
|
1282
|
+
...(icon === AccordionIcon.None ? [] : [buildCompositeIcon(create, 'icon', icon, root.id, DisclosureRole.Icon)]),
|
|
1283
|
+
];
|
|
1284
|
+
const panel = make$1(create, 'panel', Type$1.Container, DisclosureRole.Panel, Style$1.Panel, {}, 'Panel');
|
|
1285
|
+
const body = make$1(create, 'body', Type$1.Text, null, Style$1.Body, { content: words.body, tag: 'p' }, 'Text');
|
|
1286
|
+
writeStyles(body, BODY_BASE);
|
|
1287
|
+
panel.children = [body];
|
|
1288
|
+
root.children = [trigger, panel];
|
|
1289
|
+
return root;
|
|
1290
|
+
}
|
|
1291
|
+
/** Behaviour ▸ Open by default (a disclosure: on or off). */
|
|
1292
|
+
function disclosureOpenByDefault(root) {
|
|
1293
|
+
return accordionBehaviour(root).defaultOn !== AccordionDefaultOn.None;
|
|
1294
|
+
}
|
|
1295
|
+
|
|
108
1296
|
/**
|
|
109
1297
|
* BADGE + CHIP (composite T25; complex-components board 8). A Badge is a Container wearing
|
|
110
1298
|
* `badge-<tone>` › Icon + Text; its TONE is its element style, so Badge ▸ Tone writes the style and
|
|
@@ -656,6 +1844,19 @@ const COMPOSITE_ROLE_SPECS = {
|
|
|
656
1844
|
[CardRole.Title]: { parent: CardRole.Content, locked: true },
|
|
657
1845
|
[CardRole.Text]: { parent: CardRole.Content, part: CardPart.Text },
|
|
658
1846
|
[CardRole.Actions]: { parent: CardRole.Content, part: CardPart.Actions },
|
|
1847
|
+
// CC2 — an item always keeps its header and its panel; the icon is Behaviour ▸ Icon's.
|
|
1848
|
+
[AccordionRole.Accordion]: { parent: null },
|
|
1849
|
+
[AccordionRole.Item]: { parent: AccordionRole.Accordion },
|
|
1850
|
+
[AccordionRole.Trigger]: { parent: AccordionRole.Item, locked: true },
|
|
1851
|
+
[AccordionRole.Panel]: { parent: AccordionRole.Item, locked: true },
|
|
1852
|
+
[AccordionRole.Icon]: { parent: AccordionRole.Trigger },
|
|
1853
|
+
// CC2.1 — a disclosure always keeps its toggle and its panel; the words and the icon are the author's.
|
|
1854
|
+
[DisclosureRole.Disclosure]: { parent: null },
|
|
1855
|
+
[DisclosureRole.Trigger]: { parent: DisclosureRole.Disclosure, locked: true },
|
|
1856
|
+
[DisclosureRole.Panel]: { parent: DisclosureRole.Disclosure, locked: true },
|
|
1857
|
+
[DisclosureRole.More]: { parent: DisclosureRole.Trigger },
|
|
1858
|
+
[DisclosureRole.Less]: { parent: DisclosureRole.Trigger },
|
|
1859
|
+
[DisclosureRole.Icon]: { parent: DisclosureRole.Trigger },
|
|
659
1860
|
};
|
|
660
1861
|
var StructureEdit;
|
|
661
1862
|
(function (StructureEdit) {
|
|
@@ -693,7 +1894,9 @@ function isLockedPart(node) {
|
|
|
693
1894
|
* @vectoriox/iox-ui/composite-core — composites built from the builder's own parts (composite-components
|
|
694
1895
|
* plan §3a). FRAMEWORK-FREE: the builder, the SSR engine and iox-cms (generation-core's composer, the site
|
|
695
1896
|
* build) read the same recipes, roles and structure rules. Card v2 + Badge / Chip (T23 / T25) are the
|
|
696
|
-
* first; Accordion
|
|
1897
|
+
* first; the Accordion (CC2) adds the first composite RUNTIME (accordion-runtime.ts — DOM only, no
|
|
1898
|
+
* framework), which the Disclosure (CC2.1) runs too — a one-item accordion whose root is its item; Tabs join with
|
|
1899
|
+
* their own phase.
|
|
697
1900
|
* ⚠️ Consumers wire the usual trio — ng-package raw-ts assets, jest moduleNameMapper, tsconfig paths.
|
|
698
1901
|
*/
|
|
699
1902
|
|
|
@@ -701,5 +1904,5 @@ function isLockedPart(node) {
|
|
|
701
1904
|
* Generated bundle index. Do not edit.
|
|
702
1905
|
*/
|
|
703
1906
|
|
|
704
|
-
export { BADGE_TONES, BADGE_TONE_ICONS, BadgeTone, CARD_LAYOUTS, CARD_LAYOUT_CUSTOM, CARD_PARTS, CARD_PART_ROLE, CARD_STACK_BREAKPOINT, CARD_WORDS, CHIP_ICON, CHIP_STYLE, COMPOSITE_ROLE_SPECS, CardLayout, CardPart, CardRole, PILL_LABEL_STYLE, StructureEdit, StructureRefusal, applyCardLayout, badgeStyleId, badgeToneOf, buildBadge, buildCardRecipe, buildChip, cardPartsOf, cardRootOf, cardTitleOf, clearLayerStyles, detectCardLayout, findByRole, isCardLinked, isCardRoot, isLockedPart, isPillNode, parentInComposite, readLayerStyle, readProp, readStyle, removeFromComposite, setBadgeTone, setCardLinked, setCardPart, structureRefusal, walkComposite, writeLayerStyles, writeProp, writeStyles };
|
|
1907
|
+
export { ACCORDION_BIND_PROP, ACCORDION_CONTENT_LIMIT, ACCORDION_DEFAULT_ONS, ACCORDION_ICONS, ACCORDION_ICON_GLYPH, ACCORDION_NEW_ITEM_WORDS, ACCORDION_WORDS, AccordionClass, AccordionDefaultOn, AccordionEditRefusal, AccordionIcon, AccordionKeyAction, AccordionRole, AccordionRuntime, BADGE_TONES, BADGE_TONE_ICONS, BadgeTone, CARD_LAYOUTS, CARD_LAYOUT_CUSTOM, CARD_PARTS, CARD_PART_ROLE, CARD_STACK_BREAKPOINT, CARD_WORDS, CHIP_ICON, CHIP_STYLE, COMPOSITE_ATTRIBUTE, COMPOSITE_KIND_ROLES, COMPOSITE_ROLE_SPECS, CardLayout, CardPart, CardRole, CompositeBehaviourTrait, CompositeItemAction, CompositeKind, DEFAULT_ON_ATTRIBUTE, DISCLOSURE_ITEM_KEY, DISCLOSURE_WORDS, DisclosureRole, ITEM_KEY_ATTRIBUTE, ITEM_OFF_EVENT, ITEM_ON_EVENT, ITEM_OPEN_STATE, MULTIPLE_ATTRIBUTE, ON_ATTRIBUTE, ON_STATE, PILL_LABEL_STYLE, SCOPE_ATTRIBUTE, StructureEdit, StructureRefusal, UNTIL_FOUND, accordionAnswerOf, accordionBehaviour, accordionDomIds, accordionFocusIndex, accordionHeadingLevel, accordionItemAttributes, accordionItemLabel, accordionItemOf, accordionItemState, accordionItems, accordionKeyAction, accordionPanelAttributes, accordionPanelOf, accordionPartStyle, accordionQuestionOf, accordionRegionAttributes, accordionRemovalRefusal, accordionRootAttributes, accordionRootOf, accordionRuntimeOf, accordionScope, accordionToContent, accordionToManual, accordionTrackAttributes, accordionTriggerAttributes, accordionTriggerOf, addAccordionItem, applyCardLayout, attachAccordionAt, attachAccordions, badgeStyleId, badgeToneOf, buildAccordionItem, buildAccordionRecipe, buildBadge, buildCardRecipe, buildChip, buildCompositeIcon, buildDisclosureRecipe, cardPartsOf, cardRootOf, cardTitleOf, clearLayerStyles, closeAccordionItem, closestAccordion, closestAccordionItem, compositeItemOf, compositeItems, compositeKindOfRole, compositeRootOf, compositeRowKeys, compositeScopeId, detachAccordion, detectAccordionIcon, detectCardLayout, disclosureOpenByDefault, domSafeKey, findByRole, hasLabelledRegion, initialAccordionOpen, isAccordionFromContent, isAccordionItemOn, isAccordionPart, isAccordionRoot, isCardLinked, isCardRoot, isCompositeItem, isCompositePanel, isCompositeRoot, isCompositeTrigger, isDisclosurePart, isDisclosureRoot, isLockedPart, isPillNode, itemOpenTriggerIds, makeCompositePart, moveAccordionItem, nextAccordionState, nextItemKey, onAccordionItemState, openAccordionItem, parentInComposite, pickAccordionAutoBinds, readLayerStyle, readProp, readStyle, removeFromComposite, renameAccordionItem, rowItemKey, runCompositeItemAction, serializeDomAttributes, setAccordionBehaviour, setAccordionIcon, setBadgeTone, setCardLinked, setCardPart, shouldScrollToItem, structureRefusal, walkComposite, writeLayerStyles, writeProp, writeStyles };
|
|
705
1908
|
//# sourceMappingURL=vectoriox-iox-ui-composite-core.mjs.map
|