@astryxdesign/core 0.7.0 → 0.7.1-canary.ff40b78
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/BottomSheet/BottomSheet.d.ts +1 -1
- package/dist/BottomSheet/BottomSheet.d.ts.map +1 -1
- package/dist/BottomSheet/BottomSheet.js +12 -2
- package/dist/ContextMenu/ContextMenu.d.ts +1 -1
- package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
- package/dist/ContextMenu/ContextMenu.js +22 -8
- package/dist/Dialog/Dialog.d.ts.map +1 -1
- package/dist/Dialog/Dialog.js +3 -26
- package/dist/DropdownMenu/DropdownMenu.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenu.js +10 -3
- package/dist/Typeahead/BaseTypeahead.d.ts +1 -1
- package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
- package/dist/Typeahead/BaseTypeahead.js +12 -3
- package/dist/hooks/useIsomorphicLayoutEffect.d.ts +2 -2
- package/dist/hooks/useIsomorphicLayoutEffect.d.ts.map +1 -1
- package/dist/utils/focusReturn.d.ts +12 -8
- package/dist/utils/focusReturn.d.ts.map +1 -1
- package/dist/utils/focusReturn.js +44 -3
- package/package.json +4 -4
- package/src/BottomSheet/BottomSheet.test.tsx +114 -0
- package/src/BottomSheet/BottomSheet.tsx +12 -2
- package/src/BottomSheet/__tests__/BottomSheetFocusReturn.a11y.browser.spec.ts +96 -0
- package/src/Chat/__tests__/ChatLayoutScrollButtonSurface.a11y.chromium.spec.ts +5 -6
- package/src/CodeBlock/CodeBlock.spec.md +55 -44
- package/src/ContextMenu/ContextMenu.spec.md +1 -1
- package/src/ContextMenu/ContextMenu.test.tsx +36 -3
- package/src/ContextMenu/ContextMenu.tsx +27 -11
- package/src/ContextMenu/__tests__/ContextMenuCursorAnchor.a11y.browser.spec.ts +153 -0
- package/src/Dialog/Dialog.tsx +2 -30
- package/src/DropdownMenu/DropdownMenu.test.tsx +97 -1
- package/src/DropdownMenu/DropdownMenu.tsx +10 -3
- package/src/DropdownMenu/__tests__/DropdownMenuFocusReturn.a11y.browser.spec.ts +73 -0
- package/src/PowerSearch/PowerSearch.test.tsx +45 -0
- package/src/Typeahead/BaseTypeahead.test.tsx +99 -1
- package/src/Typeahead/BaseTypeahead.tsx +17 -3
- package/src/utils/focusReturn.test.ts +79 -2
- package/src/utils/focusReturn.ts +48 -3
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* @position Core implementation; consumed by index.ts
|
|
10
10
|
*
|
|
11
11
|
* Right-click context menu positioned at the cursor. The cursor point is
|
|
12
|
-
* captured as an offset *inside the trigger* and materialized as a
|
|
12
|
+
* captured as an offset *inside the trigger* and materialized as a 1px
|
|
13
13
|
* anchor element, so the menu is positioned relative to the trigger's context
|
|
14
14
|
* (via CSS anchor positioning) rather than the viewport. It therefore follows
|
|
15
15
|
* the content on scroll and auto-flips at viewport edges, while still appearing
|
|
@@ -112,14 +112,16 @@ const styles = stylex.create({
|
|
|
112
112
|
WebkitUserSelect: 'none',
|
|
113
113
|
userSelect: 'none',
|
|
114
114
|
},
|
|
115
|
-
//
|
|
116
|
-
//
|
|
115
|
+
// 1px anchor placed at the cursor point within the trigger. The menu is
|
|
116
|
+
// anchored to this element, so it sits under the cursor yet is positioned
|
|
117
117
|
// relative to the trigger's context — it follows the content on scroll and
|
|
118
|
-
// the browser can auto-flip it against the viewport edges.
|
|
118
|
+
// the browser can auto-flip it against the viewport edges. It owns a box
|
|
119
|
+
// because WebKit counts an empty anchor box as clipped, and the default
|
|
120
|
+
// `position-visibility: anchors-visible` then hides the menu.
|
|
119
121
|
cursorAnchor: {
|
|
120
122
|
position: 'absolute',
|
|
121
|
-
width:
|
|
122
|
-
height:
|
|
123
|
+
width: '1px',
|
|
124
|
+
height: '1px',
|
|
123
125
|
pointerEvents: 'none',
|
|
124
126
|
},
|
|
125
127
|
menu: {
|
|
@@ -185,6 +187,17 @@ const styles = stylex.create({
|
|
|
185
187
|
},
|
|
186
188
|
});
|
|
187
189
|
|
|
190
|
+
/**
|
|
191
|
+
* Keeps the 1px cursor anchor inside the trigger along one axis. A point in
|
|
192
|
+
* the trigger's last pixel (Shift+F10 opens on its bottom edge) steps back by
|
|
193
|
+
* that pixel: past the edge the anchor would add scrollable overflow, and a
|
|
194
|
+
* scroller whose edge meets the trigger's would clip it and hide the menu.
|
|
195
|
+
* Any other point, inside the trigger or not, stays where it is.
|
|
196
|
+
*/
|
|
197
|
+
function keepAnchorInside(offset: number, size: number): number {
|
|
198
|
+
return offset > size - 1 && offset <= size ? Math.max(size - 1, 0) : offset;
|
|
199
|
+
}
|
|
200
|
+
|
|
188
201
|
// =============================================================================
|
|
189
202
|
// Types
|
|
190
203
|
// =============================================================================
|
|
@@ -324,7 +337,7 @@ export function ContextMenu({
|
|
|
324
337
|
|
|
325
338
|
const menuId = useId();
|
|
326
339
|
// Cursor point in the trigger's local coordinate space (offset from the
|
|
327
|
-
// trigger's top-left, in-flow). Stored here and written to the
|
|
340
|
+
// trigger's top-left, in-flow). Stored here and written to the cursor
|
|
328
341
|
// anchor element so the menu is positioned relative to the trigger context
|
|
329
342
|
// — it scrolls with the content instead of sitting at a fixed viewport point.
|
|
330
343
|
const positionRef = useRef({x: 0, y: 0});
|
|
@@ -520,18 +533,21 @@ export function ContextMenu({
|
|
|
520
533
|
[listNavKeyDown, closeMenu, typeahead, ownsEvent],
|
|
521
534
|
);
|
|
522
535
|
|
|
523
|
-
// Place the
|
|
536
|
+
// Place the 1px cursor anchor at a point in the trigger's local
|
|
524
537
|
// coordinate space and open the menu. Positioning the anchor inside the
|
|
525
538
|
// trigger (rather than storing viewport coordinates on the menu itself) is
|
|
526
539
|
// what makes the menu context-relative: it scrolls with the content and the
|
|
527
540
|
// browser auto-flips it against the viewport edges via CSS anchor positioning.
|
|
528
541
|
const openAtLocalPoint = useCallback(
|
|
529
542
|
(localX: number, localY: number, focusEl: HTMLElement | null) => {
|
|
530
|
-
|
|
543
|
+
const rect = triggerRef.current?.getBoundingClientRect();
|
|
544
|
+
const x = rect ? keepAnchorInside(localX, rect.width) : localX;
|
|
545
|
+
const y = rect ? keepAnchorInside(localY, rect.height) : localY;
|
|
546
|
+
positionRef.current = {x, y};
|
|
531
547
|
const anchorEl = cursorAnchorRef.current;
|
|
532
548
|
if (anchorEl) {
|
|
533
|
-
anchorEl.style.left = `${
|
|
534
|
-
anchorEl.style.top = `${
|
|
549
|
+
anchorEl.style.left = `${x}px`;
|
|
550
|
+
anchorEl.style.top = `${y}px`;
|
|
535
551
|
}
|
|
536
552
|
// Remember the element focused before opening so we can restore it on
|
|
537
553
|
// close (Escape or outside-click), instead of dropping focus to <body>.
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file ContextMenuCursorAnchor.a11y.browser.spec.ts
|
|
5
|
+
* @input The built "Inside a scroll container" ContextMenu story and real
|
|
6
|
+
* Chromium/WebKit layout and paint
|
|
7
|
+
* @output Browser evidence that the menu paints at its cursor anchor inside a
|
|
8
|
+
* scroll container, hides while the container clips that anchor, and that
|
|
9
|
+
* the anchor adds no scrollable overflow
|
|
10
|
+
* @position Cross-browser proof; jsdom has no layout, anchor positioning or
|
|
11
|
+
* paint.
|
|
12
|
+
*
|
|
13
|
+
* The menu is anchored to a box at the pointer. The initial
|
|
14
|
+
* `position-visibility: anchors-visible` hides an anchored box whose anchor
|
|
15
|
+
* is clipped, and WebKit counts an empty anchor box inside a scroll container
|
|
16
|
+
* as clipped. Whether the menu painted is read from pixels: WebKit still
|
|
17
|
+
* hit-tests, and reports as visible, a box that rule hides.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import {expect, test, type Locator, type Page} from '@playwright/test';
|
|
21
|
+
import {holdMotionStill} from '@astryxdesign/a11y-spec/chromium';
|
|
22
|
+
import {
|
|
23
|
+
DEFAULT_STORYBOOK_DIR,
|
|
24
|
+
serveStorybook,
|
|
25
|
+
type StaticServer,
|
|
26
|
+
} from '@astryxdesign/a11y-spec/storybook';
|
|
27
|
+
|
|
28
|
+
let storybook: StaticServer;
|
|
29
|
+
test.beforeAll(async () => {
|
|
30
|
+
storybook = await serveStorybook(
|
|
31
|
+
process.env.ASTRYX_STORYBOOK_DIR ?? DEFAULT_STORYBOOK_DIR,
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
test.afterAll(async () => {
|
|
35
|
+
await storybook?.close();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
/** Mounts the story with the trigger scrolled fully into the box. */
|
|
39
|
+
async function mount(
|
|
40
|
+
page: Page,
|
|
41
|
+
): Promise<{trigger: Locator; scroller: Locator}> {
|
|
42
|
+
await page.goto(
|
|
43
|
+
`${storybook.origin}/iframe.html?id=core-contextmenu--in-scroll-container&viewMode=story`,
|
|
44
|
+
);
|
|
45
|
+
await holdMotionStill(page);
|
|
46
|
+
const scroller = page.getByTestId('context-menu-scroller');
|
|
47
|
+
// The trigger is the box's last child: at the end of the scroll its bottom
|
|
48
|
+
// edge is the box's bottom edge.
|
|
49
|
+
await scroller.evaluate(el => {
|
|
50
|
+
el.scrollTop = el.scrollHeight;
|
|
51
|
+
});
|
|
52
|
+
return {trigger: page.getByText('Right-click this area'), scroller};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Right-clicks the trigger 40px in and 30px down; returns that point. */
|
|
56
|
+
async function rightClick(
|
|
57
|
+
page: Page,
|
|
58
|
+
trigger: Locator,
|
|
59
|
+
): Promise<{x: number; y: number}> {
|
|
60
|
+
const box = await trigger.boundingBox();
|
|
61
|
+
const point = {
|
|
62
|
+
x: Math.round(box?.x ?? 0) + 40,
|
|
63
|
+
y: Math.round(box?.y ?? 0) + 30,
|
|
64
|
+
};
|
|
65
|
+
await page.mouse.click(point.x, point.y, {button: 'right'});
|
|
66
|
+
return point;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Whether the open menu is on screen: hiding it must change the pixels. */
|
|
70
|
+
async function menuPainted(page: Page): Promise<boolean> {
|
|
71
|
+
const layer = page.locator('[popover]:has(> [role="menu"])');
|
|
72
|
+
const clip = await layer.boundingBox();
|
|
73
|
+
if (clip == null) {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
const shown = await page.screenshot({clip});
|
|
77
|
+
await layer.evaluate(el => {
|
|
78
|
+
el.style.visibility = 'hidden';
|
|
79
|
+
});
|
|
80
|
+
const hidden = await page.screenshot({clip});
|
|
81
|
+
await layer.evaluate(el => {
|
|
82
|
+
el.style.visibility = '';
|
|
83
|
+
});
|
|
84
|
+
return !shown.equals(hidden);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
test('a right-click inside a scroll container paints the menu at the pointer', async ({
|
|
88
|
+
page,
|
|
89
|
+
}) => {
|
|
90
|
+
const {trigger} = await mount(page);
|
|
91
|
+
const point = await rightClick(page, trigger);
|
|
92
|
+
await expect(page.getByRole('menu')).toBeVisible();
|
|
93
|
+
await expect.poll(async () => menuPainted(page)).toBe(true);
|
|
94
|
+
|
|
95
|
+
const anchor = await page
|
|
96
|
+
.locator('span[aria-hidden="true"][style*="anchor-name"]')
|
|
97
|
+
.boundingBox();
|
|
98
|
+
// The anchor owns a box, so WebKit does not count it as clipped.
|
|
99
|
+
expect(anchor).toMatchObject({width: 1, height: 1});
|
|
100
|
+
expect(anchor?.x).toBeCloseTo(point.x, 0);
|
|
101
|
+
expect(anchor?.y).toBeCloseTo(point.y, 0);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('the menu hides while the box scrolls its anchor out of view, and returns with it', async ({
|
|
105
|
+
page,
|
|
106
|
+
}) => {
|
|
107
|
+
const {trigger, scroller} = await mount(page);
|
|
108
|
+
await rightClick(page, trigger);
|
|
109
|
+
await expect(page.getByRole('menu')).toBeVisible();
|
|
110
|
+
await expect.poll(async () => menuPainted(page)).toBe(true);
|
|
111
|
+
|
|
112
|
+
await scroller.evaluate(el => {
|
|
113
|
+
el.scrollTop -= el.clientHeight;
|
|
114
|
+
});
|
|
115
|
+
await expect(page.getByRole('menu')).toBeAttached();
|
|
116
|
+
await expect.poll(async () => menuPainted(page)).toBe(false);
|
|
117
|
+
|
|
118
|
+
await scroller.evaluate(el => {
|
|
119
|
+
el.scrollTop = el.scrollHeight;
|
|
120
|
+
});
|
|
121
|
+
await expect.poll(async () => menuPainted(page)).toBe(true);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('a keyboard open on the bottom edge paints and adds no scrollable overflow', async ({
|
|
125
|
+
page,
|
|
126
|
+
}) => {
|
|
127
|
+
const {trigger, scroller} = await mount(page);
|
|
128
|
+
const scrollHeight = await scroller.evaluate(el => el.scrollHeight);
|
|
129
|
+
// Several browsers report Shift+F10 as a contextmenu at (0, 0), which
|
|
130
|
+
// anchors the menu to the trigger's bottom edge, here the box's own edge.
|
|
131
|
+
await trigger.evaluate(el => {
|
|
132
|
+
el.dispatchEvent(
|
|
133
|
+
new MouseEvent('contextmenu', {
|
|
134
|
+
bubbles: true,
|
|
135
|
+
cancelable: true,
|
|
136
|
+
clientX: 0,
|
|
137
|
+
clientY: 0,
|
|
138
|
+
detail: 0,
|
|
139
|
+
}),
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
await expect(page.getByRole('menu')).toBeVisible();
|
|
143
|
+
const anchor = await page
|
|
144
|
+
.locator('span[aria-hidden="true"][style*="anchor-name"]')
|
|
145
|
+
.boundingBox();
|
|
146
|
+
const area = await trigger.boundingBox();
|
|
147
|
+
expect((anchor?.y ?? 0) + (anchor?.height ?? 0)).toBeCloseTo(
|
|
148
|
+
(area?.y ?? 0) + (area?.height ?? 0),
|
|
149
|
+
0,
|
|
150
|
+
);
|
|
151
|
+
await expect.poll(async () => menuPainted(page)).toBe(true);
|
|
152
|
+
expect(await scroller.evaluate(el => el.scrollHeight)).toBe(scrollHeight);
|
|
153
|
+
});
|
package/src/Dialog/Dialog.tsx
CHANGED
|
@@ -70,38 +70,10 @@ import {DialogContext} from './DialogContext';
|
|
|
70
70
|
import {themeProps} from '../utils/themeProps';
|
|
71
71
|
import type {DialogVariantMap} from './index';
|
|
72
72
|
import {focusOutlineProps} from '../utils/focusOutline.stylex';
|
|
73
|
-
import {
|
|
74
|
-
|
|
75
|
-
useInteractionModalityTracking,
|
|
76
|
-
} from '../utils/interactionModality';
|
|
73
|
+
import {useInteractionModalityTracking} from '../utils/interactionModality';
|
|
74
|
+
import {focusForLastInput} from '../utils/focusReturn';
|
|
77
75
|
|
|
78
76
|
import {useMergedRefs} from '../hooks/useMergedRefs';
|
|
79
|
-
/**
|
|
80
|
-
* Move focus programmatically so its ring follows the last input. Dialog does
|
|
81
|
-
* not render the element that opened it or the consumer's autofocus target, so
|
|
82
|
-
* the browser's focus option is the one way to reach them.
|
|
83
|
-
*/
|
|
84
|
-
function focusForLastInput(element: HTMLElement): void {
|
|
85
|
-
// A text-entry field paints its own focus treatment to show where typing
|
|
86
|
-
// goes, and an extra blur there can run validation or mark it touched.
|
|
87
|
-
const isTextEntry =
|
|
88
|
-
element instanceof HTMLInputElement ||
|
|
89
|
-
element instanceof HTMLTextAreaElement ||
|
|
90
|
-
element instanceof HTMLSelectElement ||
|
|
91
|
-
element.isContentEditable;
|
|
92
|
-
if (isTextEntry || getInteractionModality() !== 'pointer') {
|
|
93
|
-
element.focus();
|
|
94
|
-
return;
|
|
95
|
-
}
|
|
96
|
-
// Focusing the element that already has focus does nothing, so a ring the
|
|
97
|
-
// browser already drew would stay. WebKit draws one when showModal() or its
|
|
98
|
-
// native close moves focus itself, so clear that focus first.
|
|
99
|
-
if (element.matches(':focus-visible')) {
|
|
100
|
-
element.blur();
|
|
101
|
-
}
|
|
102
|
-
element.focus({focusVisible: false});
|
|
103
|
-
}
|
|
104
|
-
|
|
105
77
|
/**
|
|
106
78
|
* Calculate a directional translate offset for dialog entry animation.
|
|
107
79
|
* Returns a normalized vector from the trigger element toward the viewport
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* SYNC: When DropdownMenu.tsx changes, update tests to match new behavior
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import {describe, it, expect, vi, beforeEach} from 'vitest';
|
|
12
|
+
import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest';
|
|
13
13
|
import {readFileSync} from 'node:fs';
|
|
14
14
|
import {render, screen, fireEvent, waitFor, act} from '@testing-library/react';
|
|
15
15
|
import userEvent from '@testing-library/user-event';
|
|
@@ -3040,6 +3040,102 @@ describe('DropdownMenu focus return after a pointer pick', () => {
|
|
|
3040
3040
|
),
|
|
3041
3041
|
);
|
|
3042
3042
|
});
|
|
3043
|
+
|
|
3044
|
+
it("asks the browser for no ring on the design system's Button too", async () => {
|
|
3045
|
+
const user = userEvent.setup();
|
|
3046
|
+
const trigger = renderMenu();
|
|
3047
|
+
await user.click(trigger);
|
|
3048
|
+
const focus = vi.spyOn(trigger, 'focus');
|
|
3049
|
+
await user.click(item('Edit'));
|
|
3050
|
+
expect(trigger).toHaveFocus();
|
|
3051
|
+
expect(focus.mock.calls.at(-1)).toEqual([
|
|
3052
|
+
{preventScroll: true, focusVisible: false},
|
|
3053
|
+
]);
|
|
3054
|
+
});
|
|
3055
|
+
|
|
3056
|
+
describe('a trigger the caller renders', () => {
|
|
3057
|
+
// The style that keeps the ring off the design system's Button cannot
|
|
3058
|
+
// reach a caller's control, so the focus move itself has to: after
|
|
3059
|
+
// pointer input the browser is asked not to match `:focus-visible`, which
|
|
3060
|
+
// it otherwise does on the returned focus (Chromium after a mouse pick,
|
|
3061
|
+
// WebKit after a touch).
|
|
3062
|
+
function renderCustomMenu() {
|
|
3063
|
+
render(
|
|
3064
|
+
<DropdownMenu
|
|
3065
|
+
renderTrigger={props => (
|
|
3066
|
+
<button type="button" {...props}>
|
|
3067
|
+
More
|
|
3068
|
+
</button>
|
|
3069
|
+
)}>
|
|
3070
|
+
<DropdownMenuItem label="Edit" onClick={() => {}} />
|
|
3071
|
+
<DropdownMenuItem label="Delete" onClick={() => {}} />
|
|
3072
|
+
</DropdownMenu>,
|
|
3073
|
+
);
|
|
3074
|
+
return screen.getByRole('button', {name: 'More'});
|
|
3075
|
+
}
|
|
3076
|
+
|
|
3077
|
+
afterEach(() => {
|
|
3078
|
+
vi.restoreAllMocks();
|
|
3079
|
+
});
|
|
3080
|
+
|
|
3081
|
+
it('returns focus after a pointer pick with the browser asked for no ring', async () => {
|
|
3082
|
+
const user = userEvent.setup();
|
|
3083
|
+
const trigger = renderCustomMenu();
|
|
3084
|
+
await user.click(trigger);
|
|
3085
|
+
const focus = vi.spyOn(trigger, 'focus');
|
|
3086
|
+
await user.click(item('Edit'));
|
|
3087
|
+
expect(trigger).toHaveFocus();
|
|
3088
|
+
expect(focus.mock.calls.at(-1)).toEqual([
|
|
3089
|
+
{preventScroll: true, focusVisible: false},
|
|
3090
|
+
]);
|
|
3091
|
+
});
|
|
3092
|
+
|
|
3093
|
+
it('returns focus after a keyboard pick with the ring left to the browser', async () => {
|
|
3094
|
+
const user = userEvent.setup();
|
|
3095
|
+
const trigger = renderCustomMenu();
|
|
3096
|
+
trigger.focus();
|
|
3097
|
+
await user.keyboard('{ArrowDown}');
|
|
3098
|
+
await waitFor(() => expect(item('Edit')).toHaveFocus());
|
|
3099
|
+
const focus = vi.spyOn(trigger, 'focus');
|
|
3100
|
+
await user.keyboard('{Enter}');
|
|
3101
|
+
await waitFor(() => expect(trigger).toHaveFocus());
|
|
3102
|
+
expect(focus.mock.calls.at(-1)).toEqual([{preventScroll: true}]);
|
|
3103
|
+
});
|
|
3104
|
+
|
|
3105
|
+
it('clears a ring the browser already drew before refocusing after a pointer pick', async () => {
|
|
3106
|
+
const user = userEvent.setup();
|
|
3107
|
+
const trigger = renderCustomMenu();
|
|
3108
|
+
await user.click(trigger);
|
|
3109
|
+
// WebKit's own popover focus restoration can focus the trigger, with a
|
|
3110
|
+
// ring, before the menu moves focus, and focusing the focused element
|
|
3111
|
+
// changes nothing.
|
|
3112
|
+
const hidePopover = HTMLElement.prototype.hidePopover;
|
|
3113
|
+
HTMLElement.prototype.hidePopover = vi.fn(function (this: HTMLElement) {
|
|
3114
|
+
hidePopover.call(this);
|
|
3115
|
+
trigger.focus();
|
|
3116
|
+
});
|
|
3117
|
+
const matches = HTMLElement.prototype.matches;
|
|
3118
|
+
vi.spyOn(HTMLElement.prototype, 'matches').mockImplementation(function (
|
|
3119
|
+
this: HTMLElement,
|
|
3120
|
+
selector: string,
|
|
3121
|
+
) {
|
|
3122
|
+
return selector === ':focus-visible'
|
|
3123
|
+
? this === trigger
|
|
3124
|
+
: matches.call(this, selector);
|
|
3125
|
+
});
|
|
3126
|
+
const blur = vi.spyOn(trigger, 'blur');
|
|
3127
|
+
const focus = vi.spyOn(trigger, 'focus');
|
|
3128
|
+
await user.click(item('Edit'));
|
|
3129
|
+
expect(blur).toHaveBeenCalledTimes(1);
|
|
3130
|
+
expect(focus.mock.calls.at(-1)).toEqual([
|
|
3131
|
+
{preventScroll: true, focusVisible: false},
|
|
3132
|
+
]);
|
|
3133
|
+
expect(blur.mock.invocationCallOrder[0]).toBeLessThan(
|
|
3134
|
+
focus.mock.invocationCallOrder.at(-1) ?? 0,
|
|
3135
|
+
);
|
|
3136
|
+
expect(trigger).toHaveFocus();
|
|
3137
|
+
});
|
|
3138
|
+
});
|
|
3043
3139
|
});
|
|
3044
3140
|
|
|
3045
3141
|
describe('DropdownMenuItem href', () => {
|
|
@@ -68,6 +68,7 @@ import {useMenuPress} from '../hooks/useMenuPress';
|
|
|
68
68
|
import type {MenuPressPointerType} from '../hooks/menuPressGesture';
|
|
69
69
|
import {useTypeahead} from '../hooks/useTypeahead';
|
|
70
70
|
import {useFocusReturnVisibility} from '../hooks/useFocusReturnVisibility';
|
|
71
|
+
import {focusForLastInput} from '../utils/focusReturn';
|
|
71
72
|
import {useMenuOverflow} from './useMenuOverflow';
|
|
72
73
|
import {MenuRegionsContext} from './menuRegionsContext';
|
|
73
74
|
import {getFocusableElements} from '../hooks/useFocusTrap';
|
|
@@ -932,7 +933,12 @@ function DropdownMenuPopover({
|
|
|
932
933
|
return;
|
|
933
934
|
}
|
|
934
935
|
prepareFocusReturn();
|
|
935
|
-
trigger
|
|
936
|
+
// After pointer input the trigger takes focus without the focus-visible
|
|
937
|
+
// ring (`component:DropdownMenu/DEC-1`). The style the helper above adds
|
|
938
|
+
// reaches only the design system's own Button; the browser's focus option
|
|
939
|
+
// also reaches a control the caller renders, which the browser would
|
|
940
|
+
// otherwise ring (Chromium after a mouse pick, WebKit after a touch).
|
|
941
|
+
focusForLastInput(trigger, {preventScroll: true});
|
|
936
942
|
}, [isControlled, onOpenChange, prepareFocusReturn]);
|
|
937
943
|
|
|
938
944
|
// Defer item focus until the layer has committed open, so focus restore
|
|
@@ -1432,8 +1438,9 @@ function DropdownMenuPopover({
|
|
|
1432
1438
|
onPointerDown: menuPress.triggerProps.onPointerDown,
|
|
1433
1439
|
onContextMenu: menuPress.triggerProps.onContextMenu,
|
|
1434
1440
|
onKeyDown: handleButtonKeyDown,
|
|
1435
|
-
// The
|
|
1436
|
-
// the
|
|
1441
|
+
// The caller's control paints its own ring, and the menu's focus return
|
|
1442
|
+
// keeps the browser from drawing one after pointer input (see
|
|
1443
|
+
// handleLayerHide), so its focus needs no bookkeeping here.
|
|
1437
1444
|
onFocus: () => {},
|
|
1438
1445
|
'aria-haspopup': 'menu',
|
|
1439
1446
|
'aria-expanded': isOpen,
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file DropdownMenuFocusReturn.a11y.browser.spec.ts
|
|
5
|
+
* @input The `Core/DropdownMenu` CustomTrigger story (an icon button the
|
|
6
|
+
* caller renders) in a built Storybook, real Chromium/WebKit pointer and
|
|
7
|
+
* keyboard input.
|
|
8
|
+
* @output Browser evidence that the focus the menu returns to a trigger the
|
|
9
|
+
* caller renders follows the last input
|
|
10
|
+
* (docs/architecture/interaction-modality.md INV1,
|
|
11
|
+
* `component:DropdownMenu/DEC-1`): no ring after a pointer pick, the ring
|
|
12
|
+
* after a keyboard pick.
|
|
13
|
+
* @position Cross-browser proof; jsdom only proves which focus call the menu
|
|
14
|
+
* makes. Unless the menu asks for no ring, both engines ring the focus it
|
|
15
|
+
* returns after a pointer pick.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import {expect, test, type Locator, type Page} from '@playwright/test';
|
|
19
|
+
import {
|
|
20
|
+
DEFAULT_STORYBOOK_DIR,
|
|
21
|
+
serveStorybook,
|
|
22
|
+
type StaticServer,
|
|
23
|
+
} from '@astryxdesign/a11y-spec/storybook';
|
|
24
|
+
|
|
25
|
+
let storybook: StaticServer;
|
|
26
|
+
test.beforeAll(async () => {
|
|
27
|
+
storybook = await serveStorybook(
|
|
28
|
+
process.env.ASTRYX_STORYBOOK_DIR ?? DEFAULT_STORYBOOK_DIR,
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
test.afterAll(async () => {
|
|
32
|
+
await storybook?.close();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
async function mount(page: Page): Promise<Locator> {
|
|
36
|
+
await page.goto(
|
|
37
|
+
`${storybook.origin}/iframe.html?id=core-dropdownmenu--custom-trigger&viewMode=story`,
|
|
38
|
+
);
|
|
39
|
+
const trigger = page.getByRole('button', {name: 'More actions'});
|
|
40
|
+
await expect(trigger).toBeVisible();
|
|
41
|
+
return trigger;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function matchesFocusVisible(locator: Locator): Promise<boolean> {
|
|
45
|
+
return locator.evaluate(element => element.matches(':focus-visible'));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
test('a pointer pick returns focus to the trigger without a ring', async ({
|
|
49
|
+
page,
|
|
50
|
+
}) => {
|
|
51
|
+
const trigger = await mount(page);
|
|
52
|
+
await trigger.click();
|
|
53
|
+
const menu = page.getByRole('menu');
|
|
54
|
+
await expect(menu).toBeVisible();
|
|
55
|
+
await page.getByRole('menuitem', {name: 'Rename'}).click();
|
|
56
|
+
await expect(menu).toBeHidden();
|
|
57
|
+
await expect(trigger).toBeFocused();
|
|
58
|
+
expect(await matchesFocusVisible(trigger)).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('a keyboard pick returns focus to the trigger with its ring', async ({
|
|
62
|
+
page,
|
|
63
|
+
}) => {
|
|
64
|
+
const trigger = await mount(page);
|
|
65
|
+
await trigger.focus();
|
|
66
|
+
await page.keyboard.press('Enter');
|
|
67
|
+
const menu = page.getByRole('menu');
|
|
68
|
+
await expect(page.getByRole('menuitem', {name: 'Rename'})).toBeFocused();
|
|
69
|
+
await page.keyboard.press('Enter');
|
|
70
|
+
await expect(menu).toBeHidden();
|
|
71
|
+
await expect(trigger).toBeFocused();
|
|
72
|
+
expect(await matchesFocusVisible(trigger)).toBe(true);
|
|
73
|
+
});
|
|
@@ -733,3 +733,48 @@ describe('field menu grouping', () => {
|
|
|
733
733
|
});
|
|
734
734
|
});
|
|
735
735
|
});
|
|
736
|
+
|
|
737
|
+
describe('field menu after the search source changes', () => {
|
|
738
|
+
it('opens the new field menu on the first click after a config change', async () => {
|
|
739
|
+
const user = userEvent.setup();
|
|
740
|
+
// A caller that loads field options asynchronously hands PowerSearch a
|
|
741
|
+
// new config, which builds a new search source.
|
|
742
|
+
const loadedConfig: PowerSearchConfig = {
|
|
743
|
+
...config,
|
|
744
|
+
fields: [
|
|
745
|
+
...config.fields,
|
|
746
|
+
{
|
|
747
|
+
key: 'owner',
|
|
748
|
+
label: 'Owner',
|
|
749
|
+
defaultOperator: 'is',
|
|
750
|
+
operators: [{key: 'is', label: 'is', value: {type: 'string'}}],
|
|
751
|
+
},
|
|
752
|
+
],
|
|
753
|
+
};
|
|
754
|
+
const renderSearch = (searchConfig: PowerSearchConfig) => (
|
|
755
|
+
<>
|
|
756
|
+
<PowerSearch config={searchConfig} filters={[]} onChange={() => {}} />
|
|
757
|
+
<button type="button">Sort</button>
|
|
758
|
+
</>
|
|
759
|
+
);
|
|
760
|
+
const {rerender} = render(renderSearch(config));
|
|
761
|
+
const input = screen.getByRole('combobox');
|
|
762
|
+
|
|
763
|
+
await user.click(input);
|
|
764
|
+
await waitFor(() => {
|
|
765
|
+
expect(input).toHaveAttribute('aria-expanded', 'true');
|
|
766
|
+
});
|
|
767
|
+
await user.click(screen.getByRole('button', {name: 'Sort'}));
|
|
768
|
+
expect(input).toHaveAttribute('aria-expanded', 'false');
|
|
769
|
+
|
|
770
|
+
rerender(renderSearch(loadedConfig));
|
|
771
|
+
await user.click(input);
|
|
772
|
+
|
|
773
|
+
await waitFor(() => {
|
|
774
|
+
expect(input).toHaveAttribute('aria-expanded', 'true');
|
|
775
|
+
expect(
|
|
776
|
+
screen.getByRole('option', {name: /Owner/, hidden: true}),
|
|
777
|
+
).toBeInTheDocument();
|
|
778
|
+
});
|
|
779
|
+
});
|
|
780
|
+
});
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* @file BaseTypeahead.test.tsx
|
|
5
5
|
* @input BaseTypeahead public props and a synchronous SearchSource
|
|
6
|
-
* @output Combobox contract tests, including stale source refresh on reopen
|
|
6
|
+
* @output Combobox contract tests, including stale source refresh on focus and reopen
|
|
7
7
|
* @position Colocated verification for BaseTypeahead
|
|
8
8
|
*/
|
|
9
9
|
|
|
@@ -209,6 +209,104 @@ describe('BaseTypeahead', () => {
|
|
|
209
209
|
expect(input).toHaveFocus();
|
|
210
210
|
});
|
|
211
211
|
|
|
212
|
+
it.each([
|
|
213
|
+
['a click', 'click'],
|
|
214
|
+
['keyboard focus', 'tab'],
|
|
215
|
+
] as const)(
|
|
216
|
+
'bootstraps the replacement source when %s refocuses the input',
|
|
217
|
+
async (_label, refocus) => {
|
|
218
|
+
const user = userEvent.setup();
|
|
219
|
+
const originalSource: SearchSource<SearchableItem> = {
|
|
220
|
+
search: () => [],
|
|
221
|
+
bootstrap: () => [resultItem],
|
|
222
|
+
};
|
|
223
|
+
const updatedBootstrap = vi.fn(() => [
|
|
224
|
+
{id: '2', label: 'Updated result'},
|
|
225
|
+
]);
|
|
226
|
+
const updatedSource: SearchSource<SearchableItem> = {
|
|
227
|
+
search: () => [],
|
|
228
|
+
bootstrap: updatedBootstrap,
|
|
229
|
+
};
|
|
230
|
+
const renderField = (searchSource: SearchSource<SearchableItem>) => (
|
|
231
|
+
<>
|
|
232
|
+
<BaseTypeahead
|
|
233
|
+
searchSource={searchSource}
|
|
234
|
+
value={null}
|
|
235
|
+
onChange={() => {}}
|
|
236
|
+
hasEntriesOnFocus
|
|
237
|
+
/>
|
|
238
|
+
<button type="button">Sort</button>
|
|
239
|
+
</>
|
|
240
|
+
);
|
|
241
|
+
const {rerender} = render(renderField(originalSource));
|
|
242
|
+
const input = screen.getByRole('combobox');
|
|
243
|
+
const sortButton = screen.getByRole('button', {name: 'Sort'});
|
|
244
|
+
|
|
245
|
+
await user.click(input);
|
|
246
|
+
await waitFor(() => {
|
|
247
|
+
expect(input).toHaveAttribute('aria-expanded', 'true');
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// Focus moves to another control, which then hands the field a new
|
|
251
|
+
// source (a sort or filter change rebuilding the caller's source).
|
|
252
|
+
await user.click(sortButton);
|
|
253
|
+
expect(input).not.toHaveFocus();
|
|
254
|
+
expect(input).toHaveAttribute('aria-expanded', 'false');
|
|
255
|
+
rerender(renderField(updatedSource));
|
|
256
|
+
|
|
257
|
+
if (refocus === 'click') {
|
|
258
|
+
await user.click(input);
|
|
259
|
+
} else {
|
|
260
|
+
await user.tab({shift: true});
|
|
261
|
+
}
|
|
262
|
+
expect(input).toHaveFocus();
|
|
263
|
+
await waitFor(() => {
|
|
264
|
+
expect(input).toHaveAttribute('aria-expanded', 'true');
|
|
265
|
+
expect(
|
|
266
|
+
screen.getByRole('option', {name: 'Updated result', hidden: true}),
|
|
267
|
+
).toBeInTheDocument();
|
|
268
|
+
});
|
|
269
|
+
expect(
|
|
270
|
+
screen.queryByRole('option', {name: 'Result', hidden: true}),
|
|
271
|
+
).not.toBeInTheDocument();
|
|
272
|
+
expect(updatedBootstrap).toHaveBeenCalledTimes(1);
|
|
273
|
+
},
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
it('re-shows cached results without bootstrapping again when the source is unchanged', async () => {
|
|
277
|
+
const user = userEvent.setup();
|
|
278
|
+
const bootstrap = vi.fn(() => [resultItem]);
|
|
279
|
+
const source: SearchSource<SearchableItem> = {search: () => [], bootstrap};
|
|
280
|
+
render(
|
|
281
|
+
<>
|
|
282
|
+
<BaseTypeahead
|
|
283
|
+
searchSource={source}
|
|
284
|
+
value={null}
|
|
285
|
+
onChange={() => {}}
|
|
286
|
+
hasEntriesOnFocus
|
|
287
|
+
/>
|
|
288
|
+
<button type="button">Sort</button>
|
|
289
|
+
</>,
|
|
290
|
+
);
|
|
291
|
+
const input = screen.getByRole('combobox');
|
|
292
|
+
|
|
293
|
+
await user.click(input);
|
|
294
|
+
await waitFor(() => {
|
|
295
|
+
expect(input).toHaveAttribute('aria-expanded', 'true');
|
|
296
|
+
});
|
|
297
|
+
await user.click(screen.getByRole('button', {name: 'Sort'}));
|
|
298
|
+
expect(input).toHaveAttribute('aria-expanded', 'false');
|
|
299
|
+
|
|
300
|
+
await user.click(input);
|
|
301
|
+
await waitFor(() => {
|
|
302
|
+
expect(input).toHaveAttribute('aria-expanded', 'true');
|
|
303
|
+
});
|
|
304
|
+
expect(
|
|
305
|
+
screen.getByRole('option', {name: 'Result', hidden: true}),
|
|
306
|
+
).toBeInTheDocument();
|
|
307
|
+
expect(bootstrap).toHaveBeenCalledTimes(1);
|
|
308
|
+
});
|
|
309
|
+
|
|
212
310
|
it('counts grapheme clusters when enforcing minQueryLength', async () => {
|
|
213
311
|
const search = vi.fn(() => [resultItem]);
|
|
214
312
|
render(
|