@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
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
---
|
|
2
|
+
schema_version: 3
|
|
3
|
+
template_version: 7
|
|
4
|
+
kind: component
|
|
5
|
+
id: component:DropdownMenuRadioGroup
|
|
6
|
+
authority: draft
|
|
7
|
+
archive_reason: null
|
|
8
|
+
superseded_by: null
|
|
9
|
+
approved_by: null
|
|
10
|
+
approved_at: null
|
|
11
|
+
owners: [cixzhang]
|
|
12
|
+
review_triggers: [public-api, behavior, accessibility, theming, testing]
|
|
13
|
+
verified_by:
|
|
14
|
+
[
|
|
15
|
+
packages/core/src/DropdownMenu/DropdownMenuSelectable.test.tsx,
|
|
16
|
+
packages/core/src/DropdownMenu/DropdownMenu.test.tsx,
|
|
17
|
+
packages/core/src/DropdownMenu/__tests__/Menu.a11y.test.tsx,
|
|
18
|
+
apps/storybook/stories/DropdownMenuRadioGroup.stories.tsx,
|
|
19
|
+
apps/storybook/rtl-audit/verified-not-applicable.json,
|
|
20
|
+
scripts/check-knowledge.mjs,
|
|
21
|
+
]
|
|
22
|
+
modules: []
|
|
23
|
+
families: []
|
|
24
|
+
design_specs: []
|
|
25
|
+
architecture:
|
|
26
|
+
[
|
|
27
|
+
architecture:public-component-api,
|
|
28
|
+
architecture:component-test-sufficiency,
|
|
29
|
+
architecture:interaction-modality,
|
|
30
|
+
]
|
|
31
|
+
contributing: []
|
|
32
|
+
system_specs: [spec:AST-020, spec:AST-029]
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
# DropdownMenuRadioGroup component contract
|
|
36
|
+
|
|
37
|
+
## Contract at a glance
|
|
38
|
+
|
|
39
|
+
| Area | Contract |
|
|
40
|
+
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
41
|
+
| Public contract | One controlled, named single-selection group of `DropdownMenuRadioItem` children with value, change callback, close policy, root inputs, and an explicit React 19 ref. |
|
|
42
|
+
| Behavior | The group supplies selection and close-policy context; radio items emit values; the parent DropdownMenu owns keyboard navigation and menu lifecycle. |
|
|
43
|
+
| End-user impact | Assistive technology encounters one named group with exactly one current choice when a value matches, while pointer and keyboard selection stay aligned. |
|
|
44
|
+
| Builder impact | Keep the value controlled, provide a stable accessible label, and place only DropdownMenuRadioItem options inside the group. |
|
|
45
|
+
| Compatibility/readiness | Patch-compatible additive `ref` prop on the existing group root. Existing runtime behavior, exports, defaults, and theme surfaces remain unchanged; authority stays `draft` until owner approval. |
|
|
46
|
+
| Review checks | Reject unnamed groups, local selection state, dropped root inputs, a second keyboard owner, or a close policy that bypasses the controlled callback. |
|
|
47
|
+
| Governing rules | WAI-ARIA menu pattern; `architecture:public-component-api/INV5–INV9`; `architecture:component-test-sufficiency/INV1–INV7`; `spec:AST-020/FR1,FR2,FR6`. |
|
|
48
|
+
|
|
49
|
+
This table is a review projection; the body below is authoritative.
|
|
50
|
+
|
|
51
|
+
## Intent
|
|
52
|
+
|
|
53
|
+
DropdownMenuRadioGroup renders one named `role="group"` wrapper inside the
|
|
54
|
+
pointer presentation of DropdownMenu. It owns controlled selection context,
|
|
55
|
+
close policy, root composition, and the tokenized gap between children.
|
|
56
|
+
DropdownMenuRadioItem owns each `menuitemradio` row and its checked state;
|
|
57
|
+
DropdownMenu owns focus movement, typeahead, keyboard activation, presentation,
|
|
58
|
+
and menu open/close lifecycle.
|
|
59
|
+
|
|
60
|
+
## Compatibility and migration
|
|
61
|
+
|
|
62
|
+
- Released default preserved: yes (`hasCloseOnSelect=true`).
|
|
63
|
+
- Compatibility class: additive patch; declares and forwards the React 19 `ref`
|
|
64
|
+
prop to the existing group root without changing existing behavior.
|
|
65
|
+
- Controlled behavior: `value` is caller-owned and `onChange` is the only commit path.
|
|
66
|
+
- Migration decision: none.
|
|
67
|
+
|
|
68
|
+
Consumer migration instructions belong in consumer docs and release notes.
|
|
69
|
+
|
|
70
|
+
## Ownership boundary
|
|
71
|
+
|
|
72
|
+
**Owns**
|
|
73
|
+
|
|
74
|
+
- One `role="group"` root with an `aria-label` from the required `label` prop.
|
|
75
|
+
- Controlled `value`, `onChange`, and `hasCloseOnSelect` context for radio items.
|
|
76
|
+
- Composition of `children`, supported host inputs, ref, `className`, `style`, and
|
|
77
|
+
`xstyle` on the group root.
|
|
78
|
+
- Column layout and the semantic menu inter-item gap.
|
|
79
|
+
|
|
80
|
+
**Does not own / non-goals**
|
|
81
|
+
|
|
82
|
+
- The `menuitemradio` role, `aria-checked`, disabled behavior, indicator paint,
|
|
83
|
+
label, description, icon, or end content — owned by DropdownMenuRadioItem.
|
|
84
|
+
- Focus entry, arrow-key navigation, typeahead, Enter/Space synthesis, and menu
|
|
85
|
+
close implementation — owned by DropdownMenu and shared menu hooks.
|
|
86
|
+
- Touch and bottom-sheet selectable-row rendering — owned by the parent presentation.
|
|
87
|
+
- Uncontrolled selection state or child-value registration.
|
|
88
|
+
|
|
89
|
+
## Public concepts
|
|
90
|
+
|
|
91
|
+
| Concept | Closed values or states | Meaning | Default | Owner | Stability | Invalid-value behavior |
|
|
92
|
+
| --------------- | --------------------------------------------- | -------------------------------------------------------------------- | -------- | ------------------------ | --------- | ----------------------------------------------------- |
|
|
93
|
+
| Selection | matching value; no matching value | Controls which child reports checked. | none | caller and radio item | stable | No match leaves every child unchecked. |
|
|
94
|
+
| Commit | child value | Emits the selected child value through `onChange`. | required | group context | stable | Children outside the group throw from the item owner. |
|
|
95
|
+
| Close policy | close; remain open | Controls whether an item asks DropdownMenu to close after selection. | close | group context and parent | stable | Omission closes. |
|
|
96
|
+
| Accessible name | required label; optional labelledby | Names the group; `aria-labelledby` may point at visible text. | label | group root | stable | `label` remains required by the public type. |
|
|
97
|
+
| Root surface | ref; DOM/data/ARIA; class/style/xstyle/events | Extends the group root while preserving its role and name. | omitted | public component API | stable | Accepted inputs compose; owned role and label win. |
|
|
98
|
+
|
|
99
|
+
## Behavioral and layout contract
|
|
100
|
+
|
|
101
|
+
Draft requirements identify their basis so observed code is not mistaken for an
|
|
102
|
+
intentional decision.
|
|
103
|
+
|
|
104
|
+
| ID | Candidate invariant | Basis | Draft review state |
|
|
105
|
+
| --- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | ------------------ |
|
|
106
|
+
| FR1 | The root MUST expose `role="group"` and a non-empty accessible name. The required `label` supplies `aria-label`; caller `aria-labelledby` MAY reference visible text. | WAI-ARIA menu pattern; released source/docs | verify |
|
|
107
|
+
| FR2 | The group MUST remain controlled. It MUST compare each radio item's value with caller `value` and MUST emit a selected child value only through caller `onChange`. | Released public API and focused tests | verify |
|
|
108
|
+
| FR3 | `hasCloseOnSelect` MUST default to true. When false, successful selection MUST update through `onChange` while the parent menu remains open. | Released behavior and focused tests | verify |
|
|
109
|
+
| FR4 | DropdownMenu MUST remain the only keyboard owner. The group adds no tab stop, key handler, typeahead, or click synthesis; it only provides context to child rows. | `architecture:interaction-modality`; parent tests | verify |
|
|
110
|
+
| FR5 | Ref, supported host attributes and handlers, `className`, `style`, and `xstyle` MUST reach or compose on the group root without replacing the owned role or `aria-label`. | `architecture:public-component-api/INV5–INV9`; source/tests | verify |
|
|
111
|
+
| FR6 | The wrapper MUST remain a logical column whose tokenized gap matches the surrounding menu's item rhythm; it MUST NOT add component-specific directional paint or navigation. | Current source; token and bidirectionality rules | verify |
|
|
112
|
+
|
|
113
|
+
### Allowed variation
|
|
114
|
+
|
|
115
|
+
- **AV1 — Children.** The number, labels, descriptions, icons, and supporting
|
|
116
|
+
content of DropdownMenuRadioItem children may vary.
|
|
117
|
+
- **AV2 — Selection.** Any string value, including no matching value, may be
|
|
118
|
+
caller-controlled without adding local group state.
|
|
119
|
+
- **AV3 — Root styling.** Caller style inputs may extend the wrapper while the
|
|
120
|
+
role, accessible label, column flow, and child semantics remain intact.
|
|
121
|
+
|
|
122
|
+
### Representative states
|
|
123
|
+
|
|
124
|
+
| State | Required invariant | Allowed variation |
|
|
125
|
+
| ----------------- | -------------------------------------------------------------------------- | ------------------------------------ |
|
|
126
|
+
| Matching value | Exactly the matching child reports `aria-checked=true`. | Child content and menu size. |
|
|
127
|
+
| No matching value | Every child reports `aria-checked=false`. | Empty or unmatched caller value. |
|
|
128
|
+
| Default close | Selection emits the value and closes through the parent. | Pointer or synthesized keyboard act. |
|
|
129
|
+
| Retained open | Selection emits the value, updates controlled state, and leaves menu open. | Result copy and selected child. |
|
|
130
|
+
| RTL / narrow | Column order and tokenized gap hold with no horizontal overflow. | Parent menu placement. |
|
|
131
|
+
|
|
132
|
+
### Transformation and precedence order
|
|
133
|
+
|
|
134
|
+
- **ORD1 — Selection.** Child activation → disabled guard in the item owner →
|
|
135
|
+
group `onChange(value)` → optional parent close.
|
|
136
|
+
- **ORD2 — Root composition.** Base column/gap → caller `xstyle`; supported host
|
|
137
|
+
inputs and class/style compose while owned role and label remain fixed.
|
|
138
|
+
|
|
139
|
+
### Performance and resources
|
|
140
|
+
|
|
141
|
+
- **PR1 — Context only.** The group memoizes a small context value and owns no
|
|
142
|
+
Effect, listener, observer, timer, request, DOM read, or resource lifecycle.
|
|
143
|
+
|
|
144
|
+
## Accessibility contract
|
|
145
|
+
|
|
146
|
+
- **AR1 — Named group.** The wrapper is one named `group` inside the parent menu.
|
|
147
|
+
- **AR2 — Radio rows.** Child items own `menuitemradio` and `aria-checked`; the
|
|
148
|
+
group does not add nested native inputs or duplicate row semantics.
|
|
149
|
+
- **AR3 — Keyboard ownership.** DropdownMenu owns focus collection and activation;
|
|
150
|
+
the group itself never enters the tab or arrow-key sequence.
|
|
151
|
+
|
|
152
|
+
## Design relationships
|
|
153
|
+
|
|
154
|
+
| Anatomy or state | Design requirement | Representation authority | Hierarchy role | Component contract |
|
|
155
|
+
| ---------------- | ---------------------------------------------------------------- | ---------------------------------- | -------------- | ------------------ |
|
|
156
|
+
| Group wrapper | Logical column with the menu's semantic inter-item gap. | Current source and spacing tokens | structural | FR1, FR5, FR6 |
|
|
157
|
+
| Selected child | One child reflects caller value through `aria-checked`. | DropdownMenuRadioItem | prominent | FR2, AR2 |
|
|
158
|
+
| Direction/narrow | Child order and gap hold without physical-direction assumptions. | Bidirectionality and parent layout | structural | FR6 |
|
|
159
|
+
|
|
160
|
+
No new component-local visual decision is settled by this draft.
|
|
161
|
+
|
|
162
|
+
## Family and system relationships
|
|
163
|
+
|
|
164
|
+
- `architecture:public-component-api` owns root input composition and released compatibility.
|
|
165
|
+
- `architecture:component-test-sufficiency` owns proportionate observable evidence.
|
|
166
|
+
- `architecture:interaction-modality` and DropdownMenu own keyboard behavior.
|
|
167
|
+
- `spec:AST-020` supplies WCAG/APG adoption and evidence-layer boundaries.
|
|
168
|
+
- `spec:AST-029` keeps this observational draft separate from the audit receipt.
|
|
169
|
+
|
|
170
|
+
## Verification map
|
|
171
|
+
|
|
172
|
+
| Contract | Verification | Representative states | Mutation or failure expectation | Audit section |
|
|
173
|
+
| -------------- | ------------------------------------------------------------------------------ | ------------------------------------ | ----------------------------------------------------------------------------------------- | ------------------------------------------- |
|
|
174
|
+
| FR1–FR4, AR1–3 | `DropdownMenuSelectable.test.tsx`; reusable Menu contracts; scoped axe | matching, none, close, retained-open | An unnamed group, wrong checked row, skipped callback, unwanted close, or key owner fails | `audit:DropdownMenuRadioGroup/§1,§4,§6` |
|
|
175
|
+
| FR5 | root/ref/handler tests; lint and typecheck | ref, host attributes, class/style | An accepted input is dropped or overrides the owned role/name | `audit:DropdownMenuRadioGroup/§3` |
|
|
176
|
+
| FR6 | source-hashed RTL N/A receipt plus exact-head Neutral/Butter/RTL/narrow frames | LTR, RTL, 320px, light/dark | Physical direction, lost tokenized gap, collapsed geometry, or horizontal overflow fails | `audit:DropdownMenuRadioGroup/§2,§5,§9,§10` |
|
|
177
|
+
|
|
178
|
+
## Decision log
|
|
179
|
+
|
|
180
|
+
None. This draft records current facts and introduces no product decision.
|
|
181
|
+
|
|
182
|
+
## Open questions
|
|
183
|
+
|
|
184
|
+
None.
|
|
185
|
+
|
|
186
|
+
## Content boundary
|
|
187
|
+
|
|
188
|
+
This file does not duplicate consumer prop tables/examples, current audit scores,
|
|
189
|
+
screenshot matrices, implementation steps, or parent/item contracts. The audit
|
|
190
|
+
pull request owns the closed run evidence required by `spec:AST-029/FR5`.
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* menuitemradio rows.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import {useMemo, type ReactNode} from 'react';
|
|
17
|
+
import {useMemo, type ReactNode, type Ref} from 'react';
|
|
18
18
|
import * as stylex from '@stylexjs/stylex';
|
|
19
19
|
import {spacingVars} from '../theme/tokens.stylex';
|
|
20
20
|
import {mergeProps} from '../utils';
|
|
@@ -51,9 +51,9 @@ export interface DropdownMenuRadioGroupProps extends Omit<
|
|
|
51
51
|
/**
|
|
52
52
|
* Accessible name for the group, announced by screen readers so the radios
|
|
53
53
|
* read as a named set (e.g. "Sort by"). Applied as the group's `aria-label`.
|
|
54
|
-
* Required
|
|
55
|
-
*
|
|
56
|
-
*
|
|
54
|
+
* Required even when `aria-labelledby` (via base props) points to visible
|
|
55
|
+
* text; that visible reference becomes the computed name while `label`
|
|
56
|
+
* remains the fallback.
|
|
57
57
|
*/
|
|
58
58
|
label: string;
|
|
59
59
|
/**
|
|
@@ -63,6 +63,10 @@ export interface DropdownMenuRadioGroupProps extends Omit<
|
|
|
63
63
|
* @default true
|
|
64
64
|
*/
|
|
65
65
|
hasCloseOnSelect?: boolean;
|
|
66
|
+
/**
|
|
67
|
+
* Ref forwarded to the role="group" root.
|
|
68
|
+
*/
|
|
69
|
+
ref?: Ref<HTMLDivElement>;
|
|
66
70
|
/**
|
|
67
71
|
* The `DropdownMenuRadioItem`s that make up the group.
|
|
68
72
|
*/
|
|
@@ -92,6 +96,7 @@ export function DropdownMenuRadioGroup({
|
|
|
92
96
|
label,
|
|
93
97
|
hasCloseOnSelect = true,
|
|
94
98
|
children,
|
|
99
|
+
ref,
|
|
95
100
|
xstyle,
|
|
96
101
|
className,
|
|
97
102
|
style,
|
|
@@ -105,6 +110,7 @@ export function DropdownMenuRadioGroup({
|
|
|
105
110
|
return (
|
|
106
111
|
<div
|
|
107
112
|
{...rest}
|
|
113
|
+
ref={ref}
|
|
108
114
|
role="group"
|
|
109
115
|
aria-label={label}
|
|
110
116
|
{...mergeProps(stylex.props(styles.group, xstyle), {className, style})}>
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* outcomes live in their reusable contracts; Menu retains navigation.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
import {useState} from 'react';
|
|
12
13
|
import {describe, it, expect, vi, beforeEach} from 'vitest';
|
|
13
14
|
import {render, screen, fireEvent, waitFor} from '@testing-library/react';
|
|
14
15
|
import userEvent from '@testing-library/user-event';
|
|
@@ -328,6 +329,98 @@ describe('DropdownMenuRadioGroup / RadioItem', () => {
|
|
|
328
329
|
screen.getByRole('menuitemradio', {name: 'Oldest', hidden: true}),
|
|
329
330
|
);
|
|
330
331
|
expect(onChange).toHaveBeenCalledWith('oldest');
|
|
332
|
+
expect(
|
|
333
|
+
screen.getByRole('menuitemradio', {name: 'Newest', hidden: true}),
|
|
334
|
+
).toHaveAttribute('aria-checked', 'true');
|
|
335
|
+
expect(
|
|
336
|
+
screen.getByRole('menuitemradio', {name: 'Oldest', hidden: true}),
|
|
337
|
+
).toHaveAttribute('aria-checked', 'false');
|
|
338
|
+
expect(HTMLElement.prototype.hidePopover).toHaveBeenCalledTimes(1);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it('keeps the menu open while controlled selection updates when requested', async () => {
|
|
342
|
+
const user = userEvent.setup();
|
|
343
|
+
const onChange = vi.fn();
|
|
344
|
+
|
|
345
|
+
function ControlledGroup() {
|
|
346
|
+
const [value, setValue] = useState('newest');
|
|
347
|
+
return (
|
|
348
|
+
<DropdownMenu button={{label: 'Sort'}}>
|
|
349
|
+
<DropdownMenuRadioGroup
|
|
350
|
+
value={value}
|
|
351
|
+
onChange={nextValue => {
|
|
352
|
+
onChange(nextValue);
|
|
353
|
+
setValue(nextValue);
|
|
354
|
+
}}
|
|
355
|
+
label="Sort by"
|
|
356
|
+
hasCloseOnSelect={false}>
|
|
357
|
+
<DropdownMenuRadioItem value="newest" label="Newest" />
|
|
358
|
+
<DropdownMenuRadioItem value="oldest" label="Oldest" />
|
|
359
|
+
</DropdownMenuRadioGroup>
|
|
360
|
+
</DropdownMenu>
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
render(<ControlledGroup />);
|
|
365
|
+
await user.click(screen.getByRole('button', {name: /Sort/}));
|
|
366
|
+
const oldest = screen.getByRole('menuitemradio', {
|
|
367
|
+
name: 'Oldest',
|
|
368
|
+
hidden: true,
|
|
369
|
+
});
|
|
370
|
+
await user.click(oldest);
|
|
371
|
+
|
|
372
|
+
expect(onChange).toHaveBeenCalledWith('oldest');
|
|
373
|
+
expect(oldest).toHaveAttribute('aria-checked', 'true');
|
|
374
|
+
expect(HTMLElement.prototype.hidePopover).not.toHaveBeenCalled();
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it('names and composes the group root when no value matches', async () => {
|
|
378
|
+
const user = userEvent.setup();
|
|
379
|
+
const ref = vi.fn();
|
|
380
|
+
const onClick = vi.fn();
|
|
381
|
+
render(
|
|
382
|
+
<>
|
|
383
|
+
<span id="sort-label">Visible sort label</span>
|
|
384
|
+
<DropdownMenu button={{label: 'Sort'}}>
|
|
385
|
+
<DropdownMenuRadioGroup
|
|
386
|
+
ref={ref}
|
|
387
|
+
role="tree"
|
|
388
|
+
aria-label="Consumer label"
|
|
389
|
+
aria-labelledby="sort-label"
|
|
390
|
+
data-owner="consumer"
|
|
391
|
+
className="consumer-group"
|
|
392
|
+
style={{inlineSize: 240}}
|
|
393
|
+
onClick={onClick}
|
|
394
|
+
value={undefined}
|
|
395
|
+
onChange={() => {}}
|
|
396
|
+
label="Sort fallback">
|
|
397
|
+
<DropdownMenuRadioItem value="newest" label="Newest" />
|
|
398
|
+
<DropdownMenuRadioItem value="oldest" label="Oldest" />
|
|
399
|
+
</DropdownMenuRadioGroup>
|
|
400
|
+
</DropdownMenu>
|
|
401
|
+
</>,
|
|
402
|
+
);
|
|
403
|
+
await user.click(screen.getByRole('button', {name: /Sort/}));
|
|
404
|
+
|
|
405
|
+
const group = screen.getByRole('group', {
|
|
406
|
+
name: 'Visible sort label',
|
|
407
|
+
hidden: true,
|
|
408
|
+
});
|
|
409
|
+
expect(group).toHaveAttribute('role', 'group');
|
|
410
|
+
expect(group).toHaveAttribute('aria-label', 'Sort fallback');
|
|
411
|
+
expect(group).toHaveAttribute('aria-labelledby', 'sort-label');
|
|
412
|
+
expect(group).toHaveAttribute('data-owner', 'consumer');
|
|
413
|
+
expect(group).toHaveClass('consumer-group');
|
|
414
|
+
expect(group).toHaveStyle({inlineSize: '240px'});
|
|
415
|
+
expect(ref).toHaveBeenCalledWith(group);
|
|
416
|
+
expect(
|
|
417
|
+
screen
|
|
418
|
+
.getAllByRole('menuitemradio', {hidden: true})
|
|
419
|
+
.every(item => item.getAttribute('aria-checked') === 'false'),
|
|
420
|
+
).toBe(true);
|
|
421
|
+
|
|
422
|
+
fireEvent.click(group);
|
|
423
|
+
expect(onClick).toHaveBeenCalledTimes(1);
|
|
331
424
|
});
|
|
332
425
|
|
|
333
426
|
it('throws when a radio item is used outside a group', () => {
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file DropdownMenuRadioGroupAudit.a11y.chromium.spec.ts
|
|
5
|
+
* @input Exact-head DropdownMenuRadioGroup story across themes, modes, direction, and viewport widths
|
|
6
|
+
* @output Screenshots and fail-closed sensor receipts for the DropdownMenuRadioGroup component audit
|
|
7
|
+
* @position Component-owned Night Watch browser evidence; CI runs it only for a DropdownMenuRadioGroup audit pull request
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {createHash} from 'node:crypto';
|
|
11
|
+
import {execFileSync} from 'node:child_process';
|
|
12
|
+
import * as fs from 'node:fs';
|
|
13
|
+
import * as path from 'node:path';
|
|
14
|
+
import {expect, test, type Page} from '@playwright/test';
|
|
15
|
+
import {holdMotionStill} from '@astryxdesign/a11y-spec/chromium';
|
|
16
|
+
import {
|
|
17
|
+
DEFAULT_STORYBOOK_DIR,
|
|
18
|
+
serveStorybook,
|
|
19
|
+
type StaticServer,
|
|
20
|
+
} from '@astryxdesign/a11y-spec/storybook';
|
|
21
|
+
|
|
22
|
+
const OUTPUT = path.resolve('dropdown-menu-radio-group-audit-evidence');
|
|
23
|
+
const STORY_ID = 'core-dropdownmenuradiogroup--controlled-selection';
|
|
24
|
+
const WIDE = {width: 1024, height: 768};
|
|
25
|
+
const NARROW = {width: 320, height: 720};
|
|
26
|
+
|
|
27
|
+
type Mode = 'light' | 'dark';
|
|
28
|
+
type Direction = 'ltr' | 'rtl';
|
|
29
|
+
type Theme = 'neutral' | 'butter';
|
|
30
|
+
type AuditCase = {
|
|
31
|
+
key: string;
|
|
32
|
+
mode: Mode;
|
|
33
|
+
direction: Direction;
|
|
34
|
+
theme: Theme;
|
|
35
|
+
viewport: {width: number; height: number};
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const cases: AuditCase[] = [
|
|
39
|
+
{
|
|
40
|
+
key: 'neutral-light',
|
|
41
|
+
mode: 'light',
|
|
42
|
+
direction: 'ltr',
|
|
43
|
+
theme: 'neutral',
|
|
44
|
+
viewport: WIDE,
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
key: 'neutral-dark',
|
|
48
|
+
mode: 'dark',
|
|
49
|
+
direction: 'ltr',
|
|
50
|
+
theme: 'neutral',
|
|
51
|
+
viewport: WIDE,
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
key: 'butter-light',
|
|
55
|
+
mode: 'light',
|
|
56
|
+
direction: 'ltr',
|
|
57
|
+
theme: 'butter',
|
|
58
|
+
viewport: WIDE,
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
key: 'butter-dark',
|
|
62
|
+
mode: 'dark',
|
|
63
|
+
direction: 'ltr',
|
|
64
|
+
theme: 'butter',
|
|
65
|
+
viewport: WIDE,
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
key: 'rtl-light',
|
|
69
|
+
mode: 'light',
|
|
70
|
+
direction: 'rtl',
|
|
71
|
+
theme: 'neutral',
|
|
72
|
+
viewport: WIDE,
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
key: 'rtl-dark',
|
|
76
|
+
mode: 'dark',
|
|
77
|
+
direction: 'rtl',
|
|
78
|
+
theme: 'neutral',
|
|
79
|
+
viewport: WIDE,
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
key: 'narrow-light',
|
|
83
|
+
mode: 'light',
|
|
84
|
+
direction: 'ltr',
|
|
85
|
+
theme: 'neutral',
|
|
86
|
+
viewport: NARROW,
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
key: 'narrow-dark',
|
|
90
|
+
mode: 'dark',
|
|
91
|
+
direction: 'ltr',
|
|
92
|
+
theme: 'neutral',
|
|
93
|
+
viewport: NARROW,
|
|
94
|
+
},
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
test.describe.configure({mode: 'serial', retries: 0});
|
|
98
|
+
let server: StaticServer;
|
|
99
|
+
let checkoutSha: string;
|
|
100
|
+
let storybookSha: string;
|
|
101
|
+
|
|
102
|
+
function storyUrl(item: AuditCase) {
|
|
103
|
+
return `${server.origin}/iframe.html?id=${STORY_ID}&viewMode=story&globals=colorMode:${item.mode};astryxTheme:${item.theme};direction:${item.direction}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function settle(page: Page) {
|
|
107
|
+
await holdMotionStill(page);
|
|
108
|
+
await page.evaluate(async () => document.fonts.ready);
|
|
109
|
+
await page.waitForTimeout(40);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
test.beforeAll(async () => {
|
|
113
|
+
fs.rmSync(OUTPUT, {recursive: true, force: true});
|
|
114
|
+
fs.mkdirSync(OUTPUT, {recursive: true});
|
|
115
|
+
checkoutSha = execFileSync('git', ['rev-parse', 'HEAD'], {
|
|
116
|
+
encoding: 'utf8',
|
|
117
|
+
}).trim();
|
|
118
|
+
const expected = process.env.ASTRYX_HEAD_SHA;
|
|
119
|
+
if (expected && expected !== checkoutSha) {
|
|
120
|
+
throw new Error(
|
|
121
|
+
`checkout ${checkoutSha} does not match expected head ${expected}`,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
server = await serveStorybook(
|
|
125
|
+
process.env.ASTRYX_STORYBOOK_DIR ?? DEFAULT_STORYBOOK_DIR,
|
|
126
|
+
);
|
|
127
|
+
const response = await fetch(`${server.origin}/astryx-build-sha.txt`);
|
|
128
|
+
if (!response.ok) {
|
|
129
|
+
throw new Error(`Storybook build has no SHA stamp (${response.status})`);
|
|
130
|
+
}
|
|
131
|
+
storybookSha = (await response.text()).trim();
|
|
132
|
+
if (storybookSha !== checkoutSha) {
|
|
133
|
+
throw new Error(
|
|
134
|
+
`Storybook ${storybookSha} does not match checkout ${checkoutSha}`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test.afterAll(async () => {
|
|
140
|
+
await server?.close();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
for (const item of cases) {
|
|
144
|
+
test(item.key, async ({browser}) => {
|
|
145
|
+
const context = await browser.newContext({
|
|
146
|
+
viewport: item.viewport,
|
|
147
|
+
colorScheme: item.mode,
|
|
148
|
+
reducedMotion: 'reduce',
|
|
149
|
+
});
|
|
150
|
+
const page = await context.newPage();
|
|
151
|
+
const pageErrors: string[] = [];
|
|
152
|
+
page.on('pageerror', error => pageErrors.push(String(error)));
|
|
153
|
+
await page.goto(storyUrl(item), {waitUntil: 'load'});
|
|
154
|
+
await page.locator('#storybook-root').waitFor();
|
|
155
|
+
|
|
156
|
+
const fixture = page.getByTestId('dropdown-menu-radio-group-audit');
|
|
157
|
+
const menu = page.getByRole('menu').last();
|
|
158
|
+
const group = menu.getByRole('group', {name: 'Sort options'});
|
|
159
|
+
await expect(group).toBeVisible();
|
|
160
|
+
const radios = group.getByRole('menuitemradio');
|
|
161
|
+
await expect(radios).toHaveCount(3);
|
|
162
|
+
await expect(fixture).toHaveAttribute('data-close-request-count', '0');
|
|
163
|
+
await expect(radios.first()).toHaveAttribute('aria-checked', 'true');
|
|
164
|
+
await radios.nth(1).click();
|
|
165
|
+
await expect(radios.nth(1)).toHaveAttribute('aria-checked', 'true');
|
|
166
|
+
await expect(fixture).toHaveAttribute('data-close-request-count', '0');
|
|
167
|
+
await expect(menu).toBeVisible();
|
|
168
|
+
await settle(page);
|
|
169
|
+
|
|
170
|
+
const observed = await group.evaluate(element => {
|
|
171
|
+
const currentRadios = Array.from(
|
|
172
|
+
element.querySelectorAll<HTMLElement>('[role="menuitemradio"]'),
|
|
173
|
+
);
|
|
174
|
+
const groupStyle = getComputedStyle(element);
|
|
175
|
+
const groupBox = element.getBoundingClientRect();
|
|
176
|
+
const currentMenu = element.closest<HTMLElement>('[role="menu"]');
|
|
177
|
+
const rows = currentRadios.map(row => {
|
|
178
|
+
const box = row.getBoundingClientRect();
|
|
179
|
+
const style = getComputedStyle(row);
|
|
180
|
+
const indicator = row.querySelector<HTMLElement>(
|
|
181
|
+
'.astryx-dropdown-menu-radio',
|
|
182
|
+
);
|
|
183
|
+
const indicatorBox = indicator?.getBoundingClientRect() ?? null;
|
|
184
|
+
return {
|
|
185
|
+
role: row.getAttribute('role'),
|
|
186
|
+
checked: row.getAttribute('aria-checked'),
|
|
187
|
+
text: row.textContent?.replace(/\s+/g, ' ').trim() ?? null,
|
|
188
|
+
color: style.color,
|
|
189
|
+
geometry: {x: box.x, y: box.y, width: box.width, height: box.height},
|
|
190
|
+
indicator:
|
|
191
|
+
indicatorBox === null
|
|
192
|
+
? null
|
|
193
|
+
: {width: indicatorBox.width, height: indicatorBox.height},
|
|
194
|
+
};
|
|
195
|
+
});
|
|
196
|
+
return {
|
|
197
|
+
theme: document
|
|
198
|
+
.querySelector('[data-astryx-theme]')
|
|
199
|
+
?.getAttribute('data-astryx-theme'),
|
|
200
|
+
mode: document.documentElement.getAttribute('data-theme'),
|
|
201
|
+
direction: groupStyle.direction,
|
|
202
|
+
viewport: {width: innerWidth, height: innerHeight},
|
|
203
|
+
reducedMotion: matchMedia('(prefers-reduced-motion: reduce)').matches,
|
|
204
|
+
closeRequestCount:
|
|
205
|
+
element
|
|
206
|
+
.closest('[data-close-request-count]')
|
|
207
|
+
?.getAttribute('data-close-request-count') ?? null,
|
|
208
|
+
horizontalOverflow:
|
|
209
|
+
document.documentElement.scrollWidth > innerWidth + 1,
|
|
210
|
+
fonts: document.fonts.status,
|
|
211
|
+
group: {
|
|
212
|
+
role: element.getAttribute('role'),
|
|
213
|
+
ariaLabel: element.getAttribute('aria-label'),
|
|
214
|
+
display: groupStyle.display,
|
|
215
|
+
flexDirection: groupStyle.flexDirection,
|
|
216
|
+
rowGap: groupStyle.rowGap,
|
|
217
|
+
menuRowGap:
|
|
218
|
+
currentMenu === null ? null : getComputedStyle(currentMenu).rowGap,
|
|
219
|
+
geometry: {
|
|
220
|
+
x: groupBox.x,
|
|
221
|
+
y: groupBox.y,
|
|
222
|
+
width: groupBox.width,
|
|
223
|
+
height: groupBox.height,
|
|
224
|
+
},
|
|
225
|
+
},
|
|
226
|
+
rows,
|
|
227
|
+
};
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
const semanticsPass =
|
|
231
|
+
observed.group.role === 'group' &&
|
|
232
|
+
observed.group.ariaLabel === 'Sort options' &&
|
|
233
|
+
observed.rows.length === 3 &&
|
|
234
|
+
observed.rows.every(row => row.role === 'menuitemradio') &&
|
|
235
|
+
observed.rows.filter(row => row.checked === 'true').length === 1 &&
|
|
236
|
+
observed.rows[1]?.checked === 'true' &&
|
|
237
|
+
observed.rows[1]?.text?.includes('Oldest first') === true &&
|
|
238
|
+
observed.closeRequestCount === '0';
|
|
239
|
+
const layoutPass =
|
|
240
|
+
observed.group.display === 'flex' &&
|
|
241
|
+
observed.group.flexDirection === 'column' &&
|
|
242
|
+
observed.group.menuRowGap !== null &&
|
|
243
|
+
observed.group.rowGap === observed.group.menuRowGap &&
|
|
244
|
+
Number.parseFloat(observed.group.rowGap) > 0 &&
|
|
245
|
+
observed.group.geometry.width > 0 &&
|
|
246
|
+
observed.group.geometry.height > 0 &&
|
|
247
|
+
observed.rows.every(
|
|
248
|
+
row => row.geometry.width > 0 && row.geometry.height > 0,
|
|
249
|
+
) &&
|
|
250
|
+
(observed.rows[1]?.geometry.y ?? 0) >
|
|
251
|
+
(observed.rows[0]?.geometry.y ?? Number.POSITIVE_INFINITY);
|
|
252
|
+
const paintPass = observed.rows.every(
|
|
253
|
+
row =>
|
|
254
|
+
row.color !== 'rgba(0, 0, 0, 0)' &&
|
|
255
|
+
row.indicator !== null &&
|
|
256
|
+
row.indicator.width > 0 &&
|
|
257
|
+
row.indicator.height > 0,
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
const png = await page.screenshot({animations: 'disabled', fullPage: true});
|
|
261
|
+
const image = `${item.key}.png`;
|
|
262
|
+
const receipt = {
|
|
263
|
+
schemaVersion: 1,
|
|
264
|
+
expected: {
|
|
265
|
+
build: checkoutSha,
|
|
266
|
+
storyId: STORY_ID,
|
|
267
|
+
mode: item.mode,
|
|
268
|
+
direction: item.direction,
|
|
269
|
+
theme: item.theme,
|
|
270
|
+
viewport: item.viewport,
|
|
271
|
+
},
|
|
272
|
+
observed: {build: storybookSha, ...observed, pageErrors},
|
|
273
|
+
image: {
|
|
274
|
+
file: image,
|
|
275
|
+
sha256: createHash('sha256').update(png).digest('hex'),
|
|
276
|
+
width: png.readUInt32BE(16),
|
|
277
|
+
height: png.readUInt32BE(20),
|
|
278
|
+
},
|
|
279
|
+
checks: {semanticsPass, layoutPass, paintPass},
|
|
280
|
+
passed:
|
|
281
|
+
storybookSha === checkoutSha &&
|
|
282
|
+
pageErrors.length === 0 &&
|
|
283
|
+
observed.theme === item.theme &&
|
|
284
|
+
observed.mode === item.mode &&
|
|
285
|
+
observed.direction === item.direction &&
|
|
286
|
+
observed.viewport.width === item.viewport.width &&
|
|
287
|
+
observed.viewport.height === item.viewport.height &&
|
|
288
|
+
observed.reducedMotion &&
|
|
289
|
+
!observed.horizontalOverflow &&
|
|
290
|
+
observed.fonts === 'loaded' &&
|
|
291
|
+
semanticsPass &&
|
|
292
|
+
layoutPass &&
|
|
293
|
+
paintPass,
|
|
294
|
+
};
|
|
295
|
+
fs.writeFileSync(path.join(OUTPUT, image), png);
|
|
296
|
+
fs.writeFileSync(
|
|
297
|
+
path.join(OUTPUT, `${item.key}.sensors.json`),
|
|
298
|
+
`${JSON.stringify(receipt, null, 2)}\n`,
|
|
299
|
+
);
|
|
300
|
+
expect(receipt.passed).toBe(true);
|
|
301
|
+
await context.close();
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
test('manifest is complete', () => {
|
|
306
|
+
const receipts = fs
|
|
307
|
+
.readdirSync(OUTPUT)
|
|
308
|
+
.filter(file => file.endsWith('.sensors.json'));
|
|
309
|
+
fs.writeFileSync(
|
|
310
|
+
path.join(OUTPUT, 'manifest.json'),
|
|
311
|
+
`${JSON.stringify(
|
|
312
|
+
{
|
|
313
|
+
schemaVersion: 1,
|
|
314
|
+
component: 'core/DropdownMenuRadioGroup',
|
|
315
|
+
build: checkoutSha,
|
|
316
|
+
frames: cases.map(item => ({
|
|
317
|
+
key: item.key,
|
|
318
|
+
image: `${item.key}.png`,
|
|
319
|
+
receipt: `${item.key}.sensors.json`,
|
|
320
|
+
})),
|
|
321
|
+
},
|
|
322
|
+
null,
|
|
323
|
+
2,
|
|
324
|
+
)}\n`,
|
|
325
|
+
);
|
|
326
|
+
expect(receipts).toHaveLength(cases.length);
|
|
327
|
+
});
|