@astryxdesign/core 0.7.0 → 0.7.1-canary.06e35f5
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/CodeBlock/CodeBlock.d.ts.map +1 -1
- package/dist/CodeBlock/CodeBlock.js +25 -16
- 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 -45
- package/src/CodeBlock/CodeBlock.test.tsx +11 -9
- package/src/CodeBlock/CodeBlock.tsx +29 -18
- package/src/CodeBlock/__tests__/CodeBlockOverflowStop.a11y.chromium.spec.ts +125 -0
- 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/Dialog/__tests__/Dialog.a11y.chromium.spec.ts +21 -14
- 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/FieldStatus/__tests__/StatusMessage.a11y.chromium.spec.ts +21 -14
- package/src/Markdown/Markdown.spec.md +37 -23
- package/src/Markdown/plugins/semanticFence.test.tsx +3 -2
- package/src/NumberInput/__tests__/NumberInput.a11y.chromium.spec.ts +22 -15
- package/src/PowerSearch/PowerSearch.test.tsx +45 -0
- package/src/Switch/__tests__/Switch.a11y.chromium.spec.ts +25 -18
- package/src/TabList/__tests__/Tabs.a11y.chromium.spec.ts +19 -12
- package/src/TextInput/__tests__/TextInput.a11y.chromium.spec.ts +21 -14
- package/src/ToggleButton/__tests__/ToggleButton.a11y.chromium.spec.ts +18 -11
- 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
|
@@ -14,6 +14,7 @@ import {act, render, screen, fireEvent} from '@testing-library/react';
|
|
|
14
14
|
import {createRef, useState} from 'react';
|
|
15
15
|
import {BottomSheet} from './BottomSheet';
|
|
16
16
|
import {BottomSheetSwitcher} from './BottomSheetSwitcher';
|
|
17
|
+
import {__resetInteractionModalityForTest} from '../utils/interactionModality';
|
|
17
18
|
|
|
18
19
|
// A sheet has no stops unless its host asks for them, so the tests that
|
|
19
20
|
// exercise detents pass their own. These three — a 14% peek, a half-height
|
|
@@ -2351,6 +2352,119 @@ describe('BottomSheet', () => {
|
|
|
2351
2352
|
|
|
2352
2353
|
expect(document.activeElement).toBe(finalFocusRef.current);
|
|
2353
2354
|
});
|
|
2355
|
+
|
|
2356
|
+
// The returned focus shows the focus-visible ring only after keyboard
|
|
2357
|
+
// input (`architecture:interaction-modality` INV1). A plain focus() leaves
|
|
2358
|
+
// that to the browser, which rings it after a pointer close: WebKit after
|
|
2359
|
+
// a tap, and Chromium when the sheet was opened from the keyboard.
|
|
2360
|
+
describe('ring on the returned focus', () => {
|
|
2361
|
+
beforeEach(() => {
|
|
2362
|
+
__resetInteractionModalityForTest();
|
|
2363
|
+
});
|
|
2364
|
+
|
|
2365
|
+
afterEach(() => {
|
|
2366
|
+
vi.restoreAllMocks();
|
|
2367
|
+
});
|
|
2368
|
+
|
|
2369
|
+
function openThenClose(closeWith: 'pointer' | 'keyboard') {
|
|
2370
|
+
render(<Harness />);
|
|
2371
|
+
const opener = screen.getByRole('button', {name: 'Open sheet'});
|
|
2372
|
+
opener.focus();
|
|
2373
|
+
fireEvent.click(opener);
|
|
2374
|
+
const done = screen.getByRole('button', {name: 'Done'});
|
|
2375
|
+
if (closeWith === 'pointer') {
|
|
2376
|
+
fireEvent.pointerDown(done);
|
|
2377
|
+
} else {
|
|
2378
|
+
fireEvent.keyDown(done, {key: 'Enter'});
|
|
2379
|
+
}
|
|
2380
|
+
fireEvent.click(done);
|
|
2381
|
+
return opener;
|
|
2382
|
+
}
|
|
2383
|
+
|
|
2384
|
+
it('asks the browser for no ring after a pointer close', () => {
|
|
2385
|
+
const opener = openThenClose('pointer');
|
|
2386
|
+
const focus = vi.spyOn(opener, 'focus');
|
|
2387
|
+
finishSheetExit();
|
|
2388
|
+
|
|
2389
|
+
expect(opener).toHaveFocus();
|
|
2390
|
+
expect(focus.mock.calls).toEqual([[{focusVisible: false}]]);
|
|
2391
|
+
});
|
|
2392
|
+
|
|
2393
|
+
it('leaves the ring to the browser after a keyboard close', () => {
|
|
2394
|
+
const opener = openThenClose('keyboard');
|
|
2395
|
+
const focus = vi.spyOn(opener, 'focus');
|
|
2396
|
+
finishSheetExit();
|
|
2397
|
+
|
|
2398
|
+
expect(opener).toHaveFocus();
|
|
2399
|
+
expect(focus.mock.calls).toEqual([[]]);
|
|
2400
|
+
});
|
|
2401
|
+
|
|
2402
|
+
it('clears a ring the native close already drew before refocusing', () => {
|
|
2403
|
+
const opener = openThenClose('pointer');
|
|
2404
|
+
// The browser's own dialog close restores focus to the opener, with a
|
|
2405
|
+
// ring, before the sheet moves focus, and focusing the focused element
|
|
2406
|
+
// changes nothing.
|
|
2407
|
+
HTMLDialogElement.prototype.close = vi.fn(function (
|
|
2408
|
+
this: HTMLDialogElement,
|
|
2409
|
+
) {
|
|
2410
|
+
this.removeAttribute('open');
|
|
2411
|
+
opener.focus();
|
|
2412
|
+
});
|
|
2413
|
+
const matches = HTMLElement.prototype.matches;
|
|
2414
|
+
vi.spyOn(HTMLElement.prototype, 'matches').mockImplementation(function (
|
|
2415
|
+
this: HTMLElement,
|
|
2416
|
+
selector: string,
|
|
2417
|
+
) {
|
|
2418
|
+
return selector === ':focus-visible'
|
|
2419
|
+
? this === opener
|
|
2420
|
+
: matches.call(this, selector);
|
|
2421
|
+
});
|
|
2422
|
+
const blur = vi.spyOn(opener, 'blur');
|
|
2423
|
+
const focus = vi.spyOn(opener, 'focus');
|
|
2424
|
+
finishSheetExit();
|
|
2425
|
+
|
|
2426
|
+
expect(blur).toHaveBeenCalledTimes(1);
|
|
2427
|
+
expect(focus.mock.calls.at(-1)).toEqual([{focusVisible: false}]);
|
|
2428
|
+
expect(blur.mock.invocationCallOrder[0]).toBeLessThan(
|
|
2429
|
+
focus.mock.invocationCallOrder.at(-1) ?? 0,
|
|
2430
|
+
);
|
|
2431
|
+
expect(opener).toHaveFocus();
|
|
2432
|
+
});
|
|
2433
|
+
|
|
2434
|
+
it('asks for no ring on an explicit final focus target too', () => {
|
|
2435
|
+
const finalFocusRef = createRef<HTMLButtonElement>();
|
|
2436
|
+
function ExplicitFocusHarness() {
|
|
2437
|
+
const [isOpen, setIsOpen] = useState(true);
|
|
2438
|
+
return (
|
|
2439
|
+
<>
|
|
2440
|
+
<button ref={finalFocusRef} type="button">
|
|
2441
|
+
Adaptive trigger
|
|
2442
|
+
</button>
|
|
2443
|
+
<BottomSheet
|
|
2444
|
+
isOpen={isOpen}
|
|
2445
|
+
onOpenChange={setIsOpen}
|
|
2446
|
+
finalFocusRef={finalFocusRef}
|
|
2447
|
+
label="Filters">
|
|
2448
|
+
<button type="button" onClick={() => setIsOpen(false)}>
|
|
2449
|
+
Done
|
|
2450
|
+
</button>
|
|
2451
|
+
</BottomSheet>
|
|
2452
|
+
</>
|
|
2453
|
+
);
|
|
2454
|
+
}
|
|
2455
|
+
|
|
2456
|
+
render(<ExplicitFocusHarness />);
|
|
2457
|
+
const done = screen.getByRole('button', {name: 'Done'});
|
|
2458
|
+
fireEvent.pointerDown(done);
|
|
2459
|
+
fireEvent.click(done);
|
|
2460
|
+
const target = finalFocusRef.current!;
|
|
2461
|
+
const focus = vi.spyOn(target, 'focus');
|
|
2462
|
+
finishSheetExit();
|
|
2463
|
+
|
|
2464
|
+
expect(target).toHaveFocus();
|
|
2465
|
+
expect(focus.mock.calls).toEqual([[{focusVisible: false}]]);
|
|
2466
|
+
});
|
|
2467
|
+
});
|
|
2354
2468
|
});
|
|
2355
2469
|
|
|
2356
2470
|
describe('initial focus', () => {
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* @file BottomSheet.tsx
|
|
7
|
-
* @input Uses React, StyleX, core hooks, shared layer dismissal, named BottomSheetPanel, BottomSheetSwitcherContext
|
|
7
|
+
* @input Uses React, StyleX, core hooks, shared layer dismissal, the shared interaction modality, named BottomSheetPanel, BottomSheetSwitcherContext
|
|
8
8
|
* @output Exports BottomSheet component and BottomSheetProps
|
|
9
9
|
* @position Public BottomSheet router plus private standalone/switcher hosts
|
|
10
10
|
*
|
|
@@ -46,6 +46,8 @@ import {colorVars, durationVars, easeVars} from '../theme/tokens.stylex';
|
|
|
46
46
|
import {useDevWarning, useScrollLock} from '../hooks';
|
|
47
47
|
import {LayerDepthProvider} from '../Layer/LayerDepthContext';
|
|
48
48
|
import {useLayerDismissal} from '../Layer/useLayerDismissal';
|
|
49
|
+
import {focusForLastInput} from '../utils/focusReturn';
|
|
50
|
+
import {useInteractionModalityTracking} from '../utils/interactionModality';
|
|
49
51
|
import {
|
|
50
52
|
BottomSheetPanel,
|
|
51
53
|
type BottomSheetPanelMotion,
|
|
@@ -320,12 +322,20 @@ function StandaloneBottomSheet({
|
|
|
320
322
|
dialog.close();
|
|
321
323
|
}
|
|
322
324
|
setIsPresented(false);
|
|
323
|
-
|
|
325
|
+
// The returned focus shows the focus-visible ring only after keyboard
|
|
326
|
+
// input (`architecture:interaction-modality` INV1). The native close
|
|
327
|
+
// may already have restored focus to the target with a ring; the helper
|
|
328
|
+
// clears it first.
|
|
329
|
+
const returnTarget = finalFocusRef?.current ?? triggerRef.current;
|
|
330
|
+
if (returnTarget != null) {
|
|
331
|
+
focusForLastInput(returnTarget);
|
|
332
|
+
}
|
|
324
333
|
triggerRef.current = null;
|
|
325
334
|
},
|
|
326
335
|
[finalFocusRef, isOpen],
|
|
327
336
|
);
|
|
328
337
|
|
|
338
|
+
useInteractionModalityTracking();
|
|
329
339
|
useScrollLock(shouldPresent && hasScrim);
|
|
330
340
|
useDevWarning(
|
|
331
341
|
'BottomSheet',
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file BottomSheetFocusReturn.a11y.browser.spec.ts
|
|
5
|
+
* @input The `Core/DropdownMenu` action-sheet story with a trigger the caller
|
|
6
|
+
* renders, in a built Storybook, and real Chromium/WebKit pointer and
|
|
7
|
+
* keyboard input.
|
|
8
|
+
* @output Browser evidence that the focus a BottomSheet returns when it closes
|
|
9
|
+
* follows the last input (docs/architecture/interaction-modality.md INV1):
|
|
10
|
+
* no ring after a pointer close, the ring after a keyboard close.
|
|
11
|
+
* @position Cross-browser proof; jsdom only proves which focus call the sheet
|
|
12
|
+
* makes. The menu's sheet returns focus through BottomSheet's
|
|
13
|
+
* `finalFocusRef`, and a control the caller renders carries no style of its
|
|
14
|
+
* own that would hide a ring, so the ring shown is the browser's answer.
|
|
15
|
+
* Chromium rings it when a keyboard-opened sheet is closed by a pointer;
|
|
16
|
+
* WebKit also rings it after a tap open and a tap close.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import {expect, test, type Locator, type Page} from '@playwright/test';
|
|
20
|
+
import {
|
|
21
|
+
DEFAULT_STORYBOOK_DIR,
|
|
22
|
+
serveStorybook,
|
|
23
|
+
type StaticServer,
|
|
24
|
+
} from '@astryxdesign/a11y-spec/storybook';
|
|
25
|
+
|
|
26
|
+
let storybook: StaticServer;
|
|
27
|
+
test.beforeAll(async () => {
|
|
28
|
+
storybook = await serveStorybook(
|
|
29
|
+
process.env.ASTRYX_STORYBOOK_DIR ?? DEFAULT_STORYBOOK_DIR,
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
test.afterAll(async () => {
|
|
33
|
+
await storybook?.close();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
async function mount(page: Page): Promise<Locator> {
|
|
37
|
+
await page.goto(
|
|
38
|
+
`${storybook.origin}/iframe.html?id=core-dropdownmenu--action-sheet-custom-trigger&viewMode=story`,
|
|
39
|
+
);
|
|
40
|
+
const trigger = page.getByRole('button', {
|
|
41
|
+
name: 'Project actions',
|
|
42
|
+
exact: true,
|
|
43
|
+
});
|
|
44
|
+
await expect(trigger).toBeVisible();
|
|
45
|
+
return trigger;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function openFromKeyboard(page: Page, trigger: Locator): Promise<void> {
|
|
49
|
+
await trigger.focus();
|
|
50
|
+
await page.keyboard.press('Enter');
|
|
51
|
+
await expect(page.getByRole('dialog')).toBeVisible();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function row(page: Page): Locator {
|
|
55
|
+
return page.getByRole('dialog').getByRole('button', {name: /Edit project/});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function expectReturnedFocus(
|
|
59
|
+
page: Page,
|
|
60
|
+
trigger: Locator,
|
|
61
|
+
ring: boolean,
|
|
62
|
+
): Promise<void> {
|
|
63
|
+
await expect(page.getByRole('dialog')).toBeHidden();
|
|
64
|
+
await expect(trigger).toBeFocused();
|
|
65
|
+
expect(
|
|
66
|
+
await trigger.evaluate(element => element.matches(':focus-visible')),
|
|
67
|
+
).toBe(ring);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
test('a keyboard-opened sheet closed by a pointer pick returns focus without a ring', async ({
|
|
71
|
+
page,
|
|
72
|
+
}) => {
|
|
73
|
+
const trigger = await mount(page);
|
|
74
|
+
await openFromKeyboard(page, trigger);
|
|
75
|
+
await row(page).click();
|
|
76
|
+
await expectReturnedFocus(page, trigger, false);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('a pointer open and pointer pick return focus without a ring', async ({
|
|
80
|
+
page,
|
|
81
|
+
}) => {
|
|
82
|
+
const trigger = await mount(page);
|
|
83
|
+
await trigger.click();
|
|
84
|
+
await expect(page.getByRole('dialog')).toBeVisible();
|
|
85
|
+
await row(page).click();
|
|
86
|
+
await expectReturnedFocus(page, trigger, false);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('a keyboard-opened sheet closed by Escape keeps the ring', async ({
|
|
90
|
+
page,
|
|
91
|
+
}) => {
|
|
92
|
+
const trigger = await mount(page);
|
|
93
|
+
await openFromKeyboard(page, trigger);
|
|
94
|
+
await page.keyboard.press('Escape');
|
|
95
|
+
await expectReturnedFocus(page, trigger, true);
|
|
96
|
+
});
|
|
@@ -15,12 +15,11 @@
|
|
|
15
15
|
* output—not an event wrapper, speculative internal structure, or a node
|
|
16
16
|
* created only for layout plumbing."
|
|
17
17
|
*
|
|
18
|
-
* This is not observable in the unit lane
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* the documented target change the pill?
|
|
18
|
+
* This is not observable in the unit lane: jsdom resolves no cascade, so it
|
|
19
|
+
* cannot say which element paints. An override can land on the element carrying
|
|
20
|
+
* the class while that element is not the one a reader sees. Only a real engine
|
|
21
|
+
* can be asked the question that matters: does styling the documented target
|
|
22
|
+
* change the pill?
|
|
24
23
|
*
|
|
25
24
|
* The pill is identified structurally, as the button's parent element, so every
|
|
26
25
|
* measurement below is independent of where the class currently sits.
|