@vectoriox/iox-ui 5.38.0 → 5.39.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/composite-core/index.d.ts +392 -16
- package/design-core/index.d.ts +322 -3
- package/fesm2022/vectoriox-iox-ui-actions-core.mjs +4 -2
- package/fesm2022/vectoriox-iox-ui-actions-core.mjs.map +1 -1
- package/fesm2022/vectoriox-iox-ui-composite-core.mjs +1129 -146
- package/fesm2022/vectoriox-iox-ui-composite-core.mjs.map +1 -1
- package/fesm2022/vectoriox-iox-ui-design-core.mjs +383 -3
- package/fesm2022/vectoriox-iox-ui-design-core.mjs.map +1 -1
- package/fesm2022/vectoriox-iox-ui-generation-core.mjs +125 -3
- package/fesm2022/vectoriox-iox-ui-generation-core.mjs.map +1 -1
- package/fesm2022/vectoriox-iox-ui.mjs +16 -9
- package/fesm2022/vectoriox-iox-ui.mjs.map +1 -1
- package/generation-core/index.d.ts +43 -0
- package/index.d.ts +11 -6
- package/package.json +1 -1
- package/src/actions-core/element-actions.ts +4 -2
- package/src/composite-core/accordion-runtime.ts +3 -15
- package/src/composite-core/accordion.ts +4 -2
- package/src/composite-core/composite-kinds.ts +121 -7
- package/src/composite-core/composite-runtime.ts +68 -0
- package/src/composite-core/exclusive-runtime.ts +358 -0
- package/src/composite-core/public-api.ts +5 -2
- package/src/composite-core/structure.ts +22 -2
- package/src/composite-core/tabs.ts +628 -0
- package/src/design-core/contrast-audit.ts +5 -0
- package/src/design-core/element-styles.ts +7 -3
- package/src/design-core/flavor-spec.generated.ts +371 -0
- package/src/generation-core/compose-site.ts +68 -1
- package/src/generation-core/node-baselines.generated.ts +8 -2
- package/src/generation-core/patterns.ts +3 -0
- package/src/generation-core/site-spec.generated.ts +46 -0
- package/styles/iox-design.css +16 -0
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
import { roleClass } from '@vectoriox/iox-ui/design-core';
|
|
2
|
+
|
|
3
|
+
import { accordionDomIds, accordionFocusIndex, COMPOSITE_ATTRIBUTE, ITEM_KEY_ATTRIBUTE, ON_ATTRIBUTE, SCOPE_ATTRIBUTE } from './accordion';
|
|
4
|
+
import { ITEM_OFF_EVENT, ITEM_ON_EVENT } from './accordion-runtime';
|
|
5
|
+
import { CompositeKind, EXCLUSIVE_KIND_ROLES, ExclusiveCompositeKind, isExclusiveKind } from './composite-kinds';
|
|
6
|
+
import {
|
|
7
|
+
ACTIVATE_ON_ATTRIBUTE, ORIENTATION_ATTRIBUTE, TABS_ACTIVATE_ONS, TABS_ORIENTATIONS, TabsActivateOn, TabsBehaviour, tabsKeyAction, TabsOrientation,
|
|
8
|
+
} from './tabs';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The EXCLUSIVE composites' RUNTIME (CC4a Tabs; the Segmented control runs it too in CC4b) — ONE copy, DOM only, for
|
|
12
|
+
* the builder canvas and the live site (both engine paths render the same markup from `tabs.ts`'s attribute planners):
|
|
13
|
+
*
|
|
14
|
+
* [root] data-iox-composite="tabs" data-iox-activate-on data-iox-orientation [data-iox-scope]
|
|
15
|
+
* .iox-role-tabs-list role=tablist [aria-orientation=vertical]
|
|
16
|
+
* .iox-role-tabs-trigger role=tab id aria-selected aria-controls tabindex 0|-1 data-iox-item-key [data-iox-on]
|
|
17
|
+
* .iox-role-tabs-panels
|
|
18
|
+
* .iox-role-tabs-panel role=tabpanel id aria-labelledby tabindex=0 data-iox-item-key [hidden] | [data-iox-on]
|
|
19
|
+
*
|
|
20
|
+
* Exactly one item is on, always (a Tabs with tabs never shows none). It owns: a click on a trigger turns its item on
|
|
21
|
+
* (and a hover, when the root says `activate-on=hover`); the APG tabs keys with AUTOMATIC activation — ← → along a
|
|
22
|
+
* horizontal list (mirrored under RTL), ↑ ↓ along a vertical one, Home / End — moving focus AND turning the item on,
|
|
23
|
+
* the roving tabindex keeping one trigger in the Tab order. A trigger and its panel pair by `data-iox-item-key`
|
|
24
|
+
* (else their index among their kind — the builder's From-content rows). Every change dispatches `iox-item-on` /
|
|
25
|
+
* `iox-item-off` on the trigger AND its panel (the `itemOn` / `itemOff` interaction triggers). The item shown is
|
|
26
|
+
* FORCED ?? LIVE: the builder forces one (a selection inside an inactive panel); the live site forces nothing. The
|
|
27
|
+
* server's first state is trusted (no flash).
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
export interface ExclusiveRuntimeOptions {
|
|
31
|
+
/** The scope id the ARIA ids are built from (else `data-iox-scope`, else the root's `iox-node-<id>`). A function is
|
|
32
|
+
* read at every stamp (the builder canvas: a preview row's index is known once it is in the page). */
|
|
33
|
+
rootId?: string | (() => string);
|
|
34
|
+
/** The behaviour (else read from the root's data attributes). */
|
|
35
|
+
behaviour?: TabsBehaviour;
|
|
36
|
+
/** The list's accessible name, stamped by the runtime (the builder canvas; the engine renders it). */
|
|
37
|
+
label?: () => string;
|
|
38
|
+
/** The key forced on, on top of the live state (the builder's design state), or null. */
|
|
39
|
+
forced?: () => string | null;
|
|
40
|
+
/** The live item changed (a click, a key, a hover, an action) — the builder's "Showing on canvas" follows it. */
|
|
41
|
+
onChange?: (key: string) => void;
|
|
42
|
+
/** Re-stamp when the markup under the root changes (the builder canvas re-renders parts). */
|
|
43
|
+
observe?: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const KIND_ROOT = (kind: ExclusiveCompositeKind) => `[${COMPOSITE_ATTRIBUTE}="${kind}"]`;
|
|
47
|
+
const EXCLUSIVE_KINDS = Object.keys(EXCLUSIVE_KIND_ROLES) as ExclusiveCompositeKind[];
|
|
48
|
+
const ROOT_SELECTOR = EXCLUSIVE_KINDS.map(KIND_ROOT).join(', ');
|
|
49
|
+
const roleSelector = (pick: 'list' | 'trigger' | 'panel') =>
|
|
50
|
+
EXCLUSIVE_KINDS.map(k => EXCLUSIVE_KIND_ROLES[k][pick]).filter((r): r is string => !!r).map(r => `.${roleClass(r)}`).join(', ');
|
|
51
|
+
const LIST_SELECTOR = roleSelector('list');
|
|
52
|
+
const TRIGGER_SELECTOR = roleSelector('trigger');
|
|
53
|
+
const PANEL_SELECTOR = roleSelector('panel');
|
|
54
|
+
const ITEM_PART_SELECTOR = `${TRIGGER_SELECTOR}, ${PANEL_SELECTOR}`;
|
|
55
|
+
const TABLIST_SELECTOR = '[role="tablist"]';
|
|
56
|
+
|
|
57
|
+
/** The exclusive composite an element belongs to (the element itself when it is a root). */
|
|
58
|
+
export function closestExclusive(el: Element | null | undefined): HTMLElement | null {
|
|
59
|
+
return (el?.closest?.(ROOT_SELECTOR) as HTMLElement | null) ?? null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The trigger or panel an element sits in (itself when it is one) — its ITEM part — or null. */
|
|
63
|
+
export function closestExclusiveItemPart(el: Element | null | undefined): HTMLElement | null {
|
|
64
|
+
return (el?.closest?.(ITEM_PART_SELECTOR) as HTMLElement | null) ?? null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const kindOf = (root: Element): ExclusiveCompositeKind => {
|
|
68
|
+
const raw = root.getAttribute(COMPOSITE_ATTRIBUTE) as CompositeKind | null;
|
|
69
|
+
return isExclusiveKind(raw) ? raw : CompositeKind.Tabs;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const nodeIdOf = (el: Element): string => {
|
|
73
|
+
for (const c of Array.from(el.classList)) if (c.startsWith('iox-node-')) return c.slice('iox-node-'.length);
|
|
74
|
+
return '';
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
function behaviourOf(root: Element): TabsBehaviour {
|
|
78
|
+
const on = root.getAttribute(ACTIVATE_ON_ATTRIBUTE);
|
|
79
|
+
const orientation = root.getAttribute(ORIENTATION_ATTRIBUTE);
|
|
80
|
+
return {
|
|
81
|
+
activateOn: TABS_ACTIVATE_ONS.includes(on as TabsActivateOn) ? on as TabsActivateOn : TabsActivateOn.Click,
|
|
82
|
+
orientation: TABS_ORIENTATIONS.includes(orientation as TabsOrientation) ? orientation as TabsOrientation : TabsOrientation.Horizontal,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function setAttr(el: Element | null | undefined, name: string, value: string | null): void {
|
|
87
|
+
if (!el) return;
|
|
88
|
+
if (value === null) { if (el.hasAttribute(name)) el.removeAttribute(name); }
|
|
89
|
+
else if (el.getAttribute(name) !== value) el.setAttribute(name, value);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function dispatch(el: Element | null | undefined, type: string): void {
|
|
93
|
+
const Ctor = (globalThis as { CustomEvent?: typeof CustomEvent }).CustomEvent;
|
|
94
|
+
if (el && Ctor) el.dispatchEvent(new Ctor(type, { bubbles: false }));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export class ExclusiveRuntime {
|
|
98
|
+
private live: string | null;
|
|
99
|
+
private shown: string | null = null;
|
|
100
|
+
private observer: MutationObserver | null = null;
|
|
101
|
+
private stampQueued = false;
|
|
102
|
+
private behaviourValue: TabsBehaviour;
|
|
103
|
+
private awaitingItems = false;
|
|
104
|
+
readonly kind: ExclusiveCompositeKind;
|
|
105
|
+
|
|
106
|
+
constructor(readonly root: HTMLElement, private readonly options: ExclusiveRuntimeOptions = {}) {
|
|
107
|
+
this.kind = kindOf(root);
|
|
108
|
+
this.behaviourValue = options.behaviour ?? behaviourOf(root);
|
|
109
|
+
root.setAttribute(COMPOSITE_ATTRIBUTE, this.kind);
|
|
110
|
+
EXCLUSIVE_RUNTIMES.set(root, this);
|
|
111
|
+
// The server rendered the first state (every trigger keyed, one on); with no server state the first is on.
|
|
112
|
+
const triggers = this.triggers();
|
|
113
|
+
const serverOn = !options.behaviour ? triggers.find(t => t.hasAttribute(ON_ATTRIBUTE) && t.hasAttribute(ITEM_KEY_ATTRIBUTE)) : undefined;
|
|
114
|
+
this.live = serverOn ? this.keyOf(serverOn) : (triggers[0] ? this.keyOf(triggers[0]) : null);
|
|
115
|
+
this.awaitingItems = !triggers.length;
|
|
116
|
+
// Capture: on the builder canvas every part's own listener stops the click (it selects that part), and the tab
|
|
117
|
+
// must still turn on — the canvas is a live runtime.
|
|
118
|
+
root.addEventListener('click', this.onClick, true);
|
|
119
|
+
root.addEventListener('mouseover', this.onHover, true);
|
|
120
|
+
root.addEventListener('keydown', this.onKeydown);
|
|
121
|
+
if (options.observe && typeof MutationObserver !== 'undefined') {
|
|
122
|
+
this.observer = new MutationObserver(() => this.queueStamp());
|
|
123
|
+
this.observer.observe(root, { childList: true, subtree: true });
|
|
124
|
+
}
|
|
125
|
+
this.stamp(true);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
get behaviour(): TabsBehaviour {
|
|
129
|
+
return this.behaviourValue;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The behaviour changed in the panel (Activate on, Orientation). */
|
|
133
|
+
setBehaviour(behaviour: TabsBehaviour): void {
|
|
134
|
+
this.behaviourValue = behaviour;
|
|
135
|
+
this.root.setAttribute(ACTIVATE_ON_ATTRIBUTE, behaviour.activateOn);
|
|
136
|
+
this.root.setAttribute(ORIENTATION_ATTRIBUTE, behaviour.orientation);
|
|
137
|
+
this.stamp();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** THIS composite's triggers in document order (a nested composite's are its own). */
|
|
141
|
+
triggers(): HTMLElement[] {
|
|
142
|
+
return this.own(TRIGGER_SELECTOR);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** THIS composite's panels in document order. */
|
|
146
|
+
panels(): HTMLElement[] {
|
|
147
|
+
return this.own(PANEL_SELECTOR);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The key on right now (forced ?? live). */
|
|
151
|
+
get activeKey(): string | null {
|
|
152
|
+
return this.shown;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The key the live state holds (before anything forced). */
|
|
156
|
+
get liveKey(): string | null {
|
|
157
|
+
return this.live;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The key of a trigger or a panel: its `data-iox-item-key`, else its index among its kind. */
|
|
161
|
+
keyOf(part: Element): string {
|
|
162
|
+
const own = part.getAttribute(ITEM_KEY_ATTRIBUTE);
|
|
163
|
+
if (own) return own;
|
|
164
|
+
const peers = part.matches(TRIGGER_SELECTOR) ? this.triggers() : this.panels();
|
|
165
|
+
return `item-${peers.indexOf(part as HTMLElement)}`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** The key of the item an element sits in (a part inside a tab or a panel), or null. */
|
|
169
|
+
keyAt(el: Element | null | undefined): string | null {
|
|
170
|
+
const part = closestExclusiveItemPart(el);
|
|
171
|
+
return part && closestExclusive(part) === this.root ? this.keyOf(part) : null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Turn `key` on (a click, a key, a hover, the `openItem` action, "Showing on canvas"). */
|
|
175
|
+
activate(key: string): void {
|
|
176
|
+
if (!this.keys().includes(key)) return;
|
|
177
|
+
const changed = key !== this.live;
|
|
178
|
+
this.live = key;
|
|
179
|
+
this.stamp();
|
|
180
|
+
if (changed) this.options.onChange?.(key);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Run an item action on `key`: Open turns it on; Close and Toggle do nothing (one item is always on). True when it
|
|
184
|
+
* is on afterwards. */
|
|
185
|
+
run(open: boolean, key: string): boolean {
|
|
186
|
+
if (open) this.activate(key);
|
|
187
|
+
return this.shown === key;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** The forced key changed (builder design state): show it. */
|
|
191
|
+
refresh(): void {
|
|
192
|
+
this.stamp();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
destroy(): void {
|
|
196
|
+
if (EXCLUSIVE_RUNTIMES.get(this.root) === this) EXCLUSIVE_RUNTIMES.delete(this.root);
|
|
197
|
+
this.root.removeEventListener('click', this.onClick, true);
|
|
198
|
+
this.root.removeEventListener('mouseover', this.onHover, true);
|
|
199
|
+
this.root.removeEventListener('keydown', this.onKeydown);
|
|
200
|
+
this.observer?.disconnect();
|
|
201
|
+
this.observer = null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private keys(): string[] {
|
|
205
|
+
return this.triggers().map(t => this.keyOf(t));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** The element(s) that ARE the tablist: the ones a renderer already gave the role (the engine's component-path
|
|
209
|
+
* Repeater puts it on its inner root, which holds the tabs), else the elements carrying the list's role class. */
|
|
210
|
+
private lists(): HTMLElement[] {
|
|
211
|
+
const stamped = this.own(TABLIST_SELECTOR);
|
|
212
|
+
return stamped.length ? stamped : this.own(LIST_SELECTOR);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
private own(selector: string): HTMLElement[] {
|
|
216
|
+
return Array.from(this.root.querySelectorAll<HTMLElement>(selector)).filter(el => closestExclusive(el.parentElement) === this.root);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
private queueStamp(): void {
|
|
220
|
+
if (this.stampQueued) return;
|
|
221
|
+
this.stampQueued = true;
|
|
222
|
+
const run = () => { this.stampQueued = false; this.stamp(); };
|
|
223
|
+
if (typeof queueMicrotask === 'function') queueMicrotask(run); else Promise.resolve().then(run);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private rootId(): string {
|
|
227
|
+
const own = this.options.rootId;
|
|
228
|
+
return (typeof own === 'function' ? own() : own) ?? this.root.getAttribute(SCOPE_ATTRIBUTE) ?? nodeIdOf(this.root);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Write every part's state into its markup; announce what changed. */
|
|
232
|
+
private stamp(first = false): void {
|
|
233
|
+
const triggers = this.triggers();
|
|
234
|
+
const keys = triggers.map(t => this.keyOf(t));
|
|
235
|
+
if (this.awaitingItems && keys.length) {
|
|
236
|
+
this.awaitingItems = false;
|
|
237
|
+
this.live = keys[0];
|
|
238
|
+
first = true;
|
|
239
|
+
}
|
|
240
|
+
if (this.live === null || !keys.includes(this.live)) this.live = keys[0] ?? null;
|
|
241
|
+
const forced = this.options.forced?.() ?? null;
|
|
242
|
+
const next = forced !== null && keys.includes(forced) ? forced : this.live;
|
|
243
|
+
const rootId = this.rootId();
|
|
244
|
+
const vertical = this.behaviourValue.orientation === TabsOrientation.Vertical;
|
|
245
|
+
for (const list of this.lists()) {
|
|
246
|
+
setAttr(list, 'role', 'tablist');
|
|
247
|
+
setAttr(list, 'aria-orientation', vertical ? 'vertical' : null);
|
|
248
|
+
if (this.options.label) setAttr(list, 'aria-label', this.options.label() || null);
|
|
249
|
+
}
|
|
250
|
+
const panels = this.panels();
|
|
251
|
+
const panelOf = (key: string) => panels.find(p => this.keyOf(p) === key) ?? null;
|
|
252
|
+
for (const trigger of triggers) {
|
|
253
|
+
const key = this.keyOf(trigger);
|
|
254
|
+
const on = key === next;
|
|
255
|
+
const ids = accordionDomIds(rootId, key, CompositeKind.Tabs);
|
|
256
|
+
setAttr(trigger, ITEM_KEY_ATTRIBUTE, key);
|
|
257
|
+
setAttr(trigger, 'type', trigger.tagName === 'BUTTON' ? 'button' : null);
|
|
258
|
+
setAttr(trigger, 'role', 'tab');
|
|
259
|
+
setAttr(trigger, 'id', ids.trigger);
|
|
260
|
+
setAttr(trigger, 'aria-selected', on ? 'true' : 'false');
|
|
261
|
+
setAttr(trigger, 'aria-controls', panelOf(key) ? ids.panel : null);
|
|
262
|
+
setAttr(trigger, 'tabindex', on ? '0' : '-1');
|
|
263
|
+
setAttr(trigger, ON_ATTRIBUTE, on ? '' : null);
|
|
264
|
+
}
|
|
265
|
+
for (const panel of panels) {
|
|
266
|
+
const key = this.keyOf(panel);
|
|
267
|
+
const on = key === next;
|
|
268
|
+
const ids = accordionDomIds(rootId, key, CompositeKind.Tabs);
|
|
269
|
+
setAttr(panel, ITEM_KEY_ATTRIBUTE, key);
|
|
270
|
+
setAttr(panel, 'role', 'tabpanel');
|
|
271
|
+
setAttr(panel, 'id', ids.panel);
|
|
272
|
+
setAttr(panel, 'aria-labelledby', ids.trigger);
|
|
273
|
+
setAttr(panel, 'tabindex', '0');
|
|
274
|
+
setAttr(panel, ON_ATTRIBUTE, on ? '' : null);
|
|
275
|
+
setAttr(panel, 'hidden', on ? null : '');
|
|
276
|
+
}
|
|
277
|
+
const was = first ? null : this.shown;
|
|
278
|
+
this.shown = next;
|
|
279
|
+
if (was === next && !first) return;
|
|
280
|
+
if (was !== null) {
|
|
281
|
+
dispatch(triggers.find(t => this.keyOf(t) === was), ITEM_OFF_EVENT);
|
|
282
|
+
dispatch(panelOf(was), ITEM_OFF_EVENT);
|
|
283
|
+
}
|
|
284
|
+
if (next !== null) {
|
|
285
|
+
dispatch(triggers.find(t => this.keyOf(t) === next), ITEM_ON_EVENT);
|
|
286
|
+
dispatch(panelOf(next), ITEM_ON_EVENT);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
private ownTrigger(target: EventTarget | null): HTMLElement | null {
|
|
291
|
+
const trigger = (target as Element | null)?.closest?.(TRIGGER_SELECTOR) as HTMLElement | null;
|
|
292
|
+
return trigger && closestExclusive(trigger.parentElement) === this.root ? trigger : null;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
private readonly onClick = (event: Event): void => {
|
|
296
|
+
const trigger = this.ownTrigger(event.target);
|
|
297
|
+
if (!trigger) return;
|
|
298
|
+
event.preventDefault();
|
|
299
|
+
this.activate(this.keyOf(trigger));
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
private readonly onHover = (event: Event): void => {
|
|
303
|
+
if (this.behaviourValue.activateOn !== TabsActivateOn.Hover) return;
|
|
304
|
+
const trigger = this.ownTrigger(event.target);
|
|
305
|
+
if (trigger) this.activate(this.keyOf(trigger));
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
private readonly onKeydown = (event: Event): void => {
|
|
309
|
+
const e = event as KeyboardEvent;
|
|
310
|
+
const trigger = this.ownTrigger(e.target);
|
|
311
|
+
if (!trigger) return;
|
|
312
|
+
const rtl = isRtl(trigger);
|
|
313
|
+
const action = tabsKeyAction(e.key, this.behaviourValue.orientation, rtl);
|
|
314
|
+
if (!action) return;
|
|
315
|
+
const triggers = this.triggers();
|
|
316
|
+
const target = triggers[accordionFocusIndex(action, triggers.indexOf(trigger), triggers.length)];
|
|
317
|
+
if (!target) return;
|
|
318
|
+
e.preventDefault();
|
|
319
|
+
target.focus();
|
|
320
|
+
this.activate(this.keyOf(target));
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** The direction the tab is laid out in — the computed `direction` (a page's own CSS included), else its nearest `dir`. */
|
|
325
|
+
function isRtl(el: Element): boolean {
|
|
326
|
+
let computed = '';
|
|
327
|
+
try { computed = el.ownerDocument?.defaultView?.getComputedStyle(el).direction ?? ''; } catch { computed = ''; }
|
|
328
|
+
if (computed) return computed === 'rtl';
|
|
329
|
+
return el.closest('[dir]')?.getAttribute('dir') === 'rtl';
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Every live exclusive runtime by its root — so an item action finds the runtime of whatever item it targets. */
|
|
333
|
+
const EXCLUSIVE_RUNTIMES = new WeakMap<Element, ExclusiveRuntime>();
|
|
334
|
+
|
|
335
|
+
/** The runtime running an exclusive root, or null. */
|
|
336
|
+
export function exclusiveRuntimeOf(root: Element | null | undefined): ExclusiveRuntime | null {
|
|
337
|
+
return (root && EXCLUSIVE_RUNTIMES.get(root)) || null;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Attach the runtime to one rendered exclusive root, unless it already runs one. Returns the NEW runtime, or null. */
|
|
341
|
+
export function attachExclusiveAt(root: HTMLElement, options: Omit<ExclusiveRuntimeOptions, 'behaviour'> = {}): ExclusiveRuntime | null {
|
|
342
|
+
if (EXCLUSIVE_RUNTIMES.has(root)) return null;
|
|
343
|
+
return new ExclusiveRuntime(root, options);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** True when `el` is (or sits in) an exclusive item part — a tab or a panel. */
|
|
347
|
+
export function isExclusiveItemOn(el: Element): boolean {
|
|
348
|
+
return !!closestExclusiveItemPart(el)?.hasAttribute(ON_ATTRIBUTE);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Subscribe to the item part `el` sits in turning on / off. Returns the unsubscribe. */
|
|
352
|
+
export function onExclusiveItemState(el: Element, on: boolean, handler: () => void): () => void {
|
|
353
|
+
const part = closestExclusiveItemPart(el);
|
|
354
|
+
if (!part) return () => undefined;
|
|
355
|
+
const type = on ? ITEM_ON_EVENT : ITEM_OFF_EVENT;
|
|
356
|
+
part.addEventListener(type, handler);
|
|
357
|
+
return () => part.removeEventListener(type, handler);
|
|
358
|
+
}
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
* plan §3a). FRAMEWORK-FREE: the builder, the SSR engine and iox-cms (generation-core's composer, the site
|
|
4
4
|
* build) read the same recipes, roles and structure rules. Card v2 + Badge / Chip (T23 / T25) are the
|
|
5
5
|
* first; the Accordion (CC2) adds the first composite RUNTIME (accordion-runtime.ts — DOM only, no
|
|
6
|
-
* framework), which the Disclosure (CC2.1) runs too — a one-item accordion whose root is its item; Tabs
|
|
7
|
-
*
|
|
6
|
+
* framework), which the Disclosure (CC2.1) runs too — a one-item accordion whose root is its item; Tabs (CC4a) run the
|
|
7
|
+
* EXCLUSIVE runtime (exclusive-runtime.ts — one item always on); composite-runtime.ts is the one door to both.
|
|
8
8
|
* ⚠️ Consumers wire the usual trio — ng-package raw-ts assets, jest moduleNameMapper, tsconfig paths.
|
|
9
9
|
*/
|
|
10
10
|
export * from './composite-node';
|
|
@@ -18,3 +18,6 @@ export * from './structure';
|
|
|
18
18
|
export * from './slider';
|
|
19
19
|
export * from './slider-runtime';
|
|
20
20
|
export * from './lightbox';
|
|
21
|
+
export * from './tabs';
|
|
22
|
+
export * from './exclusive-runtime';
|
|
23
|
+
export * from './composite-runtime';
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { AccordionRole, DisclosureRole } from './composite-kinds';
|
|
1
|
+
import { AccordionRole, DisclosureRole, TabsRole } from './composite-kinds';
|
|
2
2
|
import { CardPart, CardRole } from './card';
|
|
3
3
|
import { SLIDER_TYPE, SliderRole } from './slider';
|
|
4
4
|
|
|
@@ -17,6 +17,8 @@ export interface CompositeRoleSpec {
|
|
|
17
17
|
/** May be deleted, but never moved out of its parent (a Slider's controls work only as its direct children). */
|
|
18
18
|
readonly pinned?: boolean;
|
|
19
19
|
readonly part?: CardPart;
|
|
20
|
+
/** The only roles that may be dropped INTO this part (a tab list takes tabs, a panels holder takes panels). */
|
|
21
|
+
readonly accepts?: ReadonlyArray<string>;
|
|
20
22
|
}
|
|
21
23
|
|
|
22
24
|
export const COMPOSITE_ROLE_SPECS: Readonly<Record<string, CompositeRoleSpec>> = {
|
|
@@ -41,6 +43,14 @@ export const COMPOSITE_ROLE_SPECS: Readonly<Record<string, CompositeRoleSpec>> =
|
|
|
41
43
|
[DisclosureRole.More]: { parent: DisclosureRole.Trigger },
|
|
42
44
|
[DisclosureRole.Less]: { parent: DisclosureRole.Trigger },
|
|
43
45
|
[DisclosureRole.Icon]: { parent: DisclosureRole.Trigger },
|
|
46
|
+
// CC4a — a Tabs always keeps its tab list and its panels holder; a tab lives in the list and a panel in the
|
|
47
|
+
// holder (each may be deleted — with its pair — but never moved out); the list takes only tabs, the holder only
|
|
48
|
+
// panels, and a panel takes anything.
|
|
49
|
+
[TabsRole.Tabs]: { parent: null },
|
|
50
|
+
[TabsRole.List]: { parent: TabsRole.Tabs, locked: true, accepts: [TabsRole.Trigger] },
|
|
51
|
+
[TabsRole.Panels]: { parent: TabsRole.Tabs, locked: true, accepts: [TabsRole.Panel] },
|
|
52
|
+
[TabsRole.Trigger]: { parent: TabsRole.List, pinned: true },
|
|
53
|
+
[TabsRole.Panel]: { parent: TabsRole.Panels, pinned: true },
|
|
44
54
|
// CC3 — a Slider's controls are its own direct children (Swiper is handed them). The pause control stays while
|
|
45
55
|
// Autoplay is on (WCAG 2.2.2): the Autoplay switch removes it, never Delete. The arrows and the dots follow
|
|
46
56
|
// their switches and may be deleted.
|
|
@@ -61,6 +71,8 @@ export enum StructureRefusal {
|
|
|
61
71
|
LockedPart = 'locked-part',
|
|
62
72
|
/** A locked part can only live directly inside its own parent role. */
|
|
63
73
|
LockedPlacement = 'locked-placement',
|
|
74
|
+
/** The new parent takes only certain roles (a tab list takes only tabs, CC4a). */
|
|
75
|
+
NotAccepted = 'not-accepted',
|
|
64
76
|
}
|
|
65
77
|
|
|
66
78
|
interface RoleLike { role?: string | null; type?: string }
|
|
@@ -71,9 +83,10 @@ interface RoleLike { role?: string | null; type?: string }
|
|
|
71
83
|
*/
|
|
72
84
|
export function structureRefusal(node: RoleLike | null | undefined, edit: StructureEdit, ancestors: ReadonlyArray<RoleLike> = []): StructureRefusal | null {
|
|
73
85
|
const spec = node?.role ? COMPOSITE_ROLE_SPECS[node.role] : undefined;
|
|
86
|
+
const parent = ancestors[ancestors.length - 1];
|
|
87
|
+
if (edit === StructureEdit.Move && acceptsRefusal(node, parent)) return StructureRefusal.NotAccepted;
|
|
74
88
|
if (!spec?.locked && !spec?.pinned) return null;
|
|
75
89
|
if (edit === StructureEdit.Delete) return spec.locked ? StructureRefusal.LockedPart : null;
|
|
76
|
-
const parent = ancestors[ancestors.length - 1];
|
|
77
90
|
const placed = spec.parentType ? parent?.type === spec.parentType : parent?.role === spec.parent;
|
|
78
91
|
return placed ? null : StructureRefusal.LockedPlacement;
|
|
79
92
|
}
|
|
@@ -83,3 +96,10 @@ export function structureRefusal(node: RoleLike | null | undefined, edit: Struct
|
|
|
83
96
|
export function isLockedPart(node: RoleLike | null | undefined): boolean {
|
|
84
97
|
return !!(node?.role && COMPOSITE_ROLE_SPECS[node.role]?.locked);
|
|
85
98
|
}
|
|
99
|
+
|
|
100
|
+
/** Why `parent` refuses `node` as a child (it takes only certain roles), or null. */
|
|
101
|
+
export function acceptsRefusal(node: RoleLike | null | undefined, parent: RoleLike | null | undefined): StructureRefusal | null {
|
|
102
|
+
const accepts = parent?.role ? COMPOSITE_ROLE_SPECS[parent.role]?.accepts : undefined;
|
|
103
|
+
if (!accepts) return null;
|
|
104
|
+
return node?.role && accepts.includes(node.role) ? null : StructureRefusal.NotAccepted;
|
|
105
|
+
}
|