@astryxdesign/core 0.6.1 → 0.6.2-canary.176dfa6
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/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/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/Slider/Slider.d.ts.map +1 -1
- package/dist/Slider/Slider.js +5 -2
- 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/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 +5 -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/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/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/Markdown/Markdown.doc.mjs +167 -42
- package/src/Markdown/Markdown.public.test.ts +157 -0
- package/src/Markdown/Markdown.spec.md +149 -70
- 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/PowerSearch/PowerSearchEditPopover.test.tsx +150 -1
- package/src/PowerSearch/PowerSearchEditPopover.tsx +51 -28
- package/src/ScrollableArea/modules/useScrollableArea.spec.md +50 -22
- package/src/Slider/Slider.doc.mjs +16 -0
- package/src/Slider/Slider.spec.md +61 -47
- package/src/Slider/Slider.test.tsx +18 -0
- package/src/Slider/Slider.tsx +12 -6
- 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/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/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
|
@@ -4,14 +4,14 @@
|
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* @file BottomSheetPanel.tsx
|
|
7
|
-
* @input Uses React, StyleX, theme tokens,
|
|
8
|
-
* @output Internal BottomSheetPanel
|
|
7
|
+
* @input Uses React, StyleX, theme tokens, sheet gestures, shared scroll behavior, and the host label
|
|
8
|
+
* @output Internal BottomSheetPanel with a keyboard-reachable scrolling body and motion-state types
|
|
9
9
|
* @position Shared presentation layer for standalone and switcher BottomSheets
|
|
10
10
|
*
|
|
11
11
|
* This component owns everything intrinsic to a sheet surface: height budgets,
|
|
12
12
|
* drag and snap gestures, the handle and scrolling body, motion styles, and
|
|
13
|
-
* transition completion. It
|
|
14
|
-
* state,
|
|
13
|
+
* transition completion. It owns the body's keyboard access; dialog focus
|
|
14
|
+
* entry/return, inert state, and switcher registration belong to the host.
|
|
15
15
|
*
|
|
16
16
|
* SYNC: When modified, update these files to stay in sync:
|
|
17
17
|
* - /packages/core/src/BottomSheet/BottomSheet.tsx
|
|
@@ -33,17 +33,20 @@ import {
|
|
|
33
33
|
import * as stylex from '@stylexjs/stylex';
|
|
34
34
|
import type {BaseProps} from '../BaseProps';
|
|
35
35
|
import {useDevWarning} from '../hooks';
|
|
36
|
+
import {useScrollableArea} from '../hooks/useScrollableArea';
|
|
36
37
|
import {
|
|
37
38
|
borderVars,
|
|
38
39
|
colorVars,
|
|
39
40
|
durationVars,
|
|
40
41
|
easeVars,
|
|
42
|
+
focusVars,
|
|
41
43
|
radiusVars,
|
|
42
44
|
shadowVars,
|
|
43
45
|
sizeVars,
|
|
44
46
|
spacingVars,
|
|
45
47
|
} from '../theme/tokens.stylex';
|
|
46
48
|
import {mergeProps, themeProps} from '../utils';
|
|
49
|
+
import {focusOutlineStyles} from '../utils/focusOutline.stylex';
|
|
47
50
|
import {overlayPaddingReset} from '../Layout/padding.stylex';
|
|
48
51
|
import {
|
|
49
52
|
isValidSnapPoint,
|
|
@@ -211,9 +214,11 @@ const styles = stylex.create({
|
|
|
211
214
|
flexGrow: 1,
|
|
212
215
|
minHeight: 0,
|
|
213
216
|
boxSizing: 'border-box',
|
|
214
|
-
overflowY: 'auto',
|
|
215
|
-
overscrollBehavior: 'none',
|
|
216
217
|
touchAction: 'pan-y',
|
|
218
|
+
outlineOffset: {
|
|
219
|
+
default: 0,
|
|
220
|
+
':focus-visible': `calc(-1 * ${focusVars['--focus-outline-width']})`,
|
|
221
|
+
},
|
|
217
222
|
// The scrolling area paints the surface itself, covering the sheet's whole
|
|
218
223
|
// inner box. Without it the sheet's edge is not uniform: a theme that packs
|
|
219
224
|
// an inset ring into --shadow-high (the bundled themes all add one in dark
|
|
@@ -229,6 +234,15 @@ const styles = stylex.create({
|
|
|
229
234
|
// space a content wrapper's own top padding already provides.
|
|
230
235
|
paddingBlockEnd: 0,
|
|
231
236
|
},
|
|
237
|
+
content: {
|
|
238
|
+
// Preserve the body's block formatting (including margin/float isolation)
|
|
239
|
+
// and its definite percentage-height basis. min-content lets this real
|
|
240
|
+
// observed box grow with async content beyond a fixed-height viewport.
|
|
241
|
+
display: 'flow-root',
|
|
242
|
+
boxSizing: 'border-box',
|
|
243
|
+
height: '100%',
|
|
244
|
+
minHeight: 'min-content',
|
|
245
|
+
},
|
|
232
246
|
tallKeyboardBody: {
|
|
233
247
|
scrollPaddingBlockEnd: MOBILE_KEYBOARD_BOTTOM_CLEARANCE,
|
|
234
248
|
'::after': {
|
|
@@ -265,6 +279,8 @@ interface BottomSheetPanelProps extends BaseProps<HTMLDivElement> {
|
|
|
265
279
|
ref?: React.Ref<HTMLDivElement>;
|
|
266
280
|
state: BottomSheetPanelState;
|
|
267
281
|
height: BottomSheetHeight | number | string;
|
|
282
|
+
/** Existing host name, reused for the keyboard scrolling group. */
|
|
283
|
+
label: string;
|
|
268
284
|
children: ReactNode;
|
|
269
285
|
snapPoints?: ReadonlyArray<BottomSheetSnapPoint>;
|
|
270
286
|
isSwipeDismissAllowed?: boolean;
|
|
@@ -391,6 +407,7 @@ export function BottomSheetPanel({
|
|
|
391
407
|
ref,
|
|
392
408
|
state,
|
|
393
409
|
height,
|
|
410
|
+
label,
|
|
394
411
|
children,
|
|
395
412
|
snapPoints,
|
|
396
413
|
className,
|
|
@@ -490,6 +507,17 @@ export function BottomSheetPanel({
|
|
|
490
507
|
onScrimOpacity,
|
|
491
508
|
});
|
|
492
509
|
|
|
510
|
+
const {getViewportProps, getContentProps} = useScrollableArea({
|
|
511
|
+
// The previous overflow-y:auto also computed overflow-x:auto. Keep wide
|
|
512
|
+
// content reachable instead of clipping that existing inline scroll path.
|
|
513
|
+
axis: 'both',
|
|
514
|
+
keyboardAccess: {owner: 'contentOrViewport', label},
|
|
515
|
+
overscroll: 'contain',
|
|
516
|
+
// The sheet body deliberately remains the boundary for sheet-local sticky
|
|
517
|
+
// content while fitting, as it was before shared scroll adoption.
|
|
518
|
+
stickyContainment: 'always',
|
|
519
|
+
});
|
|
520
|
+
|
|
493
521
|
const setElement = useCallback(
|
|
494
522
|
(element: HTMLDivElement | null) => {
|
|
495
523
|
sheetRef(element);
|
|
@@ -656,17 +684,22 @@ export function BottomSheetPanel({
|
|
|
656
684
|
<div {...stylex.props(styles.handlePill)} />
|
|
657
685
|
</div>
|
|
658
686
|
<div
|
|
659
|
-
{...
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
687
|
+
{...getViewportProps<HTMLDivElement>({
|
|
688
|
+
...bodyProps,
|
|
689
|
+
...mergeProps(
|
|
690
|
+
stylex.props(
|
|
691
|
+
focusOutlineStyles.focusVisible,
|
|
692
|
+
styles.body,
|
|
693
|
+
height === 'tall' && styles.tallKeyboardBody,
|
|
694
|
+
),
|
|
695
|
+
scrollPreservationInset > 0
|
|
696
|
+
? {style: {paddingBlockEnd: `${scrollPreservationInset}px`}}
|
|
697
|
+
: {},
|
|
663
698
|
),
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
{...bodyProps}>
|
|
669
|
-
{children}
|
|
699
|
+
})}>
|
|
700
|
+
<div {...getContentProps<HTMLDivElement>(stylex.props(styles.content))}>
|
|
701
|
+
{children}
|
|
702
|
+
</div>
|
|
670
703
|
</div>
|
|
671
704
|
</div>
|
|
672
705
|
);
|
|
@@ -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
|
+
});
|
|
@@ -114,7 +114,7 @@ export const docs = {
|
|
|
114
114
|
name: 'presets',
|
|
115
115
|
type: 'Array<DateRangePreset>',
|
|
116
116
|
description:
|
|
117
|
-
'Preset ranges shown as quick-select options beside the calendar.',
|
|
117
|
+
'Preset ranges shown as quick-select options beside the calendar. A preset is disabled when either endpoint violates min, max, or dateConstraints, or when its span violates minRangeSpan or maxRangeSpan.',
|
|
118
118
|
},
|
|
119
119
|
{
|
|
120
120
|
name: 'hasClear',
|
|
@@ -178,9 +178,21 @@ export const docs = {
|
|
|
178
178
|
],
|
|
179
179
|
theming: {
|
|
180
180
|
targets: [
|
|
181
|
-
{
|
|
181
|
+
{
|
|
182
|
+
className: 'astryx-date-range-input',
|
|
183
|
+
visualProps: ['size', 'status'],
|
|
184
|
+
states: ['disabled'],
|
|
185
|
+
},
|
|
182
186
|
{className: 'astryx-date-range-input-toggle-icon', states: ['state']},
|
|
183
|
-
{
|
|
187
|
+
{
|
|
188
|
+
className: 'astryx-date-range-input-clear-icon',
|
|
189
|
+
deprecatedFor: 'input-clear-icon',
|
|
190
|
+
},
|
|
191
|
+
{className: 'astryx-date-range-input-presets'},
|
|
192
|
+
{
|
|
193
|
+
className: 'astryx-date-range-input-preset',
|
|
194
|
+
states: ['selected', 'disabled'],
|
|
195
|
+
},
|
|
184
196
|
],
|
|
185
197
|
},
|
|
186
198
|
usage: {
|
|
@@ -230,6 +242,12 @@ export const docs = {
|
|
|
230
242
|
description:
|
|
231
243
|
'Text above the trigger describing what date range is expected.',
|
|
232
244
|
},
|
|
245
|
+
{
|
|
246
|
+
name: 'Field surface',
|
|
247
|
+
required: true,
|
|
248
|
+
description:
|
|
249
|
+
'Bordered control containing the calendar toggle, range trigger, and end affordances.',
|
|
250
|
+
},
|
|
233
251
|
{
|
|
234
252
|
name: 'Trigger button',
|
|
235
253
|
required: true,
|
|
@@ -252,6 +270,12 @@ export const docs = {
|
|
|
252
270
|
required: false,
|
|
253
271
|
description: 'A list of preset range options beside the calendar.',
|
|
254
272
|
},
|
|
273
|
+
{
|
|
274
|
+
name: 'Preset button',
|
|
275
|
+
required: false,
|
|
276
|
+
description:
|
|
277
|
+
'A quick-select action for one preset range, reflecting current and disabled states.',
|
|
278
|
+
},
|
|
255
279
|
{
|
|
256
280
|
name: 'Clear button',
|
|
257
281
|
required: false,
|
|
@@ -320,7 +344,8 @@ export const docsDense = {
|
|
|
320
344
|
isDisabled: 'disable trigger+picker',
|
|
321
345
|
disabledMessage:
|
|
322
346
|
'reason shown in a tooltip on hover/focus when disabled; keeps trigger focusable via aria-disabled',
|
|
323
|
-
value:
|
|
347
|
+
value:
|
|
348
|
+
'selected range {start, end} or null; import DateRange type from @astryxdesign/core/DateRangeInput (do not redeclare)',
|
|
324
349
|
onChange: 'callback on range change; null on clear',
|
|
325
350
|
min: 'min selectable date: ISODateString template literal type (YYYY-MM-DD); use string literal or cast `as ISODateString`',
|
|
326
351
|
max: 'max selectable date: ISODateString template literal type (YYYY-MM-DD); use string literal or cast `as ISODateString`',
|
|
@@ -329,15 +354,18 @@ export const docsDense = {
|
|
|
329
354
|
'max days a range may span, both endpoints counted (7 = a 7-day window); caps the window from the picked start. Selection-only; does not rewrite an over-wide value',
|
|
330
355
|
minRangeSpan:
|
|
331
356
|
'min days a range must span, both endpoints counted (2 forbids a single-day range); repeated start click commits one day when allowed, otherwise cancels; default 1',
|
|
332
|
-
presets:
|
|
357
|
+
presets:
|
|
358
|
+
'preset ranges as quick-select options; disabled when an endpoint or span violates the corresponding constraints',
|
|
333
359
|
hasClear: 'clear button when range is set (default true)',
|
|
334
360
|
placeholder: 'placeholder when empty',
|
|
335
361
|
size: 'trigger size',
|
|
336
362
|
status: 'error/warning/success status',
|
|
337
|
-
statusVariant:
|
|
363
|
+
statusVariant:
|
|
364
|
+
'How status message is placed: attached overlaps below input; detached floats below w/ spacing; tooltip hides the box and shows it on the status icon.',
|
|
338
365
|
labelTooltip: 'tooltip via info icon at label end',
|
|
339
366
|
numberOfMonths: 'months in calendar (default 2)',
|
|
340
|
-
weekStartsOn:
|
|
367
|
+
weekStartsOn:
|
|
368
|
+
'first day of week in calendar (0=Sunday, or name e.g. "mon")',
|
|
341
369
|
changeAction:
|
|
342
370
|
'async action fired after onChange; drives optimistic UI updates via useTransition',
|
|
343
371
|
isLoading: 'loading state; disables interaction + shows a spinner',
|