@astryxdesign/core 0.7.2 → 0.7.3-canary.06872b4
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/CheckboxInput/CheckboxInput.d.ts.map +1 -1
- package/dist/CheckboxInput/CheckboxInput.js +19 -24
- package/dist/DropdownMenu/DropdownMenu.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenu.js +52 -10
- package/dist/DropdownMenu/DropdownMenuContext.d.ts +34 -1
- package/dist/DropdownMenu/DropdownMenuContext.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenuContext.js +15 -0
- package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts +21 -2
- package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenuSubMenu.js +375 -42
- package/dist/DropdownMenu/menuRegionStops.d.ts +2 -2
- package/dist/DropdownMenu/menuRegionStops.js +2 -2
- package/dist/FileInput/FileInput.d.ts.map +1 -1
- package/dist/FileInput/FileInput.js +14 -12
- package/dist/Selector/SelectorOption.d.ts.map +1 -1
- package/dist/Selector/SelectorOption.js +1 -0
- package/dist/Slider/Slider.d.ts.map +1 -1
- package/dist/Slider/Slider.js +7 -14
- package/dist/Switch/Switch.d.ts.map +1 -1
- package/dist/Switch/Switch.js +12 -14
- package/dist/Switch/switch.markers.stylex.d.ts +2 -0
- package/dist/Switch/switch.markers.stylex.d.ts.map +1 -1
- package/dist/Switch/switch.markers.stylex.js +6 -0
- package/dist/TimeInput/TimeInput.d.ts +2 -1
- package/dist/TimeInput/TimeInput.d.ts.map +1 -1
- package/dist/Tooltip/Tooltip.d.ts.map +1 -1
- package/dist/Tooltip/Tooltip.js +36 -14
- package/dist/Tooltip/useTooltip.d.ts.map +1 -1
- package/dist/Tooltip/useTooltip.js +11 -2
- package/dist/TopNav/TopNav.d.ts.map +1 -1
- package/dist/TopNav/TopNav.js +2 -2
- package/dist/TopNav/TopNavHeading.d.ts.map +1 -1
- package/dist/TopNav/TopNavHeading.js +1 -0
- package/dist/Typeahead/Typeahead.d.ts.map +1 -1
- package/dist/Typeahead/Typeahead.js +14 -2
- package/dist/astryx.css +4 -2
- package/dist/hooks/useMenuPress.d.ts +11 -1
- package/dist/hooks/useMenuPress.d.ts.map +1 -1
- package/dist/hooks/useMenuPress.js +43 -5
- package/dist/utils/timeParser.d.ts +4 -0
- package/dist/utils/timeParser.d.ts.map +1 -1
- package/dist/utils/timeParser.js +9 -2
- package/package.json +4 -4
- package/scripts/agent-doc-state.mjs +35 -8
- package/src/BottomSheet/BottomSheet.spec.md +13 -6
- package/src/BottomSheet/BottomSheetSwitcher.spec.md +121 -51
- package/src/CheckboxInput/CheckboxInput.test.tsx +44 -5
- package/src/CheckboxInput/CheckboxInput.tsx +16 -26
- package/src/CheckboxInput/__tests__/Checkbox.a11y.states.ts +3 -3
- package/src/CheckboxList/CheckboxList.test.tsx +9 -6
- package/src/ContextMenu/ContextMenu.test.tsx +41 -0
- package/src/DateRangeInput/DateRangeInput.spec.md +13 -13
- package/src/DropdownMenu/DropdownMenu.spec.md +33 -2
- package/src/DropdownMenu/DropdownMenu.tsx +79 -8
- package/src/DropdownMenu/DropdownMenuContext.public.test.ts +22 -0
- package/src/DropdownMenu/DropdownMenuContext.tsx +39 -1
- package/src/DropdownMenu/DropdownMenuSubMenu.doc.mjs +16 -1
- package/src/DropdownMenu/DropdownMenuSubMenu.test.tsx +623 -1
- package/src/DropdownMenu/DropdownMenuSubMenu.tsx +556 -99
- package/src/DropdownMenu/__tests__/SubMenuHeaderFooter.a11y.chromium.spec.ts +267 -0
- package/src/DropdownMenu/menuRegionStops.ts +2 -2
- package/src/DropdownMenu/modules/useMenuPress.spec.md +9 -0
- package/src/FileInput/FileInput.test.tsx +30 -7
- package/src/FileInput/FileInput.tsx +21 -11
- package/src/Selector/Selector.spec.md +11 -11
- package/src/Selector/Selector.test.tsx +16 -0
- package/src/Selector/SelectorOption.tsx +4 -0
- package/src/SideNav/SideNav.test.tsx +35 -0
- package/src/Slider/Slider.spec.md +11 -11
- package/src/Slider/Slider.test.tsx +3 -3
- package/src/Slider/Slider.tsx +8 -15
- package/src/Switch/Switch.test.tsx +29 -8
- package/src/Switch/Switch.tsx +13 -14
- package/src/Switch/__tests__/Switch.a11y.states.ts +2 -2
- package/src/Switch/switch.markers.stylex.ts +4 -0
- package/src/Table/Table.spec.md +2 -0
- package/src/Table/plugins/infiniteScroll/useTableInfiniteScroll.spec.md +244 -0
- package/src/Table/plugins/selection/useTableSelection.test.tsx +5 -3
- package/src/Table/plugins/virtualization/useTableVirtualization.spec.md +266 -0
- package/src/TimeInput/TimeInput.doc.mjs +5 -3
- package/src/TimeInput/TimeInput.test.tsx +20 -0
- package/src/TimeInput/TimeInput.tsx +2 -1
- package/src/Tokenizer/Tokenizer.spec.md +28 -28
- package/src/Tooltip/Tooltip.test.tsx +51 -6
- package/src/Tooltip/Tooltip.tsx +41 -18
- package/src/Tooltip/__tests__/Tooltip.a11y.chromium.spec.ts +129 -0
- package/src/Tooltip/__tests__/Tooltip.a11y.renders.tsx +86 -0
- package/src/Tooltip/__tests__/Tooltip.a11y.states.ts +132 -0
- package/src/Tooltip/__tests__/Tooltip.a11y.test.tsx +126 -0
- package/src/Tooltip/__tests__/TooltipHoverBridge.a11y.chromium.spec.ts +74 -0
- package/src/Tooltip/useTooltip.test.tsx +23 -0
- package/src/Tooltip/useTooltip.tsx +22 -9
- package/src/TopNav/TopNav.tsx +11 -1
- package/src/TopNav/TopNavHeading.tsx +1 -0
- package/src/TopNav/__tests__/TopNavMobileLongHeading.a11y.chromium.spec.ts +74 -0
- package/src/Typeahead/Typeahead.test.tsx +89 -0
- package/src/Typeahead/Typeahead.tsx +15 -2
- package/src/hooks/useMenuPress.ts +45 -7
- package/src/utils/timeParser.test.ts +11 -0
- package/src/utils/timeParser.ts +9 -2
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file SubMenuHeaderFooter.a11y.chromium.spec.ts
|
|
5
|
+
* @input The built Storybook "Submenu / header and footer" story (a Labels
|
|
6
|
+
* sub-menu with a filter field above its checkbox rows and a "Create label"
|
|
7
|
+
* button below them) and real Chromium, once as a laptop (fine pointer) and
|
|
8
|
+
* once as a phone (coarse pointer, touch).
|
|
9
|
+
* @output Evidence for component:DropdownMenu FR20: a sub-menu's header and
|
|
10
|
+
* footer sit with its rows in a modal dialog named by its row, beside every
|
|
11
|
+
* menu element rather than inside one; the row says it opens a dialog; Tab
|
|
12
|
+
* and Shift+Tab walk field, rows and button and wrap; typing filters the
|
|
13
|
+
* rows and Escape returns to the row; a pick keeps the sub-menu open. On a
|
|
14
|
+
* phone the drilled-in view holds the same regions while the root menu
|
|
15
|
+
* steps aside. On the "Submenu / header with a choice" story, a flyout the
|
|
16
|
+
* pointer has left stays open while an arrow moves the header's radio choice.
|
|
17
|
+
* @position Browser proof behind the jsdom suite: the accessibility tree, real
|
|
18
|
+
* Tab order and the pointer decision are what jsdom cannot exercise.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import {devices, expect, test, type Locator, type Page} from '@playwright/test';
|
|
22
|
+
import {
|
|
23
|
+
DEFAULT_STORYBOOK_DIR,
|
|
24
|
+
serveStorybook,
|
|
25
|
+
type StaticServer,
|
|
26
|
+
} from '@astryxdesign/a11y-spec/storybook';
|
|
27
|
+
|
|
28
|
+
let storybook: StaticServer;
|
|
29
|
+
const storyId = 'core-dropdownmenu--sub-menu-header-and-footer';
|
|
30
|
+
|
|
31
|
+
test.beforeAll(async () => {
|
|
32
|
+
storybook = await serveStorybook(
|
|
33
|
+
process.env.ASTRYX_STORYBOOK_DIR ?? DEFAULT_STORYBOOK_DIR,
|
|
34
|
+
);
|
|
35
|
+
});
|
|
36
|
+
test.afterAll(async () => {
|
|
37
|
+
await storybook?.close();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
async function mount(page: Page) {
|
|
41
|
+
await page.goto(
|
|
42
|
+
`${storybook.origin}/iframe.html?id=${storyId}&viewMode=story`,
|
|
43
|
+
);
|
|
44
|
+
await expect(page.getByTestId('submenu-regions-fixture')).toBeVisible();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Whether the element sits inside any role="menu" element. */
|
|
48
|
+
async function isInsideAMenu(page: Page, testName: string): Promise<boolean> {
|
|
49
|
+
return page
|
|
50
|
+
.getByRole('dialog', {name: testName})
|
|
51
|
+
.evaluate(el => el.parentElement?.closest('[role="menu"]') != null);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function bounds(locator: Locator) {
|
|
55
|
+
const rect = await locator.boundingBox();
|
|
56
|
+
if (rect == null) {
|
|
57
|
+
throw new Error('no rendered bounds');
|
|
58
|
+
}
|
|
59
|
+
return rect;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
test.describe('on a laptop (fine pointer)', () => {
|
|
63
|
+
test('the sub-menu opens a dialog beside the menu, named by its row, holding field, rows and button', async ({
|
|
64
|
+
page,
|
|
65
|
+
}) => {
|
|
66
|
+
await mount(page);
|
|
67
|
+
await page.getByRole('button', {name: 'Session'}).click();
|
|
68
|
+
const row = page.getByRole('menuitem', {name: 'Labels'});
|
|
69
|
+
await expect(row).toHaveAttribute('aria-haspopup', 'dialog');
|
|
70
|
+
await row.click();
|
|
71
|
+
|
|
72
|
+
const dialog = page.getByRole('dialog', {name: 'Labels'});
|
|
73
|
+
await expect(dialog).toBeVisible();
|
|
74
|
+
await expect(dialog).toHaveAttribute('aria-modal', 'true');
|
|
75
|
+
await expect(row).toHaveAttribute(
|
|
76
|
+
'aria-controls',
|
|
77
|
+
(await dialog.getAttribute('id')) ?? '',
|
|
78
|
+
);
|
|
79
|
+
expect(await isInsideAMenu(page, 'Labels')).toBe(false);
|
|
80
|
+
// The sub-menu's own menu holds only its rows.
|
|
81
|
+
const menu = dialog.getByRole('menu', {name: 'Labels'});
|
|
82
|
+
expect(
|
|
83
|
+
await menu.evaluate(el =>
|
|
84
|
+
Array.from(el.querySelectorAll('[role]')).map(child =>
|
|
85
|
+
child.getAttribute('role'),
|
|
86
|
+
),
|
|
87
|
+
),
|
|
88
|
+
).toEqual(Array(5).fill('menuitemcheckbox'));
|
|
89
|
+
await expect(
|
|
90
|
+
dialog.getByRole('textbox', {name: 'Filter labels'}),
|
|
91
|
+
).toBeVisible();
|
|
92
|
+
await expect(
|
|
93
|
+
dialog.getByRole('button', {name: 'Create label'}),
|
|
94
|
+
).toBeVisible();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('Tab and Shift+Tab walk field, rows and button and wrap; typing filters; Escape returns to the row', async ({
|
|
98
|
+
page,
|
|
99
|
+
}) => {
|
|
100
|
+
await mount(page);
|
|
101
|
+
const trigger = page.getByRole('button', {name: 'Session'});
|
|
102
|
+
await trigger.focus();
|
|
103
|
+
await page.keyboard.press('Enter');
|
|
104
|
+
// A keyboard open lands on the first row; let it land before moving on.
|
|
105
|
+
await expect(page.getByRole('menuitem', {name: 'Rename'})).toBeFocused();
|
|
106
|
+
const row = page.getByRole('menuitem', {name: 'Labels'});
|
|
107
|
+
await page.keyboard.press('ArrowDown');
|
|
108
|
+
await expect(row).toBeFocused();
|
|
109
|
+
await page.keyboard.press('ArrowRight');
|
|
110
|
+
|
|
111
|
+
const dialog = page.getByRole('dialog', {name: 'Labels'});
|
|
112
|
+
const field = dialog.getByRole('textbox', {name: 'Filter labels'});
|
|
113
|
+
const create = dialog.getByRole('button', {name: 'Create label'});
|
|
114
|
+
const menu = dialog.getByRole('menu', {name: 'Labels'});
|
|
115
|
+
// A keyboard open lands on the first row, not the field.
|
|
116
|
+
await expect(
|
|
117
|
+
dialog.getByRole('menuitemcheckbox', {name: 'Bug'}),
|
|
118
|
+
).toBeFocused();
|
|
119
|
+
|
|
120
|
+
await page.keyboard.press('Tab');
|
|
121
|
+
await expect(create).toBeFocused();
|
|
122
|
+
await page.keyboard.press('Tab');
|
|
123
|
+
await expect(field).toBeFocused();
|
|
124
|
+
await page.keyboard.press('Tab');
|
|
125
|
+
await expect(menu).toBeFocused();
|
|
126
|
+
await page.keyboard.press('Shift+Tab');
|
|
127
|
+
await expect(field).toBeFocused();
|
|
128
|
+
await page.keyboard.press('Shift+Tab');
|
|
129
|
+
await expect(create).toBeFocused();
|
|
130
|
+
await expect(dialog).toBeVisible();
|
|
131
|
+
|
|
132
|
+
// The field keeps its keys: letters type and filter the rows, and the
|
|
133
|
+
// arrow that closes a sub-menu from a row moves the caret instead.
|
|
134
|
+
await field.focus();
|
|
135
|
+
await page.keyboard.type('doc');
|
|
136
|
+
await page.keyboard.press('ArrowLeft');
|
|
137
|
+
await expect(field).toHaveValue('doc');
|
|
138
|
+
await expect(dialog.getByRole('menuitemcheckbox')).toHaveText([
|
|
139
|
+
'Documentation',
|
|
140
|
+
]);
|
|
141
|
+
|
|
142
|
+
await page.keyboard.press('Escape');
|
|
143
|
+
await expect(dialog).toBeHidden();
|
|
144
|
+
await expect(row).toBeFocused();
|
|
145
|
+
await expect(page.getByRole('menu', {name: 'Session'})).toBeVisible();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('a pick keeps the sub-menu open; the button acts without closing anything', async ({
|
|
149
|
+
page,
|
|
150
|
+
}) => {
|
|
151
|
+
await mount(page);
|
|
152
|
+
await page.getByRole('button', {name: 'Session'}).click();
|
|
153
|
+
await page.getByRole('menuitem', {name: 'Labels'}).click();
|
|
154
|
+
const dialog = page.getByRole('dialog', {name: 'Labels'});
|
|
155
|
+
await dialog.getByRole('menuitemcheckbox', {name: 'Feature'}).click();
|
|
156
|
+
await expect(page.getByTestId('picked')).toHaveText('Bug, Feature');
|
|
157
|
+
await expect(dialog).toBeVisible();
|
|
158
|
+
await dialog.getByRole('button', {name: 'Create label'}).click();
|
|
159
|
+
await expect(page.getByTestId('created')).toHaveText('1');
|
|
160
|
+
await expect(dialog).toBeVisible();
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test.describe('the press model', () => {
|
|
165
|
+
test('a press on the row, dragged into the flyout and released on a row, picks it', async ({
|
|
166
|
+
page,
|
|
167
|
+
}) => {
|
|
168
|
+
await mount(page);
|
|
169
|
+
await page.getByRole('button', {name: 'Session'}).click();
|
|
170
|
+
const row = page.getByRole('menuitem', {name: 'Labels'});
|
|
171
|
+
await row.hover();
|
|
172
|
+
const dialog = page.getByRole('dialog', {name: 'Labels'});
|
|
173
|
+
await expect(dialog).toBeVisible();
|
|
174
|
+
const feature = dialog.getByRole('menuitemcheckbox', {name: 'Feature'});
|
|
175
|
+
const [from, to] = await Promise.all([row, feature].map(bounds));
|
|
176
|
+
await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2);
|
|
177
|
+
await page.mouse.down();
|
|
178
|
+
await page.mouse.move(from.x + from.width - 4, from.y + from.height / 2, {
|
|
179
|
+
steps: 4,
|
|
180
|
+
});
|
|
181
|
+
await page.mouse.move(to.x + to.width / 2, to.y + to.height / 2, {
|
|
182
|
+
steps: 8,
|
|
183
|
+
});
|
|
184
|
+
await page.mouse.up();
|
|
185
|
+
await expect(page.getByTestId('picked')).toHaveText('Bug, Feature');
|
|
186
|
+
// A checkbox pick keeps the sub-menu, and the menu, open.
|
|
187
|
+
await expect(dialog).toBeVisible();
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test.describe('on a phone (coarse pointer)', () => {
|
|
192
|
+
const {defaultBrowserType: _browser, ...phone} = devices['Pixel 7'];
|
|
193
|
+
test.use(phone);
|
|
194
|
+
|
|
195
|
+
test('the drilled-in view holds field, rows and button beside the root menu, which steps aside', async ({
|
|
196
|
+
page,
|
|
197
|
+
}) => {
|
|
198
|
+
await mount(page);
|
|
199
|
+
const trigger = page.getByRole('button', {name: 'Session'});
|
|
200
|
+
await trigger.tap();
|
|
201
|
+
const rootMenu = page.getByRole('menu', {name: 'Session'});
|
|
202
|
+
await expect(rootMenu).toBeVisible();
|
|
203
|
+
await page.getByRole('menuitem', {name: 'Labels'}).tap();
|
|
204
|
+
|
|
205
|
+
const dialog = page.getByRole('dialog', {name: 'Labels'});
|
|
206
|
+
await expect(dialog).toBeVisible();
|
|
207
|
+
await expect(rootMenu).toBeHidden();
|
|
208
|
+
expect(await isInsideAMenu(page, 'Labels')).toBe(false);
|
|
209
|
+
// Same popover: the view replaces the root's rows in place.
|
|
210
|
+
expect(
|
|
211
|
+
await dialog.evaluate(
|
|
212
|
+
el =>
|
|
213
|
+
el.closest('[popover]') ===
|
|
214
|
+
document
|
|
215
|
+
.querySelector('[role="menu"][aria-label="Session"]')
|
|
216
|
+
?.closest('[popover]'),
|
|
217
|
+
),
|
|
218
|
+
).toBe(true);
|
|
219
|
+
await expect(
|
|
220
|
+
dialog.getByRole('menuitem', {name: 'Back to Session'}),
|
|
221
|
+
).toBeVisible();
|
|
222
|
+
await expect(
|
|
223
|
+
dialog.getByRole('textbox', {name: 'Filter labels'}),
|
|
224
|
+
).toBeVisible();
|
|
225
|
+
await expect(
|
|
226
|
+
dialog.getByRole('button', {name: 'Create label'}),
|
|
227
|
+
).toBeVisible();
|
|
228
|
+
|
|
229
|
+
await dialog.getByRole('menuitem', {name: 'Back to Session'}).tap();
|
|
230
|
+
await expect(dialog).toBeHidden();
|
|
231
|
+
await expect(rootMenu).toBeVisible();
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test.describe('a header with a choice', () => {
|
|
236
|
+
test('a flyout the pointer has left stays open while an arrow moves the choice in its header', async ({
|
|
237
|
+
page,
|
|
238
|
+
}) => {
|
|
239
|
+
await page.goto(
|
|
240
|
+
`${storybook.origin}/iframe.html?id=core-dropdownmenu--sub-menu-header-choice&viewMode=story`,
|
|
241
|
+
);
|
|
242
|
+
await expect(page.getByTestId('submenu-choice-fixture')).toBeVisible();
|
|
243
|
+
await page.getByRole('button', {name: 'Filters'}).click();
|
|
244
|
+
const row = page.getByRole('menuitem', {name: 'Labels'});
|
|
245
|
+
await row.hover();
|
|
246
|
+
const dialog = page.getByRole('dialog', {name: 'Labels'});
|
|
247
|
+
await expect(dialog).toBeVisible();
|
|
248
|
+
const anyLabel = dialog.getByRole('radio', {name: 'Any label'});
|
|
249
|
+
const allLabels = dialog.getByRole('radio', {name: 'All labels'});
|
|
250
|
+
await anyLabel.click();
|
|
251
|
+
await expect(anyLabel).toBeFocused();
|
|
252
|
+
|
|
253
|
+
// The pointer leaves the flyout and the menu, and its hide delay runs out
|
|
254
|
+
// with focus still inside.
|
|
255
|
+
const viewport = page.viewportSize() ?? {width: 1280, height: 720};
|
|
256
|
+
await page.mouse.move(viewport.width - 8, viewport.height - 8, {steps: 4});
|
|
257
|
+
await page.waitForTimeout(500);
|
|
258
|
+
await expect(dialog).toBeVisible();
|
|
259
|
+
|
|
260
|
+
// The browser moves focus and the choice for the arrow itself.
|
|
261
|
+
await page.keyboard.press('ArrowDown');
|
|
262
|
+
await expect(allLabels).toBeFocused();
|
|
263
|
+
await expect(allLabels).toBeChecked();
|
|
264
|
+
await expect(page.getByTestId('match')).toHaveText('all');
|
|
265
|
+
await expect(dialog).toBeVisible();
|
|
266
|
+
});
|
|
267
|
+
});
|
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
* @output `tabbablesIn`, `focusRegionControl`, `moveRegionsTab`: the Tab stops
|
|
9
9
|
* of a header or footer region and the walk through a menu with regions,
|
|
10
10
|
* with a named radio group as one stop
|
|
11
|
-
* @position Internal; DropdownMenu
|
|
12
|
-
*
|
|
11
|
+
* @position Internal; used by DropdownMenu (the root menu's header and footer)
|
|
12
|
+
* and DropdownMenuSubMenu (a sub-menu's header and footer).
|
|
13
13
|
*
|
|
14
14
|
* A menu with a header or footer is a dialog: Tab and Shift+Tab walk the
|
|
15
15
|
* header's controls, the menu as one stop, then the footer's controls, and
|
|
@@ -163,6 +163,14 @@ Consumer migration instructions belong in consumer docs and release notes.
|
|
|
163
163
|
claimed by the innermost root carrying `data-astryx-menu-press` (a flyout
|
|
164
164
|
before its parent); the outermost root decides what counts as "inside the
|
|
165
165
|
menu" for the release.
|
|
166
|
+
- **ORD1a (proposed) — A menu beside the root.** A root carrying
|
|
167
|
+
`data-astryx-menu-press-surface`, which names another root's `id`, is nested
|
|
168
|
+
in that root for ORD1, as a flyout inside the root's DOM is: a release on
|
|
169
|
+
its rows is inside the menu. Only the menu of a sub-menu with a header or
|
|
170
|
+
footer, which the root renders beside itself (`component:DropdownMenu`
|
|
171
|
+
FR20), carries it. Anything else beside the root, a header field or a picker
|
|
172
|
+
placed in a region say, is outside the menu, as the root's own regions are,
|
|
173
|
+
and a picker there stays its own root. Pending owner review.
|
|
166
174
|
- **ORD2 — Release resolution.** Enabled row under the point → `act`; inside
|
|
167
175
|
the menu or on the trigger with no row → `settle` (stray click swallowed,
|
|
168
176
|
menu stays); outside → `settle` with `dismiss` only for a mouse.
|
|
@@ -238,6 +246,7 @@ owned by the item components.
|
|
|
238
246
|
| FR4 (scroll) | `MenuPress.a11y.chromium.spec.ts` (real Chromium) | Chromium touch pan in an overflowing menu; touch-action by overflow; finger slide and its stray click | Not provable in jsdom; a pan that acts on a row, or a slide that acts on the press row, fails |
|
|
239
247
|
| FR4 (pinch) | `MenuPinchZoom.a11y.chromium.spec.ts` (real Chromium, phone emulation) | two-finger pinch over a menu whose rows fit, over one that scrolls, and over a picker listbox | Not provable in jsdom; a pinch that leaves the page at scale 1, or acts on a row, fails |
|
|
240
248
|
| FR8, FR9 (proposed) | `menuPressGesture.test.ts` trigger states; `useMenuPress.test.tsx` trigger suite; `DropdownMenu.test.tsx` press-open cases | mouse opens at once, finger tap, held finger, settle before/after, cancel; press-open + drag-release, unsettled release, trigger toggle, held touch, tap | A menu that does not open on a mouse press, an unsettled release that acts, or a trigger that reopens in the same gesture fails |
|
|
249
|
+
| ORD1a (proposed) | `DropdownMenuSubMenu.test.tsx` press cases; `SubMenuHeaderFooter.a11y.chromium.spec.ts` press-drag case | a mouse press on a menu row released on a row of a sub-menu rendered beside the menu; the same press released on that sub-menu's header field | A release on such a row that acts on nothing or closes the menu, or a release on its header field that acts, fails. |
|
|
241
250
|
|
|
242
251
|
## Decision log
|
|
243
252
|
|
|
@@ -243,12 +243,35 @@ describe('FileInput', () => {
|
|
|
243
243
|
expect(trigger).not.toHaveAttribute('aria-required');
|
|
244
244
|
});
|
|
245
245
|
|
|
246
|
-
it('
|
|
246
|
+
it('keeps a disabled input focusable and out of form submission', async () => {
|
|
247
|
+
const user = userEvent.setup();
|
|
247
248
|
render(
|
|
248
|
-
<
|
|
249
|
+
<form>
|
|
250
|
+
<FileInput
|
|
251
|
+
label="Upload"
|
|
252
|
+
isRequired
|
|
253
|
+
isDisabled
|
|
254
|
+
value={null}
|
|
255
|
+
onChange={() => {}}
|
|
256
|
+
/>
|
|
257
|
+
</form>,
|
|
249
258
|
);
|
|
250
|
-
const input =
|
|
251
|
-
|
|
259
|
+
const input =
|
|
260
|
+
document.querySelector<HTMLInputElement>('input[type="file"]')!;
|
|
261
|
+
const trigger = screen.getByRole('button', {name: 'Upload'});
|
|
262
|
+
expect(input).not.toHaveAttribute('disabled');
|
|
263
|
+
expect(trigger).not.toHaveAttribute('disabled');
|
|
264
|
+
expect(trigger).toHaveAttribute('aria-disabled', 'true');
|
|
265
|
+
const openPicker = vi.spyOn(input, 'click');
|
|
266
|
+
await user.tab();
|
|
267
|
+
expect(trigger).toHaveFocus();
|
|
268
|
+
await user.keyboard('{Enter}');
|
|
269
|
+
expect(openPicker).not.toHaveBeenCalled();
|
|
270
|
+
expect(input.form).toBeNull();
|
|
271
|
+
expect(trigger.closest('form')!.checkValidity()).toBe(true);
|
|
272
|
+
// A label click activates the hidden input directly; it is canceled so
|
|
273
|
+
// the picker never opens (fireEvent returns false when prevented).
|
|
274
|
+
expect(fireEvent.click(input)).toBe(false);
|
|
252
275
|
});
|
|
253
276
|
|
|
254
277
|
it('sets aria-invalid when status type is error', () => {
|
|
@@ -1095,7 +1118,7 @@ describe('FileInput', () => {
|
|
|
1095
1118
|
expect(clickSpy).not.toHaveBeenCalled();
|
|
1096
1119
|
});
|
|
1097
1120
|
|
|
1098
|
-
it('keeps the trigger
|
|
1121
|
+
it('keeps the trigger focusable when disabled without a reason', () => {
|
|
1099
1122
|
render(
|
|
1100
1123
|
<FileInput
|
|
1101
1124
|
label="Resume"
|
|
@@ -1105,8 +1128,8 @@ describe('FileInput', () => {
|
|
|
1105
1128
|
/>,
|
|
1106
1129
|
);
|
|
1107
1130
|
const trigger = screen.getByRole('button');
|
|
1108
|
-
expect(trigger).toHaveAttribute('tabindex', '
|
|
1109
|
-
expect(trigger).
|
|
1131
|
+
expect(trigger).toHaveAttribute('tabindex', '0');
|
|
1132
|
+
expect(trigger).toHaveAttribute('aria-disabled', 'true');
|
|
1110
1133
|
});
|
|
1111
1134
|
});
|
|
1112
1135
|
});
|
|
@@ -179,7 +179,12 @@ const styles = stylex.create({
|
|
|
179
179
|
dropzoneDisabled: {
|
|
180
180
|
cursor: 'default',
|
|
181
181
|
opacity: 0.5,
|
|
182
|
-
|
|
182
|
+
// Keeps the :focus-within border: a focused disabled trigger still shows
|
|
183
|
+
// focus (spec:AST-085 FR6).
|
|
184
|
+
borderColor: {
|
|
185
|
+
default: colorVars['--color-border-emphasized'],
|
|
186
|
+
':focus-within': colorVars['--color-accent'],
|
|
187
|
+
},
|
|
183
188
|
},
|
|
184
189
|
compact: {
|
|
185
190
|
boxSizing: 'border-box',
|
|
@@ -221,7 +226,12 @@ const styles = stylex.create({
|
|
|
221
226
|
compactDisabled: {
|
|
222
227
|
cursor: 'default',
|
|
223
228
|
opacity: 0.5,
|
|
224
|
-
|
|
229
|
+
// Keeps the :focus-within border: a focused disabled trigger still shows
|
|
230
|
+
// focus (spec:AST-085 FR6).
|
|
231
|
+
borderColor: {
|
|
232
|
+
default: colorVars['--color-border-emphasized'],
|
|
233
|
+
':focus-within': colorVars['--color-accent'],
|
|
234
|
+
},
|
|
225
235
|
},
|
|
226
236
|
hiddenInput: {
|
|
227
237
|
position: 'absolute',
|
|
@@ -857,14 +867,9 @@ export function FileInput({
|
|
|
857
867
|
<VisuallyHidden>
|
|
858
868
|
<button
|
|
859
869
|
type="button"
|
|
860
|
-
//
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
// Otherwise the native disabled attribute removes it from the tab
|
|
864
|
-
// order.
|
|
865
|
-
disabled={isDisabled && !showsDisabledMessage}
|
|
866
|
-
tabIndex={isDisabled && !showsDisabledMessage ? -1 : 0}
|
|
867
|
-
aria-disabled={showsDisabledMessage ? 'true' : undefined}
|
|
870
|
+
// A disabled trigger keeps its Tab stop (spec:AST-085 FR1).
|
|
871
|
+
tabIndex={0}
|
|
872
|
+
aria-disabled={isDisabled ? 'true' : undefined}
|
|
868
873
|
onClick={handleClick}
|
|
869
874
|
onKeyDown={handleKeyDown}
|
|
870
875
|
// aria-label suppresses child content from the accessible name, so
|
|
@@ -888,7 +893,12 @@ export function FileInput({
|
|
|
888
893
|
type="file"
|
|
889
894
|
accept={accept}
|
|
890
895
|
multiple={isMultiple}
|
|
891
|
-
disabled
|
|
896
|
+
// Detached from its form while disabled, so selected files are not
|
|
897
|
+
// submitted and `required` does not block submission (FR3).
|
|
898
|
+
form={isDisabled ? '' : undefined}
|
|
899
|
+
// A label or description click activates this input directly; cancel
|
|
900
|
+
// it so a disabled input never opens the picker (FR2).
|
|
901
|
+
onClick={isDisabled ? event => event.preventDefault() : rest.onClick}
|
|
892
902
|
onChange={handleInputChange}
|
|
893
903
|
// The visually-hidden native input is never focused (tabIndex={-1});
|
|
894
904
|
// its describedby/required/invalid live on the trigger button.
|
|
@@ -163,17 +163,17 @@ These requirements describe shipped behavior on current `main`.
|
|
|
163
163
|
|
|
164
164
|
### Representative states
|
|
165
165
|
|
|
166
|
-
| State | Required invariant
|
|
167
|
-
| ------------------------ |
|
|
168
|
-
| closed with no value | Label and placeholder identify the field
|
|
169
|
-
| closed with a value | Selected option is represented in the trigger
|
|
170
|
-
| pointer / popover | Anchored surface exposes the listbox without modal-dialog semantics
|
|
171
|
-
| compact coarse pointer | BottomSheet exposes a modal dialog containing the listbox
|
|
172
|
-
| searching | Visible options, keyboard navigation, and announced result count use one filter
|
|
173
|
-
| loading | Trigger is busy; empty and no-results output is suppressed
|
|
174
|
-
| disabled with reason | Trigger remains focusable
|
|
175
|
-
| read-only | Value stays focusable and submittable; menu, clear, and disclosure affordances are absent
|
|
176
|
-
| selected/unselected rows | Empty marks consume no width; visible marks retain space at the logical edge
|
|
166
|
+
| State | Required invariant | Allowed variation |
|
|
167
|
+
| ------------------------ | ------------------------------------------------------------------------------------------- | ---------------------------------------------------------- |
|
|
168
|
+
| closed with no value | Label and placeholder identify the field | Consumer placeholder text |
|
|
169
|
+
| closed with a value | Selected option is represented in the trigger | Custom `renderValue` content |
|
|
170
|
+
| pointer / popover | Anchored surface exposes the listbox without modal-dialog semantics | Default or explicit placement |
|
|
171
|
+
| compact coarse pointer | BottomSheet exposes a modal dialog containing the listbox | Default or explicit `adaptive`, or explicit `bottom-sheet` |
|
|
172
|
+
| searching | Visible options, keyboard navigation, and announced result count use one filter | Consumer search and empty text |
|
|
173
|
+
| loading | Trigger is busy; empty and no-results output is suppressed | Consumer loading duration |
|
|
174
|
+
| disabled with reason | Trigger remains focusable (`spec:AST-085`) and exposes the reason; activation stays blocked | Consumer reason text |
|
|
175
|
+
| read-only | Value stays focusable and submittable; menu, clear, and disclosure affordances are absent | Value rendering, status, and busy presentation |
|
|
176
|
+
| selected/unselected rows | Empty marks consume no width; visible marks retain space at the logical edge | State-dependent label position/width and themed indicators |
|
|
177
177
|
|
|
178
178
|
### Transformation and precedence order
|
|
179
179
|
|
|
@@ -4809,6 +4809,22 @@ describe('Selector in a narrow row', () => {
|
|
|
4809
4809
|
expect(root.getAttribute('style')).toContain('80');
|
|
4810
4810
|
expect(getComputedStyle(root).minWidth).toBe('0');
|
|
4811
4811
|
});
|
|
4812
|
+
|
|
4813
|
+
it('lets a narrow list shrink an option so its label can ellipsize', () => {
|
|
4814
|
+
render(
|
|
4815
|
+
<Selector
|
|
4816
|
+
label="Project"
|
|
4817
|
+
options={['An option with a deliberately long readable label']}
|
|
4818
|
+
value={undefined}
|
|
4819
|
+
onChange={() => {}}
|
|
4820
|
+
isDefaultOpen
|
|
4821
|
+
/>,
|
|
4822
|
+
);
|
|
4823
|
+
const option = screen
|
|
4824
|
+
.getByRole('option', {hidden: true})
|
|
4825
|
+
.querySelector('.astryx-selector-option')!;
|
|
4826
|
+
expect(getComputedStyle(option).minWidth).toBe('0');
|
|
4827
|
+
});
|
|
4812
4828
|
});
|
|
4813
4829
|
|
|
4814
4830
|
describe('Selector and option actions (not adopted)', () => {
|
|
@@ -22,6 +22,10 @@ const embeddedStyles = stylex.create({
|
|
|
22
22
|
paddingBlock: 0,
|
|
23
23
|
paddingInline: 0,
|
|
24
24
|
borderRadius: 0,
|
|
25
|
+
// The host row lays this out as a flex item, whose automatic minimum
|
|
26
|
+
// size is the full label width. Without the reset a long label runs
|
|
27
|
+
// past a narrow popover and is clipped instead of ellipsizing.
|
|
28
|
+
minWidth: 0,
|
|
25
29
|
},
|
|
26
30
|
});
|
|
27
31
|
|
|
@@ -3691,3 +3691,38 @@ describe('SideNav collapse conflict warning', () => {
|
|
|
3691
3691
|
expect(warn).not.toHaveBeenCalled();
|
|
3692
3692
|
});
|
|
3693
3693
|
});
|
|
3694
|
+
|
|
3695
|
+
// =============================================================================
|
|
3696
|
+
// SideNavItem — collapsed-from-mount rail tooltip
|
|
3697
|
+
// =============================================================================
|
|
3698
|
+
|
|
3699
|
+
describe('SideNavItem collapsed-from-mount rail tooltip', () => {
|
|
3700
|
+
// #7692: a childless item that mounts inside an already-collapsed SideNav
|
|
3701
|
+
// renders its Tooltip inside the very element anchorRef points at. React
|
|
3702
|
+
// attaches that ref only after the tooltip's layout effect has run, so the
|
|
3703
|
+
// tooltip never attached and a persisted collapsed rail lost all tooltips
|
|
3704
|
+
// on page load.
|
|
3705
|
+
it('anchors the rail tooltip when the nav is collapsed at mount', async () => {
|
|
3706
|
+
render(
|
|
3707
|
+
<SideNav
|
|
3708
|
+
aria-label="Main"
|
|
3709
|
+
collapsible={{isCollapsed: true, hasButton: false}}>
|
|
3710
|
+
<SideNavItem label="Dashboard" icon={StubIcon} href="/dashboard" />
|
|
3711
|
+
</SideNav>,
|
|
3712
|
+
);
|
|
3713
|
+
const link = screen.getByRole('link', {name: 'Dashboard'});
|
|
3714
|
+
const anchor = link.parentElement as HTMLElement;
|
|
3715
|
+
|
|
3716
|
+
// The tooltip attaches despite mounting inside its own anchor: the item
|
|
3717
|
+
// row is described by the tooltip layer.
|
|
3718
|
+
await waitFor(() => {
|
|
3719
|
+
expect(anchor.getAttribute('aria-describedby')).not.toBe('');
|
|
3720
|
+
});
|
|
3721
|
+
const layer = document.getElementById(
|
|
3722
|
+
anchor.getAttribute('aria-describedby') as string,
|
|
3723
|
+
);
|
|
3724
|
+
expect(layer).not.toBeNull();
|
|
3725
|
+
expect(layer).toHaveAttribute('role', 'tooltip');
|
|
3726
|
+
expect(layer).toHaveTextContent('Dashboard');
|
|
3727
|
+
});
|
|
3728
|
+
});
|
|
@@ -134,8 +134,8 @@ value-tooltip behavior; AR1 records the keyboard model and its one correction.
|
|
|
134
134
|
thumb as one composite control: every thumb is `role="slider"` with an
|
|
135
135
|
accessible name (a range names each thumb), `aria-valuemin`, `aria-valuemax`,
|
|
136
136
|
`aria-valuenow`, `aria-valuetext` when a formatter changes the spoken value,
|
|
137
|
-
`aria-orientation`, and `aria-disabled` when disabled;
|
|
138
|
-
|
|
137
|
+
`aria-orientation`, and `aria-disabled` when disabled; every thumb is a Tab
|
|
138
|
+
stop, enabled or disabled (`spec:AST-085`).
|
|
139
139
|
ArrowUp and ArrowDown step by `step`; on a horizontal track the horizontal
|
|
140
140
|
arrows follow the track's direction — ArrowRight increases and ArrowLeft
|
|
141
141
|
decreases under LTR, and under RTL ArrowLeft increases and ArrowRight
|
|
@@ -225,15 +225,15 @@ separately listed value tooltip retains Tooltip's target.
|
|
|
225
225
|
|
|
226
226
|
## Verification map
|
|
227
227
|
|
|
228
|
-
| Contract | Verification
|
|
229
|
-
| ------------------- |
|
|
230
|
-
| FR1 | `Slider.test.tsx` structure, range, and marks suites
|
|
231
|
-
| AR1 | Slider accessibility contract (`internal/a11y-spec`, pending) bound to Slider
|
|
232
|
-
| FR2 | `Slider.test.tsx`, `themingTargets.test.ts`, generated probe theme, and Chromium
|
|
233
|
-
| FR3 | Source and consumer-doc review
|
|
234
|
-
| FR4 | Source inspection; focused tests cover label, status, and disabled-reason Tooltip only
|
|
235
|
-
| FR5 | Pending: `Slider.test.tsx` change-end cases driven by an owner that renders, clamps, or rejects the emitted value (not yet in the suite)
|
|
236
|
-
| Theming anatomy map | `scripts/check-knowledge.mjs`
|
|
228
|
+
| Contract | Verification | Representative states | Mutation or failure expectation | Audit section |
|
|
229
|
+
| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------- |
|
|
230
|
+
| FR1 | `Slider.test.tsx` structure, range, and marks suites | Single, range, horizontal, vertical, marks | Removing or misaligning stable parts breaks existing role, position, or mark assertions. | `audit:Slider/anatomy` |
|
|
231
|
+
| AR1 | Slider accessibility contract (`internal/a11y-spec`, pending) bound to Slider; disabled-thumb Tab stop pending conformance; in jsdom and Chromium; `Slider.test.tsx` keyboard suites, including "follows the mirrored track under RTL: ArrowLeft increases, ArrowRight decreases, Up/Down unchanged" and "keeps ArrowLeft decreasing on a vertical track under RTL"; the Page, Home/End and Up/Down cases under the contract | Single and range; horizontal LTR and RTL; vertical; disabled; formatted value | A thumb loses its role, name or value properties; an arrow, Page, Home or End key stops stepping or steps against the mirrored track; a range thumb crosses the other | `audit:Slider/accessibility` |
|
|
232
|
+
| FR2 | `Slider.test.tsx`, `themingTargets.test.ts`, generated probe theme, and Chromium | Root, horizontal/vertical control, disabled control, track, and thumb | A target class/state is missing, undocumented, or placed outside its owning anatomy. | `audit:Slider/theming` |
|
|
233
|
+
| FR3 | Source and consumer-doc review | Filled range, marks, labels, adjacent text value | A missing target is inaccurately documented as present or intentionally permanent. | `audit:Slider/theming` |
|
|
234
|
+
| FR4 | Source inspection; focused tests cover label, status, and disabled-reason Tooltip only | Label, status, value tooltip, disabled reason | Shared composition or its accessible association disappears; value-tooltip composition still lacks focused coverage. | `audit:Slider/anatomy` |
|
|
235
|
+
| FR5 | Pending: `Slider.test.tsx` change-end cases driven by an owner that renders, clamps, or rejects the emitted value (not yet in the suite) | Single and range; pointer release before and after the owner re-renders; each keyboard step | `onChangeEnd` reports a value the owner did not render, fires more than once per interaction, or skips a completed keyboard step | `audit:Slider/accessibility` |
|
|
236
|
+
| Theming anatomy map | `scripts/check-knowledge.mjs` | Canonical anatomy and four current local targets | Missing, extra, prefixed, stale, or unclaimed mappings fail repository validation. | `audit:Slider/theming` |
|
|
237
237
|
|
|
238
238
|
The focused Slider suite asserts the `slider-control` target's orientation and
|
|
239
239
|
disabled state. Source/metadata guards cover all four target declarations. The
|
|
@@ -385,7 +385,7 @@ describe('Slider', () => {
|
|
|
385
385
|
render(<Slider label="Volume" value={50} isDisabled />);
|
|
386
386
|
const slider = screen.getByRole('slider');
|
|
387
387
|
expect(slider).toHaveAttribute('aria-disabled', 'true');
|
|
388
|
-
expect(slider).toHaveAttribute('tabIndex', '
|
|
388
|
+
expect(slider).toHaveAttribute('tabIndex', '0');
|
|
389
389
|
});
|
|
390
390
|
|
|
391
391
|
it('does not fire onChange on pointer down when disabled', () => {
|
|
@@ -1111,11 +1111,11 @@ describe('Slider', () => {
|
|
|
1111
1111
|
expect(onChange).not.toHaveBeenCalled();
|
|
1112
1112
|
});
|
|
1113
1113
|
|
|
1114
|
-
it('
|
|
1114
|
+
it('stays focusable when disabled without a reason', () => {
|
|
1115
1115
|
render(
|
|
1116
1116
|
<Slider label="Volume" value={50} valueDisplay="none" isDisabled />,
|
|
1117
1117
|
);
|
|
1118
|
-
expect(screen.getByRole('slider')).toHaveAttribute('tabindex', '
|
|
1118
|
+
expect(screen.getByRole('slider')).toHaveAttribute('tabindex', '0');
|
|
1119
1119
|
});
|
|
1120
1120
|
});
|
|
1121
1121
|
describe('form participation', () => {
|