@astryxdesign/core 0.6.1 → 0.6.2-canary.0faf070
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +33 -0
- package/dist/BottomSheet/BottomSheet.d.ts +1 -1
- package/dist/BottomSheet/BottomSheet.d.ts.map +1 -1
- package/dist/BottomSheet/BottomSheet.js +3 -1
- package/dist/BottomSheet/BottomSheetPanel.d.ts +7 -5
- package/dist/BottomSheet/BottomSheetPanel.d.ts.map +1 -1
- package/dist/BottomSheet/BottomSheetPanel.js +56 -19
- package/dist/CheckboxInput/CheckboxInput.d.ts.map +1 -1
- package/dist/CheckboxInput/CheckboxInput.js +12 -2
- package/dist/Collapsible/Collapsible.d.ts.map +1 -1
- package/dist/Collapsible/Collapsible.js +6 -1
- package/dist/DateRangeInput/DateRangeInput.d.ts +3 -0
- package/dist/DateRangeInput/DateRangeInput.d.ts.map +1 -1
- package/dist/DateRangeInput/DateRangeInput.js +16 -9
- package/dist/Dialog/DialogHeader.d.ts +1 -1
- package/dist/Dialog/DialogHeader.d.ts.map +1 -1
- package/dist/Dialog/DialogHeader.js +10 -7
- package/dist/FileInput/FileInput.d.ts.map +1 -1
- package/dist/FileInput/FileInput.js +9 -3
- package/dist/Kbd/Kbd.d.ts +5 -3
- package/dist/Kbd/Kbd.d.ts.map +1 -1
- package/dist/Kbd/Kbd.js +36 -42
- package/dist/Link/Link.d.ts.map +1 -1
- package/dist/Link/Link.js +6 -2
- package/dist/Markdown/Markdown.d.ts +10 -2
- package/dist/Markdown/Markdown.d.ts.map +1 -1
- package/dist/Markdown/Markdown.js +58 -14
- package/dist/Markdown/index.d.ts +1 -1
- package/dist/Markdown/index.d.ts.map +1 -1
- package/dist/Markdown/parser.d.ts +126 -12
- package/dist/Markdown/parser.d.ts.map +1 -1
- package/dist/Markdown/parser.js +369 -34
- package/dist/Markdown/utils.d.ts +1 -1
- package/dist/Markdown/utils.d.ts.map +1 -1
- package/dist/PowerSearch/PowerSearchEditPopover.d.ts.map +1 -1
- package/dist/PowerSearch/PowerSearchEditPopover.js +46 -30
- package/dist/RadioList/RadioListItem.d.ts.map +1 -1
- package/dist/RadioList/RadioListItem.js +13 -1
- package/dist/SegmentedControl/SegmentedControlItem.d.ts.map +1 -1
- package/dist/SegmentedControl/SegmentedControlItem.js +5 -5
- package/dist/SideNav/SideNav.d.ts +2 -1
- package/dist/SideNav/SideNav.d.ts.map +1 -1
- package/dist/SideNav/SideNav.js +9 -3
- package/dist/Slider/Slider.d.ts.map +1 -1
- package/dist/Slider/Slider.js +19 -10
- package/dist/Spinner/Spinner.d.ts +1 -1
- package/dist/Spinner/Spinner.d.ts.map +1 -1
- package/dist/Spinner/Spinner.js +23 -15
- package/dist/Switch/Switch.d.ts.map +1 -1
- package/dist/Switch/Switch.js +11 -0
- package/dist/TabList/Tab.d.ts +1 -1
- package/dist/TabList/Tab.d.ts.map +1 -1
- package/dist/TabList/Tab.js +20 -7
- package/dist/ToggleButton/ToggleButton.d.ts +2 -1
- package/dist/ToggleButton/ToggleButton.d.ts.map +1 -1
- package/dist/ToggleButton/ToggleButton.js +7 -1
- package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
- package/dist/Typeahead/BaseTypeahead.js +15 -6
- package/dist/astryx.css +13 -2
- package/dist/hooks/scrollKeyboardDelegation.d.ts +3 -0
- package/dist/hooks/scrollKeyboardDelegation.d.ts.map +1 -0
- package/dist/hooks/scrollKeyboardDelegation.js +146 -0
- package/dist/hooks/useScrollableArea.d.ts +6 -2
- package/dist/hooks/useScrollableArea.d.ts.map +1 -1
- package/dist/hooks/useScrollableArea.js +17 -5
- package/dist/utils/interactionOverlay.stylex.d.ts +8 -0
- package/dist/utils/interactionOverlay.stylex.d.ts.map +1 -1
- package/dist/utils/interactionOverlay.stylex.js +9 -0
- package/locales/en.json +16 -0
- package/locales/pseudo.json +12 -0
- package/package.json +7 -5
- package/scripts/agent-doc-state.mjs +1 -1
- package/src/BottomSheet/BottomSheet.doc.mjs +8 -1
- package/src/BottomSheet/BottomSheet.spec.md +46 -20
- package/src/BottomSheet/BottomSheet.test.tsx +6 -3
- package/src/BottomSheet/BottomSheet.tsx +3 -1
- package/src/BottomSheet/BottomSheetKeyboard.test.tsx +195 -0
- package/src/BottomSheet/BottomSheetPanel.test.tsx +11 -1
- package/src/BottomSheet/BottomSheetPanel.tsx +49 -16
- package/src/BottomSheet/__tests__/BottomSheetKeyboard.a11y.browser.spec.ts +344 -0
- package/src/Button/__tests__/Button.a11y.chromium.spec.ts +17 -1
- package/src/Button/__tests__/Button.a11y.known-failures.ts +0 -29
- package/src/Button/__tests__/Button.a11y.renders.tsx +9 -2
- package/src/Button/__tests__/Button.a11y.states.ts +9 -0
- package/src/CheckboxInput/CheckboxInput.doc.mjs +11 -0
- package/src/CheckboxInput/CheckboxInput.test.tsx +34 -0
- package/src/CheckboxInput/CheckboxInput.tsx +21 -1
- package/src/ClickableCard/ClickableCard.test.tsx +102 -5
- package/src/Collapsible/Collapsible.doc.mjs +11 -0
- package/src/Collapsible/Collapsible.test.tsx +21 -0
- package/src/Collapsible/Collapsible.tsx +5 -0
- package/src/DateRangeInput/DateRangeInput.doc.mjs +35 -7
- package/src/DateRangeInput/DateRangeInput.spec.md +203 -0
- package/src/DateRangeInput/DateRangeInput.test.tsx +100 -4
- package/src/DateRangeInput/DateRangeInput.tsx +29 -20
- package/src/Dialog/Dialog.doc.mjs +3 -0
- package/src/Dialog/Dialog.spec.md +1 -1
- package/src/Dialog/DialogHeader.doc.mjs +38 -0
- package/src/Dialog/DialogHeader.test.tsx +49 -0
- package/src/Dialog/DialogHeader.tsx +23 -4
- package/src/Dialog/modules/DialogHeader.spec.md +152 -0
- package/src/FileInput/FileInput.doc.mjs +2 -0
- package/src/FileInput/FileInput.spec.md +199 -0
- package/src/FileInput/FileInput.test.tsx +14 -0
- package/src/FileInput/FileInput.tsx +13 -3
- package/src/Kbd/Kbd.doc.mjs +3 -3
- package/src/Kbd/Kbd.test.tsx +57 -1
- package/src/Kbd/Kbd.tsx +57 -37
- package/src/Link/Link.doc.mjs +11 -0
- package/src/Link/Link.test.tsx +24 -0
- package/src/Link/Link.tsx +5 -0
- package/src/Markdown/Markdown.doc.mjs +167 -42
- package/src/Markdown/Markdown.public.test.ts +157 -0
- package/src/Markdown/Markdown.spec.md +255 -71
- package/src/Markdown/Markdown.test.tsx +107 -3
- package/src/Markdown/Markdown.tsx +116 -35
- package/src/Markdown/incremental.test.ts +175 -7
- package/src/Markdown/index.ts +6 -0
- package/src/Markdown/parser.perf.test.ts +3 -1
- package/src/Markdown/parser.test.ts +122 -0
- package/src/Markdown/parser.ts +609 -81
- package/src/Markdown/utils.ts +6 -0
- package/src/Outline/Outline.spec.md +1 -1
- package/src/Outline/modules/parseOutlineFromMarkdown.spec.md +142 -0
- package/src/PowerSearch/PowerSearchEditPopover.test.tsx +150 -1
- package/src/PowerSearch/PowerSearchEditPopover.tsx +51 -28
- package/src/RadioList/RadioList.doc.mjs +11 -0
- package/src/RadioList/RadioList.test.tsx +32 -0
- package/src/RadioList/RadioListItem.tsx +25 -1
- package/src/ScrollableArea/modules/useScrollableArea.spec.md +50 -22
- package/src/SegmentedControl/SegmentedControl.doc.mjs +2 -2
- package/src/SegmentedControl/SegmentedControl.test.tsx +31 -0
- package/src/SegmentedControl/SegmentedControlItem.tsx +6 -9
- package/src/SideNav/SideNav.doc.mjs +1 -1
- package/src/SideNav/SideNav.test.tsx +10 -0
- package/src/SideNav/SideNav.tsx +14 -2
- package/src/Slider/Slider.doc.mjs +27 -0
- package/src/Slider/Slider.spec.md +61 -47
- package/src/Slider/Slider.test.tsx +146 -0
- package/src/Slider/Slider.tsx +37 -13
- package/src/Spinner/Spinner.doc.mjs +6 -3
- package/src/Spinner/Spinner.test.tsx +37 -0
- package/src/Spinner/Spinner.tsx +31 -14
- package/src/Switch/Switch.doc.mjs +11 -0
- package/src/Switch/Switch.test.tsx +16 -0
- package/src/Switch/Switch.tsx +28 -0
- package/src/TabList/Tab.tsx +35 -7
- package/src/TabList/TabList.doc.mjs +11 -0
- package/src/TabList/TabList.test.tsx +66 -0
- package/src/TabList/__tests__/Tabs.a11y.known-failures.ts +1 -34
- package/src/ToggleButton/ToggleButton.test.tsx +133 -0
- package/src/ToggleButton/ToggleButton.tsx +9 -2
- package/src/ToggleButton/__tests__/ToggleButton.a11y.chromium.spec.ts +209 -0
- package/src/Tokenizer/Tokenizer.spec.md +142 -75
- package/src/Typeahead/BaseTypeahead.spec.md +4 -3
- package/src/Typeahead/BaseTypeahead.tsx +15 -6
- package/src/Typeahead/Typeahead.test.tsx +53 -0
- package/src/__tests__/PressedState.a11y.chromium.spec.ts +813 -0
- package/src/__tests__/pressState.ts +93 -0
- package/src/hooks/scrollKeyboardDelegation.test.ts +155 -0
- package/src/hooks/scrollKeyboardDelegation.ts +233 -0
- package/src/hooks/useScrollableArea.doc.mjs +15 -3
- package/src/hooks/useScrollableArea.test.tsx +59 -1
- package/src/hooks/useScrollableArea.ts +34 -10
- package/src/theme/derivedVarRegistry.test.ts +6 -4
- package/src/utils/interactionOverlay.stylex.ts +20 -0
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file BottomSheetKeyboard.a11y.browser.spec.ts
|
|
5
|
+
* @input Built BottomSheet stories and real Chromium/WebKit keyboard input
|
|
6
|
+
* @output Browser evidence for shared focus delegation, traversal, and native scrolling
|
|
7
|
+
* @position Cross-browser proof for useScrollableArea's contentOrViewport policy
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {expect, test, type Page, type Locator} from '@playwright/test';
|
|
11
|
+
import {
|
|
12
|
+
DEFAULT_STORYBOOK_DIR,
|
|
13
|
+
serveStorybook,
|
|
14
|
+
type StaticServer,
|
|
15
|
+
} from '@astryxdesign/a11y-spec/storybook';
|
|
16
|
+
|
|
17
|
+
let storybook: StaticServer;
|
|
18
|
+
test.beforeAll(async () => {
|
|
19
|
+
storybook = await serveStorybook(
|
|
20
|
+
process.env.ASTRYX_STORYBOOK_DIR ?? DEFAULT_STORYBOOK_DIR,
|
|
21
|
+
);
|
|
22
|
+
});
|
|
23
|
+
test.afterAll(async () => {
|
|
24
|
+
await storybook?.close();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// macOS WebKit defaults to text-field traversal. Option+Tab selects its native
|
|
28
|
+
// all-controls traversal, equivalent to enabling Safari's keyboard preference.
|
|
29
|
+
function tabKey(browserName: string, reverse = false): string {
|
|
30
|
+
return `${browserName === 'webkit' && process.platform === 'darwin' ? 'Alt+' : ''}${reverse ? 'Shift+' : ''}Tab`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function mount(page: Page, browserName: string, variant = 'button') {
|
|
34
|
+
await page.goto(
|
|
35
|
+
`${storybook.origin}/iframe.html?id=core-bottomsheet--keyboard-delegation&viewMode=story`,
|
|
36
|
+
);
|
|
37
|
+
const viewport = page.getByRole('group', {
|
|
38
|
+
name: 'Keyboard reading',
|
|
39
|
+
exact: true,
|
|
40
|
+
});
|
|
41
|
+
await expect(viewport).toHaveAttribute('data-scrollable-block', 'true');
|
|
42
|
+
await page.getByLabel('Content case').selectOption(variant);
|
|
43
|
+
await expect(viewport).toHaveAttribute('tabindex', '0');
|
|
44
|
+
// WebKit includes the non-modal dialog host in sequential navigation;
|
|
45
|
+
// Chromium enters its descendants directly. Start immediately before the body.
|
|
46
|
+
const before =
|
|
47
|
+
browserName === 'webkit'
|
|
48
|
+
? page.getByRole('dialog', {name: 'Keyboard reading', exact: true})
|
|
49
|
+
: page.getByRole('button', {name: 'Toggle modal', exact: true});
|
|
50
|
+
await before.focus();
|
|
51
|
+
return {viewport, before};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
for (const variant of ['button', 'link']) {
|
|
55
|
+
test(`${variant}: forward entry delegates once and reverse traversal exits`, async ({
|
|
56
|
+
page,
|
|
57
|
+
browserName,
|
|
58
|
+
}) => {
|
|
59
|
+
const {viewport, before} = await mount(page, browserName, variant);
|
|
60
|
+
const first = page.getByRole(variant === 'button' ? 'button' : 'link', {
|
|
61
|
+
name: variant === 'button' ? 'First action' : 'First link',
|
|
62
|
+
exact: true,
|
|
63
|
+
});
|
|
64
|
+
await page.keyboard.press(tabKey(browserName));
|
|
65
|
+
await expect(first).toBeFocused();
|
|
66
|
+
await expect(viewport).toHaveAttribute('tabindex', '0');
|
|
67
|
+
await expect(viewport).toHaveAccessibleName('Keyboard reading');
|
|
68
|
+
await expect(first).toHaveAccessibleName(
|
|
69
|
+
variant === 'button' ? 'First action' : 'First link',
|
|
70
|
+
);
|
|
71
|
+
await page.keyboard.press(tabKey(browserName));
|
|
72
|
+
await expect(page.getByRole('button', {name: 'Last action'})).toBeFocused();
|
|
73
|
+
await page.keyboard.press(tabKey(browserName, true));
|
|
74
|
+
await expect(first).toBeFocused();
|
|
75
|
+
await page.keyboard.press(tabKey(browserName, true));
|
|
76
|
+
await expect(before).toBeFocused();
|
|
77
|
+
await expect(viewport).toHaveAttribute('tabindex', '0');
|
|
78
|
+
await page.keyboard.press(tabKey(browserName));
|
|
79
|
+
await expect(first).toBeFocused();
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
for (const variant of [
|
|
84
|
+
'text',
|
|
85
|
+
'input',
|
|
86
|
+
'disabled',
|
|
87
|
+
'nested',
|
|
88
|
+
'native-scroll',
|
|
89
|
+
'radiogroup',
|
|
90
|
+
'slider',
|
|
91
|
+
'combobox',
|
|
92
|
+
'listbox',
|
|
93
|
+
'menu',
|
|
94
|
+
'grid',
|
|
95
|
+
'tree',
|
|
96
|
+
'tablist',
|
|
97
|
+
'toolbar',
|
|
98
|
+
]) {
|
|
99
|
+
test(`${variant}: retains a named viewport stop`, async ({
|
|
100
|
+
page,
|
|
101
|
+
browserName,
|
|
102
|
+
}) => {
|
|
103
|
+
const {viewport, before} = await mount(page, browserName, variant);
|
|
104
|
+
await page.keyboard.press(tabKey(browserName));
|
|
105
|
+
await expect(viewport).toBeFocused();
|
|
106
|
+
await expect(viewport).toHaveAccessibleName('Keyboard reading');
|
|
107
|
+
await page.keyboard.press(tabKey(browserName, true));
|
|
108
|
+
await expect(before).toBeFocused();
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function scrollRange(viewport: Locator) {
|
|
113
|
+
return viewport.evaluate(element => ({
|
|
114
|
+
position: element.scrollTop,
|
|
115
|
+
max: element.scrollHeight - element.clientHeight,
|
|
116
|
+
}));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
for (const key of ['PageDown', 'ArrowDown']) {
|
|
120
|
+
test(`${key}: reaches both scroll edges after delegation`, async ({
|
|
121
|
+
page,
|
|
122
|
+
browserName,
|
|
123
|
+
}) => {
|
|
124
|
+
const {viewport} = await mount(page, browserName);
|
|
125
|
+
await page.keyboard.press(tabKey(browserName));
|
|
126
|
+
const first = page.getByRole('button', {name: 'First action', exact: true});
|
|
127
|
+
await expect(first).toBeFocused();
|
|
128
|
+
for (let count = 0; count < 300; count++) {
|
|
129
|
+
const {position, max} = await scrollRange(viewport);
|
|
130
|
+
if (position >= max - 1) {
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
await page.keyboard.press(key, {delay: key === 'ArrowDown' ? 50 : 25});
|
|
134
|
+
}
|
|
135
|
+
await expect
|
|
136
|
+
.poll(async () => {
|
|
137
|
+
const {position, max} = await scrollRange(viewport);
|
|
138
|
+
// WebKit can overshoot a native edge while showing rubber-band feedback.
|
|
139
|
+
return max - position;
|
|
140
|
+
})
|
|
141
|
+
.toBeLessThanOrEqual(1);
|
|
142
|
+
for (let count = 0; count < 300; count++) {
|
|
143
|
+
if ((await scrollRange(viewport)).position <= 1) {
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
await page.keyboard.press(key === 'PageDown' ? 'PageUp' : 'ArrowUp', {
|
|
147
|
+
delay: key === 'ArrowDown' ? 50 : 25,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
await expect
|
|
151
|
+
.poll(async () => (await scrollRange(viewport)).position)
|
|
152
|
+
.toBeLessThanOrEqual(1);
|
|
153
|
+
await expect(first).toBeFocused();
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
test('pointer and programmatic focus stay on the viewport, including during keydown', async ({
|
|
158
|
+
page,
|
|
159
|
+
browserName,
|
|
160
|
+
}) => {
|
|
161
|
+
const {viewport, before} = await mount(page, browserName);
|
|
162
|
+
await viewport.focus();
|
|
163
|
+
await expect(viewport).toBeFocused();
|
|
164
|
+
await before.focus();
|
|
165
|
+
await viewport.click({position: {x: 4, y: 40}});
|
|
166
|
+
await expect(viewport).toBeFocused();
|
|
167
|
+
await before.focus();
|
|
168
|
+
await before.evaluate(element => {
|
|
169
|
+
element.addEventListener('keydown', event => {
|
|
170
|
+
if (event instanceof KeyboardEvent && event.key === 'Tab') {
|
|
171
|
+
event.preventDefault();
|
|
172
|
+
document
|
|
173
|
+
.querySelector<HTMLElement>(
|
|
174
|
+
'[role="group"][aria-label="Keyboard reading"]',
|
|
175
|
+
)
|
|
176
|
+
?.focus();
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
await page.keyboard.press(tabKey(browserName));
|
|
181
|
+
await expect(viewport).toBeFocused();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test('dynamic eligibility is checked at the next entry without mutation observation', async ({
|
|
185
|
+
page,
|
|
186
|
+
browserName,
|
|
187
|
+
}) => {
|
|
188
|
+
await page.addInitScript(() => {
|
|
189
|
+
const Original = window.MutationObserver;
|
|
190
|
+
const observers: MutationObserver[] = [];
|
|
191
|
+
window.MutationObserver = class extends Original {
|
|
192
|
+
constructor(callback: MutationCallback) {
|
|
193
|
+
super(callback);
|
|
194
|
+
observers.push(this);
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
Object.assign(window, {
|
|
198
|
+
stopMutationObservers: () =>
|
|
199
|
+
observers.forEach(observer => observer.disconnect()),
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
const {viewport, before} = await mount(page, browserName);
|
|
203
|
+
await page.evaluate(() => {
|
|
204
|
+
(
|
|
205
|
+
window as unknown as {stopMutationObservers: () => void}
|
|
206
|
+
).stopMutationObservers();
|
|
207
|
+
});
|
|
208
|
+
await viewport.focus();
|
|
209
|
+
const first = page.getByRole('button', {name: 'First action', exact: true});
|
|
210
|
+
await first.evaluate(element => element.setAttribute('disabled', ''));
|
|
211
|
+
await expect(viewport).toBeFocused();
|
|
212
|
+
await before.focus();
|
|
213
|
+
await page.keyboard.press(tabKey(browserName));
|
|
214
|
+
await expect(page.getByRole('button', {name: 'Last action'})).toBeFocused();
|
|
215
|
+
await before.focus();
|
|
216
|
+
await first.evaluate(element => element.removeAttribute('disabled'));
|
|
217
|
+
await page.keyboard.press(tabKey(browserName));
|
|
218
|
+
await expect(first).toBeFocused();
|
|
219
|
+
await before.focus();
|
|
220
|
+
await viewport.locator('[data-scroll-content]').evaluate(element => {
|
|
221
|
+
const input = document.createElement('input');
|
|
222
|
+
input.setAttribute('aria-label', 'Inserted input');
|
|
223
|
+
element.prepend(input);
|
|
224
|
+
});
|
|
225
|
+
await page.keyboard.press(tabKey(browserName));
|
|
226
|
+
await expect(viewport).toBeFocused();
|
|
227
|
+
await page.getByLabel('Inserted input').evaluate(element => element.remove());
|
|
228
|
+
await expect(viewport).toBeFocused();
|
|
229
|
+
await before.focus();
|
|
230
|
+
await page.keyboard.press(tabKey(browserName));
|
|
231
|
+
await expect(first).toBeFocused();
|
|
232
|
+
await before.focus();
|
|
233
|
+
await first.evaluate(element => element.setAttribute('role', 'combobox'));
|
|
234
|
+
await page.keyboard.press(tabKey(browserName));
|
|
235
|
+
await expect(viewport).toBeFocused();
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test('modal BottomSheet keeps native focus containment without cycling through the viewport', async ({
|
|
239
|
+
page,
|
|
240
|
+
browserName,
|
|
241
|
+
}) => {
|
|
242
|
+
await mount(page, browserName);
|
|
243
|
+
await page.getByRole('button', {name: 'Toggle modal', exact: true}).click();
|
|
244
|
+
const dialog = page.getByRole('dialog', {
|
|
245
|
+
name: 'Keyboard reading',
|
|
246
|
+
exact: true,
|
|
247
|
+
});
|
|
248
|
+
await expect
|
|
249
|
+
.poll(async () => dialog.evaluate(element => element.matches(':modal')))
|
|
250
|
+
.toBe(true);
|
|
251
|
+
const viewport = page.getByRole('group', {
|
|
252
|
+
name: 'Keyboard reading',
|
|
253
|
+
exact: true,
|
|
254
|
+
});
|
|
255
|
+
await expect(viewport).toHaveAttribute('data-scrollable-block', 'true');
|
|
256
|
+
const panel = dialog.locator('.astryx-bottom-sheet');
|
|
257
|
+
await panel.focus();
|
|
258
|
+
await page.keyboard.press(tabKey(browserName));
|
|
259
|
+
const first = page.getByRole('button', {name: 'First action', exact: true});
|
|
260
|
+
await expect(first).toBeFocused();
|
|
261
|
+
await page.keyboard.press(tabKey(browserName, true));
|
|
262
|
+
await expect(viewport).not.toBeFocused();
|
|
263
|
+
await expect(first).not.toBeFocused();
|
|
264
|
+
// Native dialog traversal may wrap to another sheet target or browser chrome;
|
|
265
|
+
// it must never focus the inert background or create a viewport/child loop.
|
|
266
|
+
await expect(
|
|
267
|
+
page.getByRole('button', {name: 'Before sheet', includeHidden: true}),
|
|
268
|
+
).not.toBeFocused();
|
|
269
|
+
await expect(viewport).toHaveAttribute('tabindex', '0');
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test('fitting text removes the viewport stop while preserving current focus', async ({
|
|
273
|
+
page,
|
|
274
|
+
browserName,
|
|
275
|
+
}) => {
|
|
276
|
+
const {viewport} = await mount(page, browserName, 'text');
|
|
277
|
+
await viewport.focus();
|
|
278
|
+
await viewport.locator('[data-scroll-content]').evaluate(element => {
|
|
279
|
+
element.textContent = 'Short reading content';
|
|
280
|
+
});
|
|
281
|
+
await expect(viewport).not.toHaveAttribute('data-scrollable-block', 'true');
|
|
282
|
+
await expect(viewport).toHaveAttribute('tabindex', '-1');
|
|
283
|
+
await expect(viewport).toBeFocused();
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test('horizontal overflow remains keyboard scrollable after delegation', async ({
|
|
287
|
+
page,
|
|
288
|
+
browserName,
|
|
289
|
+
}) => {
|
|
290
|
+
const {viewport, before} = await mount(page, browserName);
|
|
291
|
+
await viewport.evaluate(element => {
|
|
292
|
+
const content = element.querySelector<HTMLElement>('[data-scroll-content]');
|
|
293
|
+
if (content != null) {
|
|
294
|
+
content.style.width = `${element.clientWidth + 320}px`;
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
await expect(viewport).toHaveAttribute('data-scrollable-inline', 'true');
|
|
298
|
+
await before.focus();
|
|
299
|
+
await page.keyboard.press(tabKey(browserName));
|
|
300
|
+
await expect(
|
|
301
|
+
page.getByRole('button', {name: 'First action', exact: true}),
|
|
302
|
+
).toBeFocused();
|
|
303
|
+
for (const key of ['ArrowRight', 'ArrowLeft']) {
|
|
304
|
+
for (let count = 0; count < 100; count++) {
|
|
305
|
+
const atEdge = await viewport.evaluate(
|
|
306
|
+
(element, forward) =>
|
|
307
|
+
forward
|
|
308
|
+
? element.scrollLeft >=
|
|
309
|
+
element.scrollWidth - element.clientWidth - 1
|
|
310
|
+
: element.scrollLeft <= 1,
|
|
311
|
+
key === 'ArrowRight',
|
|
312
|
+
);
|
|
313
|
+
if (atEdge) {
|
|
314
|
+
break;
|
|
315
|
+
}
|
|
316
|
+
await page.keyboard.press(key, {delay: 50});
|
|
317
|
+
}
|
|
318
|
+
await expect
|
|
319
|
+
.poll(async () =>
|
|
320
|
+
viewport.evaluate(
|
|
321
|
+
(element, forward) =>
|
|
322
|
+
forward
|
|
323
|
+
? element.scrollWidth - element.clientWidth - element.scrollLeft
|
|
324
|
+
: element.scrollLeft,
|
|
325
|
+
key === 'ArrowRight',
|
|
326
|
+
),
|
|
327
|
+
)
|
|
328
|
+
.toBeLessThanOrEqual(1);
|
|
329
|
+
}
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
test('does not skip an aria-hidden first tab stop to delegate to a later action', async ({
|
|
333
|
+
page,
|
|
334
|
+
browserName,
|
|
335
|
+
}) => {
|
|
336
|
+
const {viewport} = await mount(page, browserName);
|
|
337
|
+
await page
|
|
338
|
+
.getByRole('button', {name: 'First action', exact: true})
|
|
339
|
+
.evaluate(element => {
|
|
340
|
+
element.setAttribute('aria-hidden', 'true');
|
|
341
|
+
});
|
|
342
|
+
await page.keyboard.press(tabKey(browserName));
|
|
343
|
+
await expect(viewport).toBeFocused();
|
|
344
|
+
});
|
|
@@ -21,7 +21,13 @@
|
|
|
21
21
|
* ./Button.a11y.known-failures.ts, both shared with the jsdom lane.
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
expect,
|
|
26
|
+
test,
|
|
27
|
+
type CDPSession,
|
|
28
|
+
type Locator,
|
|
29
|
+
type Page,
|
|
30
|
+
} from '@playwright/test';
|
|
25
31
|
import {
|
|
26
32
|
BUTTON_PATTERN,
|
|
27
33
|
blockingResults,
|
|
@@ -93,6 +99,14 @@ async function activationsOn(page: Page): Promise<number> {
|
|
|
93
99
|
return Number(raw ?? '0');
|
|
94
100
|
}
|
|
95
101
|
|
|
102
|
+
function pointerTargetFor(
|
|
103
|
+
page: Page,
|
|
104
|
+
state: ButtonBindingRow,
|
|
105
|
+
): Locator | undefined {
|
|
106
|
+
const selector = (state as ButtonBindingState).pointerTargetSelector;
|
|
107
|
+
return selector == null ? undefined : page.locator(selector).first();
|
|
108
|
+
}
|
|
109
|
+
|
|
96
110
|
async function runState(
|
|
97
111
|
page: Page,
|
|
98
112
|
cdp: CDPSession,
|
|
@@ -111,6 +125,7 @@ async function runState(
|
|
|
111
125
|
return createChromiumHarness({
|
|
112
126
|
page,
|
|
113
127
|
subject: page.locator('#storybook-root').getByRole('button').first(),
|
|
128
|
+
pointerTarget: pointerTargetFor(page, state),
|
|
114
129
|
cdp,
|
|
115
130
|
});
|
|
116
131
|
},
|
|
@@ -306,6 +321,7 @@ test('every expectation is exercised by at least one bound state', async ({
|
|
|
306
321
|
.locator('#storybook-root')
|
|
307
322
|
.getByRole('button')
|
|
308
323
|
.first(),
|
|
324
|
+
pointerTarget: pointerTargetFor(page, state),
|
|
309
325
|
cdp,
|
|
310
326
|
});
|
|
311
327
|
},
|
|
@@ -83,33 +83,4 @@ export const BUTTON_KNOWN_FAILURES: ReadonlyArray<KnownFailure> = [
|
|
|
83
83
|
userImpact: 'The same, from an icon button.',
|
|
84
84
|
reason: 'Inherited from Button, as above.',
|
|
85
85
|
},
|
|
86
|
-
|
|
87
|
-
// ---- ClickableCard's role-bearing element cannot be clicked -------------
|
|
88
|
-
{
|
|
89
|
-
expectation: 'button.action.survives-an-aborted-press',
|
|
90
|
-
binding: 'ClickableCard',
|
|
91
|
-
state: 'clickable-card',
|
|
92
|
-
evidenceLayer: 'real-browser',
|
|
93
|
-
failureEquals:
|
|
94
|
-
'a pointer press cannot land on this control: something else is on top of it at its own centre, so there is no press here to abort',
|
|
95
|
-
standardsReference: 'WCAG 2.2 2.5.2 Pointer Cancellation (Level A)',
|
|
96
|
-
userImpact:
|
|
97
|
-
'The same defect seen from the other side: pointer cancellation cannot be demonstrated on a control no pointer press can land on. Recorded rather than reported green — a serene pass here would claim an outcome nobody observed.',
|
|
98
|
-
reason:
|
|
99
|
-
'Same cause as the record above. It is a second record rather than a wider one because a known failure names exactly one outcome, and these two would be fixed and verified separately.',
|
|
100
|
-
},
|
|
101
|
-
{
|
|
102
|
-
expectation: 'button.action.runs-on-pointer',
|
|
103
|
-
binding: 'ClickableCard',
|
|
104
|
-
state: 'clickable-card',
|
|
105
|
-
evidenceLayer: 'real-browser',
|
|
106
|
-
failureEquals:
|
|
107
|
-
'a pointer could not reach this control within 2000ms: the browser never found it visible, stable, and able to receive a pointer event. Something is covering it, or it is clipped to nothing.',
|
|
108
|
-
standardsReference:
|
|
109
|
-
'Current Astryx buttons family FR2 and WAI-ARIA APG Button operability requirement; supports WCAG 2.2 4.1.2 Name, Role, Value (Level A)',
|
|
110
|
-
userImpact:
|
|
111
|
-
"Anyone whose tooling acts on the accessible object rather than on pixels — a speech-input user saying the card's name, or assistive technology dispatching a click at the element it is told is the button — aims at a 1×1 control the card's own content paints over. A sighted mouse user is unaffected: they click the card surface, which handles it.",
|
|
112
|
-
reason:
|
|
113
|
-
'ClickableCard deliberately splits the surface that takes the click from the hidden button that carries the role and name. Reconciling the two is a design-system decision, not a change this contract should make.',
|
|
114
|
-
},
|
|
115
86
|
];
|
|
@@ -88,12 +88,19 @@ export const BUTTON_STATE_RENDERS: Record<ButtonStateId, StateRender> = {
|
|
|
88
88
|
),
|
|
89
89
|
|
|
90
90
|
'clickable-card': activate => (
|
|
91
|
-
<ClickableCard
|
|
91
|
+
<ClickableCard
|
|
92
|
+
data-a11y-pointer-target
|
|
93
|
+
label="Open billing settings"
|
|
94
|
+
onClick={activate}>
|
|
92
95
|
<div>Billing</div>
|
|
93
96
|
</ClickableCard>
|
|
94
97
|
),
|
|
95
98
|
'clickable-card-disabled': activate => (
|
|
96
|
-
<ClickableCard
|
|
99
|
+
<ClickableCard
|
|
100
|
+
data-a11y-pointer-target
|
|
101
|
+
label="Open billing settings"
|
|
102
|
+
isDisabled
|
|
103
|
+
onClick={activate}>
|
|
97
104
|
<div>Billing</div>
|
|
98
105
|
</ClickableCard>
|
|
99
106
|
),
|
|
@@ -92,6 +92,13 @@ export interface ButtonBindingState {
|
|
|
92
92
|
* typo here is reported as a stale inventory, not as a standards failure.
|
|
93
93
|
*/
|
|
94
94
|
readonly visibleLabel: string | null;
|
|
95
|
+
/**
|
|
96
|
+
* The surface that receives pointer input when it is not the role-bearing
|
|
97
|
+
* element. ClickableCard keeps its button/link hidden for role, name, and
|
|
98
|
+
* keyboard focus, and takes the pointer on the card surface; a binding that
|
|
99
|
+
* aimed the pointer at the hidden control would be testing the wrong surface.
|
|
100
|
+
*/
|
|
101
|
+
readonly pointerTargetSelector?: string;
|
|
95
102
|
/** The checked-in Storybook story the Chromium lane drives. */
|
|
96
103
|
readonly storyId: string;
|
|
97
104
|
/**
|
|
@@ -220,6 +227,7 @@ export const BUTTON_BINDING_STATES = [
|
|
|
220
227
|
"a card whose whole surface performs an action, through the hidden button that carries the card's role and name",
|
|
221
228
|
facts: facts(),
|
|
222
229
|
visibleLabel: null,
|
|
230
|
+
pointerTargetSelector: '[data-a11y-pointer-target]',
|
|
223
231
|
storyId: 'a11y-button-pattern--clickable-card-default',
|
|
224
232
|
},
|
|
225
233
|
{
|
|
@@ -228,6 +236,7 @@ export const BUTTON_BINDING_STATES = [
|
|
|
228
236
|
summary: 'a disabled action card',
|
|
229
237
|
facts: facts({operable: false, focusable: false, unavailable: true}),
|
|
230
238
|
visibleLabel: null,
|
|
239
|
+
pointerTargetSelector: '[data-a11y-pointer-target]',
|
|
231
240
|
storyId: 'a11y-button-pattern--clickable-card-disabled',
|
|
232
241
|
},
|
|
233
242
|
|
|
@@ -137,6 +137,17 @@ export const docs = {
|
|
|
137
137
|
],
|
|
138
138
|
},
|
|
139
139
|
usage: {
|
|
140
|
+
accessibility: [
|
|
141
|
+
{
|
|
142
|
+
name: 'Checkbox box',
|
|
143
|
+
category: 'Color contrast',
|
|
144
|
+
criterion: '1.4.11 Non-text Contrast',
|
|
145
|
+
requirement: '3:1',
|
|
146
|
+
states: ['Rest', 'Hover', 'Pointer down', 'Checked'],
|
|
147
|
+
description:
|
|
148
|
+
'The box edge (unchecked) and fill (checked) must have at least 3:1 contrast with the surface behind them. For Hover and Pointer down, measure the final colors after the tint and the pressed overlay are applied.',
|
|
149
|
+
},
|
|
150
|
+
],
|
|
140
151
|
description: 'CheckboxInput toggles a single on/off value. Use it for settings like "Enable notifications", terms acceptance, or opt-in choices. For multiple checkboxes in a group, use CheckboxList instead.',
|
|
141
152
|
bestPractices: [
|
|
142
153
|
{ guidance: true, description: 'Always provide a visible label so the user knows what they are toggling. Use isLabelHidden only when surrounding context makes it obvious.' },
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest';
|
|
16
16
|
import {render, screen, fireEvent, waitFor} from '@testing-library/react';
|
|
17
17
|
import userEvent from '@testing-library/user-event';
|
|
18
|
+
import {hasPressedArm} from '../__tests__/pressState';
|
|
18
19
|
import {CheckboxInput} from './CheckboxInput';
|
|
19
20
|
import {Theme} from '../theme/Theme';
|
|
20
21
|
import {defineTheme} from '../theme/defineTheme';
|
|
@@ -763,3 +764,36 @@ describe('label theme target', () => {
|
|
|
763
764
|
expect(label).toHaveClass('astryx-checkbox-label');
|
|
764
765
|
});
|
|
765
766
|
});
|
|
767
|
+
|
|
768
|
+
describe('pressed state', () => {
|
|
769
|
+
it('paints the pressed overlay over the indicator while the row is pressed', () => {
|
|
770
|
+
const {container} = render(
|
|
771
|
+
<CheckboxInput label="Accept terms" value={false} onChange={() => {}} />,
|
|
772
|
+
);
|
|
773
|
+
const box = container.querySelector('.astryx-checkbox-indicator');
|
|
774
|
+
const wrapper = box?.parentElement?.parentElement;
|
|
775
|
+
if (wrapper == null) {
|
|
776
|
+
throw new Error('the checkbox has no indicator wrapper to press');
|
|
777
|
+
}
|
|
778
|
+
// The owner paints over the resolved indicator, so the treatment survives a
|
|
779
|
+
// theme replacement that does not forward style props.
|
|
780
|
+
expect(hasPressedArm(wrapper)).toBe(true);
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
it('does not expose a pressed arm on a disabled checkbox', () => {
|
|
784
|
+
const {container} = render(
|
|
785
|
+
<CheckboxInput
|
|
786
|
+
label="Unavailable"
|
|
787
|
+
value={false}
|
|
788
|
+
onChange={() => {}}
|
|
789
|
+
isDisabled
|
|
790
|
+
/>,
|
|
791
|
+
);
|
|
792
|
+
const box = container.querySelector('.astryx-checkbox-indicator');
|
|
793
|
+
const wrapper = box?.parentElement?.parentElement;
|
|
794
|
+
if (wrapper == null) {
|
|
795
|
+
throw new Error('the checkbox has no indicator wrapper');
|
|
796
|
+
}
|
|
797
|
+
expect(hasPressedArm(wrapper)).toBe(false);
|
|
798
|
+
});
|
|
799
|
+
});
|
|
@@ -70,6 +70,22 @@ const styles = stylex.create({
|
|
|
70
70
|
flexShrink: 0,
|
|
71
71
|
isolation: 'isolate',
|
|
72
72
|
},
|
|
73
|
+
// The owner paints this layer over the resolved indicator, so a theme
|
|
74
|
+
// replacement cannot accidentally drop the component's pressed contract.
|
|
75
|
+
indicatorPressOverlay: {
|
|
76
|
+
'::after': {
|
|
77
|
+
content: '""',
|
|
78
|
+
position: 'absolute',
|
|
79
|
+
inset: 0,
|
|
80
|
+
borderRadius: radiusVars['--radius-inner'],
|
|
81
|
+
pointerEvents: 'none',
|
|
82
|
+
backgroundColor: {
|
|
83
|
+
default: 'transparent',
|
|
84
|
+
[stylex.when.ancestor(':active', indicatorScope)]:
|
|
85
|
+
colorVars['--color-overlay-pressed'],
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
},
|
|
73
89
|
// Holds only the indicator, so the focus ring has one unambiguous target.
|
|
74
90
|
// `display: contents` adds no box of its own — the indicator keeps whatever
|
|
75
91
|
// layout relationship it already had with the wrapper.
|
|
@@ -399,7 +415,11 @@ export function CheckboxInput({
|
|
|
399
415
|
!isDisabled && indicatorScope,
|
|
400
416
|
)}>
|
|
401
417
|
<div
|
|
402
|
-
{...stylex.props(
|
|
418
|
+
{...stylex.props(
|
|
419
|
+
styles.checkboxWrapper,
|
|
420
|
+
wrapperSizeStyles[size],
|
|
421
|
+
!isDisabled && styles.indicatorPressOverlay,
|
|
422
|
+
)}
|
|
403
423
|
{...focusProps}>
|
|
404
424
|
<input
|
|
405
425
|
{...rest}
|