@vectoriox/iox-ui 5.39.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.
Files changed (32) hide show
  1. package/composite-core/index.d.ts +210 -12
  2. package/design-core/index.d.ts +694 -17
  3. package/fesm2022/vectoriox-iox-ui-actions-core.mjs +4 -3
  4. package/fesm2022/vectoriox-iox-ui-actions-core.mjs.map +1 -1
  5. package/fesm2022/vectoriox-iox-ui-composite-core.mjs +535 -121
  6. package/fesm2022/vectoriox-iox-ui-composite-core.mjs.map +1 -1
  7. package/fesm2022/vectoriox-iox-ui-design-core.mjs +843 -45
  8. package/fesm2022/vectoriox-iox-ui-design-core.mjs.map +1 -1
  9. package/fesm2022/vectoriox-iox-ui-generation-core.mjs +894 -21
  10. package/fesm2022/vectoriox-iox-ui-generation-core.mjs.map +1 -1
  11. package/fesm2022/vectoriox-iox-ui.mjs +3945 -3914
  12. package/fesm2022/vectoriox-iox-ui.mjs.map +1 -1
  13. package/generation-core/index.d.ts +625 -5
  14. package/index.d.ts +22 -1
  15. package/package.json +1 -1
  16. package/src/actions-core/element-actions.ts +4 -3
  17. package/src/composite-core/accordion.ts +1 -0
  18. package/src/composite-core/composite-kinds.ts +42 -6
  19. package/src/composite-core/exclusive-runtime.ts +60 -10
  20. package/src/composite-core/exclusive.ts +64 -0
  21. package/src/composite-core/public-api.ts +5 -2
  22. package/src/composite-core/segmented.ts +349 -0
  23. package/src/composite-core/structure.ts +6 -1
  24. package/src/composite-core/tabs.ts +6 -3
  25. package/src/design-core/contrast-audit.ts +8 -3
  26. package/src/design-core/flavor-spec.generated.ts +832 -41
  27. package/src/generation-core/compose-site.ts +248 -12
  28. package/src/generation-core/nav-shape.ts +53 -3
  29. package/src/generation-core/node-baselines.generated.ts +49 -0
  30. package/src/generation-core/patterns.ts +20 -0
  31. package/src/generation-core/site-spec.generated.ts +595 -0
  32. 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 item key in order and the one that is on. */
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 mark = state === 'on' ? (e.on as StyleMap | undefined)?.['borderColor'] : undefined;
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) {