@astryxdesign/core 0.7.3-canary.fba04ed → 0.7.3

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 (36) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/dist/Button/Button.d.ts.map +1 -1
  3. package/dist/Button/Button.js +15 -2
  4. package/dist/Chat/ChatComposer.d.ts.map +1 -1
  5. package/dist/Chat/ChatComposer.js +25 -2
  6. package/dist/Chat/ChatSystemMessage.d.ts +4 -0
  7. package/dist/Chat/ChatSystemMessage.d.ts.map +1 -1
  8. package/dist/Chat/ChatSystemMessage.js +4 -2
  9. package/dist/DropdownMenu/DropdownMenuRadioGroup.d.ts +9 -5
  10. package/dist/DropdownMenu/DropdownMenuRadioGroup.d.ts.map +1 -1
  11. package/dist/DropdownMenu/DropdownMenuRadioGroup.js +2 -0
  12. package/package.json +4 -4
  13. package/src/Button/Button.test.tsx +13 -2
  14. package/src/Button/Button.tsx +23 -2
  15. package/src/Button/__tests__/Button.a11y.states.ts +4 -3
  16. package/src/Carousel/Carousel.spec.md +146 -0
  17. package/src/Chat/ChatComposer.spec.md +26 -26
  18. package/src/Chat/ChatComposer.test.tsx +58 -2
  19. package/src/Chat/ChatComposer.tsx +25 -1
  20. package/src/Chat/ChatSystemMessage.doc.mjs +1 -1
  21. package/src/Chat/ChatSystemMessage.test.tsx +25 -3
  22. package/src/Chat/ChatSystemMessage.tsx +6 -4
  23. package/src/Chat/__tests__/ChatSystemMessage.a11y.chromium.spec.ts +8 -9
  24. package/src/CheckboxList/__tests__/CheckboxList.a11y.chromium.spec.ts +7 -6
  25. package/src/DropdownMenu/DropdownMenuRadioGroup.doc.mjs +62 -7
  26. package/src/DropdownMenu/DropdownMenuRadioGroup.spec.md +190 -0
  27. package/src/DropdownMenu/DropdownMenuRadioGroup.tsx +10 -4
  28. package/src/DropdownMenu/DropdownMenuSelectable.test.tsx +93 -0
  29. package/src/DropdownMenu/__tests__/DropdownMenuRadioGroupAudit.a11y.chromium.spec.ts +327 -0
  30. package/src/FieldStatus/FieldStatus.spec.md +4 -4
  31. package/src/FieldStatus/__tests__/StatusMessage.a11y.renders.tsx +0 -18
  32. package/src/FieldStatus/__tests__/StatusMessage.a11y.states.ts +3 -18
  33. package/src/FieldStatus/__tests__/StatusMessage.a11y.test.tsx +1 -2
  34. package/src/Markdown/Markdown.helpers.perf.test.ts +25 -19
  35. package/src/Table/plugins/columnResize/useTableColumnResize.spec.md +11 -11
  36. package/src/Table/plugins/rowStatus/useTableRowStatus.spec.md +4 -3
@@ -104,7 +104,7 @@ Consumer migration instructions belong in consumer docs and release notes.
104
104
  | Primary action | send; stop | Sends the current draft or requests interruption. | Default ChatSendButton or a custom sendButton | send | `component:ChatComposer` | stable | A shown Stop action without `onStop` is currently rendered but inert. |
105
105
  | Density | compact; balanced; spacious | Selects composer spacing. | Every ChatComposer | balanced | `component:ChatComposer` | stable | Unknown values are rejected by TypeScript. |
106
106
  | Elevation | low; none | Selects a raised body or a flat bordered body. | Every ChatComposer | low | `component:ChatComposer` | stable | Unknown values are rejected by TypeScript. |
107
- | Status | absent; error; warning; top; bottom | Adds semantic feedback before or after the body. | Optional status object | absent; bottom when present | `component:ChatComposer` | stable | An omitted message currently leaves the semantic status region empty. |
107
+ | Status | absent; error; warning; top; bottom | Adds announced feedback before or after the body. | Optional status object | absent; bottom when present | `component:ChatComposer` | stable | An omitted message renders the bar and announces nothing. |
108
108
  | Named composition slots | drawer; header actions/context; input; footer/send actions | Lets callers compose supporting controls and information around the draft. | Optional, independently supplied | omitted | `component:ChatComposer` | stable | Omitted slot groups emit no empty header; footer remains for the send action. |
109
109
  | BaseProps surface | ref; DOM props/events; className/style; xstyle | Extends the component with DOM identity, behavior, and styling. | Every ChatComposer | omitted | `architecture:public-component-api` | stable | These surfaces currently have split root/body ownership; see OQ2. |
110
110
 
@@ -113,19 +113,19 @@ Consumer migration instructions belong in consumer docs and release notes.
113
113
  Draft requirements identify their basis so observed code is not mistaken for an
114
114
  intentional decision. A `current` contract contains no unresolved rows.
115
115
 
116
- | ID | Candidate invariant | Basis | Draft review state |
117
- | ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------- | ------------------ |
118
- | FR1 | Controlled mode MUST report changes through `onChange`; uncontrolled mode MUST retain them locally. Every successful submit MUST trim and deliver the value supplied to that operation, then clear through the same value channel. | public types and docs; `architecture:public-component-api/INV3, INV9, INV12`; focused tests | settled |
119
- | FR2 | The default editor MUST serialize its current text/tokens, submit Enter without Shift, preserve Shift+Enter, defer during IME composition, and allow a caller key handler to prevent the default operation. | implementation, docs, and focused tests | verify |
120
- | FR3 | `isDisabled` MUST prevent draft edits and submissions and MUST expose `aria-disabled="true"` on the default textbox; removing the prop MUST remove that state. | `spec:AST-020/FR1` (WCAG 2.2 4.1.2); `architecture:interaction-modality/INV5`; red/green focused test | settled |
121
- | FR4 | When `isStopShown` is true, the default primary action MUST remain an enabled, named Stop button and call `onStop`; if editing is also disabled, ancestor styling MUST NOT block its pointer, touch, or keyboard activation. | `architecture:interaction-modality/INV4, INV7, INV9`; red/green focused and browser-story tests | settled |
122
- | FR5 | Omitted header slots MUST omit the header row; the drawer precedes the body; input fills the body between header and footer; footer actions precede send actions and the primary action; status follows `statusPosition`. | implementation, props, stories, and blocks | verify |
123
- | FR6 | Compact currently reduces body padding and gap. Balanced and spacious currently use the same body spacing. Whether spacious needs distinct output MUST remain unresolved until a component/design owner decides it. | implementation and published union; no current owner selects distinct spacious geometry | human decision |
124
- | FR7 | `low` currently applies low rest and medium hover/focus-within elevation; `none` applies no shadow and a border. Public theme reachability MUST cover the painting body or the public target/model MUST change through an approved compatibility path. | implementation; `architecture:component-theming-surface/INV4, INV6`; existing public issue | human decision |
125
- | FR8 | Keyboard focus entering the registered/default editor MUST add the shared focus indication to the body; pointer focus MUST not add that extra ring. Clicking non-interactive body space MUST focus the registered control, then a textarea fallback. | implementation; `architecture:interaction-modality/INV1–INV3, INV9`; focused tests | verify |
126
- | FR9 | Error status currently renders an alert; warning status renders a status; both pair a semantic icon with color and can render above or below the body. | implementation, docs, and stories; `spec:AST-020/FR1` | verify |
127
- | FR10 | BaseProps currently split across elements: ref, DOM props/events, `className`, and `style` reach the outer frame while `xstyle` reaches the painted body. The owning element MUST remain unresolved pending compatibility review. | implementation; `architecture:public-component-api/INV5–INV6, INV8` | human decision |
128
- | FR11 | The exported context MUST expose the current value, change and submit operations, placeholder, disabled/stop/send state, stop callback, and mutable input focus control to custom composition. | public export, types, docs, stories, and focused tests; `architecture:public-component-api/INV12` | verify |
116
+ | ID | Candidate invariant | Basis | Draft review state |
117
+ | ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------- | ------------------ |
118
+ | FR1 | Controlled mode MUST report changes through `onChange`; uncontrolled mode MUST retain them locally. Every successful submit MUST trim and deliver the value supplied to that operation, then clear through the same value channel. | public types and docs; `architecture:public-component-api/INV3, INV9, INV12`; focused tests | settled |
119
+ | FR2 | The default editor MUST serialize its current text/tokens, submit Enter without Shift, preserve Shift+Enter, defer during IME composition, and allow a caller key handler to prevent the default operation. | implementation, docs, and focused tests | verify |
120
+ | FR3 | `isDisabled` MUST prevent draft edits and submissions and MUST expose `aria-disabled="true"` on the default textbox; removing the prop MUST remove that state. | `spec:AST-020/FR1` (WCAG 2.2 4.1.2); `architecture:interaction-modality/INV5`; red/green focused test | settled |
121
+ | FR4 | When `isStopShown` is true, the default primary action MUST remain an enabled, named Stop button and call `onStop`; if editing is also disabled, ancestor styling MUST NOT block its pointer, touch, or keyboard activation. | `architecture:interaction-modality/INV4, INV7, INV9`; red/green focused and browser-story tests | settled |
122
+ | FR5 | Omitted header slots MUST omit the header row; the drawer precedes the body; input fills the body between header and footer; footer actions precede send actions and the primary action; status follows `statusPosition`. | implementation, props, stories, and blocks | verify |
123
+ | FR6 | Compact currently reduces body padding and gap. Balanced and spacious currently use the same body spacing. Whether spacious needs distinct output MUST remain unresolved until a component/design owner decides it. | implementation and published union; no current owner selects distinct spacious geometry | human decision |
124
+ | FR7 | `low` currently applies low rest and medium hover/focus-within elevation; `none` applies no shadow and a border. Public theme reachability MUST cover the painting body or the public target/model MUST change through an approved compatibility path. | implementation; `architecture:component-theming-surface/INV4, INV6`; existing public issue | human decision |
125
+ | FR8 | Keyboard focus entering the registered/default editor MUST add the shared focus indication to the body; pointer focus MUST not add that extra ring. Clicking non-interactive body space MUST focus the registered control, then a textarea fallback. | implementation; `architecture:interaction-modality/INV1–INV3, INV9`; focused tests | verify |
126
+ | FR9 | Error status is announced assertively and warning status politely through the shared announcement channel, once per occasion: one state transition, which a rerender or an update to the same value does not repeat (`spec:AST-086` FR6); the status bar itself carries no live role. Both pair a semantic icon with color and can render above or below the body. | implementation, docs, and stories; `spec:AST-020/FR1`; `spec:AST-086/FR2, FR5, FR6` | verify |
127
+ | FR10 | BaseProps currently split across elements: ref, DOM props/events, `className`, and `style` reach the outer frame while `xstyle` reaches the painted body. The owning element MUST remain unresolved pending compatibility review. | implementation; `architecture:public-component-api/INV5–INV6, INV8` | human decision |
128
+ | FR11 | The exported context MUST expose the current value, change and submit operations, placeholder, disabled/stop/send state, stop callback, and mutable input focus control to custom composition. | public export, types, docs, stories, and focused tests; `architecture:public-component-api/INV12` | verify |
129
129
 
130
130
  ### Allowed variation
131
131
 
@@ -144,7 +144,7 @@ intentional decision. A `current` contract contains no unresolved rows.
144
144
  | Disabled editing | Default textbox exposes disabled state and cannot edit or submit. | Supporting content remains visible. |
145
145
  | Disabled, interruptible work | Stop remains named, enabled, and reachable by pointer, touch, and keyboard. | Product cancellation behavior varies. |
146
146
  | Flat elevation | Body has no shadow and uses its bordered presentation; keyboard focus indication remains available. | Theme tokens vary. |
147
- | Status above or below | Semantic role, icon, color, and selected placement remain synchronized. | Message content and status type vary. |
147
+ | Status above or below | Announcement urgency, icon, color, and selected placement remain synchronized. | Message content and status type vary. |
148
148
  | Narrow viewport | Composer remains within its containing inline size and the primary action remains operable. | Available inline size and optional slot content vary. |
149
149
 
150
150
  ### Transformation and precedence order
@@ -165,20 +165,20 @@ intentional decision. A `current` contract contains no unresolved rows.
165
165
  - **AR2 — Native actions.** The default Send and Stop controls remain native buttons with translated accessible names; disabled Send uses native disabled behavior, while explicit Stop remains operable.
166
166
  - **AR3 — Keyboard editing.** Enter submission MUST preserve Shift+Enter newline behavior, IME composition, and caller cancellation through `preventDefault()`.
167
167
  - **AR4 — Focus indication.** Keyboard focus in the editor MUST produce the shared visible focus treatment; pointer focus does not claim keyboard modality, and focus on an internal action remains owned by that action.
168
- - **AR5 — Status feedback.** Error feedback uses `role="alert"`; warning feedback uses `role="status"`; visible icon and color do not replace the caller's message content.
168
+ - **AR5 — Status feedback.** Error feedback is announced assertively and warning feedback politely through the shared announcement channel (`spec:AST-086` FR2, FR5); the visible status bar carries no live role and stays readable. Visible icon and color do not replace the caller's message content.
169
169
 
170
170
  ## Design relationships
171
171
 
172
- | Anatomy or state | Design requirement | Representation authority | Hierarchy role | Component contract |
173
- | ------------------- | ------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | -------------- | ------------------ |
174
- | Composer frame | Groups body and optional status into one compositional shell. | observed implementation | container | FR5 |
175
- | Composer body | Provides the painted rounded input/action surface and elevation. | elevation is shipped; public theme reachability is unsettled | prominent | FR7 |
176
- | Drawer | Places attachment/context content before the body. | delegated to `component:ChatComposerDrawer` | supporting | FR5 |
177
- | Header | Places caller actions at start and caller context at end. | observed implementation | supporting | FR5 |
178
- | Input | Owns or hosts the editing surface and its focus control. | delegated to `component:ChatComposerInput` or caller custom input | prominent | FR1–FR3, FR8, FR11 |
179
- | Footer | Places caller actions and the primary action on the final body row. | observed implementation | supporting | FR4–FR5 |
180
- | Send or stop action | Presents the current primary operation. | delegated to `component:ChatSendButton` or caller custom send button | prominent | FR1, FR4 |
181
- | Status message | Adds error or warning feedback before or after the body. | semantic role/color are shipped; separate public theme reachability is not asserted here | supporting | FR5, FR9, AR5 |
172
+ | Anatomy or state | Design requirement | Representation authority | Hierarchy role | Component contract |
173
+ | ------------------- | ------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | -------------- | ------------------ |
174
+ | Composer frame | Groups body and optional status into one compositional shell. | observed implementation | container | FR5 |
175
+ | Composer body | Provides the painted rounded input/action surface and elevation. | elevation is shipped; public theme reachability is unsettled | prominent | FR7 |
176
+ | Drawer | Places attachment/context content before the body. | delegated to `component:ChatComposerDrawer` | supporting | FR5 |
177
+ | Header | Places caller actions at start and caller context at end. | observed implementation | supporting | FR5 |
178
+ | Input | Owns or hosts the editing surface and its focus control. | delegated to `component:ChatComposerInput` or caller custom input | prominent | FR1–FR3, FR8, FR11 |
179
+ | Footer | Places caller actions and the primary action on the final body row. | observed implementation | supporting | FR4–FR5 |
180
+ | Send or stop action | Presents the current primary operation. | delegated to `component:ChatSendButton` or caller custom send button | prominent | FR1, FR4 |
181
+ | Status message | Adds error or warning feedback before or after the body. | shared-channel announcement and color are shipped; separate public theme reachability is not asserted here | supporting | FR5, FR9, AR5 |
182
182
 
183
183
  The aggregate Chat record currently owns the public `chat-composer` target. That
184
184
  target sits on the frame and cannot select the body's non-inherited elevation and
@@ -1,15 +1,23 @@
1
1
  // Copyright (c) Meta Platforms, Inc. and affiliates.
2
2
 
3
3
  import {beforeEach, describe, it, expect, vi} from 'vitest';
4
- import {act, render, fireEvent} from '@testing-library/react';
4
+ import {act, render, fireEvent, screen} from '@testing-library/react';
5
5
  import userEvent from '@testing-library/user-event';
6
- import {useEffect, useRef} from 'react';
6
+ import {StrictMode, useEffect, useRef} from 'react';
7
7
  import {ChatComposer} from './ChatComposer';
8
8
  import {useChatComposerContext} from './ChatContext';
9
9
  import {__resetInteractionModalityForTest} from '../utils/interactionModality';
10
+ import type * as AnnounceModule from '../hooks/useAnnounce';
11
+
12
+ const announce = vi.fn();
13
+ vi.mock('../hooks/useAnnounce', async importOriginal => ({
14
+ ...(await importOriginal<typeof AnnounceModule>()),
15
+ useAnnounce: () => announce,
16
+ }));
10
17
 
11
18
  beforeEach(() => {
12
19
  __resetInteractionModalityForTest();
20
+ announce.mockClear();
13
21
  });
14
22
 
15
23
  // The composer body — the elevated surface — is the div that wraps the input
@@ -252,3 +260,51 @@ describe('ChatComposer input composition contract', () => {
252
260
  expect(document.activeElement).toBe(textarea);
253
261
  });
254
262
  });
263
+
264
+ describe('ChatComposer status announcement (spec:AST-086)', () => {
265
+ const composer = (status?: {type: 'error' | 'warning'; message?: string}) => (
266
+ <ChatComposer onSubmit={() => {}} input={<div />} status={status} />
267
+ );
268
+
269
+ it('shows the status message but carries no live role (FR2)', () => {
270
+ render(composer({type: 'error', message: 'Send failed'}));
271
+ const message = screen.getByText('Send failed');
272
+ expect(
273
+ message.closest('[role="alert"], [role="status"], [aria-live]'),
274
+ ).toBeNull();
275
+ });
276
+
277
+ it('announces an error assertively and a warning politely (FR5)', () => {
278
+ const {rerender} = render(composer());
279
+ expect(announce).not.toHaveBeenCalled();
280
+ rerender(composer({type: 'error', message: 'Send failed'}));
281
+ rerender(composer({type: 'warning', message: 'Offline'}));
282
+ expect(announce.mock.calls).toEqual([
283
+ ['Send failed', 'assertive'],
284
+ ['Offline', 'polite'],
285
+ ]);
286
+ });
287
+
288
+ it('announces a mounted status once, even under StrictMode (FR6)', () => {
289
+ const status = {type: 'error' as const, message: 'Send failed'};
290
+ const {rerender} = render(<StrictMode>{composer(status)}</StrictMode>);
291
+ rerender(<StrictMode>{composer({...status})}</StrictMode>);
292
+ expect(announce.mock.calls).toEqual([['Send failed', 'assertive']]);
293
+ });
294
+
295
+ it('announces the same message again after the status clears (FR6)', () => {
296
+ const status = {type: 'error' as const, message: 'Send failed'};
297
+ const {rerender} = render(composer(status));
298
+ rerender(composer());
299
+ rerender(composer(status));
300
+ expect(announce.mock.calls).toEqual([
301
+ ['Send failed', 'assertive'],
302
+ ['Send failed', 'assertive'],
303
+ ]);
304
+ });
305
+
306
+ it('announces nothing for a status without a message', () => {
307
+ render(composer({type: 'warning'}));
308
+ expect(announce).not.toHaveBeenCalled();
309
+ });
310
+ });
@@ -26,6 +26,7 @@
26
26
  import React, {
27
27
  useState,
28
28
  useCallback,
29
+ useEffect,
29
30
  useRef,
30
31
  useMemo,
31
32
  type ReactNode,
@@ -53,6 +54,7 @@ import type {ChatComposerInputControl} from './ChatContext';
53
54
  import {ChatSendButton} from './ChatSendButton';
54
55
  import {themeProps} from '../utils/themeProps';
55
56
  import {useTranslator} from '../i18n';
57
+ import {useAnnounce} from '../hooks/useAnnounce';
56
58
  import {focusOutlineStyles} from '../utils/focusOutline.stylex';
57
59
  import {
58
60
  getInteractionModality,
@@ -455,9 +457,31 @@ export function ChatComposer(props: ChatComposerProps) {
455
457
  [],
456
458
  );
457
459
 
460
+ // The status bar mounts together with its message, and a live region born
461
+ // holding its text is not reliably announced, so the message goes through
462
+ // the shared channel instead: errors assertively, warnings politely
463
+ // (spec:AST-086 FR2, FR5). The ref keeps a rerender or a StrictMode effect
464
+ // replay from repeating it; clearing the status re-arms it, so the same
465
+ // message shown again is a new occasion (FR6).
466
+ const announce = useAnnounce();
467
+ const statusType = status?.type;
468
+ const statusMessage = status?.message;
469
+ const announcedStatusRef = useRef<string | null>(null);
470
+ useEffect(() => {
471
+ if (!statusMessage) {
472
+ announcedStatusRef.current = null;
473
+ return;
474
+ }
475
+ const occasion = `${statusType}\u0000${statusMessage}`;
476
+ if (announcedStatusRef.current === occasion) {
477
+ return;
478
+ }
479
+ announcedStatusRef.current = occasion;
480
+ announce(statusMessage, statusType === 'error' ? 'assertive' : 'polite');
481
+ }, [announce, statusType, statusMessage]);
482
+
458
483
  const statusEl = status ? (
459
484
  <div
460
- role={status.type === 'error' ? 'alert' : 'status'}
461
485
  {...stylex.props(
462
486
  styles.statusBar,
463
487
  statusPosition === 'top' ? styles.statusTop : styles.statusBottom,
@@ -52,7 +52,7 @@ export const docs = {
52
52
  },
53
53
  usage: {
54
54
  description:
55
- 'Use ChatSystemMessage for concise, non-sender content inside a chat transcript. Choose the default variant for factual status notices and the divider variant for date or section breaks. Long default content wraps within the available width. The component exposes status semantics and the divider branch includes a labelled separator.',
55
+ 'Use ChatSystemMessage for concise, non-sender content inside a chat transcript. Choose the default variant for factual status notices and the divider variant for date or section breaks. Long default content wraps within the available width. It carries no live role: inside a ChatMessageList, the log announces new notices. The divider branch includes a labelled separator.',
56
56
  bestPractices: [
57
57
  {
58
58
  guidance: true,
@@ -4,6 +4,7 @@ import {createRef, Fragment} from 'react';
4
4
  import {describe, expect, it} from 'vitest';
5
5
  import {render, screen} from '@testing-library/react';
6
6
  import * as stylex from '@stylexjs/stylex';
7
+ import {ChatMessageList} from './ChatMessageList';
7
8
  import {ChatSystemMessage} from './ChatSystemMessage';
8
9
 
9
10
  const xstyleProbe = stylex.create({
@@ -100,7 +101,7 @@ describe('ChatSystemMessage', () => {
100
101
  expect(root).toHaveStyle({outlineOffset: '3px'});
101
102
  expectProbeClasses(root);
102
103
  expect(root).toHaveAttribute('aria-label', `${variant} system message`);
103
- expect(root).toHaveAttribute('role', 'status');
104
+ expect(root).toHaveAttribute('role', 'note');
104
105
  expect(root).toHaveAttribute('data-variant', variant);
105
106
  },
106
107
  );
@@ -125,7 +126,7 @@ describe('ChatSystemMessage', () => {
125
126
  </ChatSystemMessage>,
126
127
  );
127
128
  const root = screen.getByTestId('empty-partition');
128
- expect(root).toHaveAttribute('role', 'status');
129
+ expect(root).not.toHaveAttribute('role');
129
130
  expect(root).toHaveTextContent(text);
130
131
  });
131
132
 
@@ -148,7 +149,7 @@ describe('ChatSystemMessage', () => {
148
149
  </ChatSystemMessage>,
149
150
  );
150
151
  const root = screen.getByTestId('empty-partition');
151
- expect(root).toHaveAttribute('role', 'status');
152
+ expect(root).not.toHaveAttribute('role');
152
153
  expect(root).toContainElement(screen.getByRole('separator'));
153
154
  });
154
155
 
@@ -164,4 +165,25 @@ describe('ChatSystemMessage', () => {
164
165
  expect(separator).toHaveAccessibleName('0');
165
166
  expect(separator).toHaveAttribute('aria-labelledby');
166
167
  });
168
+
169
+ it.each(['default', 'divider'] as const)(
170
+ 'leaves a %s row inside a chat log to the log (spec:AST-086 FR4)',
171
+ variant => {
172
+ render(
173
+ <ChatMessageList>
174
+ <ChatSystemMessage variant={variant} data-testid="notice">
175
+ Ana joined
176
+ </ChatSystemMessage>
177
+ </ChatMessageList>,
178
+ );
179
+ const notice = screen.getByTestId('notice');
180
+ expect(notice.closest('[role="log"]')).not.toBeNull();
181
+ for (const el of [notice, ...notice.querySelectorAll('*')]) {
182
+ expect(el).not.toHaveAttribute('aria-live');
183
+ expect(['status', 'alert', 'log']).not.toContain(
184
+ el.getAttribute('role'),
185
+ );
186
+ }
187
+ },
188
+ );
167
189
  });
@@ -104,6 +104,10 @@ const styles = stylex.create({
104
104
  * "user joined", status changes. Supports a divider variant with horizontal
105
105
  * lines on each side of the text.
106
106
  *
107
+ * It carries no live role: a notice added to a `ChatMessageList` is announced
108
+ * by the list's log, which owns content added inside it (`spec:AST-086` FR4),
109
+ * and a date or divider row is ordinary transcript content.
110
+ *
107
111
  * @example
108
112
  * ```
109
113
  * <ChatSystemMessage>Conversation started</ChatSystemMessage>
@@ -131,8 +135,7 @@ export function ChatSystemMessage({
131
135
  className,
132
136
  styleProp,
133
137
  )}
134
- {...rest}
135
- role="status">
138
+ {...rest}>
136
139
  <Divider label={children} />
137
140
  </div>
138
141
  );
@@ -147,8 +150,7 @@ export function ChatSystemMessage({
147
150
  className,
148
151
  styleProp,
149
152
  )}
150
- {...rest}
151
- role="status">
153
+ {...rest}>
152
154
  <span {...stylex.props(styles.content)}>
153
155
  {icon != null && <span {...stylex.props(styles.icon)}>{icon}</span>}
154
156
  {children}
@@ -458,7 +458,8 @@ async function capture(
458
458
  forcedColors: false,
459
459
  coarsePointer: options.coarsePointer ?? false,
460
460
  touchPoints: options.touchPoints ?? false,
461
- role: 'status',
461
+ // No live role of its own: the chat log delivers it (spec:AST-086 FR4).
462
+ role: null,
462
463
  variant: auditCase.variant,
463
464
  text: auditCase.text,
464
465
  separatorCount: auditCase.variant === 'divider' ? 1 : 0,
@@ -488,7 +489,7 @@ async function capture(
488
489
  check(actual.selectorCount === 1, 'expected exactly one audit subject');
489
490
  check(actual.visible, 'audit subject has no visible geometry');
490
491
  check(actual.text === expected.text, 'visible content drifted');
491
- check(actual.role === expected.role, 'status role drifted');
492
+ check(actual.role === expected.role, 'root role drifted');
492
493
  check(actual.variant === expected.variant, 'variant reflection drifted');
493
494
  check(
494
495
  actual.separatorCount === expected.separatorCount,
@@ -915,11 +916,11 @@ test.afterAll(async () => {
915
916
  },
916
917
  emptyContentPartitions: {
917
918
  emptyString:
918
- 'Focused unit evidence: the default variant keeps role=status and renders the content unchanged (empty). In the divider variant, the status root and separator remain; no visible label or accessible name is produced.',
919
+ 'Focused unit evidence: the default variant has no root role and renders the content unchanged (empty). In the divider variant, the root and separator remain; no visible label or accessible name is produced.',
919
920
  numericZero:
920
- 'Focused unit evidence: the default variant keeps role=status and renders the content unchanged as one 0. In the divider variant, the status root and separator remain; Divider paints two zero text nodes (textContent="00") with no accessible name.',
921
+ 'Focused unit evidence: the default variant has no root role and renders the content unchanged as one 0. In the divider variant, the root and separator remain; Divider paints two zero text nodes (textContent="00") with no accessible name.',
921
922
  emptyFragment:
922
- 'Focused unit evidence: the default variant keeps role=status and renders the content unchanged (empty). In the divider variant, the status root and separator remain; no visible label or accessible name is produced.',
923
+ 'Focused unit evidence: the default variant has no root role and renders the content unchanged (empty). In the divider variant, the root and separator remain; no visible label or accessible name is produced.',
923
924
  },
924
925
  sharedAdvisories: {
925
926
  dividerFalseyLabel:
@@ -933,9 +934,7 @@ test.afterAll(async () => {
933
934
  forcedColors:
934
935
  'Forced-colors rendering was not exercised. The default row makes no support claim, and the disappearing Divider rule is a shared component:Divider advisory.',
935
936
  assistiveTechnology:
936
- 'Browser role exposure is measured, but spoken announcement timing and output were not verified with real assistive technology.',
937
- nestedLiveRegions:
938
- 'The nested-log frame records role=status inside ChatMessageList role=log aria-live=polite, but double-announcement behavior is unverified under spec:AST-009/FR4 and FR10.',
937
+ 'Browser role exposure is measured; spoken output was not recorded with real assistive technology, which spec:AST-009 does not require for routing claims.',
939
938
  },
940
939
  stateVisualConformance: {
941
940
  default: ['text', 'text-with-icon', 'wrapped-long-content'],
@@ -943,7 +942,7 @@ test.afterAll(async () => {
943
942
  'labelled-separator',
944
943
  'unresolved-divider-icon-observation',
945
944
  ],
946
- nestedLiveLog: ['status-inside-polite-log-unverified'],
945
+ nestedLiveLog: ['no-live-role-inside-polite-log'],
947
946
  colorModes: ['light', 'dark'],
948
947
  narrowCoarsePointer: [
949
948
  'default-with-icon',
@@ -10,7 +10,8 @@
10
10
  * indicators, row paint, hit targets, wrapping, list structure) are recorded
11
11
  * for the audit and never gate. Only frames that prove a shipped remediation
12
12
  * gate on its outcome. The select-all CLI block renders through its own
13
- * story, and its source blob is bound in the manifest.
13
+ * story, and its source blob is bound in the manifest. Disabled rows expect
14
+ * focusable aria-disabled controls under AST-085 FR1 and FR7.
14
15
  */
15
16
 
16
17
  import {createHash} from 'node:crypto';
@@ -271,9 +272,9 @@ const CASES: AuditCase[] = [
271
272
  expect: {
272
273
  groupName: GROUP,
273
274
  rows: [
274
- {checked: true, disabled: 'native'},
275
- {checked: false, disabled: 'native'},
276
- {checked: false, disabled: 'native'},
275
+ {checked: true, disabled: 'focusable'},
276
+ {checked: false, disabled: 'focusable'},
277
+ {checked: false, disabled: 'focusable'},
277
278
  ],
278
279
  },
279
280
  },
@@ -285,7 +286,7 @@ const CASES: AuditCase[] = [
285
286
  groupName: GROUP,
286
287
  rows: [
287
288
  {checked: false, disabled: false},
288
- {checked: false, disabled: 'native'},
289
+ {checked: false, disabled: 'focusable'},
289
290
  {checked: false, disabled: false},
290
291
  ],
291
292
  },
@@ -471,7 +472,7 @@ const CASES: AuditCase[] = [
471
472
  rows: [
472
473
  {checked: true},
473
474
  {checked: false},
474
- {checked: false, disabled: 'native'},
475
+ {checked: false, disabled: 'focusable'},
475
476
  ],
476
477
  },
477
478
  },
@@ -8,7 +8,52 @@ export const docs = {
8
8
  displayName: 'Dropdown Menu Radio Group',
9
9
  isHiddenFromOverview: true,
10
10
  description:
11
- 'A single-select group of radio menu items (role="group" of menuitemradio). Owns the selected value and lays its items out with the menu\'s inter-item gap.',
11
+ 'A controlled, named single-select group of DropdownMenuRadioItem children (role="group" of menuitemradio). Owns value propagation, close policy, root composition, and the menu inter-item gap; the parent DropdownMenu owns keyboard navigation.',
12
+ usage: {
13
+ description:
14
+ 'Use DropdownMenuRadioGroup inside compound-mode DropdownMenu when several commands represent one mutually exclusive choice. Keep value in caller state, provide a concise accessible label, and render each choice as DropdownMenuRadioItem.',
15
+ bestPractices: [
16
+ {
17
+ guidance: true,
18
+ description:
19
+ 'Keep value controlled and update it from onChange so checked state follows caller data.',
20
+ },
21
+ {
22
+ guidance: true,
23
+ description:
24
+ 'Provide a concise label even when aria-labelledby points to visible text; label remains required and supplies the aria-label fallback.',
25
+ },
26
+ {
27
+ guidance: true,
28
+ description:
29
+ 'Set hasCloseOnSelect=false only when the menu must stay open to show another selection or an in-place result.',
30
+ },
31
+ {
32
+ guidance: false,
33
+ description:
34
+ 'Place buttons, links, checkboxes, or arbitrary menu rows inside the radio group.',
35
+ },
36
+ {
37
+ guidance: false,
38
+ description:
39
+ 'Add a second key handler or roving-focus model; DropdownMenu owns keyboard navigation.',
40
+ },
41
+ ],
42
+ anatomy: [
43
+ {
44
+ name: 'Group',
45
+ required: true,
46
+ description:
47
+ 'The role="group" root that owns the accessible name, controlled-selection context, close policy, root inputs, and inter-item gap.',
48
+ },
49
+ {
50
+ name: 'Radio items',
51
+ required: true,
52
+ description:
53
+ 'DropdownMenuRadioItem children; each child owns its menuitemradio role, checked state, content, and indicator paint.',
54
+ },
55
+ ],
56
+ },
12
57
  playground: {
13
58
  wrapper: {
14
59
  component: 'DropdownMenu',
@@ -42,17 +87,20 @@ export const docs = {
42
87
  type: 'string | undefined',
43
88
  description:
44
89
  'The currently selected value in the group. Pass undefined when nothing is selected yet.',
90
+ required: true,
45
91
  },
46
92
  {
47
93
  name: 'onChange',
48
94
  type: '(value: string) => void',
49
95
  description: 'Callback fired when the selected value changes.',
96
+ required: true,
50
97
  },
51
98
  {
52
99
  name: 'label',
53
100
  type: 'string',
54
101
  description:
55
- 'Accessible name for the group, applied as aria-label so screen readers announce the radios as a named set, e.g. "Sort by". Required. Pass aria-labelledby (via base props) instead when the name already exists as a visible element.',
102
+ 'Accessible name for the group, applied as aria-label so screen readers announce the radios as a named set, e.g. "Sort by". Required even when aria-labelledby (via base props) points to visible text; that visible reference becomes the computed name while label remains the fallback.',
103
+ required: true,
56
104
  },
57
105
  {
58
106
  name: 'hasCloseOnSelect',
@@ -61,40 +109,47 @@ export const docs = {
61
109
  description:
62
110
  'Whether selecting a value closes the menu. Radio items default to closing on selection (a single-choice commit).',
63
111
  },
112
+ {
113
+ name: 'ref',
114
+ type: 'React.Ref<HTMLDivElement>',
115
+ description: 'Ref forwarded to the role="group" root.',
116
+ },
64
117
  {
65
118
  name: 'children',
66
119
  type: 'ReactNode',
67
120
  description: 'The DropdownMenuRadioItems that make up the group.',
121
+ required: true,
68
122
  },
69
123
  ],
70
124
  };
71
125
 
72
126
  export const docsZh = {
73
127
  name: 'DropdownMenuRadioGroup',
74
- isHiddenFromOverview: true,
75
128
  displayName: 'Dropdown Menu Radio Group',
76
129
  description:
77
- '单选的单选菜单项组(menuitemradio 的 role="group")。持有所选值,并以菜单的项间距布局其子项。',
130
+ 'DropdownMenu 中由调用方控制且有名称的单选组(menuitemradio 的 role="group")。该组负责值传递、关闭策略、根元素组合和菜单项间距;父 DropdownMenu 负责键盘导航。',
78
131
  propDescriptions: {
79
132
  value: '组中当前选中的值。尚无选中项时传 undefined。',
80
133
  onChange: '所选值变化时触发的回调。',
81
134
  label:
82
- '组的无障碍名称,作为 aria-label 应用,以便屏幕阅读器将单选项作为命名集合朗读,例如“Sort by”。必需。当名称已作为可见元素存在时,可改用 aria-labelledby(通过基础属性)。',
135
+ '组的无障碍名称,作为 aria-label 应用。即使 aria-labelledby(通过基础属性)指向可见文本,label 仍然必需并作为后备名称。',
83
136
  hasCloseOnSelect: '选择某值是否关闭菜单。默认关闭。',
137
+ ref: '转发到 role="group" 根元素的 ref。',
84
138
  children: '组成该组的 DropdownMenuRadioItem。',
85
139
  },
86
140
  };
87
141
 
88
142
  export const docsDense = {
89
143
  name: 'DropdownMenuRadioGroup',
90
- isHiddenFromOverview: true,
91
144
  displayName: 'Dropdown Menu Radio Group',
92
- description: 'single-select group of menuitemradio; owns selected value',
145
+ description:
146
+ 'controlled named menuitemradio group; owns value propagation, close policy, root inputs, and item gap',
93
147
  propDescriptions: {
94
148
  value: 'selected value (undefined = none)',
95
149
  onChange: 'fired when selected value changes',
96
150
  label: 'accessible group name (applied as aria-label); required',
97
151
  hasCloseOnSelect: 'close menu on select (default true)',
152
+ ref: 'forwarded to the role="group" root',
98
153
  children: 'the DropdownMenuRadioItems',
99
154
  },
100
155
  };