@astryxdesign/core 0.7.2-canary.55d8dff → 0.7.2-canary.577a62e
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/README.md +4 -4
- package/dist/Avatar/Avatar.d.ts.map +1 -1
- package/dist/Avatar/Avatar.js +8 -1
- package/dist/AvatarGroup/AvatarGroupOverflow.d.ts.map +1 -1
- package/dist/AvatarGroup/AvatarGroupOverflow.js +4 -1
- package/dist/Chat/ChatLayoutScrollButton.d.ts.map +1 -1
- package/dist/Chat/ChatLayoutScrollButton.js +1 -0
- package/dist/CheckboxList/CheckboxList.d.ts +1 -1
- package/dist/CheckboxList/CheckboxList.d.ts.map +1 -1
- package/dist/CheckboxList/CheckboxList.js +6 -2
- package/dist/DropdownMenu/DropdownMenu.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenu.js +2 -103
- package/dist/DropdownMenu/DropdownMenuCheckboxItem.d.ts +1 -1
- package/dist/DropdownMenu/DropdownMenuGroup.d.ts +6 -4
- package/dist/DropdownMenu/DropdownMenuGroup.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenuGroup.js +4 -3
- package/dist/DropdownMenu/DropdownMenuRadioItem.d.ts +1 -1
- package/dist/DropdownMenu/menuRegionStops.d.ts +38 -0
- package/dist/DropdownMenu/menuRegionStops.d.ts.map +1 -0
- package/dist/DropdownMenu/menuRegionStops.js +131 -0
- package/dist/FormLayout/FormLayout.d.ts +5 -4
- package/dist/FormLayout/FormLayout.d.ts.map +1 -1
- package/dist/FormLayout/FormLayout.js +1 -1
- package/dist/FormLayout/FormLayoutContext.d.ts +3 -3
- package/dist/FormLayout/FormLayoutContext.js +3 -3
- package/dist/Grid/Grid.d.ts +8 -7
- package/dist/Grid/Grid.d.ts.map +1 -1
- package/dist/Grid/Grid.js +5 -4
- package/dist/Grid/GridSpan.d.ts +1 -1
- package/dist/Markdown/parser.d.ts.map +1 -1
- package/dist/Markdown/parser.js +8 -2
- package/dist/Markdown/plugins/headingLinks.d.ts.map +1 -1
- package/dist/Markdown/plugins/headingLinks.js +9 -1
- package/dist/PowerSearch/PowerSearch.d.ts.map +1 -1
- package/dist/PowerSearch/PowerSearch.js +5 -0
- package/dist/PowerSearch/PowerSearchValueEditor.d.ts.map +1 -1
- package/dist/PowerSearch/PowerSearchValueEditor.js +45 -11
- package/dist/SideNav/SideNavItem.d.ts.map +1 -1
- package/dist/SideNav/SideNavItem.js +6 -2
- package/dist/Token/Token.js +1 -1
- package/dist/TopNav/TopNavMegaMenu.js +2 -0
- package/dist/TopNav/TopNavMenu.d.ts.map +1 -1
- package/dist/TopNav/TopNavMenu.js +2 -0
- package/dist/astryx.css +1 -1
- package/dist/reset.a11y.chromium.spec.js +50 -1
- package/docs.mjs +4 -4
- package/package.json +3 -3
- package/scripts/agent-doc-state.mjs +29 -7
- package/scripts/postinstall.mjs +43 -2
- package/src/Avatar/Avatar.tsx +11 -2
- package/src/AvatarGroup/AvatarGroupOverflow.test.tsx +24 -1
- package/src/AvatarGroup/AvatarGroupOverflow.tsx +2 -0
- 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/CheckboxList/CheckboxList.fr10.test.tsx +29 -2
- package/src/CheckboxList/CheckboxList.tsx +4 -1
- package/src/ContextMenu/ContextMenu.test.tsx +12 -2
- package/src/DropdownMenu/DropdownMenu.test.tsx +3 -4
- package/src/DropdownMenu/DropdownMenu.tsx +7 -134
- package/src/DropdownMenu/DropdownMenuCheckboxItem.doc.mjs +1 -1
- package/src/DropdownMenu/DropdownMenuCheckboxItem.tsx +1 -1
- package/src/DropdownMenu/DropdownMenuGroup.doc.mjs +169 -10
- package/src/DropdownMenu/DropdownMenuGroup.tsx +6 -4
- package/src/DropdownMenu/DropdownMenuRadioItem.doc.mjs +1 -1
- package/src/DropdownMenu/DropdownMenuRadioItem.tsx +1 -1
- package/src/DropdownMenu/__tests__/DropdownMenuGroupAudit.a11y.chromium.spec.ts +334 -0
- package/src/DropdownMenu/menuRegionStops.ts +167 -0
- package/src/Field/FieldLabel.doc.mjs +133 -26
- package/src/Field/FieldLabel.spec.md +172 -0
- package/src/Field/__tests__/FieldLabelAudit.a11y.chromium.spec.ts +296 -0
- package/src/FormLayout/FormLayout.doc.mjs +6 -3
- package/src/FormLayout/FormLayout.spec.md +192 -0
- package/src/FormLayout/FormLayout.test.tsx +15 -0
- package/src/FormLayout/FormLayout.tsx +7 -6
- package/src/FormLayout/FormLayoutContext.ts +3 -3
- package/src/Grid/Grid.doc.mjs +7 -1
- package/src/Grid/Grid.spec.md +93 -113
- package/src/Grid/Grid.test.tsx +22 -0
- package/src/Grid/Grid.tsx +10 -9
- package/src/Grid/GridSpan.doc.mjs +3 -3
- package/src/Grid/GridSpan.tsx +1 -1
- package/src/Indicator/Indicator.doc.mjs +3 -3
- package/src/Markdown/parser.ts +8 -2
- package/src/Markdown/plugins/headingLinks.ts +9 -1
- package/src/PowerSearch/PowerSearch.test.tsx +62 -0
- package/src/PowerSearch/PowerSearch.tsx +9 -0
- package/src/PowerSearch/PowerSearchValueEditor.test.tsx +71 -0
- package/src/PowerSearch/PowerSearchValueEditor.tsx +45 -0
- package/src/Resizable/useResizable.doc.mjs +0 -1
- package/src/SideNav/SideNav.spec.md +227 -0
- package/src/SideNav/SideNav.test.tsx +41 -0
- package/src/SideNav/SideNavItem.tsx +5 -2
- package/src/Table/Table.spec.md +6 -1
- package/src/Table/plugins/activation/useTableActivation.spec.md +254 -0
- package/src/Token/Token.tsx +1 -1
- package/src/Token/__tests__/TokenRemoveTarget.a11y.chromium.spec.ts +86 -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
- package/src/TopNav/TopNavMegaMenu.test.tsx +25 -0
- package/src/TopNav/TopNavMegaMenu.tsx +2 -0
- package/src/TopNav/TopNavMenu.test.tsx +27 -1
- package/src/TopNav/TopNavMenu.tsx +2 -0
- package/src/postinstall.test.mjs +33 -0
- package/src/reset.a11y.chromium.spec.ts +52 -1
- package/src/reset.css +9 -0
- package/src/reset.test.ts +11 -0
- package/src/unicodePropertyEscapes.source-build.test.mjs +69 -0
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.577a62e",
|
|
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.577a62e",
|
|
796
|
+
"@astryxdesign/cli": "0.7.2-canary.577a62e",
|
|
797
797
|
"@babel/cli": "^7.29.7",
|
|
798
798
|
"@babel/core": "^7.29.7",
|
|
799
799
|
"@babel/preset-react": "^7.29.7",
|
|
@@ -145,15 +145,37 @@ function findRepositoryRoot(start) {
|
|
|
145
145
|
}
|
|
146
146
|
|
|
147
147
|
/**
|
|
148
|
-
* The
|
|
149
|
-
* (
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
* the CLI is installed. Bare `npx astryx` would fetch an unrelated look-alike
|
|
148
|
+
* The invocation the setup nudge falls back to when the CLI's picker
|
|
149
|
+
* (`getCliInvocation`) cannot be loaded. Scoped package form
|
|
150
|
+
* (`@astryxdesign/cli`) — always resolves to us, even before the CLI is
|
|
151
|
+
* installed. npx with the unscoped name would fetch an unrelated look-alike
|
|
153
152
|
* package (see PR #4151).
|
|
154
153
|
*/
|
|
155
|
-
export const
|
|
156
|
-
|
|
154
|
+
export const SETUP_INVOCATION = 'npx @astryxdesign/cli';
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The one-line next-step every setup layer prints. Kept here so layer 1
|
|
158
|
+
* (core), layer 2 (cli postinstall) and layer 3 (each command) can never word
|
|
159
|
+
* it differently. Each layer passes the stem from the CLI's picker when it can
|
|
160
|
+
* load it.
|
|
161
|
+
*
|
|
162
|
+
* @param {string} [invocation] the CLI invocation stem, e.g. `pnpm exec astryx`
|
|
163
|
+
* @param {{before?: string | null}} [options] `before`: a command to run
|
|
164
|
+
* first, such as the one that adds the CLI to a project that has none
|
|
165
|
+
* @returns {string}
|
|
166
|
+
*/
|
|
167
|
+
export function setupNudge(
|
|
168
|
+
invocation = SETUP_INVOCATION,
|
|
169
|
+
{before = null} = {},
|
|
170
|
+
) {
|
|
171
|
+
const run = before
|
|
172
|
+
? `\`${before}\`, then \`${invocation} init\``
|
|
173
|
+
: `\`${invocation} init\``;
|
|
174
|
+
return `\nNext step: run ${run} to finish setup and install the Astryx agent prompt.\n\n`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** The nudge with the fallback invocation. */
|
|
178
|
+
export const SETUP_NUDGE = setupNudge();
|
|
157
179
|
|
|
158
180
|
/**
|
|
159
181
|
* Pure decision: should a postinstall print the setup nudge? Shared by BOTH
|
package/scripts/postinstall.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* @file @astryxdesign/core postinstall — nudge to run `
|
|
4
|
+
* @file @astryxdesign/core postinstall — nudge to run `astryx init`, prefixed
|
|
5
|
+
* the way the project's installed CLI prints commands.
|
|
5
6
|
*
|
|
6
7
|
* Enforcement layer 1 of making `astryx init` foolproof: when the design system
|
|
7
8
|
* is installed and the project hasn't run init yet, print a one-line next-step
|
|
@@ -19,6 +20,8 @@
|
|
|
19
20
|
* during npx's fetch, and once set up.
|
|
20
21
|
*/
|
|
21
22
|
|
|
23
|
+
import * as fs from 'node:fs';
|
|
24
|
+
import * as path from 'node:path';
|
|
22
25
|
import {fileURLToPath, pathToFileURL} from 'node:url';
|
|
23
26
|
|
|
24
27
|
const HERE = fileURLToPath(import.meta.url);
|
|
@@ -37,6 +40,39 @@ async function loadContract() {
|
|
|
37
40
|
}
|
|
38
41
|
}
|
|
39
42
|
|
|
43
|
+
/**
|
|
44
|
+
* The stem the project's installed CLI would print, from its picker
|
|
45
|
+
* (`getCliInvocation`). Core cannot depend on the CLI, so this looks for an
|
|
46
|
+
* installed copy from the project upward and imports the picker by path; with
|
|
47
|
+
* no CLI there, or one too old to have the picker, it returns undefined and
|
|
48
|
+
* the contract's scoped fallback is printed. At install time no integration is
|
|
49
|
+
* loaded, so this is the package-manager guess.
|
|
50
|
+
* @param {string} root
|
|
51
|
+
* @returns {Promise<string | undefined>}
|
|
52
|
+
*/
|
|
53
|
+
export async function cliInvocation(root) {
|
|
54
|
+
try {
|
|
55
|
+
let dir = path.resolve(root);
|
|
56
|
+
for (;;) {
|
|
57
|
+
const picker = path.join(
|
|
58
|
+
dir,
|
|
59
|
+
'node_modules/@astryxdesign/cli/foundation/env/package-manager.mjs',
|
|
60
|
+
);
|
|
61
|
+
if (fs.existsSync(picker)) {
|
|
62
|
+
const {getCliInvocation} = await import(pathToFileURL(picker).href);
|
|
63
|
+
return typeof getCliInvocation === 'function'
|
|
64
|
+
? getCliInvocation(root)
|
|
65
|
+
: undefined;
|
|
66
|
+
}
|
|
67
|
+
const parent = path.dirname(dir);
|
|
68
|
+
if (parent === dir) return undefined;
|
|
69
|
+
dir = parent;
|
|
70
|
+
}
|
|
71
|
+
} catch {
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
40
76
|
async function main() {
|
|
41
77
|
const contract = await loadContract();
|
|
42
78
|
if (!contract) return;
|
|
@@ -48,7 +84,12 @@ async function main() {
|
|
|
48
84
|
isSetUp: contract.isAstryxPromptInReach(root),
|
|
49
85
|
})
|
|
50
86
|
) {
|
|
51
|
-
|
|
87
|
+
const invocation = await cliInvocation(root);
|
|
88
|
+
process.stdout.write(
|
|
89
|
+
typeof contract.setupNudge === 'function'
|
|
90
|
+
? contract.setupNudge(invocation)
|
|
91
|
+
: contract.SETUP_NUDGE,
|
|
92
|
+
);
|
|
52
93
|
}
|
|
53
94
|
}
|
|
54
95
|
|
package/src/Avatar/Avatar.tsx
CHANGED
|
@@ -387,9 +387,18 @@ export interface AvatarProps extends BaseProps<HTMLDivElement> {
|
|
|
387
387
|
/**
|
|
388
388
|
* A character that can stand for a word in initials: a letter, a digit, or an
|
|
389
389
|
* emoji. Punctuation and other symbols, such as `(`, `"` or `-`, cannot.
|
|
390
|
+
*
|
|
391
|
+
* Built at runtime, not written as a literal: for targets without property
|
|
392
|
+
* escapes, or when no browserslist is set, Babel's
|
|
393
|
+
* transform-unicode-property-regex rewrites a `\p{…}` literal, and the copy
|
|
394
|
+
* bundled with Next.js lacks the Unicode data it needs, so a source build fails
|
|
395
|
+
* with "Unknown property". A literal is safe once Next's bundled Babel ships
|
|
396
|
+
* that data.
|
|
390
397
|
*/
|
|
391
|
-
const INITIAL_CHARACTER =
|
|
392
|
-
|
|
398
|
+
const INITIAL_CHARACTER = new RegExp(
|
|
399
|
+
'[\\p{L}\\p{N}\\p{Extended_Pictographic}\\p{Regional_Indicator}]',
|
|
400
|
+
'u',
|
|
401
|
+
);
|
|
393
402
|
|
|
394
403
|
/**
|
|
395
404
|
* The first character of a word that can stand for it in initials, skipping
|
|
@@ -21,6 +21,29 @@ describe('AvatarGroupOverflow', () => {
|
|
|
21
21
|
expect(overflow).toHaveTextContent('+5');
|
|
22
22
|
});
|
|
23
23
|
|
|
24
|
+
it('exposes the static overflow as a named image', () => {
|
|
25
|
+
render(
|
|
26
|
+
<AvatarGroup>
|
|
27
|
+
<Avatar name="Alice" />
|
|
28
|
+
<AvatarGroupOverflow count={5} />
|
|
29
|
+
</AvatarGroup>,
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
const overflow = screen.getByRole('img', {name: '5 more'});
|
|
33
|
+
expect(overflow).toHaveAttribute('role', 'img');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('keeps a caller-supplied role on the static overflow', () => {
|
|
37
|
+
render(
|
|
38
|
+
<AvatarGroup>
|
|
39
|
+
<Avatar name="Alice" />
|
|
40
|
+
<AvatarGroupOverflow count={5} role="note" />
|
|
41
|
+
</AvatarGroup>,
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
expect(screen.getByLabelText('5 more')).toHaveAttribute('role', 'note');
|
|
45
|
+
});
|
|
46
|
+
|
|
24
47
|
it('reflects the group size on the overflow chip', () => {
|
|
25
48
|
render(
|
|
26
49
|
<AvatarGroup size="lg">
|
|
@@ -66,7 +89,7 @@ describe('AvatarGroupOverflow', () => {
|
|
|
66
89
|
</AvatarGroup>,
|
|
67
90
|
);
|
|
68
91
|
|
|
69
|
-
const overflow = screen.
|
|
92
|
+
const overflow = screen.getByRole('button', {name: '3 more'});
|
|
70
93
|
expect(overflow.tagName).toBe('BUTTON');
|
|
71
94
|
});
|
|
72
95
|
|
|
@@ -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', {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
2
|
|
|
3
|
-
import {describe, it} from 'vitest';
|
|
4
|
-
import {screen} from '@testing-library/react';
|
|
3
|
+
import {describe, it, expect} from 'vitest';
|
|
4
|
+
import {render, screen} from '@testing-library/react';
|
|
5
5
|
import {expectFieldInputProps} from '../Field/fieldInputFamily.testing';
|
|
6
6
|
import {describeFieldInputParts} from '../Field/fieldInputFamily.render-testing';
|
|
7
7
|
import {CheckboxList, type CheckboxListProps} from './CheckboxList';
|
|
@@ -22,3 +22,30 @@ describeFieldInputParts('CheckboxList', {
|
|
|
22
22
|
getControl: () => screen.getByRole('group'),
|
|
23
23
|
inGroup: false,
|
|
24
24
|
});
|
|
25
|
+
|
|
26
|
+
describe('CheckboxList FR10 details', () => {
|
|
27
|
+
it("names the group by its label and then a caller's aria-labelledby", () => {
|
|
28
|
+
render(
|
|
29
|
+
<>
|
|
30
|
+
<span id="caller-name">Billing</span>
|
|
31
|
+
<CheckboxList
|
|
32
|
+
label="Preferences"
|
|
33
|
+
value={[]}
|
|
34
|
+
onChange={() => {}}
|
|
35
|
+
aria-labelledby="caller-name">
|
|
36
|
+
<CheckboxListItem label="A" value="a" />
|
|
37
|
+
</CheckboxList>
|
|
38
|
+
</>,
|
|
39
|
+
);
|
|
40
|
+
const group = screen.getByRole('group');
|
|
41
|
+
const ids = group.getAttribute('aria-labelledby')?.split(' ');
|
|
42
|
+
expect(ids).toHaveLength(2);
|
|
43
|
+
expect(document.getElementById(ids?.[0] ?? '')?.textContent).toContain(
|
|
44
|
+
'Preferences',
|
|
45
|
+
);
|
|
46
|
+
expect(ids?.[1]).toBe('caller-name');
|
|
47
|
+
expect(screen.getByRole('group', {name: 'Preferences Billing'})).toBe(
|
|
48
|
+
group,
|
|
49
|
+
);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -38,6 +38,7 @@ import {useTooltip} from '../Tooltip';
|
|
|
38
38
|
import * as stylex from '@stylexjs/stylex';
|
|
39
39
|
import {mergeProps} from '../utils';
|
|
40
40
|
import {themeProps} from '../utils/themeProps';
|
|
41
|
+
import {joinAriaIDs} from '../utils/inputAria';
|
|
41
42
|
import {
|
|
42
43
|
CheckboxListContext,
|
|
43
44
|
type CheckboxListContextValue,
|
|
@@ -170,6 +171,7 @@ export function CheckboxList({
|
|
|
170
171
|
controlXstyle,
|
|
171
172
|
controlClassName,
|
|
172
173
|
controlStyle,
|
|
174
|
+
'aria-labelledby': ariaLabelledByProp,
|
|
173
175
|
'aria-describedby': ariaDescribedByProp,
|
|
174
176
|
...restProps
|
|
175
177
|
}: CheckboxListProps) {
|
|
@@ -290,7 +292,8 @@ export function CheckboxList({
|
|
|
290
292
|
controlStyle,
|
|
291
293
|
)}
|
|
292
294
|
role="group"
|
|
293
|
-
aria-labelledby
|
|
295
|
+
// A caller aria-labelledby names the group together with its label.
|
|
296
|
+
aria-labelledby={joinAriaIDs(labelID, ariaLabelledByProp)}
|
|
294
297
|
aria-describedby={
|
|
295
298
|
[
|
|
296
299
|
ariaDescribedByProp,
|
|
@@ -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);
|
|
@@ -268,7 +273,12 @@ describe('ContextMenu', () => {
|
|
|
268
273
|
);
|
|
269
274
|
|
|
270
275
|
fireEvent.contextMenu(screen.getByText('Right-click me'));
|
|
271
|
-
|
|
276
|
+
// The sheet is lazy: its first render suspends once even when the module
|
|
277
|
+
// is already loaded, so wait for the row rather than relying on an
|
|
278
|
+
// earlier case to have rendered a sheet.
|
|
279
|
+
await user.click(
|
|
280
|
+
await screen.findByRole('button', {name: 'Move to project'}),
|
|
281
|
+
);
|
|
272
282
|
|
|
273
283
|
const submenuHeading = screen.getByRole('heading', {
|
|
274
284
|
name: 'Move to project',
|
|
@@ -1567,10 +1567,9 @@ describe('DropdownMenuItem host attributes', () => {
|
|
|
1567
1567
|
|
|
1568
1568
|
describe('DropdownMenuGroup (compound mode)', () => {
|
|
1569
1569
|
it('a focusable node in the heading is not reachable by the arrow keys', async () => {
|
|
1570
|
-
//
|
|
1571
|
-
//
|
|
1572
|
-
//
|
|
1573
|
-
// pinned rather than discovered.
|
|
1570
|
+
// The public ReactNode type permits a focusable descendant, but the menu's
|
|
1571
|
+
// roving focus model cannot reach it. Pin that shipped gap while the public
|
|
1572
|
+
// title contract remains unsettled.
|
|
1574
1573
|
const user = userEvent.setup();
|
|
1575
1574
|
render(
|
|
1576
1575
|
<DropdownMenu button={{label: 'Actions'}}>
|
|
@@ -72,7 +72,7 @@ import {useFocusReturnVisibility} from '../hooks/useFocusReturnVisibility';
|
|
|
72
72
|
import {focusForLastInput} from '../utils/focusReturn';
|
|
73
73
|
import {useMenuOverflow} from './useMenuOverflow';
|
|
74
74
|
import {MenuRegionsContext} from './menuRegionsContext';
|
|
75
|
-
import {
|
|
75
|
+
import {moveRegionsTab} from './menuRegionStops';
|
|
76
76
|
import {useDevWarning} from '../hooks/useDevWarning';
|
|
77
77
|
import {useMenuDrillIn} from './useMenuDrillIn';
|
|
78
78
|
import {resolveMenuWidth} from './menuWidth';
|
|
@@ -112,102 +112,6 @@ const MENU_MAX_BLOCK_SIZE = `min(${MENU_BLOCK_CAP}, ${layerViewportInset.maxBloc
|
|
|
112
112
|
const MENU_MAX_BLOCK_SIZE_FALLBACK = `min(${MENU_BLOCK_CAP}, ${layerViewportInset.maxBlockSizeFallback})`;
|
|
113
113
|
const MENU_TRIGGER_OPEN_BACKGROUND = `linear-gradient(${colorVars['--color-overlay-pressed']}, ${colorVars['--color-overlay-pressed']})`;
|
|
114
114
|
|
|
115
|
-
/**
|
|
116
|
-
* The elements Tab reaches inside a header or footer region, in order. Hidden
|
|
117
|
-
* controls (`hidden`, `display: none`, `visibility: hidden`, inert or
|
|
118
|
-
* aria-hidden subtrees) are not reachable, judged by the popover focus trap's
|
|
119
|
-
* own test so the two never disagree about where Tab can go.
|
|
120
|
-
*/
|
|
121
|
-
/**
|
|
122
|
-
* Collapse each named radio group to the one Tab stop a browser gives it: the
|
|
123
|
-
* focused radio, else the checked one, else the first radio moving forward or
|
|
124
|
-
* the last moving backward. The stop takes the group's first position.
|
|
125
|
-
*/
|
|
126
|
-
function collapseRadioGroups(
|
|
127
|
-
elements: HTMLElement[],
|
|
128
|
-
active: Element | null,
|
|
129
|
-
isBackward: boolean,
|
|
130
|
-
): HTMLElement[] {
|
|
131
|
-
const groups = new Map<string, HTMLInputElement[]>();
|
|
132
|
-
const groupKey = (el: HTMLElement): string | null =>
|
|
133
|
-
el instanceof HTMLInputElement &&
|
|
134
|
-
el.getAttribute('type') === 'radio' &&
|
|
135
|
-
el.name !== ''
|
|
136
|
-
? `${el.form?.id ?? ''}\u0000${el.name}`
|
|
137
|
-
: null;
|
|
138
|
-
for (const el of elements) {
|
|
139
|
-
const key = groupKey(el);
|
|
140
|
-
if (key != null) {
|
|
141
|
-
const members = groups.get(key) ?? [];
|
|
142
|
-
members.push(el as HTMLInputElement);
|
|
143
|
-
groups.set(key, members);
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
const placed = new Set<string>();
|
|
147
|
-
const result: HTMLElement[] = [];
|
|
148
|
-
for (const el of elements) {
|
|
149
|
-
const key = groupKey(el);
|
|
150
|
-
if (key == null) {
|
|
151
|
-
result.push(el);
|
|
152
|
-
continue;
|
|
153
|
-
}
|
|
154
|
-
if (placed.has(key)) {
|
|
155
|
-
continue;
|
|
156
|
-
}
|
|
157
|
-
placed.add(key);
|
|
158
|
-
const members = groups.get(key) ?? [];
|
|
159
|
-
const stop =
|
|
160
|
-
members.find(radio => radio === active) ??
|
|
161
|
-
members.find(radio => radio.checked) ??
|
|
162
|
-
(isBackward ? members[members.length - 1] : members[0]);
|
|
163
|
-
result.push(stop);
|
|
164
|
-
}
|
|
165
|
-
return result;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
function tabbablesIn(region: HTMLElement | null): HTMLElement[] {
|
|
169
|
-
if (region == null) {
|
|
170
|
-
return [];
|
|
171
|
-
}
|
|
172
|
-
return getFocusableElements(region).filter(
|
|
173
|
-
element => element.tabIndex >= 0 && !isInsideHiddenWrapper(element, region),
|
|
174
|
-
);
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
/**
|
|
178
|
-
* Whether a `display: none` ancestor between the element and its region hides
|
|
179
|
-
* it — a control in a wrapper hidden at some widths, say. The trap's test reads
|
|
180
|
-
* only the element's own display.
|
|
181
|
-
*/
|
|
182
|
-
function isInsideHiddenWrapper(
|
|
183
|
-
element: HTMLElement,
|
|
184
|
-
region: HTMLElement,
|
|
185
|
-
): boolean {
|
|
186
|
-
if (typeof window === 'undefined' || !window.getComputedStyle) {
|
|
187
|
-
return false;
|
|
188
|
-
}
|
|
189
|
-
for (
|
|
190
|
-
let node = element.parentElement;
|
|
191
|
-
node != null && node !== region.parentElement;
|
|
192
|
-
node = node.parentElement
|
|
193
|
-
) {
|
|
194
|
-
if (window.getComputedStyle(node).display === 'none') {
|
|
195
|
-
return true;
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
return false;
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
/**
|
|
202
|
-
* Move focus to a Tab stop in the dialog. Reports whether focus actually
|
|
203
|
-
* arrived, so a caller can fall back to the menu instead of stranding focus on
|
|
204
|
-
* the page.
|
|
205
|
-
*/
|
|
206
|
-
function focusRegionControl(element: HTMLElement): boolean {
|
|
207
|
-
element.focus();
|
|
208
|
-
return element.ownerDocument.activeElement === element;
|
|
209
|
-
}
|
|
210
|
-
|
|
211
115
|
const styles = stylex.create({
|
|
212
116
|
triggerOpen: {
|
|
213
117
|
backgroundImage: MENU_TRIGGER_OPEN_BACKGROUND,
|
|
@@ -1271,44 +1175,13 @@ function DropdownMenuPopover({
|
|
|
1271
1175
|
// second time.
|
|
1272
1176
|
const moveDialogTab = useCallback(
|
|
1273
1177
|
(e: React.KeyboardEvent, isFromFlyout = false) => {
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
// A named radio group is one stop, as the browser's own Tab order has it.
|
|
1281
|
-
const cycle = collapseRadioGroups(
|
|
1282
|
-
[
|
|
1283
|
-
...tabbablesIn(headerRef.current),
|
|
1284
|
-
menu,
|
|
1285
|
-
...tabbablesIn(footerRef.current),
|
|
1286
|
-
],
|
|
1287
|
-
document.activeElement,
|
|
1288
|
-
e.shiftKey,
|
|
1178
|
+
moveRegionsTab(
|
|
1179
|
+
e,
|
|
1180
|
+
listRef.current,
|
|
1181
|
+
headerRef.current,
|
|
1182
|
+
footerRef.current,
|
|
1183
|
+
isFromFlyout,
|
|
1289
1184
|
);
|
|
1290
|
-
if (cycle.length === 1) {
|
|
1291
|
-
// Nothing else to reach: focus stays in the menu. From a flyout that
|
|
1292
|
-
// has just closed itself, that is the menu's own stop.
|
|
1293
|
-
if (isFromFlyout) {
|
|
1294
|
-
menu.focus();
|
|
1295
|
-
}
|
|
1296
|
-
return;
|
|
1297
|
-
}
|
|
1298
|
-
const active = document.activeElement;
|
|
1299
|
-
const menuIndex = cycle.indexOf(menu);
|
|
1300
|
-
// A row, or a row of an open flyout, counts as the menu's stop.
|
|
1301
|
-
let index = menu.contains(active)
|
|
1302
|
-
? menuIndex
|
|
1303
|
-
: cycle.findIndex(el => el === active || el.contains(active));
|
|
1304
|
-
if (index < 0) {
|
|
1305
|
-
index = menuIndex;
|
|
1306
|
-
}
|
|
1307
|
-
const step = e.shiftKey ? -1 : 1;
|
|
1308
|
-
const next = cycle[(index + step + cycle.length) % cycle.length];
|
|
1309
|
-
if (!focusRegionControl(next)) {
|
|
1310
|
-
menu.focus();
|
|
1311
|
-
}
|
|
1312
1185
|
},
|
|
1313
1186
|
[listRef],
|
|
1314
1187
|
);
|