@vectoriox/iox-ui 5.40.0 → 5.41.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 +210 -12
- package/design-core/index.d.ts +694 -17
- package/fesm2022/vectoriox-iox-ui-actions-core.mjs +4 -3
- package/fesm2022/vectoriox-iox-ui-actions-core.mjs.map +1 -1
- package/fesm2022/vectoriox-iox-ui-composite-core.mjs +535 -121
- package/fesm2022/vectoriox-iox-ui-composite-core.mjs.map +1 -1
- package/fesm2022/vectoriox-iox-ui-design-core.mjs +843 -45
- package/fesm2022/vectoriox-iox-ui-design-core.mjs.map +1 -1
- package/fesm2022/vectoriox-iox-ui-generation-core.mjs +801 -17
- package/fesm2022/vectoriox-iox-ui-generation-core.mjs.map +1 -1
- package/fesm2022/vectoriox-iox-ui.mjs +3945 -3914
- package/fesm2022/vectoriox-iox-ui.mjs.map +1 -1
- package/generation-core/index.d.ts +563 -4
- package/index.d.ts +22 -1
- package/package.json +1 -1
- package/src/actions-core/element-actions.ts +4 -3
- package/src/composite-core/accordion.ts +1 -0
- package/src/composite-core/composite-kinds.ts +42 -6
- package/src/composite-core/exclusive-runtime.ts +60 -10
- package/src/composite-core/exclusive.ts +64 -0
- package/src/composite-core/public-api.ts +5 -2
- package/src/composite-core/segmented.ts +349 -0
- package/src/composite-core/structure.ts +6 -1
- package/src/composite-core/tabs.ts +6 -3
- package/src/design-core/contrast-audit.ts +8 -3
- package/src/design-core/flavor-spec.generated.ts +832 -41
- package/src/generation-core/compose-site.ts +169 -12
- package/src/generation-core/node-baselines.generated.ts +49 -0
- package/src/generation-core/patterns.ts +20 -0
- package/src/generation-core/site-spec.generated.ts +595 -0
- package/styles/iox-design.css +19 -0
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AccordionKeyAction, CompositeBehaviourTrait, compositeScopeId, COMPOSITE_ATTRIBUTE, DEFAULT_ON_ATTRIBUTE, DomAttributes, ITEM_KEY_ATTRIBUTE,
|
|
3
|
+
ITEM_OPEN_STATE, ON_ATTRIBUTE, SCOPE_ATTRIBUTE,
|
|
4
|
+
} from './accordion';
|
|
5
|
+
import { BadgeTone, buildBadge } from './badge';
|
|
6
|
+
import { CompositeKind, SegmentedRole } from './composite-kinds';
|
|
7
|
+
import { CompositeNode, CompositeNodeFactory, makeCompositePart, readProp, walkComposite, writeProp, writeStyles } from './composite-node';
|
|
8
|
+
import { TabsActivateOn, TabsBehaviour, TabsOrientation, TabsScope } from './tabs';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* THE SEGMENTED CONTROL (CC4b — composite K4 T32, pulled forward; complex-components board 10, APPROVED 2026-10-23 with
|
|
12
|
+
* Q31–Q40 as recommended). One of a few, always one on, FIXED options (R3 / Q36). Not a component: a recipe of
|
|
13
|
+
* ordinary nodes, each part carrying a role, so every option, its words and its optional badge are selectable on the
|
|
14
|
+
* canvas, in the layer tree and the breadcrumb, and wear their own element styles (R2).
|
|
15
|
+
*
|
|
16
|
+
* Container `segmented` role segmented traits defaultOn (an option's key) · ariaLabel (its name)
|
|
17
|
+
* └─ ButtonBlock `segmented-option` ×2–4 role segmented.option itemKey monthly · yearly … → <button role=radio>
|
|
18
|
+
* ├─ Text `button-label` the words
|
|
19
|
+
* └─ Container `badge-success` role segmented.badge (optional, off) › Icon + Text — a placeholder (a price claim)
|
|
20
|
+
*
|
|
21
|
+
* The ARIA shape is the APG RADIO GROUP (Q31): `role=radiogroup` + its accessible name on the root, `<button role=radio
|
|
22
|
+
* aria-checked>` per option with a roving tabindex — one Tab stop; ← → follow the reading direction (mirrored under
|
|
23
|
+
* RTL), ↑ ↓ the DOM order, every move wraps and turns the option on. It runs the EXCLUSIVE runtime the Tabs run
|
|
24
|
+
* (`exclusive-runtime.ts`), on the builder canvas and both engine paths, from the attribute planners below. What follows
|
|
25
|
+
* the control (a price) is an ordinary node OUTSIDE it styled "when its option is on" — the targeted `trigger-on`.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
export enum SegmentedTrait {
|
|
29
|
+
/** The option on at first paint — an option's key. */
|
|
30
|
+
DefaultOn = CompositeBehaviourTrait.DefaultOn,
|
|
31
|
+
/** The radio group's accessible name (`aria-label` on the root). Empty → an audit finding. */
|
|
32
|
+
Label = 'ariaLabel',
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Q40: two options at least, four at most (past four the words no longer fit a phone row — a Select or Tabs then). */
|
|
36
|
+
export const SEGMENTED_MIN_OPTIONS = 2;
|
|
37
|
+
export const SEGMENTED_MAX_OPTIONS = 4;
|
|
38
|
+
|
|
39
|
+
export interface SegmentedOptionWords {
|
|
40
|
+
key: string;
|
|
41
|
+
label: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A fresh Segmented control's options (board 10: a billing period) — generic words, the author's to rename. */
|
|
45
|
+
export const SEGMENTED_WORDS: ReadonlyArray<SegmentedOptionWords> = [
|
|
46
|
+
{ key: 'monthly', label: 'Monthly' },
|
|
47
|
+
{ key: 'yearly', label: 'Yearly' },
|
|
48
|
+
];
|
|
49
|
+
export const SEGMENTED_NEW_OPTION_WORDS = 'New option';
|
|
50
|
+
/** The name a fresh control carries (board 10). */
|
|
51
|
+
export const SEGMENTED_NAME = 'Billing period';
|
|
52
|
+
/** Q33: the saving badge's words start as a PLACEHOLDER — a saving is a price claim, never invented. */
|
|
53
|
+
export const SEGMENTED_BADGE_WORDS = 'Yearly saving';
|
|
54
|
+
export const SEGMENTED_BADGE_TONE = BadgeTone.Success;
|
|
55
|
+
|
|
56
|
+
enum Type {
|
|
57
|
+
Container = 'Container',
|
|
58
|
+
ButtonBlock = 'ButtonBlock',
|
|
59
|
+
Text = 'Text',
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
enum Style {
|
|
63
|
+
Root = 'segmented',
|
|
64
|
+
Option = 'segmented-option',
|
|
65
|
+
Label = 'button-label',
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const ZERO_BOX = '0px 0px 0px 0px';
|
|
69
|
+
|
|
70
|
+
/** What each part holds — layout only; its LOOK is its element style (the track's fill, border, radius and pad; the
|
|
71
|
+
* option's ink, pad, radius and 44px target). The options sit in a flex row a gap token apart (board 10). */
|
|
72
|
+
const PART_BASE: Readonly<Record<string, Readonly<Record<string, string>>>> = {
|
|
73
|
+
[SegmentedRole.Segmented]: { display: 'inline-flex', flexDirection: 'row', alignItems: 'stretch', gap: 'var(--iox-segmented-gap)', width: 'auto', maxWidth: '100%', height: 'auto' },
|
|
74
|
+
[SegmentedRole.Option]: { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: '8px', width: 'auto', height: 'auto', cursor: 'pointer' },
|
|
75
|
+
};
|
|
76
|
+
const LABEL_BASE: Readonly<Record<string, string>> = { width: 'auto', minHeight: 'auto', padding: ZERO_BOX, margin: ZERO_BOX };
|
|
77
|
+
/** The badge inside an option keeps to the option's row (never its own line). */
|
|
78
|
+
const BADGE_IN_OPTION: Readonly<Record<string, string>> = { alignSelf: 'center' };
|
|
79
|
+
|
|
80
|
+
function make(create: CompositeNodeFactory, key: string, type: string, role: SegmentedRole | null, style: string,
|
|
81
|
+
props: Record<string, unknown> = {}, label?: string): CompositeNode {
|
|
82
|
+
return makeCompositePart(create, { key, type, props, elementStyle: style }, role, label, role ? PART_BASE[role] : undefined);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── recipe ──────────────────────────────────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
export interface SegmentedOptions {
|
|
88
|
+
options?: ReadonlyArray<SegmentedOptionWords>;
|
|
89
|
+
/** The option on by default (its key); else the first. */
|
|
90
|
+
defaultOn?: string;
|
|
91
|
+
/** The radio group's accessible name. */
|
|
92
|
+
label?: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** One option: a ButtonBlock carrying its key, holding its words. */
|
|
96
|
+
export function buildSegmentedOption(create: CompositeNodeFactory, words: SegmentedOptionWords): CompositeNode {
|
|
97
|
+
const option = make(create, `${words.key}-option`, Type.ButtonBlock, SegmentedRole.Option, Style.Option, {}, 'Option');
|
|
98
|
+
option.itemKey = words.key;
|
|
99
|
+
const label = make(create, `${words.key}-label`, Type.Text, null, Style.Label, { content: words.label, tag: 'span' }, 'Text');
|
|
100
|
+
writeStyles(label, LABEL_BASE);
|
|
101
|
+
option.children = [label];
|
|
102
|
+
return option;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** A fresh Segmented control: Monthly · Yearly, Monthly on, named "Billing period" (board 10 ▸ Palette). */
|
|
106
|
+
export function buildSegmentedRecipe(create: CompositeNodeFactory, options: SegmentedOptions = {}): CompositeNode {
|
|
107
|
+
const words = options.options ?? SEGMENTED_WORDS;
|
|
108
|
+
const root = make(create, 'segmented', Type.Container, SegmentedRole.Segmented, Style.Root, {}, 'Segmented');
|
|
109
|
+
root.children = words.map(w => buildSegmentedOption(create, w));
|
|
110
|
+
writeProp(root, SegmentedTrait.DefaultOn, options.defaultOn && words.some(w => w.key === options.defaultOn) ? options.defaultOn : words[0]?.key ?? '');
|
|
111
|
+
writeProp(root, SegmentedTrait.Label, options.label ?? SEGMENTED_NAME);
|
|
112
|
+
return root;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ── reading a tree ──────────────────────────────────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
export function isSegmentedRoot(node: { role?: string | null } | null | undefined): boolean {
|
|
118
|
+
return node?.role === SegmentedRole.Segmented;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The options in order. */
|
|
122
|
+
export function segmentedOptions(root: CompositeNode | null | undefined): CompositeNode[] {
|
|
123
|
+
return (root?.children ?? []).filter(c => c.role === SegmentedRole.Option);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The key an option renders with: its own key, else its index among the options. */
|
|
127
|
+
export function segmentedOptionKey(option: CompositeNode, index: number): string {
|
|
128
|
+
return option.itemKey || `item-${index}`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Every option's key, in order. */
|
|
132
|
+
export function segmentedKeys(root: CompositeNode | null | undefined): string[] {
|
|
133
|
+
return segmentedOptions(root).map((o, i) => segmentedOptionKey(o, i));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The words an option shows: its first Text that is not inside its badge. */
|
|
137
|
+
export function segmentedOptionLabelNodeOf(option: CompositeNode): CompositeNode | undefined {
|
|
138
|
+
for (const child of option.children ?? []) {
|
|
139
|
+
if (child.role === SegmentedRole.Badge) continue;
|
|
140
|
+
if (child.type === Type.Text) return child;
|
|
141
|
+
let hit: CompositeNode | undefined;
|
|
142
|
+
walkComposite(child, n => { if (!hit && n.type === Type.Text) hit = n; });
|
|
143
|
+
if (hit) return hit;
|
|
144
|
+
}
|
|
145
|
+
return undefined;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const stripTags = (v: unknown) => String(v ?? '').replace(/<[^>]*>/g, '').trim();
|
|
149
|
+
|
|
150
|
+
/** An option's name in the Options list, the layer tree, the item pickers and "Showing on canvas": its words. */
|
|
151
|
+
export function segmentedOptionLabel(option: CompositeNode): string {
|
|
152
|
+
const label = segmentedOptionLabelNodeOf(option);
|
|
153
|
+
return label ? stripTags(readProp(label, 'content')) : '';
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Options ▸ rename: the option's words. */
|
|
157
|
+
export function renameSegmentedOption(option: CompositeNode, words: string): boolean {
|
|
158
|
+
const label = segmentedOptionLabelNodeOf(option);
|
|
159
|
+
return !!label && writeProp(label, 'content', words);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** The radio group's accessible name (empty when unnamed). */
|
|
163
|
+
export function segmentedLabel(root: CompositeNode | null | undefined): string {
|
|
164
|
+
return root ? String(readProp(root, SegmentedTrait.Label) ?? '').trim() : '';
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** The option on by default: the stored key when it still names an option, else the first option. */
|
|
168
|
+
export function segmentedDefaultOn(root: CompositeNode | null | undefined): string | null {
|
|
169
|
+
const keys = segmentedKeys(root);
|
|
170
|
+
const stored = root ? String(readProp(root, SegmentedTrait.DefaultOn) ?? '') : '';
|
|
171
|
+
return keys.includes(stored) ? stored : keys[0] ?? null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Behaviour ▸ On by default and Accessibility ▸ Name. False when the root has no such trait. */
|
|
175
|
+
export function setSegmentedBehaviour(root: CompositeNode, patch: { defaultOn?: string; label?: string }): boolean {
|
|
176
|
+
let ok = true;
|
|
177
|
+
if (patch.defaultOn !== undefined) ok = writeProp(root, SegmentedTrait.DefaultOn, patch.defaultOn) && ok;
|
|
178
|
+
if (patch.label !== undefined) ok = writeProp(root, SegmentedTrait.Label, patch.label) && ok;
|
|
179
|
+
return ok;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ── editing (Options list, toolbar ▸ Add option, the structure guard) ───────────────────────────
|
|
183
|
+
|
|
184
|
+
export enum SegmentedEditRefusal {
|
|
185
|
+
/** Q40: a Segmented control keeps at least two options. */
|
|
186
|
+
MinOptions = 'min-options',
|
|
187
|
+
/** Q40: a Segmented control holds four options at most. */
|
|
188
|
+
MaxOptions = 'max-options',
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Why adding an option to `root` is refused (four already), or null. */
|
|
192
|
+
export function segmentedAddRefusal(root: CompositeNode | null | undefined): SegmentedEditRefusal | null {
|
|
193
|
+
return root && segmentedOptions(root).length >= SEGMENTED_MAX_OPTIONS ? SegmentedEditRefusal.MaxOptions : null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Why removing an option from `root` is refused (two left), or null. */
|
|
197
|
+
export function segmentedRemovalRefusal(root: CompositeNode | null | undefined, node?: CompositeNode | null): SegmentedEditRefusal | null {
|
|
198
|
+
if (!root || (node && node.role !== SegmentedRole.Option)) return null;
|
|
199
|
+
return segmentedOptions(root).length <= SEGMENTED_MIN_OPTIONS ? SegmentedEditRefusal.MinOptions : null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** A key no option of `root` holds yet (`k3`…). */
|
|
203
|
+
export function nextSegmentedKey(root: CompositeNode): string {
|
|
204
|
+
const taken = new Set(segmentedOptions(root).map(o => o.itemKey).filter(Boolean));
|
|
205
|
+
let n = segmentedOptions(root).length + 1;
|
|
206
|
+
while (taken.has(`k${n}`)) n++;
|
|
207
|
+
return `k${n}`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Options ▸ Add option (and the toolbar's "Add option"): a fresh option after `after` (or at the end). Null when
|
|
211
|
+
* refused (four already). */
|
|
212
|
+
export function addSegmentedOption(root: CompositeNode, create: CompositeNodeFactory, after?: CompositeNode | null,
|
|
213
|
+
words: string = SEGMENTED_NEW_OPTION_WORDS): CompositeNode | null {
|
|
214
|
+
if (segmentedAddRefusal(root)) return null;
|
|
215
|
+
const option = buildSegmentedOption(create, { key: nextSegmentedKey(root), label: words });
|
|
216
|
+
const children = [...(root.children ?? [])];
|
|
217
|
+
const at = after ? children.indexOf(after) : -1;
|
|
218
|
+
children.splice(at < 0 ? children.length : at + 1, 0, option);
|
|
219
|
+
root.children = children;
|
|
220
|
+
return option;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Options ▸ ✕: remove an option. False when refused (two left) or `option` is not one of the root's. A removed
|
|
224
|
+
* default hands "On by default" to the first option left. */
|
|
225
|
+
export function removeSegmentedOption(root: CompositeNode, option: CompositeNode): boolean {
|
|
226
|
+
if (segmentedRemovalRefusal(root, option) || !segmentedOptions(root).includes(option)) return false;
|
|
227
|
+
const wasDefault = segmentedDefaultOn(root) === option.itemKey;
|
|
228
|
+
root.children = (root.children ?? []).filter(c => c !== option);
|
|
229
|
+
if (wasDefault) writeProp(root, SegmentedTrait.DefaultOn, segmentedKeys(root)[0] ?? '');
|
|
230
|
+
return true;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Options ▸ drag to reorder: move an option to `toIndex` among the options. */
|
|
234
|
+
export function moveSegmentedOption(root: CompositeNode, option: CompositeNode, toIndex: number): boolean {
|
|
235
|
+
const children = [...(root.children ?? [])];
|
|
236
|
+
const from = children.indexOf(option);
|
|
237
|
+
if (from < 0 || option.role !== SegmentedRole.Option) return false;
|
|
238
|
+
children.splice(from, 1);
|
|
239
|
+
const options = children.filter(c => c.role === SegmentedRole.Option);
|
|
240
|
+
const anchor = options[Math.max(0, Math.min(toIndex, options.length))];
|
|
241
|
+
children.splice(anchor ? children.indexOf(anchor) : children.length, 0, option);
|
|
242
|
+
root.children = children;
|
|
243
|
+
return true;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** The saving badge inside an option, or undefined. */
|
|
247
|
+
export function segmentedBadgeOf(option: CompositeNode | null | undefined): CompositeNode | undefined {
|
|
248
|
+
return (option?.children ?? []).find(c => c.role === SegmentedRole.Badge);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Option ▸ Saving badge (Q33): stamp the badge (`badge-success` › Icon + Text, the placeholder words) inside the
|
|
252
|
+
* option, after its words — or remove it. Returns the badge stamped, or null (removed / nothing changed). */
|
|
253
|
+
export function setSegmentedBadge(option: CompositeNode, on: boolean, create?: CompositeNodeFactory): CompositeNode | null {
|
|
254
|
+
const existing = segmentedBadgeOf(option);
|
|
255
|
+
if (!on) {
|
|
256
|
+
if (existing) option.children = (option.children ?? []).filter(c => c !== existing);
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
if (existing || !create) return null;
|
|
260
|
+
const badge = buildBadge(create, { tone: SEGMENTED_BADGE_TONE, text: SEGMENTED_BADGE_WORDS, key: `${option.itemKey ?? 'option'}-badge` });
|
|
261
|
+
badge.role = SegmentedRole.Badge;
|
|
262
|
+
badge.label = 'Badge';
|
|
263
|
+
writeStyles(badge, BADGE_IN_OPTION);
|
|
264
|
+
option.children = [...(option.children ?? []), badge];
|
|
265
|
+
return badge;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Every "Option on" (`trigger-on`) state inside an option points at ITS option — the part that carries
|
|
269
|
+
* `data-iox-on` while it is on. */
|
|
270
|
+
export function segmentedItemTriggerIds(root: CompositeNode): void {
|
|
271
|
+
for (const option of segmentedOptions(root)) {
|
|
272
|
+
if (!option.id) continue;
|
|
273
|
+
walkComposite(option, n => {
|
|
274
|
+
if (n === option || !n.stateStyles?.[ITEM_OPEN_STATE]) return;
|
|
275
|
+
n.stateTriggers = { ...(n.stateTriggers ?? {}), [ITEM_OPEN_STATE]: option.id! };
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ── state and attributes (both renderers, SSR included) ────────────────────────────────────────
|
|
281
|
+
|
|
282
|
+
/** A Segmented control behaves like a horizontal Tabs activated by a click — only its keys and markup differ. */
|
|
283
|
+
export const SEGMENTED_BEHAVIOUR: Readonly<TabsBehaviour> = { activateOn: TabsActivateOn.Click, orientation: TabsOrientation.Horizontal };
|
|
284
|
+
|
|
285
|
+
/** The render scope of one Segmented control: its scope id, every option key in order, the one on at first paint (its
|
|
286
|
+
* default) and its name — ONE decision for both engine paths. */
|
|
287
|
+
export function segmentedScopeFor(root: CompositeNode, rootCssId: string, rowKeys: ReadonlyArray<string> = []): TabsScope {
|
|
288
|
+
const keys = segmentedKeys(root);
|
|
289
|
+
return {
|
|
290
|
+
kind: CompositeKind.Segmented,
|
|
291
|
+
rootId: compositeScopeId(rootCssId, rowKeys),
|
|
292
|
+
behaviour: SEGMENTED_BEHAVIOUR,
|
|
293
|
+
keys,
|
|
294
|
+
active: segmentedDefaultOn(root),
|
|
295
|
+
label: segmentedLabel(root),
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** The root: the radio group, named; its default and (inside repeated rows) its scope id ride on it. */
|
|
300
|
+
export function segmentedRootAttributes(label: string, defaultOn: string | null, scopeId?: string): DomAttributes {
|
|
301
|
+
return {
|
|
302
|
+
[COMPOSITE_ATTRIBUTE]: CompositeKind.Segmented,
|
|
303
|
+
role: 'radiogroup',
|
|
304
|
+
...(label ? { 'aria-label': label } : {}),
|
|
305
|
+
...(defaultOn ? { [DEFAULT_ON_ATTRIBUTE]: defaultOn } : {}),
|
|
306
|
+
...(scopeId ? { [SCOPE_ATTRIBUTE]: scopeId } : {}),
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** The root's attributes read off the node itself (the engine writes a root's own attributes from its parent's scope). */
|
|
311
|
+
export function segmentedRootAttributesOf(root: CompositeNode, scopeId?: string): DomAttributes {
|
|
312
|
+
return segmentedRootAttributes(segmentedLabel(root), segmentedDefaultOn(root), scopeId);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** An option: a real button announced as a radio — checked = the one option in the Tab order (roving tabindex). */
|
|
316
|
+
export function segmentedOptionAttributes(key: string, on: boolean): DomAttributes {
|
|
317
|
+
return {
|
|
318
|
+
type: 'button',
|
|
319
|
+
role: 'radio',
|
|
320
|
+
'aria-checked': on ? 'true' : 'false',
|
|
321
|
+
tabindex: on ? '0' : '-1',
|
|
322
|
+
[ITEM_KEY_ATTRIBUTE]: key,
|
|
323
|
+
...(on ? { [ON_ATTRIBUTE]: '' } : {}),
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** The attributes a part of a Segmented control writes on its own element (both engine paths, SSR included). */
|
|
328
|
+
export function segmentedPartAttributes(node: { role?: string | null }, scope: TabsScope, key: string | null, rowScoped = false): DomAttributes {
|
|
329
|
+
switch (node.role) {
|
|
330
|
+
case SegmentedRole.Segmented: return segmentedRootAttributes(scope.label, scope.active, rowScoped ? scope.rootId : undefined);
|
|
331
|
+
case SegmentedRole.Option: return key ? segmentedOptionAttributes(key, scope.active === key) : {};
|
|
332
|
+
default: return {};
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* The APG radio group's keys on an option: → / ↓ the next option, ← / ↑ the previous — ← and → follow the reading
|
|
338
|
+
* direction (mirrored under RTL: ← is the next option), ↑ ↓ always the DOM order. Every move wraps and turns the option
|
|
339
|
+
* on. Space / Enter are the button's own (a click: it turns on).
|
|
340
|
+
*/
|
|
341
|
+
export function segmentedKeyAction(key: string, rtl = false): AccordionKeyAction | null {
|
|
342
|
+
switch (key) {
|
|
343
|
+
case 'ArrowDown': return AccordionKeyAction.Next;
|
|
344
|
+
case 'ArrowUp': return AccordionKeyAction.Previous;
|
|
345
|
+
case 'ArrowRight': return rtl ? AccordionKeyAction.Previous : AccordionKeyAction.Next;
|
|
346
|
+
case 'ArrowLeft': return rtl ? AccordionKeyAction.Next : AccordionKeyAction.Previous;
|
|
347
|
+
default: return null;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { AccordionRole, DisclosureRole, TabsRole } from './composite-kinds';
|
|
1
|
+
import { AccordionRole, DisclosureRole, SegmentedRole, TabsRole } from './composite-kinds';
|
|
2
2
|
import { CardPart, CardRole } from './card';
|
|
3
3
|
import { SLIDER_TYPE, SliderRole } from './slider';
|
|
4
4
|
|
|
@@ -51,6 +51,11 @@ export const COMPOSITE_ROLE_SPECS: Readonly<Record<string, CompositeRoleSpec>> =
|
|
|
51
51
|
[TabsRole.Panels]: { parent: TabsRole.Tabs, locked: true, accepts: [TabsRole.Panel] },
|
|
52
52
|
[TabsRole.Trigger]: { parent: TabsRole.List, pinned: true },
|
|
53
53
|
[TabsRole.Panel]: { parent: TabsRole.Panels, pinned: true },
|
|
54
|
+
// CC4b — a Segmented control holds only its options (each may be deleted — two stay — but never moved out); an
|
|
55
|
+
// option's saving badge is optional (Option ▸ Saving badge) and stays in its option.
|
|
56
|
+
[SegmentedRole.Segmented]: { parent: null, accepts: [SegmentedRole.Option] },
|
|
57
|
+
[SegmentedRole.Option]: { parent: SegmentedRole.Segmented, pinned: true },
|
|
58
|
+
[SegmentedRole.Badge]: { parent: SegmentedRole.Option, pinned: true },
|
|
54
59
|
// CC3 — a Slider's controls are its own direct children (Swiper is handed them). The pause control stays while
|
|
55
60
|
// Autoplay is on (WCAG 2.2.2): the Autoplay switch removes it, never Delete. The arrows and the dots follow
|
|
56
61
|
// their switches and may be deleted.
|
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
ITEM_KEY_ATTRIBUTE, ITEM_OPEN_STATE, ON_ATTRIBUTE, rowItemKey, SCOPE_ATTRIBUTE,
|
|
6
6
|
} from './accordion';
|
|
7
7
|
import { CARD_STACK_BREAKPOINT } from './card';
|
|
8
|
-
import { CompositeKind, TabsRole } from './composite-kinds';
|
|
8
|
+
import { CompositeKind, ExclusiveCompositeKind, TabsRole } from './composite-kinds';
|
|
9
9
|
import {
|
|
10
10
|
clearLayerStyles, CompositeNode, CompositeNodeFactory, makeCompositePart, readProp, walkComposite, writeLayerStyles, writeProp, writeStyles,
|
|
11
11
|
} from './composite-node';
|
|
@@ -409,8 +409,11 @@ export function tabsItemTriggerIds(root: CompositeNode): void {
|
|
|
409
409
|
|
|
410
410
|
// ── state, ids and attributes (both renderers, SSR included) ───────────────────────────────────
|
|
411
411
|
|
|
412
|
-
/** The render scope of one Tabs: its scope id, behaviour, every
|
|
412
|
+
/** The render scope of one EXCLUSIVE composite (a Tabs; a Segmented control, CC4b): its kind, scope id, behaviour, every
|
|
413
|
+
* item key in order and the one that is on. */
|
|
413
414
|
export interface TabsScope {
|
|
415
|
+
/** The exclusive kind it renders (a scope without one is a Tabs'). */
|
|
416
|
+
kind?: ExclusiveCompositeKind;
|
|
414
417
|
rootId: string;
|
|
415
418
|
behaviour: TabsBehaviour;
|
|
416
419
|
keys: ReadonlyArray<string>;
|
|
@@ -420,7 +423,7 @@ export interface TabsScope {
|
|
|
420
423
|
}
|
|
421
424
|
|
|
422
425
|
export function tabsScope(rootId: string, behaviour: TabsBehaviour, keys: ReadonlyArray<string>, label = ''): TabsScope {
|
|
423
|
-
return { rootId, behaviour, keys, active: keys[0] ?? null, label };
|
|
426
|
+
return { kind: CompositeKind.Tabs, rootId, behaviour, keys, active: keys[0] ?? null, label };
|
|
424
427
|
}
|
|
425
428
|
|
|
426
429
|
/** The item keys a manual Tabs pairs by — its tabs' own keys, in order (a tab with none by its index). */
|
|
@@ -160,14 +160,19 @@ export function auditDesignContrast(rows: ReadonlyArray<DesignTokenLike>, mode:
|
|
|
160
160
|
const ink = s['color'] ? parseCssColour(resolveIn(values, s['color']), boxInk) : boxInk;
|
|
161
161
|
check(e.id, state === 'base' ? (glyph ? 'glyph' : 'text') : `${state} text`, ink, under, glyph ? WCAG_AA_UI : WCAG_AA_TEXT);
|
|
162
162
|
// The active tab's mark is the selected state's indicator (WCAG 1.4.11) — the contract's audit, 1:1.
|
|
163
|
-
const
|
|
163
|
+
const on = state === 'on' ? (e.on as StyleMap | undefined) : undefined;
|
|
164
|
+
const mark = on?.['borderColor'];
|
|
164
165
|
if (mark) check(e.id, 'on mark', parseCssColour(resolveIn(values, mark), ink), bgs, WCAG_AA_UI);
|
|
166
|
+
// CC4b: a Segmented option's mark IS its on fill (no border) — the fill against the ground it sits on.
|
|
167
|
+
else if (on?.['backgroundColor']) for (const f of fill) check(e.id, 'on mark', f, bgs, WCAG_AA_UI);
|
|
165
168
|
// What a filled element HOLDS (inkScopes): a button's or a badge's icon reads its ink.
|
|
166
169
|
if (state === 'base' && INK_SCOPES.some(sc => sc.styles.includes(e.id))) {
|
|
167
170
|
check(e.id, 'icon', parseCssColour(resolveIn(inkScoped(values, e.id), iconColour), ink), under, WCAG_AA_UI);
|
|
168
|
-
const focus = (e.focus as StyleMap | undefined)?.['boxShadow'];
|
|
169
|
-
if (focus) check(e.id, 'focus ring', ringColour(resolveIn(values, focus), ink), bgs, WCAG_AA_UI);
|
|
170
171
|
}
|
|
172
|
+
// Every focus ring an element style draws, on every ground the element sits on (CC4b: a Segmented
|
|
173
|
+
// option on its track — not only the buttons).
|
|
174
|
+
const focus = state === 'base' ? (e.focus as StyleMap | undefined)?.['boxShadow'] : undefined;
|
|
175
|
+
if (focus) check(e.id, 'focus ring', ringColour(resolveIn(values, focus), ink), bgs, WCAG_AA_UI);
|
|
171
176
|
}
|
|
172
177
|
}
|
|
173
178
|
for (const part of FORM_PARTS) {
|