@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.
- package/CHANGELOG.md +88 -0
- package/dist/Button/Button.d.ts.map +1 -1
- package/dist/Button/Button.js +15 -2
- package/dist/Chat/ChatComposer.d.ts.map +1 -1
- package/dist/Chat/ChatComposer.js +25 -2
- package/dist/Chat/ChatSystemMessage.d.ts +4 -0
- package/dist/Chat/ChatSystemMessage.d.ts.map +1 -1
- package/dist/Chat/ChatSystemMessage.js +4 -2
- package/dist/DropdownMenu/DropdownMenuRadioGroup.d.ts +9 -5
- package/dist/DropdownMenu/DropdownMenuRadioGroup.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenuRadioGroup.js +2 -0
- package/package.json +4 -4
- package/src/Button/Button.test.tsx +13 -2
- package/src/Button/Button.tsx +23 -2
- package/src/Button/__tests__/Button.a11y.states.ts +4 -3
- package/src/Carousel/Carousel.spec.md +146 -0
- package/src/Chat/ChatComposer.spec.md +26 -26
- package/src/Chat/ChatComposer.test.tsx +58 -2
- package/src/Chat/ChatComposer.tsx +25 -1
- package/src/Chat/ChatSystemMessage.doc.mjs +1 -1
- package/src/Chat/ChatSystemMessage.test.tsx +25 -3
- package/src/Chat/ChatSystemMessage.tsx +6 -4
- package/src/Chat/__tests__/ChatSystemMessage.a11y.chromium.spec.ts +8 -9
- package/src/CheckboxList/__tests__/CheckboxList.a11y.chromium.spec.ts +7 -6
- package/src/DropdownMenu/DropdownMenuRadioGroup.doc.mjs +62 -7
- package/src/DropdownMenu/DropdownMenuRadioGroup.spec.md +190 -0
- package/src/DropdownMenu/DropdownMenuRadioGroup.tsx +10 -4
- package/src/DropdownMenu/DropdownMenuSelectable.test.tsx +93 -0
- package/src/DropdownMenu/__tests__/DropdownMenuRadioGroupAudit.a11y.chromium.spec.ts +327 -0
- package/src/FieldStatus/FieldStatus.spec.md +4 -4
- package/src/FieldStatus/__tests__/StatusMessage.a11y.renders.tsx +0 -18
- package/src/FieldStatus/__tests__/StatusMessage.a11y.states.ts +3 -18
- package/src/FieldStatus/__tests__/StatusMessage.a11y.test.tsx +1 -2
- package/src/Markdown/Markdown.helpers.perf.test.ts +25 -19
- package/src/Table/plugins/columnResize/useTableColumnResize.spec.md +11 -11
- 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
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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`.
|
|
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.
|
|
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.
|
|
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.
|
|
126
|
-
| FR9 | Error status
|
|
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.
|
|
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.
|
|
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 |
|
|
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
|
|
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
|
|
173
|
-
| ------------------- | ------------------------------------------------------------------- |
|
|
174
|
-
| Composer frame | Groups body and optional status into one compositional shell. | observed implementation
|
|
175
|
-
| Composer body | Provides the painted rounded input/action surface and elevation. | elevation is shipped; public theme reachability is unsettled
|
|
176
|
-
| Drawer | Places attachment/context content before the body. | delegated to `component:ChatComposerDrawer`
|
|
177
|
-
| Header | Places caller actions at start and caller context at end. | observed implementation
|
|
178
|
-
| Input | Owns or hosts the editing surface and its focus control. | delegated to `component:ChatComposerInput` or caller custom input
|
|
179
|
-
| Footer | Places caller actions and the primary action on the final body row. | observed implementation
|
|
180
|
-
| Send or stop action | Presents the current primary operation. | delegated to `component:ChatSendButton` or caller custom send button
|
|
181
|
-
| Status message | Adds error or warning feedback before or after the body. |
|
|
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.
|
|
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', '
|
|
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'
|
|
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'
|
|
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:
|
|
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, '
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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: ['
|
|
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: '
|
|
275
|
-
{checked: false, disabled: '
|
|
276
|
-
{checked: false, disabled: '
|
|
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: '
|
|
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: '
|
|
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
|
|
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
|
|
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
|
-
'
|
|
130
|
+
'DropdownMenu 中由调用方控制且有名称的单选组(menuitemradio 的 role="group")。该组负责值传递、关闭策略、根元素组合和菜单项间距;父 DropdownMenu 负责键盘导航。',
|
|
78
131
|
propDescriptions: {
|
|
79
132
|
value: '组中当前选中的值。尚无选中项时传 undefined。',
|
|
80
133
|
onChange: '所选值变化时触发的回调。',
|
|
81
134
|
label:
|
|
82
|
-
'组的无障碍名称,作为 aria-label
|
|
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:
|
|
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
|
};
|