@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.
Files changed (36) hide show
  1. package/CHANGELOG.md +201 -0
  2. package/dist/BottomSheet/useSheetGestures.d.ts +0 -12
  3. package/dist/BottomSheet/useSheetGestures.d.ts.map +1 -1
  4. package/dist/BottomSheet/useSheetGestures.js +3 -30
  5. package/dist/Dialog/Dialog.d.ts.map +1 -1
  6. package/dist/Dialog/Dialog.js +8 -33
  7. package/dist/DropdownMenu/lazyMenuBottomSheet.d.ts +1 -10
  8. package/dist/DropdownMenu/lazyMenuBottomSheet.d.ts.map +1 -1
  9. package/dist/DropdownMenu/lazyMenuBottomSheet.js +2 -16
  10. package/dist/NumberInput/NumberInput.d.ts +1 -3
  11. package/dist/NumberInput/NumberInput.d.ts.map +1 -1
  12. package/dist/NumberInput/NumberInput.js +3 -3
  13. package/dist/astryx.css +0 -2
  14. package/dist/utils/focusReturn.d.ts +1 -17
  15. package/dist/utils/focusReturn.d.ts.map +1 -1
  16. package/dist/utils/focusReturn.js +4 -11
  17. package/dist/utils/interactionModality.d.ts +2 -11
  18. package/dist/utils/interactionModality.d.ts.map +1 -1
  19. package/dist/utils/interactionModality.js +5 -58
  20. package/package.json +4 -4
  21. package/src/BottomSheet/BottomSheet.test.tsx +0 -46
  22. package/src/BottomSheet/useSheetGestures.test.ts +0 -52
  23. package/src/BottomSheet/useSheetGestures.ts +6 -37
  24. package/src/DateTimeInput/DateTimeInput.spec.md +48 -71
  25. package/src/Dialog/Dialog.test.tsx +2 -216
  26. package/src/Dialog/Dialog.tsx +8 -41
  27. package/src/DropdownMenu/lazyMenuBottomSheet.ts +6 -23
  28. package/src/NumberInput/NumberInput.doc.mjs +7 -13
  29. package/src/NumberInput/NumberInput.spec.md +37 -59
  30. package/src/NumberInput/NumberInput.tsx +3 -13
  31. package/src/utils/focusReturn.ts +6 -17
  32. package/src/utils/interactionModality.test.ts +1 -45
  33. package/src/utils/interactionModality.ts +5 -64
  34. package/src/Dialog/__tests__/DialogSecondDocument.a11y.browser.spec.ts +0 -202
  35. package/src/DropdownMenu/lazyMenuBottomSheet.test.ts +0 -53
  36. 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; optional steppers are hidden on coarse pointers
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
- // These compact buttons are a fine-pointer affordance. Touch users can
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;
@@ -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. The
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 view.HTMLInputElement ||
68
- element instanceof view.HTMLTextAreaElement ||
69
- element instanceof view.HTMLSelectElement ||
56
+ element instanceof HTMLInputElement ||
57
+ element instanceof HTMLTextAreaElement ||
58
+ element instanceof HTMLSelectElement ||
70
59
  element.isContentEditable;
71
- if (isTextEntry || modality !== 'pointer') {
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, shared Shift-key modality policy,
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. A second document (a same-origin iframe, a
32
- * Document Picture-in-Picture window) gets its own store when a component
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
- * Track a second document while a consumer there is mounted: a component an
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
- }