@astryxdesign/core 0.7.0 → 0.7.1-canary.2b6d129

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 (51) hide show
  1. package/dist/BottomSheet/BottomSheet.d.ts +1 -1
  2. package/dist/BottomSheet/BottomSheet.d.ts.map +1 -1
  3. package/dist/BottomSheet/BottomSheet.js +12 -2
  4. package/dist/CodeBlock/CodeBlock.d.ts.map +1 -1
  5. package/dist/CodeBlock/CodeBlock.js +25 -16
  6. package/dist/ContextMenu/ContextMenu.d.ts +1 -1
  7. package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
  8. package/dist/ContextMenu/ContextMenu.js +22 -8
  9. package/dist/Dialog/Dialog.d.ts.map +1 -1
  10. package/dist/Dialog/Dialog.js +3 -26
  11. package/dist/DropdownMenu/DropdownMenu.d.ts.map +1 -1
  12. package/dist/DropdownMenu/DropdownMenu.js +10 -3
  13. package/dist/Typeahead/BaseTypeahead.d.ts +1 -1
  14. package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
  15. package/dist/Typeahead/BaseTypeahead.js +12 -3
  16. package/dist/hooks/useIsomorphicLayoutEffect.d.ts +2 -2
  17. package/dist/hooks/useIsomorphicLayoutEffect.d.ts.map +1 -1
  18. package/dist/utils/focusReturn.d.ts +12 -8
  19. package/dist/utils/focusReturn.d.ts.map +1 -1
  20. package/dist/utils/focusReturn.js +44 -3
  21. package/package.json +4 -4
  22. package/src/BottomSheet/BottomSheet.test.tsx +114 -0
  23. package/src/BottomSheet/BottomSheet.tsx +12 -2
  24. package/src/BottomSheet/__tests__/BottomSheetFocusReturn.a11y.browser.spec.ts +96 -0
  25. package/src/Chat/__tests__/ChatLayoutScrollButtonSurface.a11y.chromium.spec.ts +5 -6
  26. package/src/CodeBlock/CodeBlock.spec.md +55 -45
  27. package/src/CodeBlock/CodeBlock.test.tsx +11 -9
  28. package/src/CodeBlock/CodeBlock.tsx +29 -18
  29. package/src/CodeBlock/__tests__/CodeBlockOverflowStop.a11y.chromium.spec.ts +125 -0
  30. package/src/ContextMenu/ContextMenu.spec.md +1 -1
  31. package/src/ContextMenu/ContextMenu.test.tsx +36 -3
  32. package/src/ContextMenu/ContextMenu.tsx +27 -11
  33. package/src/ContextMenu/__tests__/ContextMenuCursorAnchor.a11y.browser.spec.ts +153 -0
  34. package/src/Dialog/Dialog.tsx +2 -30
  35. package/src/Dialog/__tests__/Dialog.a11y.chromium.spec.ts +21 -14
  36. package/src/DropdownMenu/DropdownMenu.test.tsx +97 -1
  37. package/src/DropdownMenu/DropdownMenu.tsx +10 -3
  38. package/src/DropdownMenu/__tests__/DropdownMenuFocusReturn.a11y.browser.spec.ts +73 -0
  39. package/src/FieldStatus/__tests__/StatusMessage.a11y.chromium.spec.ts +21 -14
  40. package/src/Markdown/Markdown.spec.md +37 -23
  41. package/src/Markdown/plugins/semanticFence.test.tsx +3 -2
  42. package/src/NumberInput/__tests__/NumberInput.a11y.chromium.spec.ts +22 -15
  43. package/src/PowerSearch/PowerSearch.test.tsx +45 -0
  44. package/src/Switch/__tests__/Switch.a11y.chromium.spec.ts +25 -18
  45. package/src/TabList/__tests__/Tabs.a11y.chromium.spec.ts +19 -12
  46. package/src/TextInput/__tests__/TextInput.a11y.chromium.spec.ts +21 -14
  47. package/src/ToggleButton/__tests__/ToggleButton.a11y.chromium.spec.ts +18 -11
  48. package/src/Typeahead/BaseTypeahead.test.tsx +99 -1
  49. package/src/Typeahead/BaseTypeahead.tsx +17 -3
  50. package/src/utils/focusReturn.test.ts +79 -2
  51. 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
- (finalFocusRef?.current ?? triggerRef.current)?.focus();
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 and not observable to the probe-reach
19
- * gate either. jsdom resolves no cascade, so it cannot say which element paints.
20
- * The gate's reach check asks whether a target's override lands on the element
21
- * carrying the class, and it does — that element simply is not the one a reader
22
- * sees. Only a real engine can be asked the question that matters: does styling
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.