@astryxdesign/core 0.7.0 → 0.7.1-canary.30b6e1b

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 (38) 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/ContextMenu/ContextMenu.d.ts +1 -1
  5. package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
  6. package/dist/ContextMenu/ContextMenu.js +22 -8
  7. package/dist/Dialog/Dialog.d.ts.map +1 -1
  8. package/dist/Dialog/Dialog.js +3 -26
  9. package/dist/DropdownMenu/DropdownMenu.d.ts.map +1 -1
  10. package/dist/DropdownMenu/DropdownMenu.js +10 -3
  11. package/dist/Typeahead/BaseTypeahead.d.ts +1 -1
  12. package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
  13. package/dist/Typeahead/BaseTypeahead.js +12 -3
  14. package/dist/hooks/useIsomorphicLayoutEffect.d.ts +2 -2
  15. package/dist/hooks/useIsomorphicLayoutEffect.d.ts.map +1 -1
  16. package/dist/utils/focusReturn.d.ts +12 -8
  17. package/dist/utils/focusReturn.d.ts.map +1 -1
  18. package/dist/utils/focusReturn.js +44 -3
  19. package/package.json +4 -4
  20. package/src/BottomSheet/BottomSheet.test.tsx +114 -0
  21. package/src/BottomSheet/BottomSheet.tsx +12 -2
  22. package/src/BottomSheet/__tests__/BottomSheetFocusReturn.a11y.browser.spec.ts +96 -0
  23. package/src/Chat/__tests__/ChatLayoutScrollButtonSurface.a11y.chromium.spec.ts +5 -6
  24. package/src/CodeBlock/CodeBlock.spec.md +54 -44
  25. package/src/ContextMenu/ContextMenu.spec.md +1 -1
  26. package/src/ContextMenu/ContextMenu.test.tsx +36 -3
  27. package/src/ContextMenu/ContextMenu.tsx +27 -11
  28. package/src/ContextMenu/__tests__/ContextMenuCursorAnchor.a11y.browser.spec.ts +153 -0
  29. package/src/Dialog/Dialog.tsx +2 -30
  30. package/src/DropdownMenu/DropdownMenu.test.tsx +97 -1
  31. package/src/DropdownMenu/DropdownMenu.tsx +10 -3
  32. package/src/DropdownMenu/__tests__/DropdownMenuFocusReturn.a11y.browser.spec.ts +73 -0
  33. package/src/Markdown/Markdown.spec.md +37 -23
  34. package/src/PowerSearch/PowerSearch.test.tsx +45 -0
  35. package/src/Typeahead/BaseTypeahead.test.tsx +99 -1
  36. package/src/Typeahead/BaseTypeahead.tsx +17 -3
  37. package/src/utils/focusReturn.test.ts +79 -2
  38. package/src/utils/focusReturn.ts +48 -3
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * @file BottomSheet.tsx
3
- * @input Uses React, StyleX, core hooks, shared layer dismissal, named BottomSheetPanel, BottomSheetSwitcherContext
3
+ * @input Uses React, StyleX, core hooks, shared layer dismissal, the shared interaction modality, named BottomSheetPanel, BottomSheetSwitcherContext
4
4
  * @output Exports BottomSheet component and BottomSheetProps
5
5
  * @position Public BottomSheet router plus private standalone/switcher hosts
6
6
  *
@@ -1 +1 @@
1
- {"version":3,"file":"BottomSheet.d.ts","sourceRoot":"","sources":["../../src/BottomSheet/BottomSheet.tsx"],"names":[],"mappings":"AAIA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAEH,OAAO,EAOL,KAAK,SAAS,EACd,KAAK,SAAS,EACf,MAAM,OAAO,CAAC;AAEf,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,cAAc,CAAC;AAC5C,OAAO,KAAK,EAAC,aAAa,EAAC,MAAM,WAAW,CAAC;AAC7C,OAAO,KAAK,EAAC,WAAW,EAAC,MAAM,gBAAgB,CAAC;AAiBhD,YAAY,EAAC,iBAAiB,EAAE,oBAAoB,EAAC,MAAM,oBAAoB,CAAC;AAChF,OAAO,KAAK,EAAC,iBAAiB,EAAE,oBAAoB,EAAC,MAAM,oBAAoB,CAAC;AA0EhF,UAAU,sBAAuB,SAAQ,SAAS,CAAC,cAAc,CAAC;IAChE,qDAAqD;IACrD,GAAG,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,cAAc,CAAC,CAAC;IAEhC,sCAAsC;IACtC,KAAK,EAAE,MAAM,CAAC;IAEd,0EAA0E;IAC1E,QAAQ,EAAE,SAAS,CAAC;IAEpB;;;;;;;;OAQG;IACH,OAAO,CAAC,EAAE,WAAW,CAAC;IAEtB,wGAAwG;IACxG,MAAM,CAAC,EAAE,iBAAiB,GAAG,MAAM,GAAG,MAAM,CAAC;IAE7C;;;;;;;OAOG;IACH,UAAU,CAAC,EAAE,aAAa,CAAC,oBAAoB,CAAC,CAAC;IAEjD;;;;;;OAMG;IACH,OAAO,CAAC,EAAE,aAAa,CAAC;CACzB;AAED,UAAU,0BAA2B,SAAQ,sBAAsB;IACjE,MAAM,EAAE,OAAO,CAAC;IAChB,YAAY,EAAE,CAAC,MAAM,EAAE,OAAO,KAAK,IAAI,CAAC;IACxC,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,0DAA0D;IAC1D,aAAa,CAAC,EAAE,SAAS,CAAC,WAAW,GAAG,IAAI,CAAC,CAAC;IAC9C,OAAO,CAAC,EAAE,KAAK,CAAC;CACjB;AAED,UAAU,wBAAyB,SAAQ,sBAAsB;IAC/D,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,KAAK,CAAC;IACf,YAAY,CAAC,EAAE,KAAK,CAAC;IACrB,QAAQ,CAAC,EAAE,KAAK,CAAC;CAClB;AAED,MAAM,MAAM,gBAAgB,GAC1B,0BAA0B,GAAG,wBAAwB,CAAC;AA8XxD;;;GAGG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,gBAAgB,+BA6BlD;yBA7Be,WAAW"}
1
+ {"version":3,"file":"BottomSheet.d.ts","sourceRoot":"","sources":["../../src/BottomSheet/BottomSheet.tsx"],"names":[],"mappings":"AAIA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAEH,OAAO,EAOL,KAAK,SAAS,EACd,KAAK,SAAS,EACf,MAAM,OAAO,CAAC;AAEf,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,cAAc,CAAC;AAC5C,OAAO,KAAK,EAAC,aAAa,EAAC,MAAM,WAAW,CAAC;AAC7C,OAAO,KAAK,EAAC,WAAW,EAAC,MAAM,gBAAgB,CAAC;AAmBhD,YAAY,EAAC,iBAAiB,EAAE,oBAAoB,EAAC,MAAM,oBAAoB,CAAC;AAChF,OAAO,KAAK,EAAC,iBAAiB,EAAE,oBAAoB,EAAC,MAAM,oBAAoB,CAAC;AA0EhF,UAAU,sBAAuB,SAAQ,SAAS,CAAC,cAAc,CAAC;IAChE,qDAAqD;IACrD,GAAG,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,cAAc,CAAC,CAAC;IAEhC,sCAAsC;IACtC,KAAK,EAAE,MAAM,CAAC;IAEd,0EAA0E;IAC1E,QAAQ,EAAE,SAAS,CAAC;IAEpB;;;;;;;;OAQG;IACH,OAAO,CAAC,EAAE,WAAW,CAAC;IAEtB,wGAAwG;IACxG,MAAM,CAAC,EAAE,iBAAiB,GAAG,MAAM,GAAG,MAAM,CAAC;IAE7C;;;;;;;OAOG;IACH,UAAU,CAAC,EAAE,aAAa,CAAC,oBAAoB,CAAC,CAAC;IAEjD;;;;;;OAMG;IACH,OAAO,CAAC,EAAE,aAAa,CAAC;CACzB;AAED,UAAU,0BAA2B,SAAQ,sBAAsB;IACjE,MAAM,EAAE,OAAO,CAAC;IAChB,YAAY,EAAE,CAAC,MAAM,EAAE,OAAO,KAAK,IAAI,CAAC;IACxC,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,0DAA0D;IAC1D,aAAa,CAAC,EAAE,SAAS,CAAC,WAAW,GAAG,IAAI,CAAC,CAAC;IAC9C,OAAO,CAAC,EAAE,KAAK,CAAC;CACjB;AAED,UAAU,wBAAyB,SAAQ,sBAAsB;IAC/D,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,KAAK,CAAC;IACf,YAAY,CAAC,EAAE,KAAK,CAAC;IACrB,QAAQ,CAAC,EAAE,KAAK,CAAC;CAClB;AAED,MAAM,MAAM,gBAAgB,GAC1B,0BAA0B,GAAG,wBAAwB,CAAC;AAsYxD;;;GAGG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,gBAAgB,+BA6BlD;yBA7Be,WAAW"}
@@ -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
  *
@@ -34,6 +34,8 @@ import { colorVars, durationVars, easeVars } from "../theme/tokens.stylex.js";
34
34
  import { useDevWarning, useScrollLock } from "../hooks/index.js";
35
35
  import { LayerDepthProvider } from "../Layer/LayerDepthContext.js";
36
36
  import { useLayerDismissal } from "../Layer/useLayerDismissal.js";
37
+ import { focusForLastInput } from "../utils/focusReturn.js";
38
+ import { useInteractionModalityTracking } from "../utils/interactionModality.js";
37
39
  import { BottomSheetPanel } from "./BottomSheetPanel.js";
38
40
  import { BottomSheetEdgeTint } from "./BottomSheetEdgeTint.js";
39
41
  import { BottomSheetSwitcherContext } from "./BottomSheetSwitcherContext.js";
@@ -168,9 +170,17 @@ function StandaloneBottomSheet({
168
170
  dialog.close();
169
171
  }
170
172
  setIsPresented(false);
171
- (finalFocusRef?.current ?? triggerRef.current)?.focus();
173
+ // The returned focus shows the focus-visible ring only after keyboard
174
+ // input (`architecture:interaction-modality` INV1). The native close
175
+ // may already have restored focus to the target with a ring; the helper
176
+ // clears it first.
177
+ const returnTarget = finalFocusRef?.current ?? triggerRef.current;
178
+ if (returnTarget != null) {
179
+ focusForLastInput(returnTarget);
180
+ }
172
181
  triggerRef.current = null;
173
182
  }, [finalFocusRef, isOpen]);
183
+ useInteractionModalityTracking();
174
184
  useScrollLock(shouldPresent && hasScrim);
175
185
  useDevWarning('BottomSheet', 'requires a non-empty `label` for an accessible name; the open sheet ' + 'has no built-in heading to derive one from.', isOpen && !label);
176
186
 
@@ -6,7 +6,7 @@
6
6
  * @position Core implementation; consumed by index.ts
7
7
  *
8
8
  * Right-click context menu positioned at the cursor. The cursor point is
9
- * captured as an offset *inside the trigger* and materialized as a zero-size
9
+ * captured as an offset *inside the trigger* and materialized as a 1px
10
10
  * anchor element, so the menu is positioned relative to the trigger's context
11
11
  * (via CSS anchor positioning) rather than the viewport. It therefore follows
12
12
  * the content on scroll and auto-flips at viewport edges, while still appearing
@@ -1 +1 @@
1
- {"version":3,"file":"ContextMenu.d.ts","sourceRoot":"","sources":["../../src/ContextMenu/ContextMenu.tsx"],"names":[],"mappings":"AAGA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AAEH,OAAO,KASN,MAAM,OAAO,CAAC;AACf,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,OAAO,CAAC;AAkCrC,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,cAAc,CAAC;AAC5C,OAAO,KAAK,EAAC,YAAY,EAAC,MAAM,gBAAgB,CAAC;AAGjD,OAAO,KAAK,EACV,kBAAkB,EAClB,oBAAoB,EACpB,uBAAuB,EACvB,mBAAmB,EACpB,MAAM,8BAA8B,CAAC;AAGtC,OAAO,EAEL,KAAK,oBAAoB,EAC1B,MAAM,kCAAkC,CAAC;AAgG1C,MAAM,MAAM,mBAAmB,GAAG,oBAAoB,CAAC;AAEvD,MAAM,MAAM,sBAAsB,GAAG,uBAAuB,CAAC;AAE7D,MAAM,MAAM,kBAAkB,GAAG,mBAAmB,CAAC;AAErD,MAAM,MAAM,iBAAiB,GAAG,kBAAkB,CAAC;AAMnD,UAAU,oBAAqB,SAAQ,SAAS;IAC9C,oDAAoD;IACpD,GAAG,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,WAAW,CAAC,CAAC;IAC7B;;;;;OAKG;IACH,SAAS,CAAC,EAAE,KAAK,GAAG,MAAM,CAAC;IAC3B;;;;;OAKG;IACH,aAAa,CAAC,EAAE,YAAY,GAAG,YAAY,EAAE,CAAC;IAC9C,+DAA+D;IAC/D,QAAQ,EAAE,SAAS,CAAC;IACpB,0CAA0C;IAC1C,SAAS,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IAC5B,wCAAwC;IACxC,IAAI,CAAC,EAAE,IAAI,GAAG,IAAI,GAAG,IAAI,CAAC;IAC1B;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,4EAA4E;IAC5E,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,4CAA4C;IAC5C,YAAY,CAAC,EAAE,CAAC,MAAM,EAAE,OAAO,KAAK,IAAI,CAAC;IACzC;;;;;;;OAOG;IACH,YAAY,CAAC,EAAE,oBAAoB,CAAC;IACpC,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAED,UAAU,oBAAqB,SAAQ,oBAAoB;IACzD,gDAAgD;IAChD,KAAK,EAAE,iBAAiB,EAAE,CAAC;IAC3B,WAAW,CAAC,EAAE,SAAS,CAAC;CACzB;AAED,UAAU,wBAAyB,SAAQ,oBAAoB;IAC7D,KAAK,CAAC,EAAE,SAAS,CAAC;IAClB,+CAA+C;IAC/C,WAAW,EAAE,SAAS,CAAC;CACxB;AAED,MAAM,MAAM,gBAAgB,GAAG,oBAAoB,GAAG,wBAAwB,CAAC;AAM/E;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,WAAW,CAAC,EAC1B,QAAQ,EACR,SAAS,EACT,IAAW,EACX,KAAK,EAAE,cAAc,EACrB,UAAkB,EAClB,YAAY,EACZ,YAAwB,EACxB,SAAiB,EACjB,GAAG,EACH,SAAS,EACT,KAAK,EACL,MAAM,EACN,aAAa,EACb,aAAa,EAAE,MAAM,EACrB,GAAG,IAAI,EACR,EAAE,gBAAgB,qBA0clB;yBA1de,WAAW"}
1
+ {"version":3,"file":"ContextMenu.d.ts","sourceRoot":"","sources":["../../src/ContextMenu/ContextMenu.tsx"],"names":[],"mappings":"AAGA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AAEH,OAAO,KASN,MAAM,OAAO,CAAC;AACf,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,OAAO,CAAC;AAkCrC,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,cAAc,CAAC;AAC5C,OAAO,KAAK,EAAC,YAAY,EAAC,MAAM,gBAAgB,CAAC;AAGjD,OAAO,KAAK,EACV,kBAAkB,EAClB,oBAAoB,EACpB,uBAAuB,EACvB,mBAAmB,EACpB,MAAM,8BAA8B,CAAC;AAGtC,OAAO,EAEL,KAAK,oBAAoB,EAC1B,MAAM,kCAAkC,CAAC;AA6G1C,MAAM,MAAM,mBAAmB,GAAG,oBAAoB,CAAC;AAEvD,MAAM,MAAM,sBAAsB,GAAG,uBAAuB,CAAC;AAE7D,MAAM,MAAM,kBAAkB,GAAG,mBAAmB,CAAC;AAErD,MAAM,MAAM,iBAAiB,GAAG,kBAAkB,CAAC;AAMnD,UAAU,oBAAqB,SAAQ,SAAS;IAC9C,oDAAoD;IACpD,GAAG,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,WAAW,CAAC,CAAC;IAC7B;;;;;OAKG;IACH,SAAS,CAAC,EAAE,KAAK,GAAG,MAAM,CAAC;IAC3B;;;;;OAKG;IACH,aAAa,CAAC,EAAE,YAAY,GAAG,YAAY,EAAE,CAAC;IAC9C,+DAA+D;IAC/D,QAAQ,EAAE,SAAS,CAAC;IACpB,0CAA0C;IAC1C,SAAS,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IAC5B,wCAAwC;IACxC,IAAI,CAAC,EAAE,IAAI,GAAG,IAAI,GAAG,IAAI,CAAC;IAC1B;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,4EAA4E;IAC5E,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,4CAA4C;IAC5C,YAAY,CAAC,EAAE,CAAC,MAAM,EAAE,OAAO,KAAK,IAAI,CAAC;IACzC;;;;;;;OAOG;IACH,YAAY,CAAC,EAAE,oBAAoB,CAAC;IACpC,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAED,UAAU,oBAAqB,SAAQ,oBAAoB;IACzD,gDAAgD;IAChD,KAAK,EAAE,iBAAiB,EAAE,CAAC;IAC3B,WAAW,CAAC,EAAE,SAAS,CAAC;CACzB;AAED,UAAU,wBAAyB,SAAQ,oBAAoB;IAC7D,KAAK,CAAC,EAAE,SAAS,CAAC;IAClB,+CAA+C;IAC/C,WAAW,EAAE,SAAS,CAAC;CACxB;AAED,MAAM,MAAM,gBAAgB,GAAG,oBAAoB,GAAG,wBAAwB,CAAC;AAM/E;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,WAAW,CAAC,EAC1B,QAAQ,EACR,SAAS,EACT,IAAW,EACX,KAAK,EAAE,cAAc,EACrB,UAAkB,EAClB,YAAY,EACZ,YAAwB,EACxB,SAAiB,EACjB,GAAG,EACH,SAAS,EACT,KAAK,EACL,MAAM,EACN,aAAa,EACb,aAAa,EAAE,MAAM,EACrB,GAAG,IAAI,EACR,EAAE,gBAAgB,qBA6clB;yBA7de,WAAW"}
@@ -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 zero-size
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
@@ -134,6 +134,17 @@ const styles = {
134
134
  }
135
135
  };
136
136
 
137
+ /**
138
+ * Keeps the 1px cursor anchor inside the trigger along one axis. A point in
139
+ * the trigger's last pixel (Shift+F10 opens on its bottom edge) steps back by
140
+ * that pixel: past the edge the anchor would add scrollable overflow, and a
141
+ * scroller whose edge meets the trigger's would clip it and hide the menu.
142
+ * Any other point, inside the trigger or not, stays where it is.
143
+ */
144
+ function keepAnchorInside(offset, size) {
145
+ return offset > size - 1 && offset <= size ? Math.max(size - 1, 0) : offset;
146
+ }
147
+
137
148
  // =============================================================================
138
149
  // Types
139
150
  // =============================================================================
@@ -203,7 +214,7 @@ export function ContextMenu({
203
214
  const menuContent = menuContentProp;
204
215
  const menuId = useId();
205
216
  // Cursor point in the trigger's local coordinate space (offset from the
206
- // trigger's top-left, in-flow). Stored here and written to the zero-size
217
+ // trigger's top-left, in-flow). Stored here and written to the cursor
207
218
  // anchor element so the menu is positioned relative to the trigger context
208
219
  // — it scrolls with the content instead of sitting at a fixed viewport point.
209
220
  const positionRef = useRef({
@@ -380,20 +391,23 @@ export function ContextMenu({
380
391
  listNavKeyDown(e);
381
392
  }, [listNavKeyDown, closeMenu, typeahead, ownsEvent]);
382
393
 
383
- // Place the zero-size cursor anchor at a point in the trigger's local
394
+ // Place the 1px cursor anchor at a point in the trigger's local
384
395
  // coordinate space and open the menu. Positioning the anchor inside the
385
396
  // trigger (rather than storing viewport coordinates on the menu itself) is
386
397
  // what makes the menu context-relative: it scrolls with the content and the
387
398
  // browser auto-flips it against the viewport edges via CSS anchor positioning.
388
399
  const openAtLocalPoint = useCallback((localX, localY, focusEl) => {
400
+ const rect = triggerRef.current?.getBoundingClientRect();
401
+ const x = rect ? keepAnchorInside(localX, rect.width) : localX;
402
+ const y = rect ? keepAnchorInside(localY, rect.height) : localY;
389
403
  positionRef.current = {
390
- x: localX,
391
- y: localY
404
+ x,
405
+ y
392
406
  };
393
407
  const anchorEl = cursorAnchorRef.current;
394
408
  if (anchorEl) {
395
- anchorEl.style.left = `${localX}px`;
396
- anchorEl.style.top = `${localY}px`;
409
+ anchorEl.style.left = `${x}px`;
410
+ anchorEl.style.top = `${y}px`;
397
411
  }
398
412
  // Remember the element focused before opening so we can restore it on
399
413
  // close (Escape or outside-click), instead of dropping focus to <body>.
@@ -534,7 +548,7 @@ export function ContextMenu({
534
548
  ref: useMergedRefs(cursorAnchorRef, layer.ref),
535
549
  "aria-hidden": "true",
536
550
  ...mergeProps({
537
- className: "x10l6tqk xnalus7 xqtp20y x47corl"
551
+ className: "x10l6tqk x1i1rx1s xjm9jq1 x47corl"
538
552
  }, {
539
553
  style: {
540
554
  left: `${positionRef.current.x}px`,
@@ -1 +1 @@
1
- {"version":3,"file":"Dialog.d.ts","sourceRoot":"","sources":["../../src/Dialog/Dialog.tsx"],"names":[],"mappings":"AAIA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAEH,OAAO,EAML,KAAK,SAAS,EACf,MAAM,OAAO,CAAC;AACf,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,cAAc,CAAC;AA2B5C,OAAO,KAAK,EAAC,WAAW,EAAC,MAAM,gBAAgB,CAAC;AAKhD,OAAO,KAAK,EAAC,gBAAgB,EAAC,MAAM,SAAS,CAAC;AAqD9C;;;;;;GAMG;AACH,MAAM,MAAM,aAAa,GAAG,MAAM,gBAAgB,CAAC;AAEnD;;;;;GAKG;AACH,MAAM,MAAM,aAAa,GAAG,UAAU,GAAG,MAAM,GAAG,MAAM,CAAC;AAEzD,4EAA4E;AAC5E,UAAU,mBAAmB;IAC3B,GAAG,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IACtB,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;CAC1B;AAED;;;;;GAKG;AACH,MAAM,WAAW,cAAe,SAAQ,mBAAmB;IACzD,6EAA6E;IAC7E,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IACxB,yEAAyE;IACzE,GAAG,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;CACvB;AAuBD,2FAA2F;AAC3F,eAAO,MAAM,uCAAuC;;;;;;;;;;;CAW1C,CAAC;AAoJX;;;;;;;;;GASG;AACH,wBAAgB,4BAA4B,CAAC,QAAQ,EAAE,cAAc;;;;;EAUpE;AAED,MAAM,WAAW,WAAY,SAAQ,SAAS,CAAC,iBAAiB,CAAC;IAC/D,wCAAwC;IACxC,GAAG,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,iBAAiB,CAAC,CAAC;IACnC;;OAEG;IACH,MAAM,EAAE,OAAO,CAAC;IAEhB;;;;;;OAMG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;IAEnB;;;;;;;OAOG;IACH,YAAY,EAAE,CAAC,MAAM,EAAE,OAAO,KAAK,OAAO,CAAC;IAE3C;;;;;OAKG;IACH,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IAExB;;;;;;OAMG;IACH,SAAS,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IAE5B;;;;OAIG;IACH,QAAQ,CAAC,EAAE,QAAQ,CAAC,cAAc,CAAC,CAAC;IAEpC;;;;;OAKG;IACH,OAAO,CAAC,EAAE,aAAa,CAAC;IAExB;;;;;;OAMG;IACH,OAAO,CAAC,EAAE,aAAa,CAAC;IAExB;;;;OAIG;IACH,OAAO,CAAC,EAAE,WAAW,CAAC;IAEtB;;;OAGG;IACH,QAAQ,EAAE,SAAS,CAAC;CACrB;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,MAAM,CAAC,EACrB,MAAM,EACN,QAAgB,EAChB,YAAY,EACZ,KAAW,EACX,SAAmB,EACnB,QAAQ,EACR,OAAoB,EACpB,OAAgB,EAChB,OAAO,EACP,QAAQ,EACR,MAAM,EACN,SAAS,EACT,KAAK,EACL,GAAG,EACH,GAAG,KAAK,EACT,EAAE,WAAW,sCAiUb;yBAjVe,MAAM"}
1
+ {"version":3,"file":"Dialog.d.ts","sourceRoot":"","sources":["../../src/Dialog/Dialog.tsx"],"names":[],"mappings":"AAIA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAEH,OAAO,EAML,KAAK,SAAS,EACf,MAAM,OAAO,CAAC;AACf,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,cAAc,CAAC;AA2B5C,OAAO,KAAK,EAAC,WAAW,EAAC,MAAM,gBAAgB,CAAC;AAKhD,OAAO,KAAK,EAAC,gBAAgB,EAAC,MAAM,SAAS,CAAC;AAyB9C;;;;;;GAMG;AACH,MAAM,MAAM,aAAa,GAAG,MAAM,gBAAgB,CAAC;AAEnD;;;;;GAKG;AACH,MAAM,MAAM,aAAa,GAAG,UAAU,GAAG,MAAM,GAAG,MAAM,CAAC;AAEzD,4EAA4E;AAC5E,UAAU,mBAAmB;IAC3B,GAAG,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IACtB,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;CAC1B;AAED;;;;;GAKG;AACH,MAAM,WAAW,cAAe,SAAQ,mBAAmB;IACzD,6EAA6E;IAC7E,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IACxB,yEAAyE;IACzE,GAAG,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;CACvB;AAuBD,2FAA2F;AAC3F,eAAO,MAAM,uCAAuC;;;;;;;;;;;CAW1C,CAAC;AAoJX;;;;;;;;;GASG;AACH,wBAAgB,4BAA4B,CAAC,QAAQ,EAAE,cAAc;;;;;EAUpE;AAED,MAAM,WAAW,WAAY,SAAQ,SAAS,CAAC,iBAAiB,CAAC;IAC/D,wCAAwC;IACxC,GAAG,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,iBAAiB,CAAC,CAAC;IACnC;;OAEG;IACH,MAAM,EAAE,OAAO,CAAC;IAEhB;;;;;;OAMG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;IAEnB;;;;;;;OAOG;IACH,YAAY,EAAE,CAAC,MAAM,EAAE,OAAO,KAAK,OAAO,CAAC;IAE3C;;;;;OAKG;IACH,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IAExB;;;;;;OAMG;IACH,SAAS,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IAE5B;;;;OAIG;IACH,QAAQ,CAAC,EAAE,QAAQ,CAAC,cAAc,CAAC,CAAC;IAEpC;;;;;OAKG;IACH,OAAO,CAAC,EAAE,aAAa,CAAC;IAExB;;;;;;OAMG;IACH,OAAO,CAAC,EAAE,aAAa,CAAC;IAExB;;;;OAIG;IACH,OAAO,CAAC,EAAE,WAAW,CAAC;IAEtB;;;OAGG;IACH,QAAQ,EAAE,SAAS,CAAC;CACrB;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,MAAM,CAAC,EACrB,MAAM,EACN,QAAgB,EAChB,YAAY,EACZ,KAAW,EACX,SAAmB,EACnB,QAAQ,EACR,OAAoB,EACpB,OAAgB,EAChB,OAAO,EACP,QAAQ,EACR,MAAM,EACN,SAAS,EACT,KAAK,EACL,GAAG,EACH,GAAG,KAAK,EACT,EAAE,WAAW,sCAiUb;yBAjVe,MAAM"}
@@ -45,38 +45,15 @@ import { devWarn } from "../utils/devWarning.js";
45
45
  import { DialogContext } from "./DialogContext.js";
46
46
  import { themeProps } from "../utils/themeProps.js";
47
47
  import { focusOutlineProps } from "../utils/focusOutline.stylex.js";
48
- import { getInteractionModality, useInteractionModalityTracking } from "../utils/interactionModality.js";
48
+ import { useInteractionModalityTracking } from "../utils/interactionModality.js";
49
+ import { focusForLastInput } from "../utils/focusReturn.js";
49
50
  import { useMergedRefs } from "../hooks/useMergedRefs.js";
50
- /**
51
- * Move focus programmatically so its ring follows the last input. Dialog does
52
- * not render the element that opened it or the consumer's autofocus target, so
53
- * the browser's focus option is the one way to reach them.
54
- */
55
- import { jsx as _jsx } from "react/jsx-runtime";
56
- function focusForLastInput(element) {
57
- // A text-entry field paints its own focus treatment to show where typing
58
- // goes, and an extra blur there can run validation or mark it touched.
59
- const isTextEntry = element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement || element instanceof HTMLSelectElement || element.isContentEditable;
60
- if (isTextEntry || getInteractionModality() !== 'pointer') {
61
- element.focus();
62
- return;
63
- }
64
- // Focusing the element that already has focus does nothing, so a ring the
65
- // browser already drew would stay. WebKit draws one when showModal() or its
66
- // native close moves focus itself, so clear that focus first.
67
- if (element.matches(':focus-visible')) {
68
- element.blur();
69
- }
70
- element.focus({
71
- focusVisible: false
72
- });
73
- }
74
-
75
51
  /**
76
52
  * Calculate a directional translate offset for dialog entry animation.
77
53
  * Returns a normalized vector from the trigger element toward the viewport
78
54
  * center, scaled to the given distance.
79
55
  */
56
+ import { jsx as _jsx } from "react/jsx-runtime";
80
57
  function getDialogDirection(triggerEl, distance = 16) {
81
58
  const rect = triggerEl.getBoundingClientRect();
82
59
  const dx = rect.left + rect.width / 2 - window.innerWidth / 2;
@@ -1 +1 @@
1
- {"version":3,"file":"DropdownMenu.d.ts","sourceRoot":"","sources":["../../src/DropdownMenu/DropdownMenu.tsx"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"DropdownMenu.d.ts","sourceRoot":"","sources":["../../src/DropdownMenu/DropdownMenu.tsx"],"names":[],"mappings":"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"}
@@ -48,6 +48,7 @@ import { useListFocus } from "../hooks/useListFocus.js";
48
48
  import { useMenuPress } from "../hooks/useMenuPress.js";
49
49
  import { useTypeahead } from "../hooks/useTypeahead.js";
50
50
  import { useFocusReturnVisibility } from "../hooks/useFocusReturnVisibility.js";
51
+ import { focusForLastInput } from "../utils/focusReturn.js";
51
52
  import { useMenuOverflow } from "./useMenuOverflow.js";
52
53
  import { MenuRegionsContext } from "./menuRegionsContext.js";
53
54
  import { getFocusableElements } from "../hooks/useFocusTrap.js";
@@ -654,7 +655,12 @@ function DropdownMenuPopover({
654
655
  return;
655
656
  }
656
657
  prepareFocusReturn();
657
- trigger.focus({
658
+ // After pointer input the trigger takes focus without the focus-visible
659
+ // ring (`component:DropdownMenu/DEC-1`). The style the helper above adds
660
+ // reaches only the design system's own Button; the browser's focus option
661
+ // also reaches a control the caller renders, which the browser would
662
+ // otherwise ring (Chromium after a mouse pick, WebKit after a touch).
663
+ focusForLastInput(trigger, {
658
664
  preventScroll: true
659
665
  });
660
666
  }, [isControlled, onOpenChange, prepareFocusReturn]);
@@ -1072,8 +1078,9 @@ function DropdownMenuPopover({
1072
1078
  onPointerDown: menuPress.triggerProps.onPointerDown,
1073
1079
  onContextMenu: menuPress.triggerProps.onContextMenu,
1074
1080
  onKeyDown: handleButtonKeyDown,
1075
- // The popover's focus-return hook is a separate change; until it lands
1076
- // the control's focus needs no bookkeeping here.
1081
+ // The caller's control paints its own ring, and the menu's focus return
1082
+ // keeps the browser from drawing one after pointer input (see
1083
+ // handleLayerHide), so its focus needs no bookkeeping here.
1077
1084
  onFocus: () => {},
1078
1085
  'aria-haspopup': 'menu',
1079
1086
  'aria-expanded': isOpen,
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * @file BaseTypeahead.tsx
3
3
  * @input Uses React, StyleX, usePopover, TypeaheadItem
4
- * @output Exports BaseTypeahead with focused-input menu reactivation
4
+ * @output Exports BaseTypeahead; focus bootstraps a replaced search source
5
5
  * @position Core implementation; used by Typeahead and Tokenizer
6
6
  *
7
7
  * Pure combobox engine: input, search, keyboard navigation, dropdown.
@@ -1 +1 @@
1
- {"version":3,"file":"BaseTypeahead.d.ts","sourceRoot":"","sources":["../../src/Typeahead/BaseTypeahead.tsx"],"names":[],"mappings":"AAIA;;;;;;;;;;;;;GAaG;AAEH,OAAO,KAAK,EAAE,EAMZ,KAAK,SAAS,EACd,KAAK,SAAS,EACf,MAAM,OAAO,CAAC;AAIf,OAAO,KAAK,EAAC,YAAY,EAAC,MAAM,kBAAkB,CAAC;AA2BnD,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,cAAc,CAAC;AAC5C,OAAO,KAAK,EAAC,cAAc,EAAE,YAAY,EAAC,MAAM,SAAS,CAAC;AAW1D,MAAM,WAAW,kBAAkB,CAAC,CAAC,SAAS,cAAc,CAAE,SAAQ,IAAI,CACxE,SAAS,CAAC,WAAW,CAAC,EACtB,UAAU,CACX;IACC,GAAG,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,gBAAgB,CAAC,CAAC;IAClC;;OAEG;IACH,YAAY,EAAE,YAAY,CAAC,CAAC,CAAC,CAAC;IAE9B;;OAEG;IACH,KAAK,EAAE,CAAC,GAAG,IAAI,CAAC;IAEhB;;OAEG;IACH,QAAQ,EAAE,CAAC,IAAI,EAAE,CAAC,GAAG,IAAI,KAAK,IAAI,CAAC;IAEnC;;OAEG;IACH,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE,CAAC,KAAK,SAAS,CAAC;IAEpC;;OAEG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IAErB;;;OAGG;IACH,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAE5B;;;OAGG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IAEtB,mEAAmE;IACnE,SAAS,CAAC,EAAE,MAAM,CAAC;IAEnB;;;;;;;;;;OAUG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IAExB;;;;;;;;;;;;;OAaG;IACH,eAAe,CAAC,EAAE,SAAS,CAAC;IAE5B;;;OAGG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;IAErB;;;;;;;;;OASG;IACH,mBAAmB,CAAC,EAAE,OAAO,CAAC;IAE9B;;;OAGG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;IAEvB;;OAEG;IACH,aAAa,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IAExC;;OAEG;IACH,YAAY,CAAC,EAAE,CAAC,MAAM,EAAE,OAAO,KAAK,IAAI,CAAC;IAEzC;;;;;;;;;;;;;;;;;;;OAmBG;IACH,cAAc,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC,EAAE,KAAK,CAAC,EAAE,CAAC;IAEtD;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IAEpB;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;IAEjB;;;OAGG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;IAEzB;;;OAGG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IAExB;;OAEG;IACH,WAAW,CAAC,EAAE,YAAY,CAAC;IAE3B;;;;;;;;OAQG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IAEvB;;;;OAIG;IACH,SAAS,CAAC,EAAE,SAAS,CAAC,WAAW,GAAG,IAAI,CAAC,CAAC;IAE1C;;;OAGG;IACH,SAAS,CAAC,EAAE,CAAC,CAAC,EAAE,KAAK,CAAC,aAAa,CAAC,gBAAgB,CAAC,KAAK,IAAI,CAAC;IAE/D;;;;OAIG;IACH,IAAI,CAAC,EAAE,IAAI,GAAG,IAAI,GAAG,IAAI,CAAC;CAC3B;AAoKD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,eAAO,MAAM,aAAa,EAs0BrB,CAAC,CAAC,SAAS,cAAc,EAC5B,KAAK,EAAE,kBAAkB,CAAC,CAAC,CAAC,KACzB,KAAK,CAAC,YAAY,CAAC"}
1
+ {"version":3,"file":"BaseTypeahead.d.ts","sourceRoot":"","sources":["../../src/Typeahead/BaseTypeahead.tsx"],"names":[],"mappings":"AAIA;;;;;;;;;;;;;GAaG;AAEH,OAAO,KAAK,EAAE,EAMZ,KAAK,SAAS,EACd,KAAK,SAAS,EACf,MAAM,OAAO,CAAC;AAIf,OAAO,KAAK,EAAC,YAAY,EAAC,MAAM,kBAAkB,CAAC;AA2BnD,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,cAAc,CAAC;AAC5C,OAAO,KAAK,EAAC,cAAc,EAAE,YAAY,EAAC,MAAM,SAAS,CAAC;AAW1D,MAAM,WAAW,kBAAkB,CAAC,CAAC,SAAS,cAAc,CAAE,SAAQ,IAAI,CACxE,SAAS,CAAC,WAAW,CAAC,EACtB,UAAU,CACX;IACC,GAAG,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,gBAAgB,CAAC,CAAC;IAClC;;OAEG;IACH,YAAY,EAAE,YAAY,CAAC,CAAC,CAAC,CAAC;IAE9B;;OAEG;IACH,KAAK,EAAE,CAAC,GAAG,IAAI,CAAC;IAEhB;;OAEG;IACH,QAAQ,EAAE,CAAC,IAAI,EAAE,CAAC,GAAG,IAAI,KAAK,IAAI,CAAC;IAEnC;;OAEG;IACH,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE,CAAC,KAAK,SAAS,CAAC;IAEpC;;OAEG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IAErB;;;OAGG;IACH,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAE5B;;;OAGG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IAEtB,mEAAmE;IACnE,SAAS,CAAC,EAAE,MAAM,CAAC;IAEnB;;;;;;;;;;OAUG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IAExB;;;;;;;;;;;;;OAaG;IACH,eAAe,CAAC,EAAE,SAAS,CAAC;IAE5B;;;OAGG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;IAErB;;;;;;;;;OASG;IACH,mBAAmB,CAAC,EAAE,OAAO,CAAC;IAE9B;;;OAGG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;IAEvB;;OAEG;IACH,aAAa,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IAExC;;OAEG;IACH,YAAY,CAAC,EAAE,CAAC,MAAM,EAAE,OAAO,KAAK,IAAI,CAAC;IAEzC;;;;;;;;;;;;;;;;;;;OAmBG;IACH,cAAc,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC,EAAE,KAAK,CAAC,EAAE,CAAC;IAEtD;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IAEpB;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;IAEjB;;;OAGG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;IAEzB;;;OAGG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IAExB;;OAEG;IACH,WAAW,CAAC,EAAE,YAAY,CAAC;IAE3B;;;;;;;;OAQG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IAEvB;;;;OAIG;IACH,SAAS,CAAC,EAAE,SAAS,CAAC,WAAW,GAAG,IAAI,CAAC,CAAC;IAE1C;;;OAGG;IACH,SAAS,CAAC,EAAE,CAAC,CAAC,EAAE,KAAK,CAAC,aAAa,CAAC,gBAAgB,CAAC,KAAK,IAAI,CAAC;IAE/D;;;;OAIG;IACH,IAAI,CAAC,EAAE,IAAI,GAAG,IAAI,GAAG,IAAI,CAAC;CAC3B;AAoKD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,eAAO,MAAM,aAAa,EAo1BrB,CAAC,CAAC,SAAS,cAAc,EAC5B,KAAK,EAAE,kBAAkB,CAAC,CAAC,CAAC,KACzB,KAAK,CAAC,YAAY,CAAC"}
@@ -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.
@@ -326,6 +326,10 @@ export const BaseTypeahead = function BaseTypeahead({
326
326
  // in the closure are stale (a selection cleared them) and shouldn't
327
327
  // be re-shown.
328
328
  const resultsGenRef = useRef(0);
329
+ // The generation at which the current searchSource took over. Results
330
+ // populated before it (resultsGenRef below this) came from a replaced
331
+ // source: they can never be re-shown, so focus bootstraps the new source.
332
+ const sourceGenRef = useRef(0);
329
333
 
330
334
  // Results still arriving from a replaced source must not land in the new
331
335
  // one's menu.
@@ -333,7 +337,7 @@ export const BaseTypeahead = function BaseTypeahead({
333
337
  if (prevSearchSourceRef.current !== searchSource) {
334
338
  prevSearchSourceRef.current.cancel?.();
335
339
  prevSearchSourceRef.current = searchSource;
336
- searchGenRef.current++;
340
+ sourceGenRef.current = ++searchGenRef.current;
337
341
  }
338
342
 
339
343
  // Layer for dropdown
@@ -551,11 +555,16 @@ export const BaseTypeahead = function BaseTypeahead({
551
555
  // Shared by handleFocus and handleClick: bootstrap entries if none are
552
556
  // loaded yet, or re-show cached results that haven't been invalidated by
553
557
  // a selection since (comparing the two generation refs catches that).
558
+ // Results cached from a replaced search source count as none, so focusing
559
+ // an empty field bootstraps the new source instead of opening nothing.
560
+ // Results a selection invalidated still keep the menu closed when the
561
+ // selection returns focus to the input.
554
562
  const openIfEligible = useCallback(() => {
555
563
  if (isDisabled) {
556
564
  return;
557
565
  }
558
- if (hasEntriesOnFocus && results.length === 0 && query.length === 0) {
566
+ const hasResultsFromCurrentSource = results.length > 0 && resultsGenRef.current >= sourceGenRef.current;
567
+ if (hasEntriesOnFocus && !hasResultsFromCurrentSource && query.length === 0) {
559
568
  void performBootstrap();
560
569
  } else if (results.length > 0 && (query.length > 0 || hasEntriesOnFocus) && resultsGenRef.current === searchGenRef.current) {
561
570
  showLayer();
@@ -11,6 +11,6 @@
11
11
  * identical — neither hook fires during server rendering — but React only
12
12
  * warns about useLayoutEffect.
13
13
  */
14
- import { useLayoutEffect } from 'react';
15
- export declare const useIsomorphicLayoutEffect: typeof useLayoutEffect;
14
+ import { useEffect } from 'react';
15
+ export declare const useIsomorphicLayoutEffect: typeof useEffect;
16
16
  //# sourceMappingURL=useIsomorphicLayoutEffect.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"useIsomorphicLayoutEffect.d.ts","sourceRoot":"","sources":["../../src/hooks/useIsomorphicLayoutEffect.ts"],"names":[],"mappings":"AAIA;;;;;;;;;;;;GAYG;AAEH,OAAO,EAAY,eAAe,EAAC,MAAM,OAAO,CAAC;AAEjD,eAAO,MAAM,yBAAyB,wBACuB,CAAC"}
1
+ {"version":3,"file":"useIsomorphicLayoutEffect.d.ts","sourceRoot":"","sources":["../../src/hooks/useIsomorphicLayoutEffect.ts"],"names":[],"mappings":"AAIA;;;;;;;;;;;;GAYG;AAEH,OAAO,EAAC,SAAS,EAAkB,MAAM,OAAO,CAAC;AAEjD,eAAO,MAAM,yBAAyB,kBACuB,CAAC"}
@@ -1,11 +1,3 @@
1
- /**
2
- * @file focusReturn.ts
3
- * @input The document's current active element.
4
- * @output Whether focus is currently "detached" — on nothing in particular.
5
- * @position Utility; used by overlay triggers (e.g. DateInput, DateTimeInput)
6
- * that return focus to their control when the overlay closes, but only when
7
- * the dismiss did not already move focus somewhere the user chose.
8
- */
9
1
  /**
10
2
  * True when focus rests on nothing in particular — no active element, or the
11
3
  * document body / root element.
@@ -21,4 +13,16 @@
21
13
  * when the trigger should reclaim it.
22
14
  */
23
15
  export declare function isFocusDetached(doc?: Document): boolean;
16
+ /**
17
+ * Move focus programmatically so its ring follows the last input
18
+ * (`architecture:interaction-modality` INV1). After pointer input the browser's
19
+ * focus option keeps the element from matching `:focus-visible`, which is the
20
+ * one way to reach an element the overlay does not render: the consumer's
21
+ * autofocus target, the control that opened a Dialog, or a menu trigger the
22
+ * caller renders. Pass `options` (for example `preventScroll`) as to `focus()`.
23
+ *
24
+ * Make sure modality tracking is active (`useInteractionModalityTracking()`)
25
+ * in the component that calls this.
26
+ */
27
+ export declare function focusForLastInput(element: HTMLElement, options?: FocusOptions): void;
24
28
  //# sourceMappingURL=focusReturn.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"focusReturn.d.ts","sourceRoot":"","sources":["../../src/utils/focusReturn.ts"],"names":[],"mappings":"AAEA;;;;;;;GAOG;AAEH;;;;;;;;;;;;;GAaG;AACH,wBAAgB,eAAe,CAAC,GAAG,GAAE,QAAmB,GAAG,OAAO,CAKjE"}
1
+ {"version":3,"file":"focusReturn.d.ts","sourceRoot":"","sources":["../../src/utils/focusReturn.ts"],"names":[],"mappings":"AAgBA;;;;;;;;;;;;;GAaG;AACH,wBAAgB,eAAe,CAAC,GAAG,GAAE,QAAmB,GAAG,OAAO,CAKjE;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,iBAAiB,CAC/B,OAAO,EAAE,WAAW,EACpB,OAAO,CAAC,EAAE,YAAY,GACrB,IAAI,CAwBN"}
@@ -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.js";
16
+
12
17
  /**
13
18
  * True when focus rests on nothing in particular — no active element, or the
14
19
  * document body / root element.
@@ -26,4 +31,40 @@
26
31
  export function isFocusDetached(doc = document) {
27
32
  const active = doc.activeElement;
28
33
  return active == null || active === doc.body || active === doc.documentElement;
34
+ }
35
+
36
+ /**
37
+ * Move focus programmatically so its ring follows the last input
38
+ * (`architecture:interaction-modality` INV1). After pointer input the browser's
39
+ * focus option keeps the element from matching `:focus-visible`, which is the
40
+ * one way to reach an element the overlay does not render: the consumer's
41
+ * autofocus target, the control that opened a Dialog, or a menu trigger the
42
+ * caller renders. Pass `options` (for example `preventScroll`) as to `focus()`.
43
+ *
44
+ * Make sure modality tracking is active (`useInteractionModalityTracking()`)
45
+ * in the component that calls this.
46
+ */
47
+ export function focusForLastInput(element, options) {
48
+ // A text-entry field paints its own focus treatment to show where typing
49
+ // goes, and an extra blur there can run validation or mark it touched.
50
+ const isTextEntry = element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement || element instanceof HTMLSelectElement || element.isContentEditable;
51
+ if (isTextEntry || getInteractionModality() !== 'pointer') {
52
+ if (options === undefined) {
53
+ element.focus();
54
+ } else {
55
+ element.focus(options);
56
+ }
57
+ return;
58
+ }
59
+ // Focusing the element that already has focus does nothing, so a ring the
60
+ // browser already drew would stay. WebKit draws one when its own focus
61
+ // restoration (a dialog's close, a popover's hide) moves focus itself, so
62
+ // clear that focus first.
63
+ if (element.matches(':focus-visible')) {
64
+ element.blur();
65
+ }
66
+ element.focus({
67
+ ...options,
68
+ focusVisible: false
69
+ });
29
70
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astryxdesign/core",
3
- "version": "0.7.0",
3
+ "version": "0.7.1-canary.30b6e1b",
4
4
  "displayName": "Astryx Core",
5
5
  "description": "The component library. Accessible, themeable React components with built-in spacing, dark mode, and StyleX styling.",
6
6
  "author": "Meta Open Source",
@@ -787,6 +787,8 @@
787
787
  "react-dom": ">=19.0.0"
788
788
  },
789
789
  "devDependencies": {
790
+ "@astryxdesign/a11y-spec": "0.7.1-canary.30b6e1b",
791
+ "@astryxdesign/cli": "0.7.1-canary.30b6e1b",
790
792
  "@babel/cli": "^7.29.7",
791
793
  "@babel/core": "^7.29.7",
792
794
  "@babel/preset-react": "^7.29.7",
@@ -797,9 +799,7 @@
797
799
  "@testing-library/jest-dom": "^6.6.0",
798
800
  "@testing-library/react": "^16.3.2",
799
801
  "@types/babel__core": "^7.20.5",
800
- "tailwindcss": "^4.3.2",
801
- "@astryxdesign/a11y-spec": "0.0.0",
802
- "@astryxdesign/cli": "0.7.0"
802
+ "tailwindcss": "^4.3.2"
803
803
  },
804
804
  "dependencies": {
805
805
  "intl-messageformat": "^11.2.9"
@@ -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',