@astryxdesign/core 0.7.5-canary.cdd75ae → 0.7.5-canary.d26cdf3

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.
@@ -136,7 +136,7 @@ export interface ComplexSelectorProps<Value> extends Omit<FieldInputBaseProps, '
136
136
  isOptional?: boolean;
137
137
  /** Marks the field required. */
138
138
  isRequired?: boolean;
139
- /** Disables the selector. */
139
+ /** Blocks opening and value commits, including from an already-open popup. */
140
140
  isDisabled?: boolean;
141
141
  /** Shows loading state on the trigger. */
142
142
  isLoading?: boolean;
@@ -1 +1 @@
1
- {"version":3,"file":"ComplexSelector.d.ts","sourceRoot":"","sources":["../../src/ComplexSelector/ComplexSelector.tsx"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"ComplexSelector.d.ts","sourceRoot":"","sources":["../../src/ComplexSelector/ComplexSelector.tsx"],"names":[],"mappings":"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"}
@@ -15,7 +15,7 @@
15
15
  * - /apps/storybook/stories/ComplexSelector.stories.tsx
16
16
  * - /packages/cli/assets/templates/blocks/components/ComplexSelector/ (showcase blocks)
17
17
  */
18
- import React, { useCallback, useId, useImperativeHandle, useOptimistic, useRef, useTransition } from 'react';
18
+ import React, { useCallback, useId, useImperativeHandle, useInsertionEffect, useOptimistic, useRef, useTransition } from 'react';
19
19
  import * as stylex from '@stylexjs/stylex';
20
20
  import { useFieldInputStyling } from "../Field/fieldParts.js";
21
21
  import { useDevWarning } from "../hooks/useDevWarning.js";
@@ -367,7 +367,23 @@ export function ComplexSelector({
367
367
  },
368
368
  isOpen: () => popover.isOpen
369
369
  }), [isDisabled, popover]);
370
+
371
+ // The helper reads the selector's availability when it runs, not the
372
+ // render it came from: content may keep a helper from an earlier render
373
+ // (after an await, say) across a change either way. The insertion effect
374
+ // runs before any layout effect of the content, so a commit from the
375
+ // content's own layout effect in the render that changes it sees the new
376
+ // availability too.
377
+ const isDisabledRef = useRef(isDisabled);
378
+ useInsertionEffect(() => {
379
+ isDisabledRef.current = isDisabled;
380
+ }, [isDisabled]);
370
381
  const commitValue = useCallback(nextValue => {
382
+ // Caller-owned popup controls may remain mounted after disablement.
383
+ // Guard the supplied commit boundary, not just the trigger.
384
+ if (isDisabledRef.current) {
385
+ return;
386
+ }
371
387
  onChange?.(nextValue);
372
388
  if (changeAction) {
373
389
  startTransition(async () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astryxdesign/core",
3
- "version": "0.7.5-canary.cdd75ae",
3
+ "version": "0.7.5-canary.d26cdf3",
4
4
  "displayName": "Astryx Core",
5
5
  "description": "The component library. Accessible, themeable React components with built-in spacing, dark mode, and StyleX styling.",
6
6
  "author": "Meta Open Source",
@@ -792,8 +792,8 @@
792
792
  "react-dom": ">=19.0.0"
793
793
  },
794
794
  "devDependencies": {
795
- "@astryxdesign/a11y-spec": "0.7.5-canary.cdd75ae",
796
- "@astryxdesign/cli": "0.7.5-canary.cdd75ae",
795
+ "@astryxdesign/a11y-spec": "0.7.5-canary.d26cdf3",
796
+ "@astryxdesign/cli": "0.7.5-canary.d26cdf3",
797
797
  "@babel/cli": "^7.29.7",
798
798
  "@babel/core": "^7.29.7",
799
799
  "@babel/preset-react": "^7.29.7",
@@ -9,7 +9,7 @@
9
9
  * SYNC: When ComplexSelector.tsx API changes, update these tests.
10
10
  */
11
11
 
12
- import React from 'react';
12
+ import React, {useLayoutEffect} from 'react';
13
13
  import {beforeEach, describe, expect, it, vi} from 'vitest';
14
14
  import {act, fireEvent, render, screen, waitFor} from '@testing-library/react';
15
15
  import userEvent from '@testing-library/user-event';
@@ -213,6 +213,121 @@ describe('ComplexSelector', () => {
213
213
  });
214
214
  });
215
215
 
216
+ it('blocks a commit helper kept from an enabled render once disabled', async () => {
217
+ const user = userEvent.setup();
218
+ const onChange = vi.fn();
219
+ const kept: {current: ((value: string) => void) | null} = {current: null};
220
+ const renderSelector = (isDisabled: boolean) => (
221
+ <ComplexSelector
222
+ label="Fruit"
223
+ value="Apple"
224
+ isDisabled={isDisabled}
225
+ onChange={onChange}>
226
+ {(_value, commit) => {
227
+ kept.current ??= commit;
228
+ return <span>Choices</span>;
229
+ }}
230
+ </ComplexSelector>
231
+ );
232
+ const {rerender} = render(renderSelector(false));
233
+ await user.click(screen.getByRole('combobox', {name: 'Fruit'}));
234
+ expect(kept.current).not.toBeNull();
235
+ rerender(renderSelector(true));
236
+ act(() => kept.current?.('Banana'));
237
+ expect(onChange).not.toHaveBeenCalled();
238
+ });
239
+
240
+ it('blocks a commit from content in the render that disables it', async () => {
241
+ const user = userEvent.setup();
242
+ const onChange = vi.fn();
243
+ function CommitOnDisable({
244
+ isDisabled,
245
+ commit,
246
+ }: {
247
+ isDisabled: boolean;
248
+ commit: (value: string) => void;
249
+ }) {
250
+ useLayoutEffect(() => {
251
+ if (isDisabled) {
252
+ commit('Banana');
253
+ }
254
+ }, [isDisabled, commit]);
255
+ return <span>Choices</span>;
256
+ }
257
+ const renderSelector = (isDisabled: boolean) => (
258
+ <ComplexSelector
259
+ label="Fruit"
260
+ value="Apple"
261
+ isDisabled={isDisabled}
262
+ onChange={onChange}>
263
+ {(_value, commit) => (
264
+ <CommitOnDisable isDisabled={isDisabled} commit={commit} />
265
+ )}
266
+ </ComplexSelector>
267
+ );
268
+ const {rerender} = render(renderSelector(false));
269
+ await user.click(screen.getByRole('combobox', {name: 'Fruit'}));
270
+ rerender(renderSelector(true));
271
+ expect(onChange).not.toHaveBeenCalled();
272
+ });
273
+
274
+ it('commits through a helper kept from a disabled render once enabled', async () => {
275
+ const user = userEvent.setup();
276
+ const onChange = vi.fn();
277
+ const kept: {current: ((value: string) => void) | null} = {current: null};
278
+ const renderSelector = (isDisabled: boolean) => (
279
+ <ComplexSelector
280
+ label="Fruit"
281
+ value="Apple"
282
+ isDisabled={isDisabled}
283
+ onChange={onChange}>
284
+ {(_value, commit) => {
285
+ kept.current ??= commit;
286
+ return <span>Choices</span>;
287
+ }}
288
+ </ComplexSelector>
289
+ );
290
+ const {rerender} = render(renderSelector(true));
291
+ expect(kept.current).not.toBeNull();
292
+ rerender(renderSelector(false));
293
+ await user.click(screen.getByRole('combobox', {name: 'Fruit'}));
294
+ act(() => kept.current?.('Banana'));
295
+ expect(onChange).toHaveBeenCalledExactlyOnceWith('Banana');
296
+ });
297
+
298
+ it('blocks commits when disabled while open and allows them after re-enabling', async () => {
299
+ const user = userEvent.setup();
300
+ const onChange = vi.fn();
301
+ const changeAction = vi.fn();
302
+ const renderSelector = (isDisabled: boolean) => (
303
+ <ComplexSelector
304
+ label="Fruit"
305
+ value="Apple"
306
+ isDisabled={isDisabled}
307
+ onChange={onChange}
308
+ changeAction={changeAction}>
309
+ {(_value, commit) => (
310
+ <button type="button" onClick={() => commit('Banana')}>
311
+ Select Banana
312
+ </button>
313
+ )}
314
+ </ComplexSelector>
315
+ );
316
+ const {rerender} = render(renderSelector(false));
317
+ await user.click(screen.getByRole('combobox', {name: 'Fruit'}));
318
+ const choice = screen.getByRole('button', {name: 'Select Banana', ...h});
319
+
320
+ rerender(renderSelector(true));
321
+ await user.click(choice);
322
+ expect(onChange).not.toHaveBeenCalled();
323
+ expect(changeAction).not.toHaveBeenCalled();
324
+
325
+ rerender(renderSelector(false));
326
+ await user.click(choice);
327
+ expect(onChange).toHaveBeenCalledExactlyOnceWith('Banana');
328
+ expect(changeAction).toHaveBeenCalledExactlyOnceWith('Banana');
329
+ });
330
+
216
331
  it('passes a close helper to composed content', async () => {
217
332
  const user = userEvent.setup();
218
333
 
@@ -20,6 +20,7 @@ import React, {
20
20
  useCallback,
21
21
  useId,
22
22
  useImperativeHandle,
23
+ useInsertionEffect,
23
24
  useOptimistic,
24
25
  useRef,
25
26
  useTransition,
@@ -378,7 +379,7 @@ export interface ComplexSelectorProps<Value> extends Omit<
378
379
  isOptional?: boolean;
379
380
  /** Marks the field required. */
380
381
  isRequired?: boolean;
381
- /** Disables the selector. */
382
+ /** Blocks opening and value commits, including from an already-open popup. */
382
383
  isDisabled?: boolean;
383
384
  /** Shows loading state on the trigger. */
384
385
  isLoading?: boolean;
@@ -687,8 +688,24 @@ export function ComplexSelector<Value>({
687
688
  [isDisabled, popover],
688
689
  );
689
690
 
691
+ // The helper reads the selector's availability when it runs, not the
692
+ // render it came from: content may keep a helper from an earlier render
693
+ // (after an await, say) across a change either way. The insertion effect
694
+ // runs before any layout effect of the content, so a commit from the
695
+ // content's own layout effect in the render that changes it sees the new
696
+ // availability too.
697
+ const isDisabledRef = useRef(isDisabled);
698
+ useInsertionEffect(() => {
699
+ isDisabledRef.current = isDisabled;
700
+ }, [isDisabled]);
701
+
690
702
  const commitValue = useCallback(
691
703
  (nextValue: Value) => {
704
+ // Caller-owned popup controls may remain mounted after disablement.
705
+ // Guard the supplied commit boundary, not just the trigger.
706
+ if (isDisabledRef.current) {
707
+ return;
708
+ }
692
709
  onChange?.(nextValue);
693
710
  if (changeAction) {
694
711
  startTransition(async () => {
@@ -0,0 +1,256 @@
1
+ ---
2
+ schema_version: 3
3
+ template_version: 7
4
+ kind: component
5
+ id: component:ToggleButton
6
+ authority: current
7
+ archive_reason: null
8
+ superseded_by: null
9
+ approved_by: cixzhang
10
+ approved_at: 2026-10-25
11
+ owners: [cixzhang]
12
+ review_triggers: [public-api, behavior, layout, theming, accessibility]
13
+ verified_by:
14
+ [
15
+ packages/core/src/ToggleButton/ToggleButton.test.tsx,
16
+ packages/core/src/ToggleButton/__tests__/ToggleButton.a11y.test.tsx,
17
+ packages/core/src/ToggleButton/__tests__/ToggleButton.a11y.chromium.spec.ts,
18
+ ]
19
+ modules: []
20
+ families: [family:buttons]
21
+ design_specs: [design:reduced-motion]
22
+ architecture:
23
+ [
24
+ architecture:component-style-authoring,
25
+ architecture:component-theming-surface,
26
+ architecture:public-component-api,
27
+ ]
28
+ contributing: []
29
+ system_specs: [spec:AST-085]
30
+ ---
31
+
32
+ # ToggleButton component contract
33
+
34
+ ## Contract at a glance
35
+
36
+ | Area | Contract |
37
+ | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
38
+ | Public contract | None changes. ToggleButton takes `label`, `isPressed`, `onPressedChange`, `pressedChangeAction`, `icon`, `pressedIcon`, `isIconOnly`, `children`, `size`, `elevation`, `isDisabled`, `isLoading`, `tooltip`, and `value`. |
39
+ | Behavior | A ghost Button with `aria-pressed`. Standalone, it requests the next pressed value from the caller and may run an interruptible Action with an optimistic pressed state. As a valued ToggleButtonGroup member, the group owns both. |
40
+ | End-user impact | People see and hear whether a toggle is on. A pending toggle shows its new state at once and can be reversed by clicking again. The label does not shift width when pressed. A disabled toggle without a tooltip leaves the tab order. |
41
+ | Builder impact | None. |
42
+ | Compatibility/readiness | Records shipped behavior; no API, default, or DOM change. Native `disabled` without a tooltip conflicts with `spec:AST-085` FR1 and FR4; Button's reduced-motion gaps apply; a pre-prevented click skips the Action; caller pressed attributes override ToggleButton's own; the label renders twice; consumer docs promise a tooltip that does not exist. All are listed under Current-state impact. |
43
+ | Review checks | Reject a toggle without `aria-pressed`, a caller `onClick` that replaces the toggle request, an Action that runs after a prevented callback, a re-click derived from a stale committed value, or pressed state that shifts outer size. |
44
+ | Governing rules | `family:buttons` FR1–FR11, AV1–AV4; `component:Button`; `spec:AST-085` FR1, FR4; `design:reduced-motion` DR1, DR3; `architecture:public-component-api` INV5; `architecture:component-style-authoring` INV7. |
45
+
46
+ This table is a review projection; the body below is authoritative.
47
+
48
+ ## Intent
49
+
50
+ ToggleButton is the family's persistent-press control. It reuses Button's
51
+ surface and adds a controlled pressed state, pressed-state paint, an optional
52
+ pressed icon, and an interruptible pressed Action, as `family:buttons`
53
+ assigns. Consumer usage lives in `ToggleButton.doc.mjs`.
54
+
55
+ ## Compatibility and migration
56
+
57
+ - Released default preserved: `yes`; unpressed, `size="md"`, `elevation="none"`
58
+ - Compatibility class: records shipped behavior; no public prop, default,
59
+ export, or DOM change
60
+ - Controlled/uncontrolled behavior: controlled only. `isPressed` (default
61
+ `false`) is the committed value; ToggleButton holds no committed state of its
62
+ own, only an optimistic value while an Action is pending
63
+ - Migration decision: none
64
+
65
+ Consumer migration instructions belong in consumer docs and release notes.
66
+
67
+ ## Ownership boundary
68
+
69
+ **Owns**
70
+
71
+ - The effective pressed state, `aria-pressed`, and the next-state request.
72
+ - Callback, caller `onClick`, and Action ordering for a standalone toggle.
73
+ - The optimistic pressed value while an Action is pending.
74
+ - Pressed paint, the pressed icon swap, the pressed label weight, and a
75
+ stable outer size while pressed.
76
+ - The `toggle-button` target and its `isPressed` and `elevation` reflection.
77
+ - How a valued member defers to its ToggleButtonGroup.
78
+
79
+ **Does not own / non-goals**
80
+
81
+ - The surface, naming rules, size heights, pending presentation, disabled
82
+ rendering, tooltip, link mode, and feedback — owned by `component:Button`.
83
+ - Group selection state, single or multiple mode, and group layout — owned by
84
+ `component:ToggleButtonGroup`.
85
+ - On/off settings — owned by `component:Switch`.
86
+ - Inheriting an outer size provider — not provided (OQ1).
87
+
88
+ ## Public concepts
89
+
90
+ | Concept | Closed values or states | Meaning | Availability by variant/orientation/state | Default | Owner | Stability | Invalid-value behavior |
91
+ | -------------- | ---------------------------- | ----------------------------------------------- | ----------------------------------------------------------------- | --------------------- | ------------------------ | --------- | --------------------------------------------- |
92
+ | Pressed | pressed, unpressed | The retained on/off state. | Standalone from `isPressed`; valued group members from the group. | unpressed | `component:ToggleButton` | Stable | Not applicable. |
93
+ | Pressed Action | absent, idle, pending | Async work run for a standalone toggle. | Standalone only; ignored for valued group members. | absent | `component:ToggleButton` | Stable | Not applicable. |
94
+ | Pressed icon | caller node | The icon shown while pressed. | When pressed; falls back to `icon`. | `icon` | `component:ToggleButton` | Stable | Not applicable. |
95
+ | Group value | string | Identifies the member to its ToggleButtonGroup. | Inside a ToggleButtonGroup. | none | `component:ToggleButton` | Stable | Without `value`, a member behaves standalone. |
96
+ | Size | `sm`, `md`, `lg` | Control height. | Always. | Group size, else `md` | `family:buttons` | Stable | TypeScript rejects other values. |
97
+ | Elevation | `none`, `low`, `med`, `high` | Resting shadow depth. | Standalone and inside a ToggleButtonGroup. | `none` | `family:buttons` | Stable | TypeScript rejects other values. |
98
+
99
+ ## Behavioral and layout contract
100
+
101
+ | ID | Invariant | Basis |
102
+ | ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
103
+ | FR1 | ToggleButton renders one Button with the `ghost` variant and Button's interruptible pending mode, and sets `aria-pressed` to the effective pressed value. Other supplied props reach Button. | `family:buttons` FR2, FR6, AV3; current source |
104
+ | FR2 | The effective pressed value is the group's selection for a valued member inside a ToggleButtonGroup, otherwise `isPressed` (default `false`); while a standalone Action is pending, the optimistic value replaces it. | `family:buttons` FR6, FR11; current tests |
105
+ | FR3 | Standalone activation: a caller `onClick` runs first. If that `onClick` calls `preventDefault()`, nothing else runs. Otherwise `onPressedChange(next, event)` runs synchronously, where `next` is the inverse of the effective value. If it calls `preventDefault()`, the Action does not run. A click already prevented before the caller `onClick` ran still calls `onPressedChange`, and `pressedChangeAction` MUST then run (`family:buttons` FR5). Implementation gap: Button skips its `clickAction` for any click already prevented, so `pressedChangeAction` does not run and an Action-only toggle ignores that click. | `family:buttons` FR5; current tests |
106
+ | FR4 | With `pressedChangeAction`, a standalone activation sets the optimistic value to `next` and runs the Action through Button's `clickAction`; the Button is pending until it settles. The Action runs with or without `onPressedChange`. A re-click while pending is accepted and derives `next` from the optimistic value. When it settles, the committed `isPressed` shows. | `family:buttons` FR4–FR6; current tests |
107
+ | FR5 | Without `pressedChangeAction`, activation is synchronous and reports no pending state. | `family:buttons` FR5; current tests |
108
+ | FR6 | A valued member inside a ToggleButtonGroup asks the group to toggle its value on every enabled activation, after any caller `onClick` and whether or not that `onClick` prevented default. It never calls its own `onPressedChange` or `pressedChangeAction` and never reports Action pending. | `family:buttons` FR11; current tests |
109
+ | FR7 | Disabled when its own `isDisabled` or its group's `isDisabled` is true; an enabled group never re-enables a member that disables itself. A disabled toggle never requests a state change and follows `component:Button` FR10, including its implementation gap. | `family:buttons` FR3; current tests |
110
+ | FR8 | While pressed, the surface paints the pressed overlay color; under forced colors it paints `Highlight` with `HighlightText`. The visible label renders semibold while pressed, and pressing MUST NOT change outer size. Content MUST render once, with no hidden twin to reserve width (`architecture:component-style-authoring` INV7). Implementation gap: a labelled toggle wraps its label in a span beside a hidden semibold copy that reserves the width, so the label renders twice and `children` mount twice. Icon-only toggles render no copy. | `family:buttons` FR7, FR9; current source and tests; `architecture:component-style-authoring` INV7 |
111
+ | FR9 | While pressed and `pressedIcon` is supplied, the icon slot shows `pressedIcon`; otherwise it shows `icon`. | `family:buttons` AV2; current tests |
112
+ | FR10 | Size is the explicit `size`, then the enclosing ToggleButtonGroup's `size`, then `md`; ToggleButton does not read an outer size provider. | `family:buttons` FR7; current source |
113
+ | FR11 | `elevation` paints Button's shadow standalone and inside a ToggleButtonGroup. | `family:buttons` FR8, FR12; current tests |
114
+ | FR12 | The root carries both `astryx-button` and `astryx-toggle-button`, reflects `data-is-pressed` as `"true"` or `"false"`, and reflects `data-elevation`. A caller `className` is kept alongside them. A caller `data-is-pressed` MUST NOT replace the reflected value. Implementation gap: caller props spread last, so a caller `aria-pressed` or `data-is-pressed` overrides ToggleButton's own. | `family:buttons` FR10; `architecture:component-theming-surface`; tests; `architecture:public-component-api` INV5 |
115
+ | FR13 | Visible content is `children` when supplied, else `label`; icon-only shows no visible text. When `label` is non-empty it is the accessible name, because Button sets it as `aria-label` (`component:Button` ORD4). | `family:buttons` FR1; current tests |
116
+ | FR14 | Pending presentation and press feedback follow `component:Button` FR8 and FR15: ToggleButton is a standalone Button inside or outside a ToggleButtonGroup, so it takes the press scale, which MUST NOT apply under reduced motion (`design:reduced-motion` DR1), and its interruptible Spinner waits the medium delay and MUST appear at once under reduced motion with a fine pointer (DR3). Implementation gap: both the press scale and the Spinner fade still apply under reduced motion, and content that paints its own colors stays visible beside the Spinner. | `component:Button`; `design:reduced-motion`; current source |
117
+
118
+ ### Allowed variation
119
+
120
+ - **AV1 — Pressed paint.** Themes may restyle the pressed state through
121
+ `toggle-button[data-is-pressed="true"]`; pressed must stay distinguishable
122
+ from unpressed, including under forced colors.
123
+ - **AV2 — Icons.** Any caller node may fill `icon` and `pressedIcon`.
124
+
125
+ ### Representative states
126
+
127
+ | State | Required invariant | Allowed variation |
128
+ | --------------------- | ---------------------------------------------------------------------------------------------- | ----------------- |
129
+ | Unpressed | `aria-pressed="false"`; ghost surface; regular weight. | Icon. |
130
+ | Pressed | `aria-pressed="true"`; pressed paint; semibold label; same outer size. | Pressed icon. |
131
+ | Action pending | Optimistic pressed value; Spinner and `aria-busy`; still operable. | Spinner delay. |
132
+ | Valued group member | Pressed from the group; activation calls the group; no Action. | Elevation. |
133
+ | Disabled with tooltip | `aria-disabled`; focusable; no state request. | — |
134
+ | Disabled, no tooltip | Focusable with `aria-disabled`; no state request. Implementation gap: native `disabled` (FR7). | — |
135
+
136
+ ### Transformation and precedence order
137
+
138
+ - **ORD1 — Effective pressed.** Group selection for a valued member, then the
139
+ pending optimistic value, then `isPressed`, then `false`.
140
+ - **ORD2 — Standalone activation.** Caller `onClick`, then `onPressedChange`,
141
+ then the Action; a newly prevented event stops the steps after it. A click
142
+ prevented before the caller `onClick` still reaches `onPressedChange`
143
+ (FR3 gap for the Action).
144
+ - **ORD3 — Disabled.** Group disabled or own disabled; either is enough.
145
+
146
+ ### Performance and resources
147
+
148
+ - **PR1 — Width reservation.** A labelled toggle renders three elements where
149
+ its label text would do: a wrapper, the visible label, and a hidden copy;
150
+ with `children` the children subtree mounts twice. No measurement runs. This
151
+ cost is the FR8 gap.
152
+
153
+ ## Accessibility contract
154
+
155
+ - **AR1 — State.** `aria-pressed` MUST reflect the effective pressed value
156
+ (FR2), including the optimistic value while pending, and a caller prop MUST
157
+ NOT replace it (`architecture:public-component-api` INV5). Implementation
158
+ gap: caller props spread last, so a caller `aria-pressed` overrides it.
159
+ - **AR2 — Name.** A non-empty `label` is the accessible name (FR13); the
160
+ width-reservation copy (FR8 gap) is hidden from assistive technology.
161
+ - **AR3 — Forced colors.** The pressed state stays visible under forced colors
162
+ (FR8), as WCAG 1.4.11 requires.
163
+ - **AR4 — Visible hint.** An icon-only toggle shows a tooltip only when the
164
+ caller sets `tooltip`.
165
+ - **AR5 — Everything else.** Keyboard, focus indicator, loading announcement,
166
+ busy and disabled exposure, and target size follow `component:Button`
167
+ AR2–AR6.
168
+
169
+ ## Design relationships
170
+
171
+ | Anatomy or state | Design requirement | Representation authority | Hierarchy role | Component contract |
172
+ | ---------------- | ------------------------------------------------------------------------------- | ------------------------ | -------------- | ------------------ |
173
+ | Icon | Leading icon; replaced by Pressed icon while pressed. | Current source and tests | Supporting | FR9 |
174
+ | Pressed icon | Alternate icon while pressed. | Current source and tests | Supporting | FR9 |
175
+ | Label | Visible text, semibold while pressed, at a stable width (FR8 gap: hidden copy). | Current source and tests | Prominent | FR8, FR13 |
176
+ | Spinner | Button's Spinner over hidden content while an Action runs. | Current source | Supporting | FR4 |
177
+ | Pressed surface | Pressed overlay fill; `Highlight` under forced colors. | Current source and tests | Prominent | FR8 |
178
+
179
+ `design:reduced-motion` governs the inherited press scale and Spinner reveal
180
+ (FR14); no other design record governs ToggleButton. The root carries `toggle-button` (and
181
+ Button's `button`); Icon, Pressed icon, and Label take color from them, and the
182
+ Spinner is a Spinner instance. The consumer anatomy names no root part, so this
183
+ record carries no anatomy-theming map.
184
+
185
+ ## Family and system relationships
186
+
187
+ - `family:buttons` owns persistent-press semantics, callback and Action order,
188
+ and group ownership of valued members (FR5, FR6, FR11); ToggleButton adopts
189
+ them. The family's elevation axis is shipped on ToggleButton (FR11).
190
+ - `component:Button` owns the rendered surface and pending, disabled, and
191
+ tooltip behavior.
192
+ - `component:ToggleButtonGroup` owns selection for valued members.
193
+ - `spec:AST-085` applies through Button; the gap is FR7 here.
194
+
195
+ ## Verification map
196
+
197
+ | Contract | Verification | Representative states | Mutation or failure expectation | Audit section |
198
+ | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------- |
199
+ | FR1, FR2, ORD1, AR1 | `ToggleButton.test.tsx`; `ToggleButton.a11y.chromium.spec.ts` standalone and group states | Unpressed, pressed, icon-only, composed label | Missing or stale `aria-pressed` fails. | `audit:ToggleButton/accessibility` |
200
+ | FR3–FR5, ORD2 | `ToggleButton.test.tsx` callback, Action, and caller-`onClick` suites | Callback only, Action only, both, prevented, pending re-click | Order flips, a prevented callback still runs the Action, or callback-only reports pending fails. Evidence gap: no test runs `pressedChangeAction` on a click prevented before the toggle's handler. | `audit:ToggleButton/behavior` |
201
+ | FR6 | `ToggleButton.test.tsx` group-ownership cases | Member with standalone handlers; member with `onClick` | A member that runs its own handler or skips the group toggle fails. | `audit:ToggleButton/behavior` |
202
+ | FR7, ORD3 | `ToggleButton.test.tsx` disabled suite; `ToggleButton.a11y.test.tsx` and `ToggleButton.a11y.chromium.spec.ts` disabled states | Member and group disabled; tooltip-bearing disabled | A disabled toggle that toggles, or an enabled group re-enabling a member, fails. The shared disabled state expects native `disabled`, matching the FR7 gap. | `audit:ToggleButton/accessibility` |
203
+ | FR8, FR9, AR3 | `ToggleButton.test.tsx` width-reservation, pressed-icon, and forced-colors cases | Labelled and icon-only; pressed and unpressed | A wrong icon or no forced-colors pressed paint fails. The width-reservation case asserts the hidden copy, matching the FR8 gap. | `audit:ToggleButton/visual` |
204
+ | FR11, FR12 | `ToggleButton.test.tsx` elevation and className cases | Standalone and grouped elevation; caller className | A lost elevation, missing target, or dropped caller class fails. Evidence gap: no test reads the painted shadow or passes a caller `aria-pressed` or `data-is-pressed`. | `audit:ToggleButton/theming` |
205
+ | FR8 (outer size) | Evidence gap: no test measures that pressing keeps the outer size | Labelled toggle pressed and unpressed | — | `audit:ToggleButton/layout` |
206
+ | FR8, FR12 (gaps) | Evidence gap: no test asserts the pressed overlay paint, the semibold pressed label, or `data-is-pressed` | Pressed and unpressed | — | `audit:ToggleButton/theming` |
207
+ | FR14 | Evidence gap: no ToggleButton test presses or loads under `prefers-reduced-motion: reduce`, or loads self-colored content; the shipped scale and fade fail the reduced-motion clauses | Mouse and touch press; pending Action with a fine pointer | A computed transform other than `none` or `scale(1)` during a press, or a Spinner opacity below 1 after it appears. | `audit:ToggleButton/behavior` |
208
+ | FR10 | Evidence gap: no test of ToggleButton size precedence | — | — | `audit:ToggleButton/layout` |
209
+ | FR13, AR2 | `ToggleButton.test.tsx` children, icon-only, and width-reservation cases; `ToggleButton.a11y.chromium.spec.ts` state-inventory check for the rendered visible label | Labelled; custom children; icon-only | A labelled toggle without its visible label, children that do not replace it, an icon-only toggle without `aria-label`, or a width copy not hidden from assistive technology fails. Evidence gap: no jsdom test asserts `aria-label` on a labelled toggle. | `audit:ToggleButton/accessibility` |
210
+ | AR5 | `ToggleButton.a11y.test.tsx` and `ToggleButton.a11y.chromium.spec.ts` operable and focusable facts | Standalone and grouped | A toggle that is not operable or that Tab does not reach fails. Evidence gap: no test asserts the focus indicator or the target size. | `audit:ToggleButton/accessibility` |
211
+ | AR4, PR1 | Evidence gap: no test asserts the absence of an automatic tooltip or the cost of the width copy | — | — | `audit:ToggleButton/behavior` |
212
+
213
+ ## Current-state impact
214
+
215
+ - **Disabled focusability (FR7).** A disabled ToggleButton without a tooltip
216
+ renders native `disabled`, as `component:Button` FR10 records, against
217
+ `spec:AST-085` FR1 and FR4 and `family:buttons` FR3. The shared toggle-pattern browser states expect
218
+ that native `disabled`.
219
+ - **Duplicated label (FR8, PR1).** A labelled toggle renders a hidden semibold
220
+ copy of its label to reserve width, so `children` mount twice, against
221
+ `architecture:component-style-authoring` INV7.
222
+ - **Pre-prevented click (FR3).** A click already prevented before the toggle's
223
+ handler calls `onPressedChange` but not `pressedChangeAction`, so an
224
+ Action-only toggle ignores it, against `family:buttons` FR5.
225
+ - **Reduced motion (FR14).** A ToggleButton scales to 0.98 on a press, and its
226
+ Spinner fades in, when the person prefers reduced motion, against
227
+ `design:reduced-motion` DR1 and DR3, as `component:Button` FR8 and FR15
228
+ record.
229
+ - **Self-colored content while pending (FR14).** Content that paints its own
230
+ colors stays visible beside the Spinner, as `component:Button` FR8 records.
231
+ - **Consumer docs.** `ToggleButton.doc.mjs` says an icon without children makes
232
+ the toggle icon-only, and that icon-only adds an automatic tooltip from the
233
+ label; its anatomy rows and the `isIconOnly` JSDoc in `ToggleButton.tsx`
234
+ promise the same automatic tooltip. Icon-only depends on `isIconOnly` alone,
235
+ and no tooltip appears without `tooltip` (FR13, AR4).
236
+ - **Caller pressed attributes (FR12, AR1).** A caller `aria-pressed` or
237
+ `data-is-pressed` replaces ToggleButton's own, against
238
+ `architecture:public-component-api` INV5.
239
+ - **Family adoption table.** `family:buttons` lists ToggleButton elevation as an
240
+ adoption gap; the axis is shipped (FR11).
241
+
242
+ ## Decision log
243
+
244
+ No component-local decision. ToggleButton applies `family:buttons` and
245
+ `component:Button`.
246
+
247
+ ## Open questions
248
+
249
+ - **OQ1 — Should ToggleButton inherit an outer size provider like Button?**
250
+ (`human-api`) It reads only its own `size` and its group's (FR10); Button and
251
+ IconButton also read the nearest size provider.
252
+
253
+ ## Content boundary
254
+
255
+ This file does not duplicate consumer prop tables/examples, current audit
256
+ results, implementation steps, or family/system rules. It links to their owners.
@@ -0,0 +1,194 @@
1
+ ---
2
+ schema_version: 3
3
+ template_version: 7
4
+ kind: component
5
+ id: component:ToggleButtonGroup
6
+ authority: current
7
+ archive_reason: null
8
+ superseded_by: null
9
+ approved_by: cixzhang
10
+ approved_at: 2026-10-25
11
+ owners: [cixzhang]
12
+ review_triggers: [public-api, behavior, layout, accessibility]
13
+ verified_by:
14
+ [
15
+ packages/core/src/ToggleButton/ToggleButton.test.tsx,
16
+ packages/core/src/ToggleButton/__tests__/ToggleButton.a11y.test.tsx,
17
+ packages/core/src/ToggleButton/__tests__/ToggleButton.a11y.chromium.spec.ts,
18
+ packages/core/src/Layer/layerScopedContext.test.tsx,
19
+ ]
20
+ modules: []
21
+ families: [family:buttons]
22
+ design_specs: []
23
+ architecture:
24
+ [architecture:component-theming-surface, architecture:public-component-api]
25
+ contributing: []
26
+ system_specs: [spec:AST-085, spec:AST-038]
27
+ ---
28
+
29
+ # ToggleButtonGroup component contract
30
+
31
+ ## Contract at a glance
32
+
33
+ | Area | Contract |
34
+ | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
35
+ | Public contract | None changes. ToggleButtonGroup takes `label`, `type` (`single` or `multiple`), a controlled `value` and `onChange`, `orientation`, `size`, `isDisabled`, `xstyle`, and `data-testid`. |
36
+ | Behavior | A labelled `role="group"` of separate ToggleButtons. Valued members show the group's selection and ask the group to toggle; the group reports the next selection through `onChange`. |
37
+ | End-user impact | People pick one or several options with plain toggle buttons. Each member is its own Tab stop. A disabled group's members leave the tab order. |
38
+ | Builder impact | None. |
39
+ | Compatibility/readiness | Records shipped behavior; no API, default, or DOM change. Disabled members use native `disabled` without a tooltip, against `spec:AST-085` FR1 and FR7; listed under Current-state impact. |
40
+ | Review checks | Reject a group without a name, a member whose pressed state disagrees with `value`, a member Action started by group activation, a wrapper shadow, or connected surfaces. |
41
+ | Governing rules | `family:buttons` FR8, FR11, FR12, AV5; `spec:AST-085` FR1, FR7. |
42
+
43
+ This table is a review projection; the body below is authoritative.
44
+
45
+ ## Intent
46
+
47
+ ToggleButtonGroup owns single or multiple selection across a set of
48
+ ToggleButtons and lays them out as separate surfaces, as `family:buttons`
49
+ assigns. Consumer usage lives in `ToggleButtonGroup.doc.mjs`.
50
+
51
+ ## Compatibility and migration
52
+
53
+ - Released default preserved: `yes`; `type="single"`,
54
+ `orientation="horizontal"`, `isDisabled={false}`
55
+ - Compatibility class: records shipped behavior; no public prop, default,
56
+ export, or DOM change
57
+ - Controlled/uncontrolled behavior: controlled only; the group holds no
58
+ selection of its own
59
+ - Migration decision: none
60
+
61
+ Consumer migration instructions belong in consumer docs and release notes.
62
+
63
+ ## Ownership boundary
64
+
65
+ **Owns**
66
+
67
+ - The group root: role, accessible name, layout, and the
68
+ `toggle-button-group` target.
69
+ - Selection: which valued members are pressed, and the next selection a member
70
+ activation requests.
71
+ - The default size and disabled state valued and unvalued members read.
72
+
73
+ **Does not own / non-goals**
74
+
75
+ - Each member's surface, `aria-pressed`, paint, and disabled rendering — owned
76
+ by `component:ToggleButton` and `component:Button`.
77
+ - Connected geometry and a shared elevation — not provided; members are
78
+ separate surfaces (`family:buttons` FR12).
79
+ - Roving focus — not provided; each member is a Tab stop (`family:buttons`
80
+ AV5).
81
+ - Requiring a selection in single mode — not provided; the active member
82
+ deselects.
83
+
84
+ ## Public concepts
85
+
86
+ | Concept | Closed values or states | Meaning | Availability by variant/orientation/state | Default | Owner | Stability | Invalid-value behavior |
87
+ | ----------- | ---------------------------- | -------------------------------- | ----------------------------------------- | ------------- | ----------------------------- | --------- | ------------------------------------------------------------------------ |
88
+ | Type | `single`, `multiple` | How many members may be pressed. | Always; fixes the `value` shape. | `single` | `component:ToggleButtonGroup` | Stable | TypeScript rejects a `value` or `onChange` that does not match the type. |
89
+ | Value | `string \| null`, `string[]` | The selected member values. | Always; required. | required | `component:ToggleButtonGroup` | Stable | A value no member carries selects nothing visible. |
90
+ | Orientation | `horizontal`, `vertical` | Row or column layout. | Always. | `horizontal` | `component:ToggleButtonGroup` | Stable | TypeScript rejects other values. |
91
+ | Size | `sm`, `md`, `lg` | Default member size. | Always; a member's own `size` wins. | members' `md` | `family:buttons` | Stable | TypeScript rejects other values. |
92
+ | Disabled | off, on | Every member is disabled. | Always. | off | `component:ToggleButtonGroup` | Stable | Not applicable. |
93
+
94
+ ## Behavioral and layout contract
95
+
96
+ | ID | Invariant | Basis |
97
+ | --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- |
98
+ | FR1 | The root is one element with `role="group"` and `aria-label` set to `label`, carrying `astryx-toggle-button-group`, `data-testid`, and `xstyle`. It exposes no orientation or disabled attribute of its own. | `family:buttons` FR11; current source and tests |
99
+ | FR2 | Members sit in a row (or a column when vertical) separated by the `--spacing-1` gap. The wrapper paints no surface and no shadow; each member keeps its own surface and elevation. | `family:buttons` FR8, FR12; current source and tests |
100
+ | FR3 | A member whose `value` is in the selection renders pressed; every other valued member renders unpressed. | `family:buttons` FR11; current tests |
101
+ | FR4 | Single mode: activating an unpressed member calls `onChange` with its value; activating the pressed member calls `onChange(null)`. | Current tests |
102
+ | FR5 | Multiple mode: activating an unpressed member calls `onChange` with the current values plus its value appended; activating a pressed member calls it with that value removed. | Current tests |
103
+ | FR6 | Member activation only requests a selection. The pressed state changes when the caller passes the new `value`, never earlier, and no member Action or Action-pending state runs (`component:ToggleButton` FR6). | `family:buttons` FR11; current tests |
104
+ | FR7 | Members read the group's `size` as their default and its disabled state. While disabled, every member MUST be disabled under `component:ToggleButton` FR7: focusable with `aria-disabled` and inert (`spec:AST-085` FR1, FR7); an enabled group does not re-enable a member that disables itself. Implementation gap: members without a tooltip render native `disabled`. | `family:buttons` FR3, FR11; current tests |
105
+ | FR8 | Each member is its own Tab stop in DOM order; the group adds no arrow-key handling. | `family:buttons` AV5; current source |
106
+ | FR9 | The group's selection, size, and disabled context does not reach ToggleButtons rendered inside a layer opened from a member (`spec:AST-038` FR7, FR8). | Current source |
107
+
108
+ ### Allowed variation
109
+
110
+ - **AV1 — Members.** Members are ToggleButtons; a member without `value` reads
111
+ only the group's size and disabled state and otherwise behaves standalone.
112
+
113
+ ### Representative states
114
+
115
+ | State | Required invariant | Allowed variation |
116
+ | ----------------------- | ------------------------------------------------------------- | ----------------------- |
117
+ | Single, none selected | Every member unpressed; activating one requests its value. | Orientation. |
118
+ | Single, one selected | That member pressed; activating it requests `null`. | — |
119
+ | Multiple, some selected | Each selected member pressed; activation adds or removes one. | — |
120
+ | Member disabled | That member inert; others selectable. | Tooltip present or not. |
121
+ | Group disabled | Every member inert (FR7 gap for members without a tooltip). | — |
122
+
123
+ ### Transformation and precedence order
124
+
125
+ - **ORD1 — Member pressed state.** For a valued member, the group's selection;
126
+ the member's own `isPressed` is ignored.
127
+ - **ORD2 — Member size.** Member `size`, then group `size`, then `md`.
128
+ - **ORD3 — Member disabled.** Group disabled or member disabled.
129
+
130
+ ### Performance and resources
131
+
132
+ - **PR1 — No group listeners.** The group adds no event listener, observer, or
133
+ measurement.
134
+
135
+ ## Accessibility contract
136
+
137
+ - **AR1 — Group name.** The group exposes `role="group"` with `label` as its
138
+ name; members keep their own names and `aria-pressed`.
139
+ - **AR2 — Keyboard.** Tab moves through members in order (FR8); each member
140
+ activates with Enter and Space as a Button does.
141
+ - **AR3 — Disabled.** Members expose disabled per `component:Button` AR5 (FR7
142
+ gap); the group root carries no disabled state.
143
+
144
+ ## Design relationships
145
+
146
+ | Anatomy or state | Design requirement | Representation authority | Hierarchy role | Component contract |
147
+ | ---------------- | ------------------------------------------------ | ------------------------ | -------------- | ------------------ |
148
+ | Members | Separate ToggleButton surfaces with a small gap. | Current source | Prominent | FR2 |
149
+ | Group wrapper | Layout only; no paint or shadow. | Current source | Supporting | FR2 |
150
+
151
+ No design record governs ToggleButtonGroup. Its root carries the
152
+ `toggle-button-group` target with no reflected visual props.
153
+
154
+ ## Family and system relationships
155
+
156
+ - `family:buttons` assigns selection ownership, spaced layout, no wrapper
157
+ elevation, and the toggle group's own keyboard model (FR11, FR12, AV5);
158
+ ToggleButtonGroup adopts them.
159
+ - `component:ToggleButton` defers its pressed state and requests to the group
160
+ for valued members (`component:ToggleButton` FR6).
161
+ - `spec:AST-085` FR7 applies to group disablement; the gap is FR7 here.
162
+
163
+ ## Verification map
164
+
165
+ | Contract | Verification | Representative states | Mutation or failure expectation | Audit section |
166
+ | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- |
167
+ | FR1, AR1 | `ToggleButton.test.tsx` group role and label cases | Single and multiple | A missing name or role fails. Evidence gap: no test asserts the root target, `data-testid`, or `xstyle`. | `audit:ToggleButtonGroup/accessibility` |
168
+ | FR3–FR5, ORD1 | `ToggleButton.test.tsx` single and multiple suites | Select, switch, deselect, add, remove | A wrong pressed member or a wrong `onChange` payload fails. | `audit:ToggleButtonGroup/behavior` |
169
+ | FR6 | `ToggleButton.test.tsx` group-ownership cases | Members with standalone handlers and `onClick` | A member Action runs, or pressed state moves before `value`, fails. | `audit:ToggleButtonGroup/behavior` |
170
+ | FR7, ORD3, AR3 | `ToggleButton.test.tsx` disabled suite; `ToggleButton.a11y.test.tsx` | Member disabled; group disabled; tooltip-bearing member | A disabled member that toggles, or a re-enabled member, fails. The shared disabled states expect native `disabled`, matching the FR7 gap. | `audit:ToggleButtonGroup/accessibility` |
171
+ | FR2 | `ToggleButton.test.tsx` grouped elevation case | Member with its own elevation | A member losing its elevation inside the group fails. Evidence gap: no test asserts the spacing or the unpainted wrapper. Evidence gap: no test reads a member's painted shadow. | `audit:ToggleButtonGroup/layout` |
172
+ | FR9 (selection) | `layerScopedContext.test.tsx` "ends ancestor selection and callbacks while honoring explicit toggle props" | Member inside a fixed Layer and an inline Dialog | A member inside a layer that follows the outer group's selection or callbacks fails. | `audit:ToggleButtonGroup/behavior` |
173
+ | FR8, FR9 (size and disabled) | Evidence gap: no test of member Tab order, or of size and disabled scoping at a layer | — | — | `audit:ToggleButtonGroup/accessibility` |
174
+ | FR7 (size), ORD2, PR1 | Evidence gap: no test asserts the group size default, member size precedence, or the absence of group listeners | — | — | `audit:ToggleButtonGroup/behavior` |
175
+ | AR2 | `ToggleButton.a11y.test.tsx` and `ToggleButton.a11y.chromium.spec.ts` operable and focusable facts for the single- and multiple-group states | Group members | A group member that is not operable or that Tab does not reach fails. | `audit:ToggleButtonGroup/accessibility` |
176
+
177
+ ## Current-state impact
178
+
179
+ - **Disabled focusability (FR7).** A disabled group renders members without a
180
+ tooltip with native `disabled`, against `spec:AST-085` FR1 and FR7 and
181
+ `family:buttons` FR3.
182
+
183
+ ## Decision log
184
+
185
+ No component-local decision. ToggleButtonGroup applies `family:buttons`.
186
+
187
+ ## Open questions
188
+
189
+ None.
190
+
191
+ ## Content boundary
192
+
193
+ This file does not duplicate consumer prop tables/examples, current audit
194
+ results, implementation steps, or family/system rules. It links to their owners.