@astryxdesign/core 0.7.0 → 0.7.1-canary.4285bdd

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 (44) 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/DropdownMenu/DropdownMenu.test.tsx +97 -1
  36. package/src/DropdownMenu/DropdownMenu.tsx +10 -3
  37. package/src/DropdownMenu/__tests__/DropdownMenuFocusReturn.a11y.browser.spec.ts +73 -0
  38. package/src/Markdown/Markdown.spec.md +37 -23
  39. package/src/Markdown/plugins/semanticFence.test.tsx +3 -2
  40. package/src/PowerSearch/PowerSearch.test.tsx +45 -0
  41. package/src/Typeahead/BaseTypeahead.test.tsx +99 -1
  42. package/src/Typeahead/BaseTypeahead.tsx +17 -3
  43. package/src/utils/focusReturn.test.ts +79 -2
  44. package/src/utils/focusReturn.ts +48 -3
@@ -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
+ });
@@ -64,18 +64,30 @@ constrained renderer seams. Callers may opt into the canonical plugin protocol f
64
64
  bounded source syntax, immutable document transformation, typed extension
65
65
  rendering, and native typed document-start frontmatter. They may separately opt into dollar-delimited math by supplying one typed
66
66
  renderer for both inline and display expressions. The parser accepts matching
67
- explicit options. Existing parsing, rendering, styling, and streaming behavior
68
- remain unchanged when plugins and math are absent.
67
+ explicit options. Plugins and math change nothing when absent; the default Code
68
+ block path renders `diff` and `diff-<language>` fences as CodeBlock's diff layer
69
+ (FR29), and every other parsing, rendering, styling, and streaming behavior is
70
+ the released one.
69
71
 
70
72
  ## Compatibility and migration
71
73
 
72
- - Released default preserved: `yes`
73
- - Compatibility class: additive, opt-in public API; existing parser nodes, DOM,
74
- styling, targets, dollar-delimited text, `components`, and `inlinePlugins` remain
75
- unchanged unless the caller supplies `plugins`, supplies `components.math`, or
76
- passes the matching explicit parser option.
74
+ - Released default preserved: `yes`, except the default Code block path for `diff` fences (FR29, below)
75
+ - Compatibility class: additive, opt-in public API, with one breaking default-path
76
+ rendering change (FR29, below); existing parser nodes, DOM, styling, targets,
77
+ dollar-delimited text, `components`, and `inlinePlugins` remain unchanged unless
78
+ the caller supplies `plugins`, supplies `components.math`, or passes the matching
79
+ explicit parser option.
77
80
  - Controlled/uncontrolled behavior: not applicable
78
81
  - Migration decision: none
82
+ - Default-path rendering change for diff fences (FR29), breaking, shipped in a
83
+ minor: a `diff` fence that rendered as plain text renders as CodeBlock's diff
84
+ layer over plaintext with a `diff` label, and a `diff-<language>` fence —
85
+ which rendered as plain text with a `diff` label and viewport name, its
86
+ leading word being all Markdown read — as the layer over that language with
87
+ a `<language> diff` label; the header, copy, disclosure, and viewport name
88
+ stay, and a supplied `code` renderer's contract is unchanged. The way back to
89
+ plain text is a `text` fence or a custom `code` renderer. Every other fence
90
+ is unchanged.
79
91
  - Default-path layout change (FR26–FR28): a default table's columns may become
80
92
  wider than the released fixed buckets, never narrower; header labels wrap
81
93
  instead of truncating; a table that squashed its columns may now scroll.
@@ -228,6 +240,7 @@ unions. Enabled calls return the explicit `InlineNodeWithMath` and
228
240
  | FR26 | On the default table path each column has a content-derived width floor: the larger of its own longest unbreakable token and a readable minimum derived from that column's content, bounded so a table of short columns still fits its container. The floor applies to the cell's text, not to its padded box, so it means the same amount of content at every density. A header label contributes its own readable minimum to that comparison. A table whose floors exceed the container grows and scrolls rather than dividing the container equally. |
229
241
  | FR27 | Default table header cells never truncate: no ellipsis and no width clamp. A header reads in full, on one line up to a bounded width and wrapping past it. Inline code inside a default table cell keeps its token whole rather than breaking mid-token; a supplied `inlineCode` renderer owns its own wrapping. |
230
242
  | FR28 | Markdown's Table block owns spacing, sizing, and alignment. The nested Table's own Scroll region remains the sole owner of the table's horizontal overflow, its accessible name, and its conditional keyboard focusability; Markdown adds no second scroll container, name, or tab stop of its own. |
243
+ | FR29 | On the default Code block path the fence's full first word selects CodeBlock's diff layer (`component:CodeBlock` FR9): `diff` renders the layer over plaintext, `diff-<language>` the layer over `<language>` (the language read by the same leading-word rule as any other fence); every other fence is unchanged. A supplied `code` renderer's contract is unchanged: it keeps receiving the leading word characters as `language` — `diff` for both `diff` and `diff-<language>` fences — and nothing new. |
231
244
 
232
245
  FR23 includes CommonMark-compatible lazy paragraph continuation inside blockquotes
233
246
  and ordered, unordered, or task-list items. Omitting a repeated quote marker or
@@ -421,22 +434,23 @@ and this change preserves the existing spelling exactly.
421
434
 
422
435
  ## Verification map
423
436
 
424
- | Contract | Verification | Representative states | Failure signal |
425
- | ---------------------- | --------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
426
- | FR1–FR5 | `Markdown.test.tsx`, theme-target tests, and `scripts/check-knowledge.mjs` | Default block/inline output and all current targets | Existing DOM, target, spacing, or renderer behavior changes. |
427
- | FR6–FR10 | `parser.test.ts` and `Markdown.test.tsx` | Opt-out, inline/display math, escapes, currency, code, links, plugins | A delimiter is claimed without opt-in, TeX is formatted as Markdown, or opaque contexts leak. |
428
- | FR11 | `incremental.test.ts` and `Markdown.test.tsx` | Every-character top-level/list/task-list/blockquote splits, CRLF, source ranges | Streaming diverges from a full parse, shows partial syntax, mistakes a nested closer for an opener, or crosses a closed container. |
429
- | FR12–FR16 | plugin, transform, adapter, performance, and Outline parser tests | omitted/empty lists, immutable transforms, invalid outputs, live updates, one compatible Remark plugin, duplicate headings | Empty behavior forks, input mutates, invalid structure escapes, transforms reparse, adapter loses content, budgets fail, or heading targets diverge. |
430
- | FR17–FR18 | container parse/validation, ownership, and dependency tests | leaf/container declarations, nested containers, foreign read/remove/mint/edit, unmet/misordered dependencies | Plugin-built parsed children, invalid content, lost fallback children, foreign mint/edit, or generic ownership codes. |
431
- | FR19 | diagnostic-channel tests in development and production | every phase, advisory reports, rate suppression, no handler, malformed list, duplicate Core, version skew | A silent production failure, document content in a diagnostic, or one entrypoint throwing where another recovers. |
432
- | FR20–FR22 | canonical/server imports, inference, streaming, preparation, theming tests | server imports, no explicit type args, chunk boundaries, recreated lists, themed/unthemed extensions | Client references, explicit-type workarounds, oscillation, per-render re-preparation, or unreachable opted-in output. |
433
- | FR23 | parser, incremental, renderer, nesting, and public option tests | mixed lists; escaped prose/code table cells, nested inline code, full/streaming parity; autolink omitted/enabled | A mixed list splits or loses state, an escaped table pipe changes cells or exposes its backslash, or autolink changes other syntax. |
434
- | FR24 | `plugins/frontmatter.test.tsx`, Storybook, and server rendering | complete, malformed, non-leading, LF/CRLF, unfinished streaming, full plugin stack | Metadata syntax renders after completion, unfinished syntax leaks while streaming, typing is lost, or later plugins stop composing. |
435
- | FR25 | `parser.public.test.ts`, plugin SSR tests, and package export checks | server/RSC parsing with plugins, synchronous SSR, suspending renderer streaming boundary | A server import gains `use client`, plugin execution needs serialization, SSR loses fallback, or direct RSC rendering is misrepresented as supported. |
436
- | FR26–FR28 | `Markdown.test.tsx` and a real-browser layer over checked-in narrow-width stories | short, prose, and token-heavy tables; header-only rows and empty cells; long headers over short bodies; reading columns from phone to document width | A column collapses below its content floor, cell padding eats the floor, a header truncates, an inline-code token breaks mid-token, or a second scroll viewport, name, or tab stop appears around a table. |
437
- | Public syntax/types | `Markdown.public.test.ts`, core typecheck, and `Markdown.doc.mjs` | Legacy exhaustive switches, math opt-ins, inferred extension-node unions | A released union widens, an enabled union loses nodes, or docs drift from declarations. |
438
- | Navigation contract | `parser.test.ts`, `Markdown.test.tsx`, and `Markdown.renderBoundary.test.tsx` | Parsed and rendered links, including transformed built-in links; accepted ordinary schemes; rejected destinations; links versus images | A blocked destination reaches navigation or a custom link renderer, or resource policy narrows accepted navigation. |
439
- | Security/accessibility | `parser.test.ts`, `Markdown.test.tsx`, and renderer guidance | Inert expression strings and renderer-owned semantics | Astryx executes math as HTML or silently claims renderer-owned accessibility. |
437
+ | Contract | Verification | Representative states | Failure signal |
438
+ | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
439
+ | FR1–FR5 | `Markdown.test.tsx`, theme-target tests, and `scripts/check-knowledge.mjs` | Default block/inline output and all current targets | Existing DOM, target, spacing, or renderer behavior changes. |
440
+ | FR6–FR10 | `parser.test.ts` and `Markdown.test.tsx` | Opt-out, inline/display math, escapes, currency, code, links, plugins | A delimiter is claimed without opt-in, TeX is formatted as Markdown, or opaque contexts leak. |
441
+ | FR11 | `incremental.test.ts` and `Markdown.test.tsx` | Every-character top-level/list/task-list/blockquote splits, CRLF, source ranges | Streaming diverges from a full parse, shows partial syntax, mistakes a nested closer for an opener, or crosses a closed container. |
442
+ | FR12–FR16 | plugin, transform, adapter, performance, and Outline parser tests | omitted/empty lists, immutable transforms, invalid outputs, live updates, one compatible Remark plugin, duplicate headings | Empty behavior forks, input mutates, invalid structure escapes, transforms reparse, adapter loses content, budgets fail, or heading targets diverge. |
443
+ | FR17–FR18 | container parse/validation, ownership, and dependency tests | leaf/container declarations, nested containers, foreign read/remove/mint/edit, unmet/misordered dependencies | Plugin-built parsed children, invalid content, lost fallback children, foreign mint/edit, or generic ownership codes. |
444
+ | FR19 | diagnostic-channel tests in development and production | every phase, advisory reports, rate suppression, no handler, malformed list, duplicate Core, version skew | A silent production failure, document content in a diagnostic, or one entrypoint throwing where another recovers. |
445
+ | FR20–FR22 | canonical/server imports, inference, streaming, preparation, theming tests | server imports, no explicit type args, chunk boundaries, recreated lists, themed/unthemed extensions | Client references, explicit-type workarounds, oscillation, per-render re-preparation, or unreachable opted-in output. |
446
+ | FR23 | parser, incremental, renderer, nesting, and public option tests | mixed lists; escaped prose/code table cells, nested inline code, full/streaming parity; autolink omitted/enabled | A mixed list splits or loses state, an escaped table pipe changes cells or exposes its backslash, or autolink changes other syntax. |
447
+ | FR24 | `plugins/frontmatter.test.tsx`, Storybook, and server rendering | complete, malformed, non-leading, LF/CRLF, unfinished streaming, full plugin stack | Metadata syntax renders after completion, unfinished syntax leaks while streaming, typing is lost, or later plugins stop composing. |
448
+ | FR25 | `parser.public.test.ts`, plugin SSR tests, and package export checks | server/RSC parsing with plugins, synchronous SSR, suspending renderer streaming boundary | A server import gains `use client`, plugin execution needs serialization, SSR loses fallback, or direct RSC rendering is misrepresented as supported. |
449
+ | FR26–FR28 | `Markdown.test.tsx` and a real-browser layer over checked-in narrow-width stories | short, prose, and token-heavy tables; header-only rows and empty cells; long headers over short bodies; reading columns from phone to document width | A column collapses below its content floor, cell padding eats the floor, a header truncates, an inline-code token breaks mid-token, or a second scroll viewport, name, or tab stop appears around a table. |
450
+ | FR29 | `Markdown.test.tsx` (a `diff` fence → plaintext + layer, a `diff-tsx` fence → tsx + layer, a plain fence unchanged, a custom `code` renderer receives `language="diff"` for both diff fences and nothing new) | default path and custom renderer, `diff` and `diff-<language>` fences | a fence word other than `diff`/`diff-<language>` reaching the layer, or a custom renderer receiving a new prop or a different `language` |
451
+ | Public syntax/types | `Markdown.public.test.ts`, core typecheck, and `Markdown.doc.mjs` | Legacy exhaustive switches, math opt-ins, inferred extension-node unions | A released union widens, an enabled union loses nodes, or docs drift from declarations. |
452
+ | Navigation contract | `parser.test.ts`, `Markdown.test.tsx`, and `Markdown.renderBoundary.test.tsx` | Parsed and rendered links, including transformed built-in links; accepted ordinary schemes; rejected destinations; links versus images | A blocked destination reaches navigation or a custom link renderer, or resource policy narrows accepted navigation. |
453
+ | Security/accessibility | `parser.test.ts`, `Markdown.test.tsx`, and renderer guidance | Inert expression strings and renderer-owned semantics | Astryx executes math as HTML or silently claims renderer-owned accessibility. |
440
454
 
441
455
  Focused tests continue to pin all nine current target names and default block
442
456
  placement. Math intentionally adds no target and no default anatomy.
@@ -412,9 +412,10 @@ describe('createMarkdownFenceTransform', () => {
412
412
  expect(createNode).not.toHaveBeenCalled();
413
413
  expect(document.querySelector('pre')).toHaveTextContent('plain source');
414
414
  expect(screen.getByRole('button', {name: 'Copy code'})).toBeInTheDocument();
415
+ // CodeBlock's named viewport; its tab stop follows overflow (none in jsdom).
415
416
  expect(screen.getByRole('group', {name: 'text'})).toHaveAttribute(
416
- 'tabindex',
417
- '0',
417
+ 'data-scroll-axis',
418
+ 'both',
418
419
  );
419
420
 
420
421
  rerender(
@@ -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(
@@ -5,7 +5,7 @@
5
5
  /**
6
6
  * @file BaseTypeahead.tsx
7
7
  * @input Uses React, StyleX, usePopover, TypeaheadItem
8
- * @output Exports BaseTypeahead with focused-input menu reactivation
8
+ * @output Exports BaseTypeahead; focus bootstraps a replaced search source
9
9
  * @position Core implementation; used by Typeahead and Tokenizer
10
10
  *
11
11
  * Pure combobox engine: input, search, keyboard navigation, dropdown.
@@ -610,6 +610,10 @@ export const BaseTypeahead = function BaseTypeahead<T extends SearchableItem>({
610
610
  // in the closure are stale (a selection cleared them) and shouldn't
611
611
  // be re-shown.
612
612
  const resultsGenRef = useRef(0);
613
+ // The generation at which the current searchSource took over. Results
614
+ // populated before it (resultsGenRef below this) came from a replaced
615
+ // source: they can never be re-shown, so focus bootstraps the new source.
616
+ const sourceGenRef = useRef(0);
613
617
 
614
618
  // Results still arriving from a replaced source must not land in the new
615
619
  // one's menu.
@@ -617,7 +621,7 @@ export const BaseTypeahead = function BaseTypeahead<T extends SearchableItem>({
617
621
  if (prevSearchSourceRef.current !== searchSource) {
618
622
  prevSearchSourceRef.current.cancel?.();
619
623
  prevSearchSourceRef.current = searchSource;
620
- searchGenRef.current++;
624
+ sourceGenRef.current = ++searchGenRef.current;
621
625
  }
622
626
 
623
627
  // Layer for dropdown
@@ -889,11 +893,21 @@ export const BaseTypeahead = function BaseTypeahead<T extends SearchableItem>({
889
893
  // Shared by handleFocus and handleClick: bootstrap entries if none are
890
894
  // loaded yet, or re-show cached results that haven't been invalidated by
891
895
  // a selection since (comparing the two generation refs catches that).
896
+ // Results cached from a replaced search source count as none, so focusing
897
+ // an empty field bootstraps the new source instead of opening nothing.
898
+ // Results a selection invalidated still keep the menu closed when the
899
+ // selection returns focus to the input.
892
900
  const openIfEligible = useCallback(() => {
893
901
  if (isDisabled) {
894
902
  return;
895
903
  }
896
- if (hasEntriesOnFocus && results.length === 0 && query.length === 0) {
904
+ const hasResultsFromCurrentSource =
905
+ results.length > 0 && resultsGenRef.current >= sourceGenRef.current;
906
+ if (
907
+ hasEntriesOnFocus &&
908
+ !hasResultsFromCurrentSource &&
909
+ query.length === 0
910
+ ) {
897
911
  void performBootstrap();
898
912
  } else if (
899
913
  results.length > 0 &&
@@ -1,7 +1,11 @@
1
1
  // Copyright (c) Meta Platforms, Inc. and affiliates.
2
2
 
3
- import {describe, it, expect, afterEach} from 'vitest';
4
- import {isFocusDetached} from './focusReturn';
3
+ import {describe, it, expect, afterEach, beforeEach, vi} from 'vitest';
4
+ import {focusForLastInput, isFocusDetached} from './focusReturn';
5
+ import {
6
+ __resetInteractionModalityForTest,
7
+ __startInteractionModalityTrackingForTest,
8
+ } from './interactionModality';
5
9
 
6
10
  describe('isFocusDetached', () => {
7
11
  afterEach(() => {
@@ -40,3 +44,76 @@ describe('isFocusDetached', () => {
40
44
  expect(isFocusDetached(doc)).toBe(true);
41
45
  });
42
46
  });
47
+
48
+ describe('focusForLastInput', () => {
49
+ beforeEach(() => {
50
+ __startInteractionModalityTrackingForTest();
51
+ __resetInteractionModalityForTest();
52
+ });
53
+
54
+ afterEach(() => {
55
+ vi.restoreAllMocks();
56
+ document.body.innerHTML = '';
57
+ });
58
+
59
+ function button(): HTMLButtonElement {
60
+ const element = document.createElement('button');
61
+ document.body.appendChild(element);
62
+ return element;
63
+ }
64
+
65
+ it('leaves the ring to the browser after keyboard input, passing the options through', () => {
66
+ const element = button();
67
+ const focus = vi.spyOn(element, 'focus');
68
+ focusForLastInput(element);
69
+ focusForLastInput(element, {preventScroll: true});
70
+ expect(focus.mock.calls).toEqual([[], [{preventScroll: true}]]);
71
+ });
72
+
73
+ it('asks the browser for no ring after pointer input, keeping the options', () => {
74
+ const element = button();
75
+ document.dispatchEvent(new Event('pointerdown'));
76
+ const focus = vi.spyOn(element, 'focus');
77
+ focusForLastInput(element);
78
+ focusForLastInput(element, {preventScroll: true});
79
+ expect(focus.mock.calls).toEqual([
80
+ [{focusVisible: false}],
81
+ [{preventScroll: true, focusVisible: false}],
82
+ ]);
83
+ });
84
+
85
+ it('keeps plain focus on a text field after pointer input', () => {
86
+ const input = document.createElement('input');
87
+ document.body.appendChild(input);
88
+ document.dispatchEvent(new Event('pointerdown'));
89
+ const focus = vi.spyOn(input, 'focus');
90
+ focusForLastInput(input, {preventScroll: true});
91
+ expect(focus.mock.calls).toEqual([[{preventScroll: true}]]);
92
+ });
93
+
94
+ it('clears a ring the browser already drew before refocusing after pointer input', () => {
95
+ const element = button();
96
+ element.focus();
97
+ document.dispatchEvent(new Event('pointerdown'));
98
+ const matches = HTMLElement.prototype.matches;
99
+ vi.spyOn(HTMLElement.prototype, 'matches').mockImplementation(function (
100
+ this: HTMLElement,
101
+ selector: string,
102
+ ) {
103
+ return selector === ':focus-visible'
104
+ ? this === element
105
+ : matches.call(this, selector);
106
+ });
107
+ const blur = vi.spyOn(element, 'blur');
108
+ const focus = vi.spyOn(element, 'focus');
109
+ focusForLastInput(element, {preventScroll: true});
110
+ expect(blur).toHaveBeenCalledTimes(1);
111
+ expect(focus.mock.calls).toEqual([
112
+ [{preventScroll: true, focusVisible: false}],
113
+ ]);
114
+ expect(blur.mock.invocationCallOrder[0]).toBeLessThan(
115
+ focus.mock.invocationCallOrder[0] ?? 0,
116
+ );
117
+ expect(document.activeElement).toBe(element);
118
+ });
119
+ });
@@ -2,13 +2,18 @@
2
2
 
3
3
  /**
4
4
  * @file focusReturn.ts
5
- * @input The document's current active element.
6
- * @output Whether focus is currently "detached" — on nothing in particular.
5
+ * @input The document's current active element and the shared interaction
6
+ * modality.
7
+ * @output Whether focus is currently "detached" — on nothing in particular —
8
+ * and a focus move whose ring follows the last input.
7
9
  * @position Utility; used by overlay triggers (e.g. DateInput, DateTimeInput)
8
10
  * that return focus to their control when the overlay closes, but only when
9
- * the dismiss did not already move focus somewhere the user chose.
11
+ * the dismiss did not already move focus somewhere the user chose, and by
12
+ * overlays (Dialog, DropdownMenu) that move focus programmatically.
10
13
  */
11
14
 
15
+ import {getInteractionModality} from './interactionModality';
16
+
12
17
  /**
13
18
  * True when focus rests on nothing in particular — no active element, or the
14
19
  * document body / root element.
@@ -29,3 +34,43 @@ export function isFocusDetached(doc: Document = document): boolean {
29
34
  active == null || active === doc.body || active === doc.documentElement
30
35
  );
31
36
  }
37
+
38
+ /**
39
+ * Move focus programmatically so its ring follows the last input
40
+ * (`architecture:interaction-modality` INV1). After pointer input the browser's
41
+ * focus option keeps the element from matching `:focus-visible`, which is the
42
+ * one way to reach an element the overlay does not render: the consumer's
43
+ * autofocus target, the control that opened a Dialog, or a menu trigger the
44
+ * caller renders. Pass `options` (for example `preventScroll`) as to `focus()`.
45
+ *
46
+ * Make sure modality tracking is active (`useInteractionModalityTracking()`)
47
+ * in the component that calls this.
48
+ */
49
+ export function focusForLastInput(
50
+ element: HTMLElement,
51
+ options?: FocusOptions,
52
+ ): void {
53
+ // A text-entry field paints its own focus treatment to show where typing
54
+ // goes, and an extra blur there can run validation or mark it touched.
55
+ const isTextEntry =
56
+ element instanceof HTMLInputElement ||
57
+ element instanceof HTMLTextAreaElement ||
58
+ element instanceof HTMLSelectElement ||
59
+ element.isContentEditable;
60
+ if (isTextEntry || getInteractionModality() !== 'pointer') {
61
+ if (options === undefined) {
62
+ element.focus();
63
+ } else {
64
+ element.focus(options);
65
+ }
66
+ return;
67
+ }
68
+ // Focusing the element that already has focus does nothing, so a ring the
69
+ // browser already drew would stay. WebKit draws one when its own focus
70
+ // restoration (a dialog's close, a popover's hide) moves focus itself, so
71
+ // clear that focus first.
72
+ if (element.matches(':focus-visible')) {
73
+ element.blur();
74
+ }
75
+ element.focus({...options, focusVisible: false});
76
+ }