@astryxdesign/core 0.7.2-canary.22c592a → 0.7.2-canary.25548b9
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/Chat/ChatLayoutScrollButton.d.ts.map +1 -1
- package/dist/Chat/ChatLayoutScrollButton.js +1 -0
- package/package.json +3 -3
- package/src/Chat/ChatLayoutScrollButton.test.tsx +13 -0
- package/src/Chat/ChatLayoutScrollButton.tsx +4 -2
- package/src/Chat/__tests__/ChatLayoutScrollButton.a11y.chromium.spec.ts +64 -19
- package/src/ContextMenu/ContextMenu.test.tsx +6 -1
- package/src/SideNav/SideNav.spec.md +227 -0
- package/src/Toolbar/Toolbar.test.tsx +0 -8
- package/src/Toolbar/__tests__/Toolbar.a11y.chromium.spec.ts +163 -0
- package/src/Toolbar/__tests__/Toolbar.a11y.renders.tsx +63 -0
- package/src/Toolbar/__tests__/Toolbar.a11y.states.ts +142 -0
- package/src/Toolbar/__tests__/Toolbar.a11y.test.tsx +126 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ChatLayoutScrollButton.d.ts","sourceRoot":"","sources":["../../src/Chat/ChatLayoutScrollButton.tsx"],"names":[],"mappings":"AAIA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,MAAM,OAAO,CAAC;AAa1B,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,cAAc,CAAC;AAS5C,MAAM,WAAW,2BAA4B,SAAQ,IAAI,CACvD,SAAS,CAAC,cAAc,CAAC,EACzB,SAAS,CACV;IACC,GAAG,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,cAAc,CAAC,CAAC;IAChC,qCAAqC;IACrC,SAAS,EAAE,OAAO,CAAC;IACnB,iEAAiE;IACjE,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,qBAAqB;IACrB,OAAO,EAAE,MAAM,IAAI,CAAC;CACrB;
|
|
1
|
+
{"version":3,"file":"ChatLayoutScrollButton.d.ts","sourceRoot":"","sources":["../../src/Chat/ChatLayoutScrollButton.tsx"],"names":[],"mappings":"AAIA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,MAAM,OAAO,CAAC;AAa1B,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,cAAc,CAAC;AAS5C,MAAM,WAAW,2BAA4B,SAAQ,IAAI,CACvD,SAAS,CAAC,cAAc,CAAC,EACzB,SAAS,CACV;IACC,GAAG,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,cAAc,CAAC,CAAC;IAChC,qCAAqC;IACrC,SAAS,EAAE,OAAO,CAAC;IACnB,iEAAiE;IACjE,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,qBAAqB;IACrB,OAAO,EAAE,MAAM,IAAI,CAAC;CACrB;AAuED;;;;;;;GAOG;AACH,wBAAgB,sBAAsB,CAAC,EACrC,GAAG,EACH,SAAS,EACT,KAAK,EACL,OAAO,EACP,MAAM,EACN,SAAS,EACT,KAAK,EACL,GAAG,IAAI,EACR,EAAE,2BAA2B,qBAwC7B;yBAjDe,sBAAsB"}
|
|
@@ -89,6 +89,7 @@ export function ChatLayoutScrollButton({
|
|
|
89
89
|
...mergeProps(stylex.props(styles.wrapper, xstyle), className, style),
|
|
90
90
|
...rest,
|
|
91
91
|
children: /*#__PURE__*/_jsx("div", {
|
|
92
|
+
inert: !isVisible,
|
|
92
93
|
...mergeProps(themeProps('chat-layout-scroll-button'), {
|
|
93
94
|
0: {
|
|
94
95
|
className: "xawy1a7 xb3r6kr xjspbzw x1prclbq x14hfi27 x1ueg155 xrgg2z9 xlr8y92 x2rcmwa x12w9bfk xg01cxk x47corl xlshs6z x1jys5uj"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@astryxdesign/core",
|
|
3
|
-
"version": "0.7.2-canary.
|
|
3
|
+
"version": "0.7.2-canary.25548b9",
|
|
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.2-canary.
|
|
796
|
-
"@astryxdesign/cli": "0.7.2-canary.
|
|
795
|
+
"@astryxdesign/a11y-spec": "0.7.2-canary.25548b9",
|
|
796
|
+
"@astryxdesign/cli": "0.7.2-canary.25548b9",
|
|
797
797
|
"@babel/cli": "^7.29.7",
|
|
798
798
|
"@babel/core": "^7.29.7",
|
|
799
799
|
"@babel/preset-react": "^7.29.7",
|
|
@@ -70,6 +70,19 @@ describe('ChatLayoutScrollButton', () => {
|
|
|
70
70
|
).toHaveFocus();
|
|
71
71
|
});
|
|
72
72
|
|
|
73
|
+
it('makes the pill inert whenever the visible state turns off', () => {
|
|
74
|
+
const {container, rerender} = render(
|
|
75
|
+
<ChatLayoutScrollButton isVisible onClick={() => {}} />,
|
|
76
|
+
);
|
|
77
|
+
const pill = container.querySelector('.astryx-chat-layout-scroll-button');
|
|
78
|
+
|
|
79
|
+
expect(pill).not.toHaveAttribute('inert');
|
|
80
|
+
|
|
81
|
+
rerender(<ChatLayoutScrollButton isVisible={false} onClick={() => {}} />);
|
|
82
|
+
|
|
83
|
+
expect(pill).toHaveAttribute('inert');
|
|
84
|
+
});
|
|
85
|
+
|
|
73
86
|
it('is not keyboard reachable while hidden', async () => {
|
|
74
87
|
const user = userEvent.setup();
|
|
75
88
|
render(
|
|
@@ -86,8 +86,9 @@ const styles = stylex.create({
|
|
|
86
86
|
opacity: 0,
|
|
87
87
|
pointerEvents: 'none',
|
|
88
88
|
// The hidden pill paints nothing, so focus landing on it would have no
|
|
89
|
-
// visible indicator (WCAG 2.2 SC 2.4.7). `
|
|
90
|
-
//
|
|
89
|
+
// visible indicator (WCAG 2.2 SC 2.4.7). `visibility` removes it from
|
|
90
|
+
// sequential navigation after the fade; `inert` immediately prevents new
|
|
91
|
+
// focus and makes an already-focused descendant relinquish focus.
|
|
91
92
|
visibility: 'hidden',
|
|
92
93
|
maxWidth: sizeVars['--size-element-md'],
|
|
93
94
|
},
|
|
@@ -151,6 +152,7 @@ export function ChatLayoutScrollButton({
|
|
|
151
152
|
{...mergeProps(stylex.props(styles.wrapper, xstyle), className, style)}
|
|
152
153
|
{...rest}>
|
|
153
154
|
<div
|
|
155
|
+
inert={!isVisible}
|
|
154
156
|
{...mergeProps(
|
|
155
157
|
themeProps('chat-layout-scroll-button'),
|
|
156
158
|
stylex.props(
|
|
@@ -44,6 +44,7 @@ const STORY_ID = 'core-chatlayout--scroll-affordance-states';
|
|
|
44
44
|
* a stable handle for it.
|
|
45
45
|
*/
|
|
46
46
|
const PILL = '.astryx-chat-layout-scroll-button';
|
|
47
|
+
const AFFORDANCE = `${PILL} button`;
|
|
47
48
|
|
|
48
49
|
/** One observed tab stop, identified structurally rather than by its text. */
|
|
49
50
|
type FocusStop = {
|
|
@@ -126,7 +127,11 @@ test.afterAll(async () => {
|
|
|
126
127
|
|
|
127
128
|
async function openStory(
|
|
128
129
|
page: Page,
|
|
129
|
-
{
|
|
130
|
+
{
|
|
131
|
+
theme = 'neutral',
|
|
132
|
+
colorMode = 'light',
|
|
133
|
+
holdMotion = true,
|
|
134
|
+
}: {theme?: string; colorMode?: string; holdMotion?: boolean} = {},
|
|
130
135
|
): Promise<Locator> {
|
|
131
136
|
page.on('pageerror', error => pageErrors.push(String(error)));
|
|
132
137
|
browserVersion = page.context().browser()?.version() ?? 'unknown';
|
|
@@ -136,7 +141,9 @@ async function openStory(
|
|
|
136
141
|
);
|
|
137
142
|
const root = page.locator('.astryx-chat-layout');
|
|
138
143
|
await root.waitFor({state: 'visible'});
|
|
139
|
-
|
|
144
|
+
if (holdMotion) {
|
|
145
|
+
await holdMotionStill(page);
|
|
146
|
+
}
|
|
140
147
|
await page.evaluate(async () => document.fonts.ready);
|
|
141
148
|
return root;
|
|
142
149
|
}
|
|
@@ -169,21 +176,25 @@ async function pillVisibility(page: Page): Promise<string> {
|
|
|
169
176
|
}
|
|
170
177
|
|
|
171
178
|
/**
|
|
172
|
-
* Can the affordance take focus at all? This is the mechanism
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
* is used rather than a role query because a hidden button
|
|
176
|
-
* accessibility tree and a role query would not resolve it.
|
|
179
|
+
* Can the affordance take new focus at all? This is one half of the mechanism
|
|
180
|
+
* under test: `inert` refuses programmatic focus immediately, while delayed
|
|
181
|
+
* `visibility: hidden` removes the control from sequential navigation after the
|
|
182
|
+
* fade. A CSS locator is used rather than a role query because a hidden button
|
|
183
|
+
* is not in the accessibility tree and a role query would not resolve it.
|
|
177
184
|
*/
|
|
178
|
-
async function affordanceAcceptsFocus(
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
185
|
+
async function affordanceAcceptsFocus(
|
|
186
|
+
page: Page,
|
|
187
|
+
resetFocus: Locator,
|
|
188
|
+
): Promise<boolean> {
|
|
189
|
+
// Start outside the affordance so this probe proves a NEW focus transfer;
|
|
190
|
+
// equality alone would also be true when a button stayed active as it hid.
|
|
191
|
+
await resetFocus.focus();
|
|
192
|
+
return page.locator(AFFORDANCE).evaluate(element => {
|
|
193
|
+
// preventScroll: focusing must not move the scroll position, or the
|
|
194
|
+
// probe would change the very state the next assertion reads.
|
|
195
|
+
(element as HTMLElement).focus({preventScroll: true});
|
|
196
|
+
return document.activeElement === element;
|
|
197
|
+
});
|
|
187
198
|
}
|
|
188
199
|
|
|
189
200
|
/**
|
|
@@ -340,6 +351,36 @@ async function capture(
|
|
|
340
351
|
return receipt;
|
|
341
352
|
}
|
|
342
353
|
|
|
354
|
+
test('the hiding affordance releases focus before its fade finishes', async ({
|
|
355
|
+
page,
|
|
356
|
+
}) => {
|
|
357
|
+
const root = await openStory(page, {holdMotion: false});
|
|
358
|
+
const pill = page.locator(PILL);
|
|
359
|
+
const affordance = page.locator(AFFORDANCE);
|
|
360
|
+
|
|
361
|
+
await scrollToTop(root);
|
|
362
|
+
await expect(pill).toHaveCSS('visibility', 'visible');
|
|
363
|
+
await affordance.focus();
|
|
364
|
+
await expect(affordance).toBeFocused();
|
|
365
|
+
|
|
366
|
+
await page.keyboard.press('Enter');
|
|
367
|
+
await expect
|
|
368
|
+
.poll(
|
|
369
|
+
async () =>
|
|
370
|
+
affordance.evaluate(element => document.activeElement === element),
|
|
371
|
+
{timeout: 5000},
|
|
372
|
+
)
|
|
373
|
+
.toBe(false);
|
|
374
|
+
|
|
375
|
+
// `visibility` flips only after the fade. Focus leaving while it is still
|
|
376
|
+
// visible proves the immediate `inert` state rather than the delayed CSS.
|
|
377
|
+
await expect(pill).toHaveCSS('visibility', 'visible');
|
|
378
|
+
await expect
|
|
379
|
+
.poll(async () => distanceFromBottom(root), {timeout: 5000})
|
|
380
|
+
.toBeLessThan(2);
|
|
381
|
+
await expect(pill).toHaveCSS('visibility', 'hidden');
|
|
382
|
+
});
|
|
383
|
+
|
|
343
384
|
test('the scroll affordance is keyboard reachable exactly while it is visible', async ({
|
|
344
385
|
page,
|
|
345
386
|
}) => {
|
|
@@ -367,7 +408,7 @@ test('the scroll affordance is keyboard reachable exactly while it is visible',
|
|
|
367
408
|
expect(await distanceFromBottom(root)).toBeLessThan(2);
|
|
368
409
|
await expect(pill).toHaveCSS('visibility', 'hidden');
|
|
369
410
|
expect(
|
|
370
|
-
await affordanceAcceptsFocus(page),
|
|
411
|
+
await affordanceAcceptsFocus(page, before),
|
|
371
412
|
'a control that paints nothing must refuse focus (WCAG 2.2 SC 2.4.7)',
|
|
372
413
|
).toBe(false);
|
|
373
414
|
const restFrame = await capture(page, 'rest-hidden', {
|
|
@@ -384,7 +425,7 @@ test('the scroll affordance is keyboard reachable exactly while it is visible',
|
|
|
384
425
|
await expect(pill).toHaveCSS('visibility', 'visible');
|
|
385
426
|
expect(await distanceFromBottom(root)).toBeGreaterThan(100);
|
|
386
427
|
expect(
|
|
387
|
-
await affordanceAcceptsFocus(page),
|
|
428
|
+
await affordanceAcceptsFocus(page, before),
|
|
388
429
|
'the visible affordance must keep its keyboard access',
|
|
389
430
|
).toBe(true);
|
|
390
431
|
await expect(
|
|
@@ -415,8 +456,12 @@ test('the scroll affordance is keyboard reachable exactly while it is visible',
|
|
|
415
456
|
.poll(async () => distanceFromBottom(root), {timeout: 5000})
|
|
416
457
|
.toBeLessThan(2);
|
|
417
458
|
await expect(pill).toHaveCSS('visibility', 'hidden');
|
|
459
|
+
await expect(
|
|
460
|
+
page.locator(AFFORDANCE),
|
|
461
|
+
'the affordance must relinquish focus when it hides',
|
|
462
|
+
).not.toBeFocused();
|
|
418
463
|
expect(
|
|
419
|
-
await affordanceAcceptsFocus(page),
|
|
464
|
+
await affordanceAcceptsFocus(page, before),
|
|
420
465
|
'after returning to the bottom the affordance must refuse focus again',
|
|
421
466
|
).toBe(false);
|
|
422
467
|
const returnedFrame = await capture(page, 'returned-hidden', {
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import {useRef, useState} from 'react';
|
|
12
12
|
import {createPortal} from 'react-dom';
|
|
13
|
-
import {describe, it, expect, vi, beforeEach} from 'vitest';
|
|
13
|
+
import {describe, it, expect, vi, beforeAll, beforeEach} from 'vitest';
|
|
14
14
|
import {render, screen, fireEvent, act, waitFor} from '@testing-library/react';
|
|
15
15
|
import userEvent from '@testing-library/user-event';
|
|
16
16
|
import {readFileSync} from 'node:fs';
|
|
@@ -26,6 +26,11 @@ import {
|
|
|
26
26
|
import {DropdownMenuItem} from '../DropdownMenu/DropdownMenuItem';
|
|
27
27
|
import {Divider} from '../Divider';
|
|
28
28
|
import {useLayerDismissal} from '../Layer/useLayerDismissal';
|
|
29
|
+
import {warmMenuBottomSheet} from '../DropdownMenu/__tests__/warmMenuBottomSheet';
|
|
30
|
+
|
|
31
|
+
// The touch sheet is loaded lazily; resolve its modules once up front, so
|
|
32
|
+
// ContextMenu's own lazy sheet renders within the cases' waitFor.
|
|
33
|
+
beforeAll(warmMenuBottomSheet, 20000);
|
|
29
34
|
|
|
30
35
|
function NestedDismissibleLayer({onDismiss}: {onDismiss: () => void}) {
|
|
31
36
|
const [isOpen, setIsOpen] = useState(true);
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
---
|
|
2
|
+
schema_version: 3
|
|
3
|
+
template_version: 7
|
|
4
|
+
kind: component
|
|
5
|
+
id: component:SideNav
|
|
6
|
+
authority: current
|
|
7
|
+
archive_reason: null
|
|
8
|
+
superseded_by: null
|
|
9
|
+
approved_by: cixzhang
|
|
10
|
+
approved_at: 2026-10-16
|
|
11
|
+
owners: [cixzhang]
|
|
12
|
+
review_triggers: [layout, behavior, accessibility]
|
|
13
|
+
verified_by:
|
|
14
|
+
[
|
|
15
|
+
packages/core/src/SideNav/SideNav.test.tsx,
|
|
16
|
+
apps/storybook/stories/SideNav.stories.tsx,
|
|
17
|
+
scripts/check-knowledge.mjs,
|
|
18
|
+
]
|
|
19
|
+
modules: []
|
|
20
|
+
families: [family:navigation-destinations]
|
|
21
|
+
design_specs: []
|
|
22
|
+
architecture:
|
|
23
|
+
[
|
|
24
|
+
architecture:component-theming-surface,
|
|
25
|
+
architecture:interaction-modality,
|
|
26
|
+
architecture:internationalization,
|
|
27
|
+
architecture:public-component-api,
|
|
28
|
+
]
|
|
29
|
+
contributing: []
|
|
30
|
+
system_specs: [spec:AST-017/FR3]
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
# SideNav component contract
|
|
34
|
+
|
|
35
|
+
<!-- Describe the system, not the project: present tense, what it does. No proposals, history, pull requests, or research in the record; see docs/contributing/spec-writing.md and report its rubric results in the pull request. -->
|
|
36
|
+
|
|
37
|
+
This record governs how nested SideNavItem rows are drawn: their width, where
|
|
38
|
+
their nesting indent sits, and what the indent area does. It covers SideNav,
|
|
39
|
+
SideNavSection, and SideNavItem only as far as that geometry depends on them.
|
|
40
|
+
SideNavItem's link and destination safety is governed by
|
|
41
|
+
`family:navigation-destinations`; other SideNav behavior remains described by
|
|
42
|
+
its source, tests, and consumer docs until a record claims it.
|
|
43
|
+
|
|
44
|
+
## Contract at a glance
|
|
45
|
+
|
|
46
|
+
| Area | Contract |
|
|
47
|
+
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
48
|
+
| Public contract | None. No prop, value, default, or theme target changes. |
|
|
49
|
+
| Behavior | Every SideNavItem row spans the full width of its nav list at any depth. Depth shows as inline-start indent inside the row: one `--spacing-6` step per level, added to the row's own padding. |
|
|
50
|
+
| End-user impact | A person scanning a nested nav sees hover, pressed, selected, and focus fills that line up with the top-level rows, and can click a nested row's indent to activate it, as they would its label. |
|
|
51
|
+
| Builder impact | No new choice or prop. Icons and labels keep their per-depth position. Content placed directly inside a SideNavItem that is not itself a SideNavItem sits at the nav list edge, because the nested group adds no inset; no supported setting insets it. |
|
|
52
|
+
| Compatibility/readiness | Nonbreaking change of visual direction (`spec:AST-017/FR3`). The collapsed rail and top-level rows are unchanged. Authority is current; implementation may be staged. |
|
|
53
|
+
| Review checks | Reject a nested row narrower than a top-level row, an indent drawn outside the row, an indent area that shows feedback but does not activate, a clipped focus ring, or an indent in the flyout's top level. |
|
|
54
|
+
| Governing rules | DD1 and FR3–FR5 below; `family:navigation-destinations` for destinations; `architecture:internationalization` for logical direction. |
|
|
55
|
+
|
|
56
|
+
This table is a review projection; the body below is authoritative.
|
|
57
|
+
|
|
58
|
+
## Intent
|
|
59
|
+
|
|
60
|
+
SideNav is the vertical navigation region of an app. SideNavItem rows can nest
|
|
61
|
+
to show a hierarchy of destinations. Nesting must read as one list of equal
|
|
62
|
+
rows, so the eye compares labels and states across depths rather than seeing a
|
|
63
|
+
second, narrower kind of row. SideNav owns this because only SideNavItem knows
|
|
64
|
+
a row's depth and its row padding.
|
|
65
|
+
|
|
66
|
+
## Compatibility and migration
|
|
67
|
+
|
|
68
|
+
- Released default preserved: `no`; nested rows change from inset to full width.
|
|
69
|
+
Top-level rows and the collapsed rail are unchanged.
|
|
70
|
+
- Compatibility class: nonbreaking appearance change of visual direction under
|
|
71
|
+
`spec:AST-017/FR3`; no public API, accepted input, or accessibility semantic
|
|
72
|
+
changes.
|
|
73
|
+
- Controlled/uncontrolled behavior: unchanged.
|
|
74
|
+
- Migration decision: none; there is no supported way to keep the inset look.
|
|
75
|
+
|
|
76
|
+
Consumer migration instructions belong in consumer docs and release notes.
|
|
77
|
+
|
|
78
|
+
## Ownership boundary
|
|
79
|
+
|
|
80
|
+
**Owns**
|
|
81
|
+
|
|
82
|
+
- The width of every expanded SideNavItem row, at every depth.
|
|
83
|
+
- Where a nested row's indent sits, its step, and how it adds to the row's
|
|
84
|
+
padding.
|
|
85
|
+
- What the indent area paints and what a click on it does.
|
|
86
|
+
- Whether the nested group clips its rows.
|
|
87
|
+
|
|
88
|
+
**Does not own / non-goals**
|
|
89
|
+
|
|
90
|
+
- Destination acceptance, blocking, and router handoff — owned by
|
|
91
|
+
`family:navigation-destinations`.
|
|
92
|
+
- The collapsed item's tooltip and how it attaches — outside this record.
|
|
93
|
+
- Exact fill colors, radius, row height, and row padding per size — owned by
|
|
94
|
+
the shared nav item styles and the `side-nav-item` theme target.
|
|
95
|
+
- Layout of caller content placed directly inside a SideNavItem that is not a
|
|
96
|
+
SideNavItem — owned by the product callsite.
|
|
97
|
+
- SideNavHeading, SideNavCollapseButton, and the SideNav header, footer, and
|
|
98
|
+
resize zones.
|
|
99
|
+
|
|
100
|
+
## Public concepts
|
|
101
|
+
|
|
102
|
+
| Concept | Closed values or states | Meaning | Availability by variant/orientation/state | Default | Owner | Stability | Invalid-value behavior |
|
|
103
|
+
| ------------- | ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | ----------------------------------------- | ------- | ------------------- | --------- | ---------------------- |
|
|
104
|
+
| Nesting depth | `0`, `1`, `2`, … (derived, not a prop) | The number of SideNavItem ancestors of a row within the same list. A collapsed rail's flyout restarts at 0. | Expanded rail; mobile drawer; flyout | `0` | `component:SideNav` | stable | Not caller-settable |
|
|
105
|
+
| Row size | `sm`, `md`, `lg` (existing SideNavItem `size`) | Sets the row's height and base inline padding; the indent adds to that padding. | Every row | `md` | `component:SideNav` | stable | Rejected by the type |
|
|
106
|
+
|
|
107
|
+
## Behavioral and layout contract
|
|
108
|
+
|
|
109
|
+
| ID | Invariant | Basis | Draft review state |
|
|
110
|
+
| --- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | -------------------------------------------- |
|
|
111
|
+
| FR1 | SideNav renders its navigation children in a scrollable list, which may be grouped by SideNavSection. A SideNavItem with children renders them, after its own row, in a group labelled by the parent's label; unless `collapsible` is `false`, the group expands and collapses and is hidden from assistive technology and inert while collapsed. | Current source, docs, and tests | Current behavior |
|
|
112
|
+
| FR2 | In a collapsed rail, a SideNavItem without an icon is not rendered; a leaf item renders icon-only, named by a non-blank caller `aria-label` or else its label; an item with children renders an icon-only trigger that opens a flyout listing its children as the flyout's top level. | Current source, docs, and tests | Current behavior |
|
|
113
|
+
| FR3 | In an expanded rail, in a flyout, and in the mobile navigation drawer, every SideNavItem row MUST span the full inline size of its list at any depth. A row at depth `n` MUST start its content `n` × `--spacing-6` beyond the row's own inline-start padding for its size, inside the row; the nested group MUST add no inset. A flyout's top level is depth 0. | `component:SideNav/DEC-1` | Settled intent; implementation may be staged |
|
|
114
|
+
| FR4 | The indent area MUST be part of the row's target: a click there, including a modified or middle click on a link row, does what the same click on the row's primary element does. When the row also carries an independent expand toggle or `actions`, the indent belongs to the primary link or button, and the toggle and actions keep their own activation at the row's trailing edge. | `component:SideNav/DEC-1`; `family:navigation-destinations` | Settled intent; implementation may be staged |
|
|
115
|
+
| FR5 | An expanded nested group MUST NOT clip its rows once it has finished opening, so a nested row's focus ring paints in full. The group MAY clip while collapsed and while it opens or closes. | `component:SideNav/DEC-1` | Settled intent; implementation may be staged |
|
|
116
|
+
|
|
117
|
+
### Allowed variation
|
|
118
|
+
|
|
119
|
+
- **AV1 — Depth and size.** Any depth and any row size may appear; FR3 holds for
|
|
120
|
+
each row from its own size's padding.
|
|
121
|
+
- **AV2 — Theme tokens.** A theme that changes `--spacing-6` or the row padding
|
|
122
|
+
tokens changes the step or the base with them.
|
|
123
|
+
- **AV3 — Row implementation.** The element that carries the indent may change
|
|
124
|
+
while the painted row stays full width and the indent stays clickable.
|
|
125
|
+
|
|
126
|
+
### Representative states
|
|
127
|
+
|
|
128
|
+
| State | Required invariant | Allowed variation |
|
|
129
|
+
| ------------------------------------- | ----------------------------------------------------------------------------------------- | ------------------------------- |
|
|
130
|
+
| Depth 1–3, `md` | Row width equals a top-level row's; content starts one step further in per level. | Labels, icons, selection. |
|
|
131
|
+
| `sm` and `lg` nested rows | Indent adds to that size's base padding. | Row height by size. |
|
|
132
|
+
| Nested row with own link and children | Full-width row; indent is part of the link; the expand toggle stays at the trailing edge. | Collapsed or expanded children. |
|
|
133
|
+
| Nested row with `actions` | Full-width row; indent is part of the primary element; actions stay at the trailing edge. | Action content. |
|
|
134
|
+
| Keyboard focus on a nested row | The focus ring paints in full around the row. | Ring style by theme. |
|
|
135
|
+
| Right-to-left | Indent sits at the inline start (right); same widths. | Writing direction. |
|
|
136
|
+
| Collapsed rail | Icon-only items; the flyout's top level has no indent; deeper flyout levels indent. | Flyout content. |
|
|
137
|
+
|
|
138
|
+
### Transformation and precedence order
|
|
139
|
+
|
|
140
|
+
- **ORD1 — Row inline-start padding.** The row's size padding, then plus depth ×
|
|
141
|
+
`--spacing-6`. A caller's `style` on a nested item composes after the indent in the
|
|
142
|
+
shared order (`architecture:public-component-api`): properties the caller does
|
|
143
|
+
not set keep the indent, and a caller-set inline-start padding replaces it.
|
|
144
|
+
|
|
145
|
+
### Performance and resources
|
|
146
|
+
|
|
147
|
+
- **PR1 — No measurement.** Depth and indent come from composition; no
|
|
148
|
+
measurement, observer, or extra render pass is added.
|
|
149
|
+
|
|
150
|
+
## Accessibility contract
|
|
151
|
+
|
|
152
|
+
- **AR1 — Presentation only.** Width and indent MUST NOT change any row's role,
|
|
153
|
+
accessible name, state, DOM order, or tab order.
|
|
154
|
+
- **AR2 — Visible focus.** A nested row's focus indicator MUST paint in full at
|
|
155
|
+
every depth (FR5).
|
|
156
|
+
|
|
157
|
+
## Design relationships
|
|
158
|
+
|
|
159
|
+
| Anatomy or state | Design requirement | Representation authority | Hierarchy role | Component contract |
|
|
160
|
+
| ---------------- | ------------------------------------------------------------------ | ------------------------ | -------------- | ------------------ |
|
|
161
|
+
| Navigation items | Nested rows read as equal rows of one list; depth reads as indent. | Owner choice (DD1) | Prominent | FR3–FR5 |
|
|
162
|
+
|
|
163
|
+
### Design decisions
|
|
164
|
+
|
|
165
|
+
<!-- design-decisions:v1 -->
|
|
166
|
+
|
|
167
|
+
| ID | Decision | Intent or reason | Applies to | Allowed variation |
|
|
168
|
+
| --- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
|
|
169
|
+
| DD1 | Every SideNavItem row spans the full width of its nav list at any depth; nesting shows as indent inside the row, ahead of its content. Hover, pressed, selected, and focus paint the whole row, indent included. | Fills line up across depths, so the nav reads as one list of equal rows while labels keep their per-level alignment; the indent belongs to the row's primary target rather than sitting beside it as a gap. | SideNavItem rows at every depth, at rest and through hover, pressed, selected, disabled, and focus | Themes restyle fill, radius, and padding through the `side-nav-item` target; the step follows `--spacing-6`. |
|
|
170
|
+
|
|
171
|
+
## Family and system relationships
|
|
172
|
+
|
|
173
|
+
- `family:navigation-destinations` owns the accept/block result for a
|
|
174
|
+
SideNavItem `href`; the indent area activates the same destination the primary
|
|
175
|
+
element does (FR4).
|
|
176
|
+
- `architecture:internationalization` owns logical direction; the indent is
|
|
177
|
+
inline-start and mirrors under right-to-left.
|
|
178
|
+
- `architecture:interaction-modality` owns shared focus-visible behavior; FR5
|
|
179
|
+
keeps the ring visible at every depth.
|
|
180
|
+
- `architecture:component-theming-surface` owns the `side-nav-item` target;
|
|
181
|
+
this record adds no target or state.
|
|
182
|
+
- `architecture:public-component-api` owns how caller styling inputs compose;
|
|
183
|
+
ORD1 places the indent before a caller's `style`.
|
|
184
|
+
- `spec:AST-017/FR3` classifies the change as a nonbreaking change of visual
|
|
185
|
+
direction.
|
|
186
|
+
- `component:TreeList` DD1–DD3 record the same row geometry for TreeList. They
|
|
187
|
+
are precedent, not authority here; each component owns its own rows.
|
|
188
|
+
|
|
189
|
+
## Verification map
|
|
190
|
+
|
|
191
|
+
| Contract | Verification | Representative states | Mutation or failure expectation | Audit section |
|
|
192
|
+
| --------- | ------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------ |
|
|
193
|
+
| FR1, FR2 | `SideNav.test.tsx` nesting, collapsible, and collapsed-rail suites | Nested, collapsible, collapsed rail, flyout | Removing the labelled group, its collapsed inert state, or the collapsed icon-only and flyout paths fails. | `audit:SideNav/anatomy` |
|
|
194
|
+
| FR3, ORD1 | Focused `SideNav.test.tsx` indent coverage, required with implementation | Depth 1 and 2; `sm` and `md`; split and actions rows; caller `style`; flyout | A group inset, a missing or wrong indent, a dropped caller `style`, an indent that overrides a caller-set inline-start padding, or an indented flyout top level fails. | `audit:SideNav/layout` |
|
|
195
|
+
| FR3, DD1 | Real-browser geometry over the SideNav nested stories | Depths 1–3; `sm`, `md`, `lg`; light and dark; right-to-left; mobile drawer | A nested row's box narrower than a top-level row's, or a label moved from its per-depth position, fails. | `audit:SideNav/layout` |
|
|
196
|
+
| FR4 | Real-browser click in the indent area of plain, split, and actions rows | Link and button rows; plain, modified, and middle clicks; nested rows with toggle and actions | A click in the indent that does nothing or differs from the same click on the primary element, or a toggle or action that activates the row, fails. | `audit:SideNav/behavior` |
|
|
197
|
+
| FR5, AR2 | `SideNav.test.tsx` group clipping case and real-browser keyboard focus on nested rows | Only-child and last nested rows; after opening; collapsed | A focus ring cut at any edge of an expanded group, or an expanded group that still clips, fails. | `audit:SideNav/a11y` |
|
|
198
|
+
| FR2, FR3 | Real-browser collapsed rail and flyout | Collapsed rail with nested children | Any change to the collapsed rail, or an indent on the flyout's top level, fails. | `audit:SideNav/layout` |
|
|
199
|
+
| AR1 | Accessibility-tree comparison of nested rows | Plain, split, and actions rows | Any change in role, name, state, or order fails. | `audit:SideNav/a11y` |
|
|
200
|
+
|
|
201
|
+
## Decision log
|
|
202
|
+
|
|
203
|
+
<!-- Record the boundary or requirement, not how it was reached. A rejected alternative is at most one line here, kept only when it is consequential and likely to recur. -->
|
|
204
|
+
|
|
205
|
+
### DEC-1 — Nested rows are full width; depth is indent inside the row
|
|
206
|
+
|
|
207
|
+
**Reference:** `component:SideNav/DEC-1`
|
|
208
|
+
**Decider:** cixzhang, 2026-10-16
|
|
209
|
+
|
|
210
|
+
A nested row that starts at its indent paints its fills from a different edge
|
|
211
|
+
than the rows above it, so it reads as a different, narrower component. A row that
|
|
212
|
+
spans the full width with its indent inside lines up every fill and keeps each
|
|
213
|
+
label at its per-depth position. The indent is part of the row's primary
|
|
214
|
+
target, so it acts as well as lights up. Full-width rows reach the nested group's
|
|
215
|
+
edges, so an open group must not clip them, or their focus ring is cut off.
|
|
216
|
+
|
|
217
|
+
Rejected: indenting the nested group from outside the rows, because fills then
|
|
218
|
+
start at a different edge per depth.
|
|
219
|
+
|
|
220
|
+
## Open questions
|
|
221
|
+
|
|
222
|
+
None.
|
|
223
|
+
|
|
224
|
+
## Content boundary
|
|
225
|
+
|
|
226
|
+
This file does not duplicate consumer prop tables/examples, current audit
|
|
227
|
+
results, implementation steps, or family/system rules. It links to their owners.
|
|
@@ -140,14 +140,6 @@ describe('Toolbar', () => {
|
|
|
140
140
|
);
|
|
141
141
|
});
|
|
142
142
|
|
|
143
|
-
it('sets aria-orientation to vertical', () => {
|
|
144
|
-
render(<Toolbar label="Actions" orientation="vertical" />);
|
|
145
|
-
expect(screen.getByRole('toolbar')).toHaveAttribute(
|
|
146
|
-
'aria-orientation',
|
|
147
|
-
'vertical',
|
|
148
|
-
);
|
|
149
|
-
});
|
|
150
|
-
|
|
151
143
|
it('reflects the size', () => {
|
|
152
144
|
render(<Toolbar label="Actions" size="sm" />);
|
|
153
145
|
const toolbar = screen.getByRole('toolbar');
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file Toolbar.a11y.chromium.spec.ts
|
|
5
|
+
* @input Built toolbar stories and TOOLBAR_PATTERN in real Chromium
|
|
6
|
+
* @output Browser evidence for every toolbar expectation across Toolbar's representative states, and a paint check that each state's controls sit on the axis and in the direction it declares
|
|
7
|
+
* @position Chromium lane of the Toolbar binding
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
expect,
|
|
12
|
+
test,
|
|
13
|
+
type CDPSession,
|
|
14
|
+
type Locator,
|
|
15
|
+
type Page,
|
|
16
|
+
} from '@playwright/test';
|
|
17
|
+
import {
|
|
18
|
+
TOOLBAR_PATTERN,
|
|
19
|
+
blockingResults,
|
|
20
|
+
checkAccessibilitySpec,
|
|
21
|
+
formatFailures,
|
|
22
|
+
neverExercised,
|
|
23
|
+
summarize,
|
|
24
|
+
type BindingResult,
|
|
25
|
+
} from '@astryxdesign/a11y-spec';
|
|
26
|
+
import {
|
|
27
|
+
createChromiumHarness,
|
|
28
|
+
holdMotionStill,
|
|
29
|
+
} from '@astryxdesign/a11y-spec/chromium';
|
|
30
|
+
import {
|
|
31
|
+
DEFAULT_STORYBOOK_DIR,
|
|
32
|
+
serveStorybook,
|
|
33
|
+
type StaticServer,
|
|
34
|
+
} from '@astryxdesign/a11y-spec/storybook';
|
|
35
|
+
import {
|
|
36
|
+
TOOLBAR_BINDING_STATES,
|
|
37
|
+
type ToolbarBindingState,
|
|
38
|
+
} from './Toolbar.a11y.states';
|
|
39
|
+
|
|
40
|
+
let storybook: StaticServer;
|
|
41
|
+
|
|
42
|
+
test.beforeAll(async () => {
|
|
43
|
+
storybook = await serveStorybook(
|
|
44
|
+
process.env.ASTRYX_STORYBOOK_DIR ?? DEFAULT_STORYBOOK_DIR,
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test.afterAll(async () => {
|
|
49
|
+
await storybook?.close();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
function storyUrl(storyId: string): string {
|
|
53
|
+
return `${storybook.origin}/iframe.html?id=${storyId}&viewMode=story`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function mountState(
|
|
57
|
+
page: Page,
|
|
58
|
+
state: ToolbarBindingState,
|
|
59
|
+
): Promise<{subject: Locator; related: Record<string, Locator>}> {
|
|
60
|
+
await page.goto(storyUrl(state.storyId), {waitUntil: 'load'});
|
|
61
|
+
await holdMotionStill(page);
|
|
62
|
+
const root = page.locator('#storybook-root');
|
|
63
|
+
const subject = root.getByRole('toolbar', {name: state.label, exact: true});
|
|
64
|
+
await expect(subject).toBeVisible();
|
|
65
|
+
const related: Record<string, Locator> = {
|
|
66
|
+
before: root.getByRole('button', {
|
|
67
|
+
name: 'Before the toolbar',
|
|
68
|
+
exact: true,
|
|
69
|
+
}),
|
|
70
|
+
after: root.getByRole('button', {name: 'After the toolbar', exact: true}),
|
|
71
|
+
};
|
|
72
|
+
for (const [relation, name] of Object.entries(state.controls)) {
|
|
73
|
+
related[relation] = subject.getByRole('button', {name, exact: true});
|
|
74
|
+
}
|
|
75
|
+
return {subject, related};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function runState(
|
|
79
|
+
page: Page,
|
|
80
|
+
cdp: CDPSession,
|
|
81
|
+
state: ToolbarBindingState,
|
|
82
|
+
): Promise<BindingResult> {
|
|
83
|
+
return checkAccessibilitySpec({
|
|
84
|
+
spec: TOOLBAR_PATTERN,
|
|
85
|
+
binding: 'Toolbar',
|
|
86
|
+
state: state.id,
|
|
87
|
+
facts: state.facts,
|
|
88
|
+
mount: async () => {
|
|
89
|
+
const {subject, related} = await mountState(page, state);
|
|
90
|
+
return createChromiumHarness({page, cdp, subject, related});
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
test('every toolbar expectation is exercised by a Toolbar state', async ({
|
|
96
|
+
page,
|
|
97
|
+
}) => {
|
|
98
|
+
const cdp = await page.context().newCDPSession(page);
|
|
99
|
+
const results: BindingResult[] = [];
|
|
100
|
+
for (const state of TOOLBAR_BINDING_STATES) {
|
|
101
|
+
results.push(await runState(page, cdp, state));
|
|
102
|
+
}
|
|
103
|
+
expect(neverExercised(results)).toEqual([]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The contract reads `orientation` and `direction` as facts the binding
|
|
108
|
+
* declares. This checks the painted page agrees: the second control sits
|
|
109
|
+
* below the first in a vertical state, and after it in reading order — to
|
|
110
|
+
* its right, or its left under right-to-left — in a horizontal one.
|
|
111
|
+
*/
|
|
112
|
+
for (const state of TOOLBAR_BINDING_STATES) {
|
|
113
|
+
test(`Toolbar [${state.id}] paints its controls on the declared axis and direction`, async ({
|
|
114
|
+
page,
|
|
115
|
+
}) => {
|
|
116
|
+
const {related} = await mountState(page, state);
|
|
117
|
+
const first = await related['first-control'].boundingBox();
|
|
118
|
+
const second = await related['second-control'].boundingBox();
|
|
119
|
+
if (first == null || second == null) {
|
|
120
|
+
throw new Error(`state "${state.id}" has an unpainted control`);
|
|
121
|
+
}
|
|
122
|
+
const firstMiddle = {
|
|
123
|
+
x: first.x + first.width / 2,
|
|
124
|
+
y: first.y + first.height / 2,
|
|
125
|
+
};
|
|
126
|
+
const secondMiddle = {
|
|
127
|
+
x: second.x + second.width / 2,
|
|
128
|
+
y: second.y + second.height / 2,
|
|
129
|
+
};
|
|
130
|
+
if (state.facts.orientation === 'vertical') {
|
|
131
|
+
expect(second.y, 'second control below the first').toBeGreaterThanOrEqual(
|
|
132
|
+
first.y + first.height,
|
|
133
|
+
);
|
|
134
|
+
} else {
|
|
135
|
+
expect(
|
|
136
|
+
Math.abs(secondMiddle.y - firstMiddle.y),
|
|
137
|
+
'controls share a row',
|
|
138
|
+
).toBeLessThan(first.height / 2);
|
|
139
|
+
if (state.facts.direction === 'rtl') {
|
|
140
|
+
expect(secondMiddle.x, 'second control left of the first').toBeLessThan(
|
|
141
|
+
firstMiddle.x,
|
|
142
|
+
);
|
|
143
|
+
} else {
|
|
144
|
+
expect(
|
|
145
|
+
secondMiddle.x,
|
|
146
|
+
'second control right of the first',
|
|
147
|
+
).toBeGreaterThan(firstMiddle.x);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
for (const state of TOOLBAR_BINDING_STATES) {
|
|
154
|
+
test(`Toolbar [${state.id}] — ${state.summary}`, async ({page}) => {
|
|
155
|
+
const cdp = await page.context().newCDPSession(page);
|
|
156
|
+
const result = await runState(page, cdp, state);
|
|
157
|
+
const report = summarize(TOOLBAR_PATTERN, [result]);
|
|
158
|
+
// Required rows block (AST-020 FR9).
|
|
159
|
+
expect(formatFailures(blockingResults([result]))).toBe('');
|
|
160
|
+
expect(report.unrunLayers).toEqual([]);
|
|
161
|
+
expect(report.counts.unexpectedPass).toBe(0);
|
|
162
|
+
});
|
|
163
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
'use client';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @file Toolbar.a11y.renders.tsx
|
|
7
|
+
* @input One toolbar binding state from Toolbar.a11y.states.ts
|
|
8
|
+
* @output The React tree every lane renders for that state (the jsdom test directly, the story through Storybook): a page button before the Toolbar, the Toolbar, and a page button after it
|
|
9
|
+
* @position Shared fixture for the Toolbar binding
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type {ReactElement} from 'react';
|
|
13
|
+
import {Button} from '../../Button/Button';
|
|
14
|
+
import {VStack} from '../../VStack/VStack';
|
|
15
|
+
import {Toolbar} from '../Toolbar';
|
|
16
|
+
import type {ToolbarBindingState, ToolbarControl} from './Toolbar.a11y.states';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* One slot's controls. A slot lays its children out in a row, so a vertical
|
|
20
|
+
* toolbar's caller stacks them, as a product toolbar would, to arrange the
|
|
21
|
+
* controls on the axis the state declares.
|
|
22
|
+
*/
|
|
23
|
+
function controls(
|
|
24
|
+
list: ReadonlyArray<ToolbarControl>,
|
|
25
|
+
orientation: ToolbarBindingState['facts']['orientation'],
|
|
26
|
+
): ReactElement | undefined {
|
|
27
|
+
if (list.length === 0) {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
const buttons = list.map(control => (
|
|
31
|
+
<Button
|
|
32
|
+
key={control.label}
|
|
33
|
+
label={control.label}
|
|
34
|
+
variant="ghost"
|
|
35
|
+
isDisabled={control.isDisabled}
|
|
36
|
+
/>
|
|
37
|
+
));
|
|
38
|
+
return orientation === 'vertical' ? (
|
|
39
|
+
<VStack gap={1}>{buttons}</VStack>
|
|
40
|
+
) : (
|
|
41
|
+
<>{buttons}</>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function renderToolbarState(
|
|
46
|
+
state: Pick<
|
|
47
|
+
ToolbarBindingState,
|
|
48
|
+
'label' | 'startControls' | 'endControls' | 'facts'
|
|
49
|
+
>,
|
|
50
|
+
): ReactElement {
|
|
51
|
+
return (
|
|
52
|
+
<div dir={state.facts.direction} style={{padding: 24, maxWidth: 640}}>
|
|
53
|
+
<button type="button">Before the toolbar</button>
|
|
54
|
+
<Toolbar
|
|
55
|
+
label={state.label}
|
|
56
|
+
orientation={state.facts.orientation}
|
|
57
|
+
startContent={controls(state.startControls, state.facts.orientation)}
|
|
58
|
+
endContent={controls(state.endControls, state.facts.orientation)}
|
|
59
|
+
/>
|
|
60
|
+
<button type="button">After the toolbar</button>
|
|
61
|
+
</div>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file Toolbar.a11y.states.ts
|
|
5
|
+
* @input Toolbar's representative states and the facts TOOLBAR_PATTERN reads
|
|
6
|
+
* @output TOOLBAR_BINDING_STATES and the explicit ownership exclusions for this binding
|
|
7
|
+
* @position Binding inventory shared by the jsdom test and the Chromium spec (Toolbar under spec:AST-020 FR15)
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type {ToolbarStateFacts} from '@astryxdesign/a11y-spec';
|
|
11
|
+
|
|
12
|
+
export interface ToolbarControl {
|
|
13
|
+
readonly label: string;
|
|
14
|
+
readonly isDisabled?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ToolbarBindingState {
|
|
18
|
+
readonly id: string;
|
|
19
|
+
readonly summary: string;
|
|
20
|
+
readonly storyId: string;
|
|
21
|
+
/** The Toolbar `label`, which names the toolbar. */
|
|
22
|
+
readonly label: string;
|
|
23
|
+
/** Controls in the start slot, in order. */
|
|
24
|
+
readonly startControls: ReadonlyArray<ToolbarControl>;
|
|
25
|
+
/** Controls in the end slot, in order. */
|
|
26
|
+
readonly endControls: ReadonlyArray<ToolbarControl>;
|
|
27
|
+
/** Relation name → accessible name of the control it designates. */
|
|
28
|
+
readonly controls: Readonly<
|
|
29
|
+
Record<'first-control' | 'second-control' | 'last-control', string>
|
|
30
|
+
>;
|
|
31
|
+
readonly facts: ToolbarStateFacts;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const story = (id: string) => `a11y-toolbar--${id}`;
|
|
35
|
+
|
|
36
|
+
const FORMATTING = [
|
|
37
|
+
{label: 'Bold'},
|
|
38
|
+
{label: 'Italic'},
|
|
39
|
+
{label: 'Underline'},
|
|
40
|
+
] as const;
|
|
41
|
+
|
|
42
|
+
const FORMATTING_CONTROLS = {
|
|
43
|
+
'first-control': 'Bold',
|
|
44
|
+
'second-control': 'Italic',
|
|
45
|
+
'last-control': 'Settings',
|
|
46
|
+
} as const;
|
|
47
|
+
|
|
48
|
+
const HORIZONTAL_LTR: ToolbarStateFacts = {
|
|
49
|
+
orientation: 'horizontal',
|
|
50
|
+
direction: 'ltr',
|
|
51
|
+
wraps: true,
|
|
52
|
+
homeEnd: true,
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export const TOOLBAR_BINDING_STATES = [
|
|
56
|
+
{
|
|
57
|
+
id: 'horizontal',
|
|
58
|
+
summary:
|
|
59
|
+
'a named horizontal Toolbar with three formatting buttons in the start slot and Settings in the end slot',
|
|
60
|
+
storyId: story('horizontal'),
|
|
61
|
+
label: 'Text formatting',
|
|
62
|
+
startControls: FORMATTING,
|
|
63
|
+
endControls: [{label: 'Settings'}],
|
|
64
|
+
controls: FORMATTING_CONTROLS,
|
|
65
|
+
facts: HORIZONTAL_LTR,
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
id: 'horizontal-rtl',
|
|
69
|
+
summary:
|
|
70
|
+
'the same Toolbar inside a right-to-left container, where ArrowLeft moves to the next control',
|
|
71
|
+
storyId: story('horizontal-rtl'),
|
|
72
|
+
label: 'Text formatting',
|
|
73
|
+
startControls: FORMATTING,
|
|
74
|
+
endControls: [{label: 'Settings'}],
|
|
75
|
+
controls: FORMATTING_CONTROLS,
|
|
76
|
+
facts: {...HORIZONTAL_LTR, direction: 'rtl'},
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
id: 'vertical',
|
|
80
|
+
summary:
|
|
81
|
+
'a vertical Toolbar of drawing tools, moved through with ArrowDown and ArrowUp',
|
|
82
|
+
storyId: story('vertical'),
|
|
83
|
+
label: 'Drawing tools',
|
|
84
|
+
startControls: [{label: 'Select'}, {label: 'Pen'}, {label: 'Eraser'}],
|
|
85
|
+
endControls: [],
|
|
86
|
+
controls: {
|
|
87
|
+
'first-control': 'Select',
|
|
88
|
+
'second-control': 'Pen',
|
|
89
|
+
'last-control': 'Eraser',
|
|
90
|
+
},
|
|
91
|
+
facts: {...HORIZONTAL_LTR, orientation: 'vertical'},
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
id: 'first-control-disabled',
|
|
95
|
+
summary:
|
|
96
|
+
'a Toolbar whose first button is disabled, so Tab enters on the first enabled button and the arrows skip the disabled one',
|
|
97
|
+
storyId: story('first-control-disabled'),
|
|
98
|
+
label: 'Edit actions',
|
|
99
|
+
startControls: [
|
|
100
|
+
{label: 'Undo', isDisabled: true},
|
|
101
|
+
{label: 'Cut'},
|
|
102
|
+
{label: 'Copy'},
|
|
103
|
+
],
|
|
104
|
+
endControls: [{label: 'Paste'}],
|
|
105
|
+
controls: {
|
|
106
|
+
'first-control': 'Cut',
|
|
107
|
+
'second-control': 'Copy',
|
|
108
|
+
'last-control': 'Paste',
|
|
109
|
+
},
|
|
110
|
+
facts: HORIZONTAL_LTR,
|
|
111
|
+
},
|
|
112
|
+
] as const satisfies ReadonlyArray<ToolbarBindingState>;
|
|
113
|
+
|
|
114
|
+
export const TOOLBAR_EXCLUSIONS = [
|
|
115
|
+
{
|
|
116
|
+
id: 'control-semantics',
|
|
117
|
+
owner:
|
|
118
|
+
'the button, toggle-button and menu-button patterns and their bindings',
|
|
119
|
+
reason:
|
|
120
|
+
'Each control placed in a Toolbar exposes its own role, name, state and activation through its own pattern; this binding renders plain Buttons only so the toolbar container and the movement between controls are what is checked.',
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
id: 'caret-guard-and-nested-popovers',
|
|
124
|
+
owner:
|
|
125
|
+
'Toolbar.test.tsx (caret-guard, nested popover and onKeyDown composition suites)',
|
|
126
|
+
reason:
|
|
127
|
+
'Leaving caret keys to a text field mid-line, leaving keys inside an open MoreMenu to that menu, and composing a caller onKeyDown are binding behaviour the APG leaves to the author, so no state here places a text field or popover in the toolbar.',
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
id: 'three-lane-layout',
|
|
131
|
+
owner:
|
|
132
|
+
'Toolbar.test.tsx (centerContent and three-slot layout tests) and the repository visual gate',
|
|
133
|
+
reason:
|
|
134
|
+
'Center content switches the row to a three-column grid, but the controls stay in DOM order inside the same toolbar element and the same roving tab stop walks them, so the role, name, Tab stop and arrow order this contract reads do not change; the grid itself is layout paint.',
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
id: 'outer-chrome',
|
|
138
|
+
owner: 'component:Section and the repository visual gate',
|
|
139
|
+
reason:
|
|
140
|
+
'Variant, dividers, block padding and edge compensation are Section and layout paint around the toolbar row; they change no role, name, Tab stop or arrow movement, so every state uses the default transparent chrome.',
|
|
141
|
+
},
|
|
142
|
+
] as const;
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
/** @vitest-environment jsdom */
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @file Toolbar.a11y.test.tsx
|
|
6
|
+
* @input TOOLBAR_PATTERN bound to Toolbar's representative states
|
|
7
|
+
* @output jsdom-lane proof of the DOM expectations and an honest "unrun" report for the browser layers
|
|
8
|
+
* @position Binding test for Toolbar (the Chromium spec covers the rest)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import {cleanup, render, screen} from '@testing-library/react';
|
|
12
|
+
import {describe, expect, it} from 'vitest';
|
|
13
|
+
import {
|
|
14
|
+
TOOLBAR_PATTERN,
|
|
15
|
+
checkAccessibilitySpec,
|
|
16
|
+
createJsdomHarness,
|
|
17
|
+
expectAccessibilitySpec,
|
|
18
|
+
summarize,
|
|
19
|
+
type BindingResult,
|
|
20
|
+
} from '@astryxdesign/a11y-spec';
|
|
21
|
+
import {renderToolbarState} from './Toolbar.a11y.renders';
|
|
22
|
+
import {
|
|
23
|
+
TOOLBAR_BINDING_STATES,
|
|
24
|
+
TOOLBAR_EXCLUSIONS,
|
|
25
|
+
type ToolbarBindingState,
|
|
26
|
+
} from './Toolbar.a11y.states';
|
|
27
|
+
|
|
28
|
+
function toolbarFor(state: ToolbarBindingState): HTMLElement {
|
|
29
|
+
return screen.getByRole('toolbar', {name: state.label});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function relatedFor(
|
|
33
|
+
state: ToolbarBindingState,
|
|
34
|
+
): Readonly<Record<string, Element>> {
|
|
35
|
+
const related: Record<string, Element> = {
|
|
36
|
+
before: screen.getByRole('button', {name: 'Before the toolbar'}),
|
|
37
|
+
after: screen.getByRole('button', {name: 'After the toolbar'}),
|
|
38
|
+
};
|
|
39
|
+
for (const [relation, name] of Object.entries(state.controls)) {
|
|
40
|
+
related[relation] = screen.getByRole('button', {name});
|
|
41
|
+
}
|
|
42
|
+
return related;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function checkState(state: ToolbarBindingState): Promise<BindingResult> {
|
|
46
|
+
return checkAccessibilitySpec({
|
|
47
|
+
spec: TOOLBAR_PATTERN,
|
|
48
|
+
binding: 'Toolbar',
|
|
49
|
+
state: state.id,
|
|
50
|
+
facts: state.facts,
|
|
51
|
+
mount: async () => {
|
|
52
|
+
render(renderToolbarState(state));
|
|
53
|
+
return createJsdomHarness({
|
|
54
|
+
subject: toolbarFor(state),
|
|
55
|
+
related: relatedFor(state),
|
|
56
|
+
});
|
|
57
|
+
},
|
|
58
|
+
unmount: cleanup,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
describe('Toolbar — toolbar pattern, jsdom lane', () => {
|
|
63
|
+
it.each(
|
|
64
|
+
TOOLBAR_BINDING_STATES.map(
|
|
65
|
+
state => [state.id, state.summary, state] as const,
|
|
66
|
+
),
|
|
67
|
+
)('%s — %s', async (_id, _summary, state) => {
|
|
68
|
+
await expectAccessibilitySpec({
|
|
69
|
+
spec: TOOLBAR_PATTERN,
|
|
70
|
+
binding: 'Toolbar',
|
|
71
|
+
state: state.id,
|
|
72
|
+
facts: state.facts,
|
|
73
|
+
render: () => {
|
|
74
|
+
render(renderToolbarState(state));
|
|
75
|
+
},
|
|
76
|
+
subject: () => toolbarFor(state),
|
|
77
|
+
related: () => relatedFor(state),
|
|
78
|
+
cleanup,
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('runs the DOM layer and reports the browser-owned outcomes as unrun', async () => {
|
|
83
|
+
const results: BindingResult[] = [];
|
|
84
|
+
for (const state of TOOLBAR_BINDING_STATES) {
|
|
85
|
+
results.push(await checkState(state));
|
|
86
|
+
}
|
|
87
|
+
const report = summarize(TOOLBAR_PATTERN, results);
|
|
88
|
+
expect(report.counts.pass).toBeGreaterThan(0);
|
|
89
|
+
expect(report.unrunLayers).toEqual(['accessibility-tree', 'real-browser']);
|
|
90
|
+
expect(report.counts.unexpectedPass).toBe(0);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('toolbar binding inventory', () => {
|
|
95
|
+
it('gives every representative state a distinct checked-in story', () => {
|
|
96
|
+
const stories = TOOLBAR_BINDING_STATES.map(state => state.storyId);
|
|
97
|
+
expect(new Set(stories).size).toBe(stories.length);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('keeps control semantics, editing and popover keys, the three-lane layout, and outer chrome explicit', () => {
|
|
101
|
+
expect(TOOLBAR_EXCLUSIONS.map(row => row.id)).toEqual([
|
|
102
|
+
'control-semantics',
|
|
103
|
+
'caret-guard-and-nested-popovers',
|
|
104
|
+
'three-lane-layout',
|
|
105
|
+
'outer-chrome',
|
|
106
|
+
]);
|
|
107
|
+
for (const exclusion of TOOLBAR_EXCLUSIONS) {
|
|
108
|
+
expect(exclusion.owner.length, exclusion.id).toBeGreaterThan(20);
|
|
109
|
+
expect(exclusion.reason.length, exclusion.id).toBeGreaterThan(80);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('renders the toolbar and every control relation it names for every state', () => {
|
|
114
|
+
for (const state of TOOLBAR_BINDING_STATES) {
|
|
115
|
+
render(renderToolbarState(state));
|
|
116
|
+
const toolbar = toolbarFor(state);
|
|
117
|
+
for (const name of Object.values(state.controls)) {
|
|
118
|
+
expect(
|
|
119
|
+
toolbar.contains(screen.getByRole('button', {name})),
|
|
120
|
+
`${state.id}: ${name}`,
|
|
121
|
+
).toBe(true);
|
|
122
|
+
}
|
|
123
|
+
cleanup();
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
});
|