@astryxdesign/core 0.7.3-canary.02f5fe4 → 0.7.3-canary.05bfcc5
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/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 +3 -3
- package/src/Carousel/Carousel.spec.md +146 -0
- 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/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
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* comes from the parent DropdownMenu's useListFocus, which matches
|
|
10
10
|
* menuitemradio rows.
|
|
11
11
|
*/
|
|
12
|
-
import { type ReactNode } from 'react';
|
|
12
|
+
import { type ReactNode, type Ref } from 'react';
|
|
13
13
|
import type { BaseProps } from '../BaseProps';
|
|
14
14
|
export interface DropdownMenuRadioGroupProps extends Omit<BaseProps, 'onChange'> {
|
|
15
15
|
/**
|
|
@@ -24,9 +24,9 @@ export interface DropdownMenuRadioGroupProps extends Omit<BaseProps, 'onChange'>
|
|
|
24
24
|
/**
|
|
25
25
|
* Accessible name for the group, announced by screen readers so the radios
|
|
26
26
|
* read as a named set (e.g. "Sort by"). Applied as the group's `aria-label`.
|
|
27
|
-
* Required
|
|
28
|
-
*
|
|
29
|
-
*
|
|
27
|
+
* Required even when `aria-labelledby` (via base props) points to visible
|
|
28
|
+
* text; that visible reference becomes the computed name while `label`
|
|
29
|
+
* remains the fallback.
|
|
30
30
|
*/
|
|
31
31
|
label: string;
|
|
32
32
|
/**
|
|
@@ -36,6 +36,10 @@ export interface DropdownMenuRadioGroupProps extends Omit<BaseProps, 'onChange'>
|
|
|
36
36
|
* @default true
|
|
37
37
|
*/
|
|
38
38
|
hasCloseOnSelect?: boolean;
|
|
39
|
+
/**
|
|
40
|
+
* Ref forwarded to the role="group" root.
|
|
41
|
+
*/
|
|
42
|
+
ref?: Ref<HTMLDivElement>;
|
|
39
43
|
/**
|
|
40
44
|
* The `DropdownMenuRadioItem`s that make up the group.
|
|
41
45
|
*/
|
|
@@ -58,7 +62,7 @@ export interface DropdownMenuRadioGroupProps extends Omit<BaseProps, 'onChange'>
|
|
|
58
62
|
* </DropdownMenu>
|
|
59
63
|
* ```
|
|
60
64
|
*/
|
|
61
|
-
export declare function DropdownMenuRadioGroup({ value, onChange, label, hasCloseOnSelect, children, xstyle, className, style, ...rest }: DropdownMenuRadioGroupProps): import("react").JSX.Element;
|
|
65
|
+
export declare function DropdownMenuRadioGroup({ value, onChange, label, hasCloseOnSelect, children, ref, xstyle, className, style, ...rest }: DropdownMenuRadioGroupProps): import("react").JSX.Element;
|
|
62
66
|
export declare namespace DropdownMenuRadioGroup {
|
|
63
67
|
var displayName: string;
|
|
64
68
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"DropdownMenuRadioGroup.d.ts","sourceRoot":"","sources":["../../src/DropdownMenu/DropdownMenuRadioGroup.tsx"],"names":[],"mappings":"AAIA;;;;;;;;;;GAUG;AAEH,OAAO,EAAU,KAAK,SAAS,EAAC,MAAM,OAAO,CAAC;
|
|
1
|
+
{"version":3,"file":"DropdownMenuRadioGroup.d.ts","sourceRoot":"","sources":["../../src/DropdownMenu/DropdownMenuRadioGroup.tsx"],"names":[],"mappings":"AAIA;;;;;;;;;;GAUG;AAEH,OAAO,EAAU,KAAK,SAAS,EAAE,KAAK,GAAG,EAAC,MAAM,OAAO,CAAC;AAIxD,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,cAAc,CAAC;AAiB5C,MAAM,WAAW,2BAA4B,SAAQ,IAAI,CACvD,SAAS,EACT,UAAU,CACX;IACC;;;OAGG;IACH,KAAK,EAAE,MAAM,GAAG,SAAS,CAAC;IAC1B;;OAEG;IACH,QAAQ,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IAClC;;;;;;OAMG;IACH,KAAK,EAAE,MAAM,CAAC;IACd;;;;;OAKG;IACH,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B;;OAEG;IACH,GAAG,CAAC,EAAE,GAAG,CAAC,cAAc,CAAC,CAAC;IAC1B;;OAEG;IACH,QAAQ,EAAE,SAAS,CAAC;CACrB;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,sBAAsB,CAAC,EACrC,KAAK,EACL,QAAQ,EACR,KAAK,EACL,gBAAuB,EACvB,QAAQ,EACR,GAAG,EACH,MAAM,EACN,SAAS,EACT,KAAK,EACL,GAAG,IAAI,EACR,EAAE,2BAA2B,+BAkB7B;yBA7Be,sBAAsB"}
|
|
@@ -51,6 +51,7 @@ export function DropdownMenuRadioGroup({
|
|
|
51
51
|
label,
|
|
52
52
|
hasCloseOnSelect = true,
|
|
53
53
|
children,
|
|
54
|
+
ref,
|
|
54
55
|
xstyle,
|
|
55
56
|
className,
|
|
56
57
|
style,
|
|
@@ -63,6 +64,7 @@ export function DropdownMenuRadioGroup({
|
|
|
63
64
|
}), [value, onChange, hasCloseOnSelect]);
|
|
64
65
|
return /*#__PURE__*/_jsx("div", {
|
|
65
66
|
...rest,
|
|
67
|
+
ref: ref,
|
|
66
68
|
role: "group",
|
|
67
69
|
"aria-label": label,
|
|
68
70
|
...mergeProps(stylex.props(styles.group, xstyle), {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@astryxdesign/core",
|
|
3
|
-
"version": "0.7.3-canary.
|
|
3
|
+
"version": "0.7.3-canary.05bfcc5",
|
|
4
4
|
"displayName": "Astryx Core",
|
|
5
5
|
"description": "The component library. Accessible, themeable React components with built-in spacing, dark mode, and StyleX styling.",
|
|
6
6
|
"author": "Meta Open Source",
|
|
@@ -792,8 +792,8 @@
|
|
|
792
792
|
"react-dom": ">=19.0.0"
|
|
793
793
|
},
|
|
794
794
|
"devDependencies": {
|
|
795
|
-
"@astryxdesign/a11y-spec": "0.7.3-canary.
|
|
796
|
-
"@astryxdesign/cli": "0.7.3-canary.
|
|
795
|
+
"@astryxdesign/a11y-spec": "0.7.3-canary.05bfcc5",
|
|
796
|
+
"@astryxdesign/cli": "0.7.3-canary.05bfcc5",
|
|
797
797
|
"@babel/cli": "^7.29.7",
|
|
798
798
|
"@babel/core": "^7.29.7",
|
|
799
799
|
"@babel/preset-react": "^7.29.7",
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
---
|
|
2
|
+
schema_version: 3
|
|
3
|
+
template_version: 7
|
|
4
|
+
kind: component
|
|
5
|
+
id: component:Carousel
|
|
6
|
+
authority: current
|
|
7
|
+
archive_reason: null
|
|
8
|
+
superseded_by: null
|
|
9
|
+
approved_by: cixzhang
|
|
10
|
+
approved_at: 2026-10-22
|
|
11
|
+
owners: [cixzhang]
|
|
12
|
+
review_triggers: [behavior, accessibility]
|
|
13
|
+
verified_by:
|
|
14
|
+
[
|
|
15
|
+
packages/core/src/Carousel/Carousel.test.tsx,
|
|
16
|
+
packages/core/src/Carousel/__tests__/Carousel.a11y.chromium.spec.ts,
|
|
17
|
+
]
|
|
18
|
+
modules: []
|
|
19
|
+
families: [family:buttons]
|
|
20
|
+
design_specs: []
|
|
21
|
+
architecture: [architecture:interaction-modality]
|
|
22
|
+
contributing: []
|
|
23
|
+
system_specs: [spec:AST-085, spec:AST-025]
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
# Carousel component contract
|
|
27
|
+
|
|
28
|
+
## Contract at a glance
|
|
29
|
+
|
|
30
|
+
| Area | Contract |
|
|
31
|
+
| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
32
|
+
| Public contract | None changes. |
|
|
33
|
+
| Behavior | An edge scroll button whose direction has run out is hidden and takes no tab stop. When the focused edge button runs out, focus moves to the scroll area. |
|
|
34
|
+
| End-user impact | A keyboard user who scrolls to the first or last item stays inside the carousel instead of landing on the page body or on an invisible control. The scroll area has no role or name yet, so screen-reader users hear little at the destination until it meets `spec:AST-025` FR12. |
|
|
35
|
+
| Builder impact | None. |
|
|
36
|
+
| Compatibility/readiness | No API change. Shipped Carousel and its current tests still use native `disabled` and hand focus to the opposite button; they change with conformance work. |
|
|
37
|
+
| Review checks | Reject focus left on a hidden edge button, focus dropped to the page body, or an edge handoff that moves focus to anything but the scroll area. |
|
|
38
|
+
| Governing rules | `spec:AST-085` FR1, FR4; `spec:AST-025` FR12; `architecture:interaction-modality` INV1. |
|
|
39
|
+
|
|
40
|
+
This table is a review projection; the body below is authoritative.
|
|
41
|
+
|
|
42
|
+
## Intent
|
|
43
|
+
|
|
44
|
+
Carousel shows a horizontally scrolling row of items inside a labelled region,
|
|
45
|
+
with optional scroll buttons at each edge. This record covers only what the edge
|
|
46
|
+
scroll buttons do when their direction runs out. Other Carousel behavior is
|
|
47
|
+
uncontracted until this record grows.
|
|
48
|
+
|
|
49
|
+
## Compatibility and migration
|
|
50
|
+
|
|
51
|
+
- Released default preserved: yes, except where focus lands at an edge.
|
|
52
|
+
- Compatibility class: behavior correction; no API change.
|
|
53
|
+
- Controlled/uncontrolled behavior: unchanged.
|
|
54
|
+
- Migration decision: DEC-1.
|
|
55
|
+
|
|
56
|
+
Consumer migration instructions belong in consumer docs and release notes.
|
|
57
|
+
|
|
58
|
+
## Ownership boundary
|
|
59
|
+
|
|
60
|
+
**Owns**
|
|
61
|
+
|
|
62
|
+
- Edge scroll button visibility, tab order, and the focus destination when one
|
|
63
|
+
runs out.
|
|
64
|
+
|
|
65
|
+
**Does not own / non-goals**
|
|
66
|
+
|
|
67
|
+
- Scroll area keyboard access and naming — `spec:AST-025`.
|
|
68
|
+
- Disabled control semantics in general — `spec:AST-085`.
|
|
69
|
+
- Button naming, sizing, and activation — `family:buttons`.
|
|
70
|
+
- Looping, snapping, edge fades, and slide semantics — uncontracted.
|
|
71
|
+
|
|
72
|
+
## Public concepts
|
|
73
|
+
|
|
74
|
+
| Concept | Closed values or states | Meaning | Availability by variant/orientation/state | Default | Owner | Stability | Invalid-value behavior |
|
|
75
|
+
| ------------------ | ----------------------- | -------------------------------------------------- | ----------------------------------------- | ------- | -------------------- | --------- | ---------------------- |
|
|
76
|
+
| Edge scroll button | available, run out | Whether content remains in that button's direction | Both edges when the carousel overflows | — | `component:Carousel` | stable | not applicable |
|
|
77
|
+
|
|
78
|
+
## Behavioral and layout contract
|
|
79
|
+
|
|
80
|
+
| ID | Candidate invariant | Basis | Draft review state |
|
|
81
|
+
| --- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------- | ------------------ |
|
|
82
|
+
| FR1 | An edge scroll button whose direction has run out is hidden. While hidden it takes no tab stop and is not exposed to assistive technology. It stays mounted so layout does not shift. This is removal under `spec:AST-085` FR4, not a disabled control. | DEC-1 | settled |
|
|
83
|
+
| FR2 | When the focused edge scroll button runs out, focus moves to the scroll area without scrolling it. This holds when the content no longer overflows and the scroll area is out of the tab order (`spec:AST-025` FR12): it still takes focus programmatically. Focus never lands on the page body or on a hidden button. | DEC-1; `spec:AST-085` FR4 | settled |
|
|
84
|
+
| FR3 | The handoff happens once per edge transition, and only while the edge button held focus. An edge reached by swipe, resize, or wheel while focus is elsewhere moves nothing. | `architecture:interaction-modality` INV8 | settled |
|
|
85
|
+
| FR4 | An edge scroll button that is available behaves as an ordinary Button. | `family:buttons` | settled |
|
|
86
|
+
|
|
87
|
+
### Representative states
|
|
88
|
+
|
|
89
|
+
| State | Required invariant | Allowed variation |
|
|
90
|
+
| -------------------------------------- | --------------------------------------------------------------- | ----------------------- |
|
|
91
|
+
| Mid-scroll | Both edge buttons available | Loop keeps both enabled |
|
|
92
|
+
| Focused "Scroll right" reaches the end | Button hides; focus is on the scroll area; no extra scroll | Reduced motion |
|
|
93
|
+
| Edge reached by swipe, focus elsewhere | Button hides; focus does not move | — |
|
|
94
|
+
| Content shrinks to fit | Both buttons hidden; a focused one hands off to the scroll area | — |
|
|
95
|
+
|
|
96
|
+
## Accessibility contract
|
|
97
|
+
|
|
98
|
+
- **AR1 — No invisible tab stops.** A hidden edge button is not focusable and is
|
|
99
|
+
not exposed to assistive technology (FR1).
|
|
100
|
+
- **AR2 — Focus stays in the region.** The edge handoff keeps focus inside the
|
|
101
|
+
labelled carousel region (FR2), and the keyboard focus indicator follows
|
|
102
|
+
`architecture:interaction-modality` INV1.
|
|
103
|
+
|
|
104
|
+
## Design relationships
|
|
105
|
+
|
|
106
|
+
No design record governs Carousel yet. Edge button visuals stay in code and
|
|
107
|
+
visual evidence.
|
|
108
|
+
|
|
109
|
+
## Family and system relationships
|
|
110
|
+
|
|
111
|
+
- `spec:AST-085` FR4 lets a component name a focus destination for a control it
|
|
112
|
+
removes; FR2 names the scroll area.
|
|
113
|
+
- `spec:AST-025` FR12 owns the scroll area's tab stop and naming.
|
|
114
|
+
- `family:buttons` owns the edge buttons' behavior while available.
|
|
115
|
+
|
|
116
|
+
## Verification map
|
|
117
|
+
|
|
118
|
+
| Contract | Verification | Representative states | Mutation or failure expectation | Audit section |
|
|
119
|
+
| -------- | --------------------------------------- | -------------------------------------------------------- | ------------------------------------------------------------------------------ | ------------------------------ |
|
|
120
|
+
| FR1, AR1 | Unit and accessibility-tree tests | at rest at each edge; no overflow | A hidden button that is focusable or exposed fails | `audit:Carousel/accessibility` |
|
|
121
|
+
| FR2, AR2 | Real-browser keyboard test | Enter on "Scroll right" until the end; same on the start | Focus on the page body, a hidden button, or the opposite button fails | `audit:Carousel/accessibility` |
|
|
122
|
+
| FR3 | Real-browser test with swipe and resize | edge reached with focus outside the carousel | Focus moving without an edge-button press, or moving twice for one edge, fails | `audit:Carousel/accessibility` |
|
|
123
|
+
|
|
124
|
+
## Decision log
|
|
125
|
+
|
|
126
|
+
### DEC-1 — The scroll area receives focus when an edge button runs out
|
|
127
|
+
|
|
128
|
+
**Reference:** `component:Carousel/DEC-1`
|
|
129
|
+
**Decider:** cixzhang, 2026-10-22
|
|
130
|
+
|
|
131
|
+
Carousel hides an edge button whose direction has run out, so keeping focus on
|
|
132
|
+
it would leave a keyboard user on an invisible control. The scroll area is always
|
|
133
|
+
mounted inside the labelled region and can take focus whether or not it is in the
|
|
134
|
+
tab order, so focus moving there keeps the user in place inside the carousel.
|
|
135
|
+
|
|
136
|
+
Rejected: the opposite edge button — it may be hidden too, and focus would jump
|
|
137
|
+
across the carousel.
|
|
138
|
+
|
|
139
|
+
## Open questions
|
|
140
|
+
|
|
141
|
+
None.
|
|
142
|
+
|
|
143
|
+
## Content boundary
|
|
144
|
+
|
|
145
|
+
This file does not duplicate consumer prop tables/examples, current audit
|
|
146
|
+
results, implementation steps, or family/system rules. It links to their owners.
|
|
@@ -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
|
};
|
|
@@ -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
|
+
});
|
|
@@ -130,9 +130,9 @@ intentional decision.
|
|
|
130
130
|
region, including on first mount and when message or type changes, without
|
|
131
131
|
moving focus. Inside a mounted live container, `spec:AST-086` FR4 applies.
|
|
132
132
|
- **AR5 — Assistive-technology output.** Claims about whether, when, or how the
|
|
133
|
-
message is spoken or presented in braille
|
|
134
|
-
|
|
135
|
-
|
|
133
|
+
message is spoken or presented in braille are recommended real-AT follow-up
|
|
134
|
+
under `spec:AST-009`, not a merge or release requirement; DOM and
|
|
135
|
+
accessibility-tree checks prove programmatic exposure and routing.
|
|
136
136
|
- **AR2 — Visible description.** The rendered message box MUST remain available
|
|
137
137
|
to assistive technology for `aria-describedby` association and MUST NOT become
|
|
138
138
|
its own newly mounted live region.
|
|
@@ -182,7 +182,7 @@ change either presentation.
|
|
|
182
182
|
| FR5, FR6 | `FieldStatus.test.tsx` forwarding and update suites | Initial render and rerender | Breaking the released root or update behavior fails compatibility assertions. | `audit:FieldStatus/behavior` |
|
|
183
183
|
| AR1 | `StatusMessage.a11y.test.tsx` and `StatusMessage.a11y.chromium.spec.ts` | Error, warning, and success; post-mount message replacement | Removing the persistent channel, changing its role/urgency, or dropping a post-mount update fails the shared status-message contract. | `audit:FieldStatus/accessibility` |
|
|
184
184
|
| AR1 | `FieldStatus.test.tsx` channel-routing suite | First non-empty mount; same-instance message and type changes | Breaking FieldStatus's first-use hook call or severity rerouting fails component-specific assertions. | `audit:FieldStatus/accessibility` |
|
|
185
|
-
| AR5 |
|
|
185
|
+
| AR5 | Recommended real-AT follow-up under `spec:AST-009` | First mount; message replacement; type/urgency change | Spoken and braille output stay unverified until a real-AT receipt exists; this does not block merge or release. | `audit:FieldStatus/accessibility` |
|
|
186
186
|
| AR2 | `FieldStatus.test.tsx` | Attached/detached visible message and empty message | The visible description becomes a second live region or is hidden from assistive technology. | `audit:FieldStatus/accessibility` |
|
|
187
187
|
| Theming anatomy map | `scripts/check-knowledge.mjs` | Consumer anatomy and current targets | Missing, extra, duplicated, prefixed, or stale mappings fail repository validation. | `audit:FieldStatus/theming` |
|
|
188
188
|
|
|
@@ -357,17 +357,17 @@ describe('Markdown helper performance', () => {
|
|
|
357
357
|
}),
|
|
358
358
|
];
|
|
359
359
|
|
|
360
|
-
// Timed on the transform stage alone
|
|
361
|
-
//
|
|
362
|
-
// parse
|
|
363
|
-
//
|
|
360
|
+
// Timed on the transform stage alone: this is a few hundred
|
|
361
|
+
// microseconds against a parse of milliseconds, so measuring the whole
|
|
362
|
+
// parse buries it. Each side is compared at its FASTEST sample, the
|
|
363
|
+
// least-disturbed run it achieved, and the two sides alternate sample by
|
|
364
|
+
// sample so a slow stretch of the machine reaches both instead of one.
|
|
364
365
|
const root = parseMarkdownAst(source);
|
|
365
|
-
const
|
|
366
|
+
const transformOnly = (
|
|
366
367
|
plugins: ReadonlyArray<ReturnType<typeof createMarkdownPlugin>>,
|
|
367
|
-
)
|
|
368
|
+
) => {
|
|
368
369
|
const prepared = prepareMarkdownPlugins(plugins);
|
|
369
|
-
|
|
370
|
-
for (let sample = 0; sample < 60; sample++) {
|
|
370
|
+
return () => {
|
|
371
371
|
const start = performance.now();
|
|
372
372
|
benchmarkSink ^= applyMarkdownTransforms(
|
|
373
373
|
root,
|
|
@@ -376,18 +376,24 @@ describe('Markdown helper performance', () => {
|
|
|
376
376
|
true,
|
|
377
377
|
'block',
|
|
378
378
|
).children.length;
|
|
379
|
-
|
|
380
|
-
}
|
|
381
|
-
void benchmarkSink;
|
|
382
|
-
return fastest;
|
|
379
|
+
return performance.now() - start;
|
|
380
|
+
};
|
|
383
381
|
};
|
|
384
|
-
|
|
385
|
-
const
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
382
|
+
const single = transformOnly(plugin('scale-single', gaps));
|
|
383
|
+
const double = transformOnly(plugin('scale-double', doubled));
|
|
384
|
+
let fastestSingle = Number.POSITIVE_INFINITY;
|
|
385
|
+
let fastestDouble = Number.POSITIVE_INFINITY;
|
|
386
|
+
for (let sample = 0; sample < 80; sample++) {
|
|
387
|
+
// The first rounds only warm both sides.
|
|
388
|
+
const singleTime = single();
|
|
389
|
+
const doubleTime = double();
|
|
390
|
+
if (sample >= 20) {
|
|
391
|
+
fastestSingle = Math.min(fastestSingle, singleTime);
|
|
392
|
+
fastestDouble = Math.min(fastestDouble, doubleTime);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
void benchmarkSink;
|
|
396
|
+
const ratio = fastestDouble / fastestSingle;
|
|
391
397
|
console.log(
|
|
392
398
|
` ${sections} sections, ${doubled.length} ranges over ${gaps.length}: ${ratio.toFixed(3)}`,
|
|
393
399
|
);
|
|
@@ -227,17 +227,17 @@ without a target of its own.
|
|
|
227
227
|
|
|
228
228
|
## Verification map
|
|
229
229
|
|
|
230
|
-
| Contract | Verification
|
|
231
|
-
| ------------------ |
|
|
232
|
-
| FR1, FR6 | `useTableColumnResize.test.tsx` boundary-presence cases plus hover-capability and coarse-primary cases
|
|
233
|
-
| FR2 | Browser measurement of scroll-region vertical overflow with the plugin enabled
|
|
234
|
-
| FR3-FR5, FR9, ORD1 | New focused tests for resting, header-hovered, pointer-on-boundary, focused, and dragging states
|
|
235
|
-
| FR10, ORD0 | Scroll-gate test driving the scroll region with a stationary pointer over the header
|
|
236
|
-
| FR7 | Snapshot or assertion that header hover changes no other header or row treatment
|
|
237
|
-
| FR8, AR1-AR3, AR6 | Existing keyboard and ARIA suites
|
|
238
|
-
| PR1, PR2 | Render-count assertion around header pointer enter/leave and during a scroll; listener and observer inspection
|
|
239
|
-
| Plugin composition | Test mounting the resize and sticky-columns plugins together in both insertion orders
|
|
240
|
-
| Record shape | `scripts/check-knowledge.mjs`
|
|
230
|
+
| Contract | Verification | Representative states | Mutation or failure expectation |
|
|
231
|
+
| ------------------ | --------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
232
|
+
| FR1, FR6 | `useTableColumnResize.test.tsx` boundary-presence cases plus hover-capability and coarse-primary cases | Resizable, opted-out, and generated columns; hover-capable, coarse-primary with keyboard, and coarse-only inputs | A boundary appears on a non-resizable column, disappears from a resizable one, the hover reveal fires where no pointer can hover, or the control is absent where an input can operate it. |
|
|
233
|
+
| FR2 | Browser measurement of scroll-region vertical overflow with the plugin enabled | Short and tall tables, wrapped and single-line headers, dense and default | The indicator ends below the last row or the scroll region gains vertical scroll that the same table lacks without resize. |
|
|
234
|
+
| FR3-FR5, FR9, ORD1 | New focused tests for resting, header-hovered, pointer-on-boundary, focused, and dragging states | Rest, header hover, hover plus pointer on one boundary, keyboard focus with no pointer in the row, drag | A resting header draws boundaries, header hover misses a boundary, the pointed/focused boundary fails to outrank the hint, or focus alone draws a neighbor. |
|
|
235
|
+
| FR10, ORD0 | Scroll-gate test driving the scroll region with a stationary pointer over the header | Pointer resting over a header while the region scrolls and after it settles | Boundaries reveal mid-scroll, an existing hint survives the scroll, or disclosure fails to resume once scrolling stops. |
|
|
236
|
+
| FR7 | Snapshot or assertion that header hover changes no other header or row treatment | Header hover over a sortable and a selectable header | Header hover changes background, label, cursor, width, or another control's state. |
|
|
237
|
+
| FR8, AR1-AR3, AR6 | Existing keyboard and ARIA suites and a coarse-primary keyboard case; an assistive-technology spot check is recommended follow-up | Tab to boundary, arrow resize, screen-reader navigation of the header, coarse-primary device with a keyboard | The boundary leaves tab order on any input that can operate it, loses its name or values, or the hint reaches the accessibility tree. |
|
|
238
|
+
| PR1, PR2 | Render-count assertion around header pointer enter/leave and during a scroll; listener and observer inspection | Pointer enters and leaves the header repeatedly; the region scrolls under a resting pointer | Disclosure rerenders the Table, the scroll gate sets React state, more than one scroll listener or any timer is added, or the extent measurement observes what it resizes. |
|
|
239
|
+
| Plugin composition | Test mounting the resize and sticky-columns plugins together in both insertion orders | Pinned start column with resize enabled, before and after a drag | A pinned column loses `position: sticky` in either order, or the composition depends on caller insertion order. |
|
|
240
|
+
| Record shape | `scripts/check-knowledge.mjs` | Colocated module record, canonical id and filename, parent backlink | A missing, misnamed, mis-parented, or unlinked record passes validation. |
|
|
241
241
|
|
|
242
242
|
Browser, render-count, and assistive-technology evidence are pending. Passing this
|
|
243
243
|
specification pull request proves record integrity only.
|
|
@@ -214,8 +214,9 @@ add semantic/custom cases to representative Table render and no-op update budget
|
|
|
214
214
|
- **AR5 — Supplemental tooltip.** Tooltip may repeat `label` as pointer help, but
|
|
215
215
|
the indicator's accessible name MUST remain sufficient without opening it. The
|
|
216
216
|
current indicator is not focusable, so tooltip presence is not keyboard evidence.
|
|
217
|
-
- **AR6 — Row context.**
|
|
218
|
-
|
|
217
|
+
- **AR6 — Row context.** The named status is exposed in its row context without a
|
|
218
|
+
duplicate name, proved at the accessibility tree. Assistive-technology
|
|
219
|
+
observation of the announcement is recommended follow-up under `spec:AST-009`.
|
|
219
220
|
|
|
220
221
|
## Design relationships
|
|
221
222
|
|
|
@@ -259,7 +260,7 @@ own AST-002 review and synchronized consumer anatomy.
|
|
|
259
260
|
| FR3, FR4, FR6 | Add public-subpath compile fixtures for the unchanged interface, declaration merging and extension, semantic interface, callback union, missing labels, unknown statuses, and forbidden mixtures. | Stable custom annotations/extensions; all semantic statuses. | `TableRowStatus` changes or stops merging, semantic usage is inaccessible, or supported status mixes with custom fields. |
|
|
260
261
|
| FR3-FR5 | Extend row-status runtime tests with semantic-looking custom colors, raw/palette dots, explicit custom icons, all semantic statuses, and active-theme substitution. | Custom dot, custom icon, and three semantic outcomes. | Custom color selects an implicit glyph, semantic status bypasses shared resolution, or variant becomes caller/theme input. |
|
|
261
262
|
| FR7, ORD2-ORD4, PR4 | Runtime tests under development and production modes with repeated mixed objects, rows, and renders. | `status + color`, `status + icon`, and all three fields. | Custom input overrides supported status, forbidden fields affect output, production warns, or development warns more than once per loaded module instance. |
|
|
262
|
-
| AR1-AR6 | Existing role/name tests plus browser axe, forced-colors, tooltip modality, and VoiceOver table
|
|
263
|
+
| AR1-AR6 | Existing role/name tests plus browser axe, forced-colors, tooltip modality, and accessibility-tree row-context evidence; VoiceOver table navigation is recommended follow-up. | Semantic glyphs, custom icon, custom dot, `null`, light/dark, and every shipped theme. | Header or indicator loses its name, nested glyph is announced twice, glyph distinctions disappear, tooltip becomes sole meaning, or row context duplicates status. |
|
|
263
264
|
| PR1-PR3 | Extend `Table.perf.test.tsx` with semantic/custom initial render and no-op update cases. | Stable rerender, mixed rows, and theme substitution. | Resolution adds measurement/resources, creates a parallel registry, or exceeds existing representative Table budgets. |
|
|
264
265
|
| Structural ownership | `scripts/check-knowledge.mjs`. | Colocated module, canonical filename/id, and parent backlink. | A missing, duplicate, orphaned, mis-parented, misnamed, or undiscovered record passes validation. |
|
|
265
266
|
| Public hygiene and scope | Prettier, `check:knowledge`, checker tests, `check:repo`, changed-file scan, and public-content scan. | Parent backlink plus one module specification; no Changeset. | Runtime/docs files, private context, invalid record shape, or unrelated changes enter this contract proposal. |
|