@astryxdesign/core 0.7.5-canary.fd0aba8 → 0.7.5
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 +201 -0
- package/dist/BottomSheet/useSheetGestures.d.ts +0 -12
- package/dist/BottomSheet/useSheetGestures.d.ts.map +1 -1
- package/dist/BottomSheet/useSheetGestures.js +3 -30
- package/dist/Dialog/Dialog.d.ts.map +1 -1
- package/dist/Dialog/Dialog.js +8 -33
- package/dist/DropdownMenu/lazyMenuBottomSheet.d.ts +1 -10
- package/dist/DropdownMenu/lazyMenuBottomSheet.d.ts.map +1 -1
- package/dist/DropdownMenu/lazyMenuBottomSheet.js +2 -16
- package/dist/NumberInput/NumberInput.d.ts +1 -3
- package/dist/NumberInput/NumberInput.d.ts.map +1 -1
- package/dist/NumberInput/NumberInput.js +3 -3
- package/dist/astryx.css +0 -2
- package/dist/utils/focusReturn.d.ts +1 -17
- package/dist/utils/focusReturn.d.ts.map +1 -1
- package/dist/utils/focusReturn.js +4 -11
- package/dist/utils/interactionModality.d.ts +2 -11
- package/dist/utils/interactionModality.d.ts.map +1 -1
- package/dist/utils/interactionModality.js +5 -58
- package/package.json +4 -4
- package/src/BottomSheet/BottomSheet.test.tsx +0 -46
- package/src/BottomSheet/useSheetGestures.test.ts +0 -52
- package/src/BottomSheet/useSheetGestures.ts +6 -37
- package/src/DateTimeInput/DateTimeInput.spec.md +48 -71
- package/src/Dialog/Dialog.test.tsx +2 -216
- package/src/Dialog/Dialog.tsx +8 -41
- package/src/DropdownMenu/lazyMenuBottomSheet.ts +6 -23
- package/src/NumberInput/NumberInput.doc.mjs +7 -13
- package/src/NumberInput/NumberInput.spec.md +37 -59
- package/src/NumberInput/NumberInput.tsx +3 -13
- package/src/utils/focusReturn.ts +6 -17
- package/src/utils/interactionModality.test.ts +1 -45
- package/src/utils/interactionModality.ts +5 -64
- package/src/Dialog/__tests__/DialogSecondDocument.a11y.browser.spec.ts +0 -202
- package/src/DropdownMenu/lazyMenuBottomSheet.test.ts +0 -53
- package/src/NumberInput/__tests__/NumberInput.coarse-pointer.a11y.chromium.spec.ts +0 -102
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
/**
|
|
6
6
|
* @file NumberInput.tsx
|
|
7
7
|
* @input Uses React, useId, useState, useMemo, useCallback, Field, Icon, InputGroupContext
|
|
8
|
-
* @output Exports NumberInput component, NumberInputProps
|
|
8
|
+
* @output Exports NumberInput component, NumberInputProps
|
|
9
9
|
* @position Core implementation; consumed by index.ts, tested by NumberInput.test.tsx
|
|
10
10
|
*
|
|
11
11
|
* SYNC: When modified, update these files to stay in sync:
|
|
@@ -100,10 +100,7 @@ const styles = stylex.create({
|
|
|
100
100
|
paddingInlineEnd: padInlineEnd,
|
|
101
101
|
},
|
|
102
102
|
wrapperWithNumberSteppers: {
|
|
103
|
-
paddingInlineEnd:
|
|
104
|
-
default: 0,
|
|
105
|
-
'@media (pointer: coarse)': padInlineEnd,
|
|
106
|
-
},
|
|
103
|
+
paddingInlineEnd: 0,
|
|
107
104
|
},
|
|
108
105
|
input: {
|
|
109
106
|
display: 'block',
|
|
@@ -145,12 +142,7 @@ const styles = stylex.create({
|
|
|
145
142
|
},
|
|
146
143
|
numberSteppers: {
|
|
147
144
|
alignSelf: 'stretch',
|
|
148
|
-
|
|
149
|
-
// enter a value directly; keyboard stepping remains available.
|
|
150
|
-
display: {
|
|
151
|
-
default: 'flex',
|
|
152
|
-
'@media (pointer: coarse)': 'none',
|
|
153
|
-
},
|
|
145
|
+
display: 'flex',
|
|
154
146
|
flexDirection: 'column',
|
|
155
147
|
flexShrink: 0,
|
|
156
148
|
width: spacingVars['--spacing-4'],
|
|
@@ -393,8 +385,6 @@ interface NumberInputPropsBase extends Omit<
|
|
|
393
385
|
isWheelEnabled?: boolean;
|
|
394
386
|
/**
|
|
395
387
|
* Whether to show increment and decrement buttons at the end of the input.
|
|
396
|
-
* The buttons are hidden on coarse pointers (such as touch screens).
|
|
397
|
-
* Direct text entry and keyboard stepping remain available.
|
|
398
388
|
* @default false
|
|
399
389
|
*/
|
|
400
390
|
hasNumberSteppers?: boolean;
|
package/src/utils/focusReturn.ts
CHANGED
|
@@ -12,10 +12,7 @@
|
|
|
12
12
|
* overlays (Dialog, DropdownMenu) that move focus programmatically.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import {
|
|
16
|
-
getInteractionModality,
|
|
17
|
-
type InteractionModality,
|
|
18
|
-
} from './interactionModality';
|
|
15
|
+
import {getInteractionModality} from './interactionModality';
|
|
19
16
|
|
|
20
17
|
/**
|
|
21
18
|
* True when focus rests on nothing in particular — no active element, or the
|
|
@@ -46,29 +43,21 @@ export function isFocusDetached(doc: Document = document): boolean {
|
|
|
46
43
|
* autofocus target, the control that opened a Dialog, or a menu trigger the
|
|
47
44
|
* caller renders. Pass `options` (for example `preventScroll`) as to `focus()`.
|
|
48
45
|
*
|
|
49
|
-
* `modality` defaults to the page's last input. A caller moving focus inside a
|
|
50
|
-
* second document (a same-origin iframe, a Document Picture-in-Picture window)
|
|
51
|
-
* passes that document's, which it tracks with `trackInteractionModalityIn`.
|
|
52
|
-
*
|
|
53
46
|
* Make sure modality tracking is active (`useInteractionModalityTracking()`)
|
|
54
47
|
* in the component that calls this.
|
|
55
48
|
*/
|
|
56
49
|
export function focusForLastInput(
|
|
57
50
|
element: HTMLElement,
|
|
58
51
|
options?: FocusOptions,
|
|
59
|
-
modality: InteractionModality = getInteractionModality(),
|
|
60
52
|
): void {
|
|
61
53
|
// A text-entry field paints its own focus treatment to show where typing
|
|
62
|
-
// goes, and an extra blur there can run validation or mark it touched.
|
|
63
|
-
// field's own window: one in an iframe or a Picture-in-Picture window is
|
|
64
|
-
// never an instance of the page's constructors.
|
|
65
|
-
const view = element.ownerDocument.defaultView ?? window;
|
|
54
|
+
// goes, and an extra blur there can run validation or mark it touched.
|
|
66
55
|
const isTextEntry =
|
|
67
|
-
element instanceof
|
|
68
|
-
element instanceof
|
|
69
|
-
element instanceof
|
|
56
|
+
element instanceof HTMLInputElement ||
|
|
57
|
+
element instanceof HTMLTextAreaElement ||
|
|
58
|
+
element instanceof HTMLSelectElement ||
|
|
70
59
|
element.isContentEditable;
|
|
71
|
-
if (isTextEntry ||
|
|
60
|
+
if (isTextEntry || getInteractionModality() !== 'pointer') {
|
|
72
61
|
if (options === undefined) {
|
|
73
62
|
element.focus();
|
|
74
63
|
} else {
|
|
@@ -3,8 +3,7 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* @file interactionModality.test.ts
|
|
5
5
|
* @input The interaction-modality utility loaded more than once
|
|
6
|
-
* @output Verifies document-wide continuity
|
|
7
|
-
* and a second document's separate value
|
|
6
|
+
* @output Verifies document-wide continuity and shared Shift-key modality policy
|
|
8
7
|
* @position Unit coverage for interactionModality.ts
|
|
9
8
|
*/
|
|
10
9
|
|
|
@@ -89,49 +88,6 @@ describe('interactionModality', () => {
|
|
|
89
88
|
).toHaveLength(1);
|
|
90
89
|
});
|
|
91
90
|
|
|
92
|
-
it("keeps a second document's input apart from the page's", async () => {
|
|
93
|
-
const pageDocument = document.implementation.createHTMLDocument();
|
|
94
|
-
const secondDocument = document.implementation.createHTMLDocument();
|
|
95
|
-
vi.stubGlobal('document', pageDocument);
|
|
96
|
-
const tracker = await loadTracker();
|
|
97
|
-
tracker.__startInteractionModalityTrackingForTest();
|
|
98
|
-
tracker.trackInteractionModalityIn(secondDocument);
|
|
99
|
-
|
|
100
|
-
pageDocument.dispatchEvent(new Event('pointerdown'));
|
|
101
|
-
secondDocument.dispatchEvent(new KeyboardEvent('keydown', {key: 'Escape'}));
|
|
102
|
-
expect(tracker.getInteractionModality()).toBe('pointer');
|
|
103
|
-
expect(tracker.getInteractionModality(pageDocument)).toBe('pointer');
|
|
104
|
-
expect(tracker.getInteractionModality(secondDocument)).toBe('keyboard');
|
|
105
|
-
|
|
106
|
-
secondDocument.dispatchEvent(new Event('pointerdown'));
|
|
107
|
-
pageDocument.dispatchEvent(new KeyboardEvent('keydown', {key: 'Tab'}));
|
|
108
|
-
expect(tracker.getInteractionModality()).toBe('keyboard');
|
|
109
|
-
expect(tracker.getInteractionModality(secondDocument)).toBe('pointer');
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
it("releases a second document's listeners with its last consumer", async () => {
|
|
113
|
-
const secondDocument = document.implementation.createHTMLDocument();
|
|
114
|
-
const tracker = await loadTracker();
|
|
115
|
-
const removeEventListener = vi.spyOn(secondDocument, 'removeEventListener');
|
|
116
|
-
const releaseFirst = tracker.trackInteractionModalityIn(secondDocument);
|
|
117
|
-
const releaseSecond = tracker.trackInteractionModalityIn(secondDocument);
|
|
118
|
-
secondDocument.dispatchEvent(new Event('pointerdown'));
|
|
119
|
-
expect(tracker.getInteractionModality(secondDocument)).toBe('pointer');
|
|
120
|
-
|
|
121
|
-
releaseFirst();
|
|
122
|
-
releaseFirst();
|
|
123
|
-
secondDocument.dispatchEvent(new KeyboardEvent('keydown', {key: 'Tab'}));
|
|
124
|
-
expect(tracker.getInteractionModality(secondDocument)).toBe('keyboard');
|
|
125
|
-
expect(removeEventListener).not.toHaveBeenCalled();
|
|
126
|
-
|
|
127
|
-
releaseSecond();
|
|
128
|
-
expect(removeEventListener.mock.calls.map(([type]) => type).sort()).toEqual(
|
|
129
|
-
['keydown', 'pointerdown'],
|
|
130
|
-
);
|
|
131
|
-
secondDocument.dispatchEvent(new Event('pointerdown'));
|
|
132
|
-
expect(tracker.getInteractionModality(secondDocument)).toBe('keyboard');
|
|
133
|
-
});
|
|
134
|
-
|
|
135
91
|
it('uses the keyboard-safe default without a document', async () => {
|
|
136
92
|
vi.stubGlobal('document', undefined);
|
|
137
93
|
const serverModule = await loadTracker();
|
|
@@ -6,9 +6,7 @@
|
|
|
6
6
|
* @file interactionModality.ts
|
|
7
7
|
* @input Global pointerdown/keydown events
|
|
8
8
|
* @output `useInteractionModalityTracking()` activates document-lifetime tracking;
|
|
9
|
-
* `getInteractionModality()` reads how the user last interacted
|
|
10
|
-
* `trackInteractionModalityIn(doc)` and `getInteractionModality(doc)` do the
|
|
11
|
-
* same for a second document
|
|
9
|
+
* `getInteractionModality()` reads how the user last interacted
|
|
12
10
|
* @position Internal document-wide singleton; used where `:focus-visible` alone
|
|
13
11
|
* is too broad
|
|
14
12
|
*
|
|
@@ -28,12 +26,8 @@
|
|
|
28
26
|
* The store lives on `document`, so duplicate Astryx bundles share one state
|
|
29
27
|
* value and one listener pair. The first mounted consumer activates tracking;
|
|
30
28
|
* the listeners then remain for the document lifetime so input during a gap
|
|
31
|
-
* between consumers is not lost.
|
|
32
|
-
*
|
|
33
|
-
* tracks it, since input there never reaches the page's listeners; its
|
|
34
|
-
* listeners stay only while a consumer there is mounted. Importing this module
|
|
35
|
-
* has no DOM side effects, and server rendering keeps the keyboard-safe
|
|
36
|
-
* default.
|
|
29
|
+
* between consumers is not lost. Importing this module has no DOM side effects,
|
|
30
|
+
* and server rendering keeps the keyboard-safe default.
|
|
37
31
|
*/
|
|
38
32
|
|
|
39
33
|
import {useEffect} from 'react';
|
|
@@ -43,8 +37,6 @@ export type InteractionModality = 'keyboard' | 'pointer';
|
|
|
43
37
|
type InteractionModalityStore = {
|
|
44
38
|
modality: InteractionModality;
|
|
45
39
|
isListening: boolean;
|
|
46
|
-
/** Mounted consumers holding a second document's listeners. */
|
|
47
|
-
consumers?: number;
|
|
48
40
|
onPointerDown: () => void;
|
|
49
41
|
onKeyDown: (event: KeyboardEvent) => void;
|
|
50
42
|
};
|
|
@@ -115,59 +107,8 @@ export function useInteractionModalityTracking(): void {
|
|
|
115
107
|
useEffect(() => trackInteractionModality(), []);
|
|
116
108
|
}
|
|
117
109
|
|
|
118
|
-
/**
|
|
119
|
-
|
|
120
|
-
* app renders into a same-origin iframe or a Document Picture-in-Picture
|
|
121
|
-
* window gets input there that never reaches the page's listeners. The first
|
|
122
|
-
* consumer installs the document's listener pair and the last one's cleanup
|
|
123
|
-
* removes it. Read the value with `getInteractionModality(doc)`. The page's
|
|
124
|
-
* own document keeps its document-lifetime listeners.
|
|
125
|
-
*/
|
|
126
|
-
export function trackInteractionModalityIn(
|
|
127
|
-
targetDocument: Document,
|
|
128
|
-
): () => void {
|
|
129
|
-
if (typeof document !== 'undefined' && targetDocument === document) {
|
|
130
|
-
return trackInteractionModality();
|
|
131
|
-
}
|
|
132
|
-
const store = getStore(targetDocument);
|
|
133
|
-
store.consumers = (store.consumers ?? 0) + 1;
|
|
134
|
-
if (store.consumers === 1) {
|
|
135
|
-
store.isListening = true;
|
|
136
|
-
targetDocument.addEventListener('pointerdown', store.onPointerDown, {
|
|
137
|
-
capture: true,
|
|
138
|
-
passive: true,
|
|
139
|
-
});
|
|
140
|
-
targetDocument.addEventListener('keydown', store.onKeyDown, {
|
|
141
|
-
capture: true,
|
|
142
|
-
passive: true,
|
|
143
|
-
});
|
|
144
|
-
}
|
|
145
|
-
let isReleased = false;
|
|
146
|
-
return () => {
|
|
147
|
-
if (isReleased) {
|
|
148
|
-
return;
|
|
149
|
-
}
|
|
150
|
-
isReleased = true;
|
|
151
|
-
store.consumers = (store.consumers ?? 1) - 1;
|
|
152
|
-
if (store.consumers === 0) {
|
|
153
|
-
store.isListening = false;
|
|
154
|
-
targetDocument.removeEventListener('pointerdown', store.onPointerDown, {
|
|
155
|
-
capture: true,
|
|
156
|
-
});
|
|
157
|
-
targetDocument.removeEventListener('keydown', store.onKeyDown, {
|
|
158
|
-
capture: true,
|
|
159
|
-
});
|
|
160
|
-
}
|
|
161
|
-
};
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
/** How the user last interacted with the page, or with `targetDocument`. */
|
|
165
|
-
export function getInteractionModality(
|
|
166
|
-
targetDocument?: Document,
|
|
167
|
-
): InteractionModality {
|
|
168
|
-
if (targetDocument != null) {
|
|
169
|
-
return getStore(targetDocument).modality;
|
|
170
|
-
}
|
|
110
|
+
/** How the user last interacted with the page. */
|
|
111
|
+
export function getInteractionModality(): InteractionModality {
|
|
171
112
|
return typeof document === 'undefined'
|
|
172
113
|
? 'keyboard'
|
|
173
114
|
: getStore(document).modality;
|
|
@@ -1,202 +0,0 @@
|
|
|
1
|
-
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
-
// @pr-a11y scope ^(packages/core/src/Dialog/|packages/core/src/utils/(focusReturn|interactionModality)\.ts|apps/storybook/stories/DialogSecondDocumentA11y)
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* @file DialogSecondDocument.a11y.browser.spec.ts
|
|
6
|
-
* @input Uses @playwright/test, the a11y-spec static Storybook server, and the
|
|
7
|
-
* checked-in DialogSecondDocumentA11y story
|
|
8
|
-
* @output Real-browser evidence that a Dialog rendered into a second document
|
|
9
|
-
* (a same-origin iframe, a Document Picture-in-Picture window) returns focus
|
|
10
|
-
* to the control that opened it when it closes, by Escape or by a control
|
|
11
|
-
* inside it, exactly as the same Dialog does in the page, with the focus
|
|
12
|
-
* ring that close's input calls for, a text field that opened it keeping
|
|
13
|
-
* its ring with no extra blur; and that a Dialog in an iframe opened by a
|
|
14
|
-
* control in the page returns focus to that control.
|
|
15
|
-
* @position The half of Dialog's focus return jsdom cannot reach: which
|
|
16
|
-
* document holds focus, and the browser's own focus restore when a modal
|
|
17
|
-
* dialog closes. The Vitest case in `Dialog/Dialog.test.tsx` models the
|
|
18
|
-
* same rule with an iframe. Document Picture-in-Picture is Chromium's
|
|
19
|
-
* alone, so the window's cases skip in other engines; the page and the
|
|
20
|
-
* iframe run everywhere.
|
|
21
|
-
*
|
|
22
|
-
* SYNC: Fixture lives in
|
|
23
|
-
* /apps/storybook/stories/DialogSecondDocumentA11y.stories.tsx.
|
|
24
|
-
*/
|
|
25
|
-
|
|
26
|
-
import {
|
|
27
|
-
expect,
|
|
28
|
-
test,
|
|
29
|
-
type Frame,
|
|
30
|
-
type Keyboard,
|
|
31
|
-
type Locator,
|
|
32
|
-
type Page,
|
|
33
|
-
} from '@playwright/test';
|
|
34
|
-
import {holdMotionStill} from '@astryxdesign/a11y-spec/chromium';
|
|
35
|
-
import {
|
|
36
|
-
DEFAULT_STORYBOOK_DIR,
|
|
37
|
-
serveStorybook,
|
|
38
|
-
type StaticServer,
|
|
39
|
-
} from '@astryxdesign/a11y-spec/storybook';
|
|
40
|
-
|
|
41
|
-
const STORY_ID = 'a11y-dialog-in-a-second-document--second-documents';
|
|
42
|
-
|
|
43
|
-
let storybook: StaticServer;
|
|
44
|
-
|
|
45
|
-
test.beforeAll(async () => {
|
|
46
|
-
storybook = await serveStorybook(
|
|
47
|
-
process.env.ASTRYX_STORYBOOK_DIR ?? DEFAULT_STORYBOOK_DIR,
|
|
48
|
-
);
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
test.afterAll(async () => {
|
|
52
|
-
await storybook?.close();
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
type Where = 'page' | 'iframe' | 'Picture-in-Picture window';
|
|
56
|
-
|
|
57
|
-
/** One document of the story: where its controls live and where keys go. */
|
|
58
|
-
interface Surface {
|
|
59
|
-
frame: Frame;
|
|
60
|
-
keyboard: Keyboard;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/** Load the story and return the document the test drives. */
|
|
64
|
-
async function mountStory(page: Page, where: Where): Promise<Surface> {
|
|
65
|
-
await page.goto(
|
|
66
|
-
`${storybook.origin}/iframe.html?id=${STORY_ID}&viewMode=story`,
|
|
67
|
-
{waitUntil: 'load'},
|
|
68
|
-
);
|
|
69
|
-
await holdMotionStill(page);
|
|
70
|
-
const popOut = page.getByRole('button', {name: 'Pop out'});
|
|
71
|
-
await popOut.waitFor();
|
|
72
|
-
|
|
73
|
-
if (where === 'page') {
|
|
74
|
-
return {frame: page.mainFrame(), keyboard: page.keyboard};
|
|
75
|
-
}
|
|
76
|
-
if (where === 'iframe') {
|
|
77
|
-
const element = await page
|
|
78
|
-
.locator('iframe[title="Second document"]')
|
|
79
|
-
.elementHandle();
|
|
80
|
-
const frame = await element?.contentFrame();
|
|
81
|
-
if (frame == null) {
|
|
82
|
-
throw new Error('the story renders no iframe');
|
|
83
|
-
}
|
|
84
|
-
await frame
|
|
85
|
-
.getByRole('button', {name: 'Open dialog', exact: true})
|
|
86
|
-
.waitFor();
|
|
87
|
-
// Key presses go to whichever frame holds focus; the clicks below put it
|
|
88
|
-
// in this one.
|
|
89
|
-
return {frame, keyboard: page.keyboard};
|
|
90
|
-
}
|
|
91
|
-
// Opening the window needs a user gesture; the click is one.
|
|
92
|
-
const [pictureInPicture] = await Promise.all([
|
|
93
|
-
page.context().waitForEvent('page'),
|
|
94
|
-
popOut.click(),
|
|
95
|
-
]);
|
|
96
|
-
await pictureInPicture
|
|
97
|
-
.getByRole('button', {
|
|
98
|
-
name: 'Open dialog',
|
|
99
|
-
exact: true,
|
|
100
|
-
})
|
|
101
|
-
.waitFor();
|
|
102
|
-
return {
|
|
103
|
-
frame: pictureInPicture.mainFrame(),
|
|
104
|
-
keyboard: pictureInPicture.keyboard,
|
|
105
|
-
};
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/** Whether the element matches `:focus-visible`, i.e. would show a ring. */
|
|
109
|
-
async function showsRing(element: Locator): Promise<boolean> {
|
|
110
|
-
return element.evaluate(node => node.matches(':focus-visible'));
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/** Open the dialog from its control with the pointer; returns the control. */
|
|
114
|
-
async function open(frame: Frame): Promise<{opener: Locator; dialog: Locator}> {
|
|
115
|
-
const opener = frame.getByRole('button', {name: 'Open dialog', exact: true});
|
|
116
|
-
await opener.click();
|
|
117
|
-
const dialog = frame.getByRole('dialog', {name: 'Review changes'});
|
|
118
|
-
await expect(dialog).toBeVisible();
|
|
119
|
-
return {opener, dialog};
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
for (const where of [
|
|
123
|
-
'page',
|
|
124
|
-
'iframe',
|
|
125
|
-
'Picture-in-Picture window',
|
|
126
|
-
] as const satisfies ReadonlyArray<Where>) {
|
|
127
|
-
test.describe(`a Dialog in the ${where}`, () => {
|
|
128
|
-
test.skip(
|
|
129
|
-
({browserName}) =>
|
|
130
|
-
where === 'Picture-in-Picture window' && browserName !== 'chromium',
|
|
131
|
-
'Document Picture-in-Picture is a Chromium API',
|
|
132
|
-
);
|
|
133
|
-
|
|
134
|
-
test('closing with Escape returns focus to the control that opened it', async ({
|
|
135
|
-
page,
|
|
136
|
-
}) => {
|
|
137
|
-
const {frame, keyboard} = await mountStory(page, where);
|
|
138
|
-
const {opener, dialog} = await open(frame);
|
|
139
|
-
|
|
140
|
-
await keyboard.press('Escape');
|
|
141
|
-
await expect(dialog).toBeHidden();
|
|
142
|
-
await expect(opener).toBeFocused();
|
|
143
|
-
// A key closed it, so the ring shows (`architecture:interaction-modality`
|
|
144
|
-
// INV1), whatever the page's own last input was (a pointer press on Pop
|
|
145
|
-
// out, for the Picture-in-Picture window).
|
|
146
|
-
await expect.poll(async () => showsRing(opener)).toBe(true);
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
test('closing from a control inside returns focus to the control that opened it', async ({
|
|
150
|
-
page,
|
|
151
|
-
}) => {
|
|
152
|
-
const {frame} = await mountStory(page, where);
|
|
153
|
-
const {opener, dialog} = await open(frame);
|
|
154
|
-
|
|
155
|
-
await frame.getByRole('button', {name: 'Close dialog'}).click();
|
|
156
|
-
await expect(dialog).toBeHidden();
|
|
157
|
-
await expect(opener).toBeFocused();
|
|
158
|
-
// A pointer closed it, so no ring.
|
|
159
|
-
await expect.poll(async () => showsRing(opener)).toBe(false);
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
test('a text field that opened it keeps focus and its ring after a pointer close', async ({
|
|
163
|
-
page,
|
|
164
|
-
}) => {
|
|
165
|
-
const {frame} = await mountStory(page, where);
|
|
166
|
-
const field = frame.getByRole('textbox', {name: 'Search'});
|
|
167
|
-
await field.click();
|
|
168
|
-
await field.press('Enter');
|
|
169
|
-
const dialog = frame.getByRole('dialog', {name: 'Review changes'});
|
|
170
|
-
await expect(dialog).toBeVisible();
|
|
171
|
-
// Count the field's blurs from here on: the open already moved focus
|
|
172
|
-
// away, so any blur now would be the return's own.
|
|
173
|
-
await field.evaluate(node => {
|
|
174
|
-
node.dataset.blurs = '0';
|
|
175
|
-
node.addEventListener('blur', () => {
|
|
176
|
-
node.dataset.blurs = String(Number(node.dataset.blurs) + 1);
|
|
177
|
-
});
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
await frame.getByRole('button', {name: 'Close dialog'}).click();
|
|
181
|
-
await expect(dialog).toBeHidden();
|
|
182
|
-
await expect(field).toBeFocused();
|
|
183
|
-
// A text field shows where typing goes whatever moved focus there.
|
|
184
|
-
await expect.poll(async () => showsRing(field)).toBe(true);
|
|
185
|
-
await expect(field).toHaveAttribute('data-blurs', '0');
|
|
186
|
-
});
|
|
187
|
-
});
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
test.describe('a Dialog in the iframe that a control in the page opens', () => {
|
|
191
|
-
test('closing with Escape returns focus to that control', async ({page}) => {
|
|
192
|
-
const {frame, keyboard} = await mountStory(page, 'iframe');
|
|
193
|
-
const opener = page.getByRole('button', {name: 'Open dialog in the frame'});
|
|
194
|
-
await opener.click();
|
|
195
|
-
const dialog = frame.getByRole('dialog', {name: 'Opened from the page'});
|
|
196
|
-
await expect(dialog).toBeVisible();
|
|
197
|
-
|
|
198
|
-
await keyboard.press('Escape');
|
|
199
|
-
await expect(dialog).toBeHidden();
|
|
200
|
-
await expect(opener).toBeFocused();
|
|
201
|
-
});
|
|
202
|
-
});
|
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* @file lazyMenuBottomSheet.test.ts
|
|
5
|
-
* @input lazyMenuBottomSheet's action-list preload, with the list's module
|
|
6
|
-
* failing to load once
|
|
7
|
-
* @output A failed preload is no unhandled rejection, and is not kept: the
|
|
8
|
-
* next load asks the module loader again
|
|
9
|
-
* @position Unit test of ./lazyMenuBottomSheet; the hydration case is in
|
|
10
|
-
* DropdownMenu.ssr.test.tsx
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
import {afterEach, describe, expect, it, vi} from 'vitest';
|
|
14
|
-
|
|
15
|
-
afterEach(() => {
|
|
16
|
-
vi.doUnmock('./MenuBottomSheetActionList');
|
|
17
|
-
vi.resetModules();
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
describe('preloadMenuBottomSheetActionList', () => {
|
|
21
|
-
it('drops a failed preload without an unhandled rejection, and asks the module loader again on the next call', async () => {
|
|
22
|
-
vi.resetModules();
|
|
23
|
-
let attempts = 0;
|
|
24
|
-
vi.doMock('./MenuBottomSheetActionList', () => {
|
|
25
|
-
attempts += 1;
|
|
26
|
-
if (attempts === 1) {
|
|
27
|
-
throw new Error('the connection dropped');
|
|
28
|
-
}
|
|
29
|
-
return {MenuBottomSheetActionList: () => null};
|
|
30
|
-
});
|
|
31
|
-
const unhandled: unknown[] = [];
|
|
32
|
-
const onUnhandled = (reason: unknown) => unhandled.push(reason);
|
|
33
|
-
process.on('unhandledRejection', onUnhandled);
|
|
34
|
-
try {
|
|
35
|
-
const {preloadMenuBottomSheetActionList} =
|
|
36
|
-
await import('./lazyMenuBottomSheet');
|
|
37
|
-
preloadMenuBottomSheetActionList();
|
|
38
|
-
await vi.waitFor(() => expect(attempts).toBe(1));
|
|
39
|
-
// An unhandled rejection is reported after the rejecting task's
|
|
40
|
-
// microtasks: give it two macrotasks.
|
|
41
|
-
await new Promise(resolve => setTimeout(resolve, 0));
|
|
42
|
-
await new Promise(resolve => setTimeout(resolve, 0));
|
|
43
|
-
expect(unhandled).toEqual([]);
|
|
44
|
-
|
|
45
|
-
preloadMenuBottomSheetActionList();
|
|
46
|
-
await vi.waitFor(() => expect(attempts).toBe(2));
|
|
47
|
-
const loaded = await import('./MenuBottomSheetActionList');
|
|
48
|
-
expect(typeof loaded.MenuBottomSheetActionList).toBe('function');
|
|
49
|
-
} finally {
|
|
50
|
-
process.off('unhandledRejection', onUnhandled);
|
|
51
|
-
}
|
|
52
|
-
});
|
|
53
|
-
});
|
|
@@ -1,102 +0,0 @@
|
|
|
1
|
-
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
-
|
|
3
|
-
/** Real-browser proof of NumberInput's coarse-pointer stepper presentation. */
|
|
4
|
-
|
|
5
|
-
import {expect, test} from '@playwright/test';
|
|
6
|
-
import {holdMotionStill} from '@astryxdesign/a11y-spec/chromium';
|
|
7
|
-
import {
|
|
8
|
-
DEFAULT_STORYBOOK_DIR,
|
|
9
|
-
serveStorybook,
|
|
10
|
-
type StaticServer,
|
|
11
|
-
} from '@astryxdesign/a11y-spec/storybook';
|
|
12
|
-
|
|
13
|
-
let storybook: StaticServer;
|
|
14
|
-
|
|
15
|
-
test.beforeAll(async () => {
|
|
16
|
-
storybook = await serveStorybook(
|
|
17
|
-
process.env.ASTRYX_STORYBOOK_DIR ?? DEFAULT_STORYBOOK_DIR,
|
|
18
|
-
);
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
test.afterAll(async () => storybook?.close());
|
|
22
|
-
|
|
23
|
-
for (const width of [390, 1280]) {
|
|
24
|
-
for (const hasTouch of [false, true]) {
|
|
25
|
-
test(`${hasTouch ? 'coarse' : 'fine'} pointer at ${width}px preserves entry and the appropriate end padding`, async ({
|
|
26
|
-
browser,
|
|
27
|
-
}) => {
|
|
28
|
-
const context = await browser.newContext({
|
|
29
|
-
viewport: {width, height: 800},
|
|
30
|
-
hasTouch,
|
|
31
|
-
});
|
|
32
|
-
try {
|
|
33
|
-
const page = await context.newPage();
|
|
34
|
-
await page.goto(
|
|
35
|
-
`${storybook.origin}/iframe.html?id=core-numberinput--with-number-steppers&viewMode=story`,
|
|
36
|
-
);
|
|
37
|
-
await holdMotionStill(page);
|
|
38
|
-
const input = page.getByRole('spinbutton', {name: 'Quantity'});
|
|
39
|
-
const increment = page.locator(
|
|
40
|
-
'button[aria-label="Increment Quantity"]',
|
|
41
|
-
);
|
|
42
|
-
const decrement = page.locator(
|
|
43
|
-
'button[aria-label="Decrement Quantity"]',
|
|
44
|
-
);
|
|
45
|
-
const control = page.locator('.astryx-number-input');
|
|
46
|
-
await expect(input).toBeVisible();
|
|
47
|
-
expect(
|
|
48
|
-
await page.evaluate(() => matchMedia('(pointer: coarse)').matches),
|
|
49
|
-
).toBe(hasTouch);
|
|
50
|
-
|
|
51
|
-
// Public padding tokens still apply when the compact stepper lane is
|
|
52
|
-
// hidden. Use a non-default value to catch a hardcoded restoration.
|
|
53
|
-
await control.evaluate(element => {
|
|
54
|
-
element.style.setProperty(
|
|
55
|
-
'--astryx-number-input-padding-inline-end',
|
|
56
|
-
'20px',
|
|
57
|
-
);
|
|
58
|
-
});
|
|
59
|
-
for (const direction of ['ltr', 'rtl']) {
|
|
60
|
-
await control.evaluate((element, dir) => {
|
|
61
|
-
element.setAttribute('dir', dir);
|
|
62
|
-
}, direction);
|
|
63
|
-
await expect(control).toHaveCSS('direction', direction);
|
|
64
|
-
await expect(control).toHaveCSS(
|
|
65
|
-
'padding-inline-end',
|
|
66
|
-
hasTouch ? '20px' : '0px',
|
|
67
|
-
);
|
|
68
|
-
if (hasTouch) {
|
|
69
|
-
await expect(increment).toBeHidden();
|
|
70
|
-
await expect(decrement).toBeHidden();
|
|
71
|
-
await expect(
|
|
72
|
-
page.getByRole('button', {
|
|
73
|
-
name: /^(Increment|Decrement) Quantity$/,
|
|
74
|
-
}),
|
|
75
|
-
).toHaveCount(0);
|
|
76
|
-
} else {
|
|
77
|
-
await expect(increment).toBeVisible();
|
|
78
|
-
await expect(decrement).toBeVisible();
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
if (!hasTouch) {
|
|
83
|
-
await increment.click();
|
|
84
|
-
await expect(input).toHaveValue('3');
|
|
85
|
-
await decrement.click();
|
|
86
|
-
await expect(input).toHaveValue('2');
|
|
87
|
-
}
|
|
88
|
-
await input.focus();
|
|
89
|
-
await input.press('ArrowUp');
|
|
90
|
-
await expect(input).toHaveValue('3');
|
|
91
|
-
await input.press('ArrowDown');
|
|
92
|
-
await expect(input).toHaveValue('2');
|
|
93
|
-
await input.fill('7');
|
|
94
|
-
await input.press('Enter');
|
|
95
|
-
await expect(input).toHaveAttribute('aria-valuenow', '7');
|
|
96
|
-
await expect(input).toHaveAttribute('inputmode', 'decimal');
|
|
97
|
-
} finally {
|
|
98
|
-
await context.close();
|
|
99
|
-
}
|
|
100
|
-
});
|
|
101
|
-
}
|
|
102
|
-
}
|