@astryxdesign/core 0.7.7-canary.bf14580 → 0.7.7-canary.c19ca12

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 (266) hide show
  1. package/dist/AlertDialog/AlertDialog.d.ts +4 -1
  2. package/dist/AlertDialog/AlertDialog.d.ts.map +1 -1
  3. package/dist/AlertDialog/AlertDialog.js +36 -6
  4. package/dist/BottomSheet/BottomSheet.d.ts.map +1 -1
  5. package/dist/BottomSheet/BottomSheet.js +76 -6
  6. package/dist/BottomSheet/BottomSheetPanel.d.ts.map +1 -1
  7. package/dist/BottomSheet/BottomSheetPanel.js +23 -4
  8. package/dist/BottomSheet/bottomSheetStack.d.ts.map +1 -1
  9. package/dist/BottomSheet/bottomSheetStack.js +5 -4
  10. package/dist/BottomSheet/useSheetGestures.d.ts.map +1 -1
  11. package/dist/BottomSheet/useSheetGestures.js +3 -1
  12. package/dist/Button/Button.d.ts.map +1 -1
  13. package/dist/Button/Button.js +47 -3
  14. package/dist/CheckboxInput/CheckboxInput.d.ts.map +1 -1
  15. package/dist/CheckboxInput/CheckboxInput.js +13 -2
  16. package/dist/ComplexSelector/ComplexSelector.d.ts +4 -1
  17. package/dist/ComplexSelector/ComplexSelector.d.ts.map +1 -1
  18. package/dist/ComplexSelector/ComplexSelector.js +14 -4
  19. package/dist/ContextMenu/ContextMenu.js +2 -2
  20. package/dist/DateInput/MonthScroller.d.ts.map +1 -1
  21. package/dist/DateInput/MonthScroller.js +13 -27
  22. package/dist/DateInput/Wheel.d.ts.map +1 -1
  23. package/dist/DateInput/Wheel.js +22 -41
  24. package/dist/Dialog/Dialog.d.ts.map +1 -1
  25. package/dist/Dialog/Dialog.js +7 -31
  26. package/dist/Field/Field.js +2 -2
  27. package/dist/HoverCard/HoverCard.d.ts +4 -3
  28. package/dist/HoverCard/HoverCard.d.ts.map +1 -1
  29. package/dist/HoverCard/HoverCard.js +27 -2
  30. package/dist/HoverCard/useHoverCard.d.ts +5 -4
  31. package/dist/HoverCard/useHoverCard.d.ts.map +1 -1
  32. package/dist/HoverCard/useHoverCard.js +17 -31
  33. package/dist/Icon/Icon.d.ts +5 -1
  34. package/dist/Icon/Icon.d.ts.map +1 -1
  35. package/dist/Icon/Icon.js +5 -2
  36. package/dist/Layer/layerAnimations.stylex.d.ts +7 -8
  37. package/dist/Layer/layerAnimations.stylex.d.ts.map +1 -1
  38. package/dist/Layer/layerAnimations.stylex.js +25 -20
  39. package/dist/Layer/usePressPin.d.ts +17 -5
  40. package/dist/Layer/usePressPin.d.ts.map +1 -1
  41. package/dist/Layer/usePressPin.js +62 -12
  42. package/dist/Lightbox/Lightbox.d.ts.map +1 -1
  43. package/dist/Lightbox/Lightbox.js +18 -0
  44. package/dist/PowerSearch/PowerSearch.d.ts +36 -3
  45. package/dist/PowerSearch/PowerSearch.d.ts.map +1 -1
  46. package/dist/PowerSearch/PowerSearch.js +522 -269
  47. package/dist/PowerSearch/PowerSearchBottomSheet.d.ts +100 -0
  48. package/dist/PowerSearch/PowerSearchBottomSheet.d.ts.map +1 -0
  49. package/dist/PowerSearch/PowerSearchBottomSheet.js +1913 -0
  50. package/dist/PowerSearch/PowerSearchEditPopover.d.ts +32 -1
  51. package/dist/PowerSearch/PowerSearchEditPopover.d.ts.map +1 -1
  52. package/dist/PowerSearch/PowerSearchEditPopover.js +17 -6
  53. package/dist/PowerSearch/PowerSearchValueEditor.d.ts +38 -3
  54. package/dist/PowerSearch/PowerSearchValueEditor.d.ts.map +1 -1
  55. package/dist/PowerSearch/PowerSearchValueEditor.js +170 -61
  56. package/dist/PowerSearch/formatFilterValue.d.ts +7 -0
  57. package/dist/PowerSearch/formatFilterValue.d.ts.map +1 -1
  58. package/dist/PowerSearch/formatFilterValue.js +11 -1
  59. package/dist/PowerSearch/formatTokenValue.d.ts +28 -0
  60. package/dist/PowerSearch/formatTokenValue.d.ts.map +1 -0
  61. package/dist/PowerSearch/formatTokenValue.js +160 -0
  62. package/dist/PowerSearch/powerSearchSheetUtils.d.ts +65 -0
  63. package/dist/PowerSearch/powerSearchSheetUtils.d.ts.map +1 -0
  64. package/dist/PowerSearch/powerSearchSheetUtils.js +168 -0
  65. package/dist/RadioList/RadioList.d.ts.map +1 -1
  66. package/dist/RadioList/RadioList.js +3 -2
  67. package/dist/RadioList/RadioListItem.d.ts +1 -1
  68. package/dist/RadioList/RadioListItem.d.ts.map +1 -1
  69. package/dist/RadioList/RadioListItem.js +5 -3
  70. package/dist/SegmentedControl/useSegmentedSelectionMotion.d.ts.map +1 -1
  71. package/dist/SegmentedControl/useSegmentedSelectionMotion.js +3 -3
  72. package/dist/Slider/sliderTouchController.js +2 -2
  73. package/dist/Spinner/Spinner.d.ts +1 -1
  74. package/dist/Spinner/Spinner.d.ts.map +1 -1
  75. package/dist/Spinner/Spinner.js +60 -58
  76. package/dist/Switch/useSwitchTouch.js +4 -4
  77. package/dist/Table/BaseTable.d.ts.map +1 -1
  78. package/dist/Table/BaseTable.js +29 -8
  79. package/dist/TextInput/TextInput.d.ts +2 -2
  80. package/dist/TextInput/TextInput.d.ts.map +1 -1
  81. package/dist/TextInput/TextInput.js +10 -7
  82. package/dist/Toast/Toast.d.ts +11 -1
  83. package/dist/Toast/Toast.d.ts.map +1 -1
  84. package/dist/Toast/Toast.js +25 -15
  85. package/dist/Toast/ToastViewport.d.ts.map +1 -1
  86. package/dist/Toast/ToastViewport.js +94 -34
  87. package/dist/Toast/types.d.ts +9 -3
  88. package/dist/Toast/types.d.ts.map +1 -1
  89. package/dist/Token/Token.d.ts +2 -2
  90. package/dist/Token/Token.d.ts.map +1 -1
  91. package/dist/Token/Token.js +12 -4
  92. package/dist/Token/TokenNamingContext.d.ts +14 -0
  93. package/dist/Token/TokenNamingContext.d.ts.map +1 -0
  94. package/dist/Token/TokenNamingContext.js +23 -0
  95. package/dist/Tooltip/useTooltip.d.ts +19 -1
  96. package/dist/Tooltip/useTooltip.d.ts.map +1 -1
  97. package/dist/Tooltip/useTooltip.js +34 -53
  98. package/dist/Typeahead/TypeaheadItem.d.ts +2 -1
  99. package/dist/Typeahead/TypeaheadItem.d.ts.map +1 -1
  100. package/dist/animator/createAnimator.d.ts +98 -0
  101. package/dist/animator/createAnimator.d.ts.map +1 -0
  102. package/dist/animator/createAnimator.js +259 -0
  103. package/dist/astryx.css +22 -6
  104. package/dist/gestures/GestureHost.d.ts +27 -1
  105. package/dist/gestures/GestureHost.d.ts.map +1 -1
  106. package/dist/gestures/GestureHost.js +90 -8
  107. package/dist/gestures/PressTracker.d.ts.map +1 -1
  108. package/dist/gestures/PressTracker.js +4 -3
  109. package/dist/hooks/actionGroupTracker.d.ts +4 -1
  110. package/dist/hooks/actionGroupTracker.d.ts.map +1 -1
  111. package/dist/hooks/actionGroupTracker.js +13 -3
  112. package/dist/hooks/index.d.ts +1 -0
  113. package/dist/hooks/index.d.ts.map +1 -1
  114. package/dist/hooks/index.js +1 -0
  115. package/dist/hooks/pressGroupContext.d.ts +6 -0
  116. package/dist/hooks/pressGroupContext.d.ts.map +1 -0
  117. package/dist/hooks/pressGroupContext.js +20 -0
  118. package/dist/hooks/useActionGroupTracking.d.ts +53 -0
  119. package/dist/hooks/useActionGroupTracking.d.ts.map +1 -0
  120. package/dist/hooks/useActionGroupTracking.js +174 -0
  121. package/dist/hooks/useAnimator.d.ts +41 -0
  122. package/dist/hooks/useAnimator.d.ts.map +1 -0
  123. package/dist/hooks/useAnimator.js +70 -0
  124. package/dist/hooks/useAttachment.d.ts +1 -1
  125. package/dist/hooks/useAttachment.js +1 -1
  126. package/dist/hooks/useInputStatusIcon.d.ts +1 -1
  127. package/dist/hooks/useInputStatusIcon.d.ts.map +1 -1
  128. package/dist/hooks/useInputStatusIcon.js +2 -2
  129. package/dist/hooks/useLongPress.d.ts +6 -2
  130. package/dist/hooks/useLongPress.d.ts.map +1 -1
  131. package/dist/hooks/useLongPress.js +95 -15
  132. package/dist/hooks/useMenuPress.d.ts.map +1 -1
  133. package/dist/hooks/useMenuPress.js +4 -2
  134. package/dist/i18n/generated-english/PowerSearch/PowerSearchBottomSheet.generated.d.ts +8 -0
  135. package/dist/i18n/generated-english/PowerSearch/PowerSearchBottomSheet.generated.d.ts.map +1 -0
  136. package/dist/i18n/generated-english/PowerSearch/PowerSearchBottomSheet.generated.js +42 -0
  137. package/dist/i18n/generated-english/PowerSearch/PowerSearchValueEditor.generated.d.ts.map +1 -1
  138. package/dist/i18n/generated-english/PowerSearch/PowerSearchValueEditor.generated.js +6 -0
  139. package/dist/i18n/generated-english/PowerSearch/formatTokenValue.generated.d.ts +8 -0
  140. package/dist/i18n/generated-english/PowerSearch/formatTokenValue.generated.d.ts.map +1 -0
  141. package/dist/i18n/generated-english/PowerSearch/formatTokenValue.generated.js +14 -0
  142. package/dist/i18n/generated-locales/en.generated.d.ts.map +1 -1
  143. package/dist/i18n/generated-locales/en.generated.js +30 -0
  144. package/dist/i18n/generated-locales/pseudo.generated.d.ts.map +1 -1
  145. package/dist/i18n/generated-locales/pseudo.generated.js +30 -0
  146. package/dist/index.d.ts +1 -0
  147. package/dist/index.d.ts.map +1 -1
  148. package/dist/index.js +4 -0
  149. package/dist/internal/indicatorPress.stylex.d.ts +20 -0
  150. package/dist/internal/indicatorPress.stylex.d.ts.map +1 -0
  151. package/dist/internal/indicatorPress.stylex.js +26 -0
  152. package/dist/motion/animator.d.ts +14 -14
  153. package/dist/motion/animator.d.ts.map +1 -1
  154. package/dist/motion/animator.js +41 -18
  155. package/dist/motion/reducedMotion.d.ts +24 -0
  156. package/dist/motion/reducedMotion.d.ts.map +1 -1
  157. package/dist/motion/reducedMotion.js +78 -1
  158. package/dist/motion/uikit/alert.d.ts +41 -0
  159. package/dist/motion/uikit/alert.d.ts.map +1 -0
  160. package/dist/motion/uikit/alert.js +43 -0
  161. package/dist/privateAttributes/pressGroupItem.stylex.d.ts +15 -0
  162. package/dist/privateAttributes/pressGroupItem.stylex.d.ts.map +1 -0
  163. package/dist/privateAttributes/pressGroupItem.stylex.js +23 -0
  164. package/dist/utils/clickGuard.d.ts +8 -1
  165. package/dist/utils/clickGuard.d.ts.map +1 -1
  166. package/dist/utils/clickGuard.js +24 -1
  167. package/dist/utils/dateParser.d.ts.map +1 -1
  168. package/dist/utils/dateParser.js +12 -0
  169. package/dist/utils/focusReturn.d.ts +15 -0
  170. package/dist/utils/focusReturn.d.ts.map +1 -1
  171. package/dist/utils/focusReturn.js +28 -0
  172. package/dist/utils/interactionOverlay.stylex.d.ts +3 -2
  173. package/dist/utils/interactionOverlay.stylex.d.ts.map +1 -1
  174. package/dist/utils/interactionOverlay.stylex.js +3 -2
  175. package/dist/utils/pressFeedback.d.ts.map +1 -1
  176. package/dist/utils/pressFeedback.js +19 -6
  177. package/dist/utils/triggerFocus.d.ts +36 -0
  178. package/dist/utils/triggerFocus.d.ts.map +1 -0
  179. package/dist/utils/triggerFocus.js +65 -0
  180. package/locales/en.json +120 -0
  181. package/locales/pseudo.json +90 -0
  182. package/package.json +9 -4
  183. package/src/AlertDialog/AlertDialog.doc.mjs +2 -2
  184. package/src/AlertDialog/AlertDialog.tsx +37 -5
  185. package/src/BottomSheet/BottomSheet.tsx +85 -5
  186. package/src/BottomSheet/BottomSheetPanel.tsx +40 -5
  187. package/src/BottomSheet/bottomSheetStack.ts +8 -4
  188. package/src/BottomSheet/useSheetGestures.ts +3 -1
  189. package/src/Button/Button.tsx +63 -5
  190. package/src/CheckboxInput/CheckboxInput.doc.mjs +18 -0
  191. package/src/CheckboxInput/CheckboxInput.tsx +36 -9
  192. package/src/ComplexSelector/ComplexSelector.tsx +20 -5
  193. package/src/ContextMenu/ContextMenu.tsx +2 -2
  194. package/src/DateInput/MonthScroller.tsx +18 -22
  195. package/src/DateInput/Wheel.tsx +30 -32
  196. package/src/Dialog/Dialog.tsx +10 -31
  197. package/src/Field/Field.tsx +2 -2
  198. package/src/HoverCard/HoverCard.doc.mjs +4 -2
  199. package/src/HoverCard/HoverCard.tsx +35 -4
  200. package/src/HoverCard/useHoverCard.doc.mjs +178 -36
  201. package/src/HoverCard/useHoverCard.tsx +46 -52
  202. package/src/Icon/Icon.doc.mjs +49 -8
  203. package/src/Icon/Icon.tsx +15 -7
  204. package/src/Layer/layerAnimations.stylex.ts +24 -16
  205. package/src/Layer/usePressPin.ts +81 -15
  206. package/src/Lightbox/Lightbox.tsx +18 -0
  207. package/src/PowerSearch/PowerSearch.doc.mjs +54 -5
  208. package/src/PowerSearch/PowerSearch.tsx +734 -288
  209. package/src/PowerSearch/PowerSearchBottomSheet.tsx +2515 -0
  210. package/src/PowerSearch/PowerSearchEditPopover.tsx +28 -6
  211. package/src/PowerSearch/PowerSearchValueEditor.tsx +203 -38
  212. package/src/PowerSearch/formatFilterValue.ts +18 -1
  213. package/src/PowerSearch/formatTokenValue.ts +215 -0
  214. package/src/PowerSearch/powerSearchSheetUtils.ts +224 -0
  215. package/src/RadioList/RadioList.tsx +3 -2
  216. package/src/RadioList/RadioListItem.tsx +5 -3
  217. package/src/SegmentedControl/useSegmentedSelectionMotion.ts +6 -3
  218. package/src/Slider/sliderTouchController.ts +2 -2
  219. package/src/Spinner/Spinner.tsx +77 -63
  220. package/src/Switch/useSwitchTouch.ts +4 -4
  221. package/src/Table/BaseTable.tsx +46 -12
  222. package/src/TextInput/TextInput.tsx +11 -10
  223. package/src/Toast/Toast.doc.mjs +4 -3
  224. package/src/Toast/Toast.tsx +52 -32
  225. package/src/Toast/ToastViewport.tsx +121 -35
  226. package/src/Toast/types.ts +9 -3
  227. package/src/Token/Token.tsx +13 -4
  228. package/src/Token/TokenNamingContext.ts +28 -0
  229. package/src/Tooltip/useTooltip.doc.mjs +24 -0
  230. package/src/Tooltip/useTooltip.tsx +61 -56
  231. package/src/Typeahead/TypeaheadItem.doc.mjs +6 -3
  232. package/src/Typeahead/TypeaheadItem.tsx +2 -1
  233. package/src/animator/createAnimator.doc.mjs +153 -0
  234. package/src/animator/createAnimator.ts +324 -0
  235. package/src/gestures/GestureHost.ts +105 -10
  236. package/src/gestures/PressTracker.ts +4 -3
  237. package/src/gestures/README.md +7 -1
  238. package/src/hooks/actionGroupTracker.ts +20 -5
  239. package/src/hooks/index.ts +2 -0
  240. package/src/hooks/pressGroupContext.ts +21 -0
  241. package/src/hooks/useActionGroupTracking.ts +224 -0
  242. package/src/hooks/useAnimator.doc.mjs +109 -0
  243. package/src/hooks/useAnimator.ts +67 -0
  244. package/src/hooks/useAttachment.ts +1 -1
  245. package/src/hooks/useInputStatusIcon.tsx +3 -2
  246. package/src/hooks/useLongPress.doc.mjs +2 -2
  247. package/src/hooks/useLongPress.ts +136 -15
  248. package/src/hooks/useMenuPress.ts +8 -2
  249. package/src/i18n/generated-english/PowerSearch/PowerSearchBottomSheet.generated.ts +43 -0
  250. package/src/i18n/generated-english/PowerSearch/PowerSearchValueEditor.generated.ts +6 -0
  251. package/src/i18n/generated-english/PowerSearch/formatTokenValue.generated.ts +15 -0
  252. package/src/i18n/generated-locales/en.generated.ts +41 -0
  253. package/src/i18n/generated-locales/pseudo.generated.ts +42 -0
  254. package/src/index.ts +13 -0
  255. package/src/internal/indicatorPress.stylex.ts +24 -0
  256. package/src/motion/README.md +8 -5
  257. package/src/motion/animator.ts +61 -29
  258. package/src/motion/reducedMotion.ts +79 -1
  259. package/src/motion/uikit/alert.ts +43 -0
  260. package/src/privateAttributes/pressGroupItem.stylex.ts +23 -0
  261. package/src/utils/clickGuard.ts +37 -1
  262. package/src/utils/dateParser.ts +16 -0
  263. package/src/utils/focusReturn.ts +30 -0
  264. package/src/utils/interactionOverlay.stylex.ts +3 -2
  265. package/src/utils/pressFeedback.ts +19 -6
  266. package/src/utils/triggerFocus.ts +80 -0
@@ -75,6 +75,7 @@ import {
75
75
  import {useDevWarning} from '../hooks/useDevWarning';
76
76
  import {useScrollLock} from '../hooks/useScrollLock';
77
77
  import {useMergedRefs} from '../hooks/useMergedRefs';
78
+ import {observeMutations} from '../utils/sharedMutationObserver';
78
79
  import {mergeProps} from '../utils/mergeProps';
79
80
  import {LayerDepthProvider} from '../Layer/LayerDepthContext';
80
81
  import {ModalAnnounceProvider} from '../hooks/ModalAnnounceProvider';
@@ -212,6 +213,10 @@ const styles = stylex.create({
212
213
  transitionTimingFunction: 'linear',
213
214
  },
214
215
  },
216
+ // A closing sheet that keeps focus takes no pointer input during its exit.
217
+ exitHoldingFocus: {
218
+ pointerEvents: 'none',
219
+ },
215
220
  positioner: {
216
221
  position: 'absolute',
217
222
  insetInline: 0,
@@ -260,6 +265,24 @@ function stackPositionerStyles(view: BottomSheetStackView) {
260
265
  }
261
266
 
262
267
  /** The `data-sheet-stack` value for a host: only set while sheets stack. */
268
+ /** Stops a click, from any input, before it reaches its target. */
269
+ function ignoreActivation(event: React.MouseEvent): void {
270
+ event.preventDefault();
271
+ event.stopPropagation();
272
+ }
273
+
274
+ /**
275
+ * Stops every key except Tab before it reaches the focused control, so no
276
+ * key handler runs and no text changes (a search field's Enter, a typed
277
+ * letter, Backspace, an arrow that reopens a list). Tab still moves focus.
278
+ */
279
+ function ignoreKeyDuringExit(event: React.KeyboardEvent): void {
280
+ if (event.key !== 'Tab') {
281
+ event.preventDefault();
282
+ event.stopPropagation();
283
+ }
284
+ }
285
+
263
286
  function stackStateAttribute(
264
287
  view: BottomSheetStackView,
265
288
  isOpen: boolean,
@@ -450,10 +473,14 @@ function StandaloneBottomSheet({
450
473
  // `isOpen` restarts the flag during render, and the layout effect below
451
474
  // settles a close.
452
475
  const [isCloseSettled, setIsCloseSettled] = useState(false);
476
+ // Whether focus was inside the sheet when it closed. Such a sheet stays out
477
+ // of `inert` and `aria-hidden` until its exit ends and focus has left.
478
+ const [isExitFocusHeld, setIsExitFocusHeld] = useState(false);
453
479
  const [settledIsOpen, setSettledIsOpen] = useState(isOpen);
454
480
  if (settledIsOpen !== isOpen) {
455
481
  setSettledIsOpen(isOpen);
456
482
  setIsCloseSettled(false);
483
+ setIsExitFocusHeld(false);
457
484
  }
458
485
  const panelState: BottomSheetPanelState = isOpen
459
486
  ? {kind: 'open', entering: stackView.isReplacing}
@@ -581,9 +608,8 @@ function StandaloneBottomSheet({
581
608
  }, [hasScrim, shouldPresent, writeScrim]);
582
609
  useLayoutEffect(() => {
583
610
  if (!isOpen) {
584
- // Read before the browser's focus fixup for the exiting sheet's
585
- // `inert` runs (at the next rendering opportunity), so this is where
586
- // focus was when the person closed the sheet.
611
+ // Where focus was when the person closed the sheet. The closing sheet
612
+ // is not inert yet, so focus has not moved.
587
613
  const dialog = dialogRef.current;
588
614
  hadFocusAtCloseRef.current =
589
615
  dialog != null && dialog.contains(dialog.ownerDocument.activeElement);
@@ -596,6 +622,8 @@ function StandaloneBottomSheet({
596
622
  // that was not paired as a replace is an exit from here on.
597
623
  // eslint-disable-next-line @eslint-react/set-state-in-effect -- settles the close once the registry has answered
598
624
  setIsCloseSettled(true);
625
+ // Read with the close, before the sheet can go inert.
626
+ setIsExitFocusHeld(hadFocusAtCloseRef.current);
599
627
  }
600
628
  }, [hasScrim, isOpen, shouldPresent]);
601
629
 
@@ -699,7 +727,44 @@ function StandaloneBottomSheet({
699
727
  );
700
728
 
701
729
  const {ref: modalityRef} = useInteractionModalityTracking();
702
- const dialogComposedRef = useMergedRefs(dialogRef, modalityRef);
730
+ // Ends the exit hold once focus has left the sheet: moved elsewhere, or
731
+ // dropped to the page because the focused control was removed or disabled.
732
+ // Native listeners, because React drops the `focusout` a browser sends while
733
+ // React removes the focused element, and Firefox and WebKit send no
734
+ // `focusout` for a removed element at all, so a subtree change is read too.
735
+ // Each reads the focus after the event. They attach only while a closing
736
+ // sheet holds focus, so a closed or open sheet observes nothing.
737
+ const isWatchingExitFocus =
738
+ !isOpen && isPresented && !isReplaced && isExitFocusHeld;
739
+ const exitFocusRef = useCallback(
740
+ (dialog: HTMLDialogElement | null) => {
741
+ if (dialog == null || !isWatchingExitFocus) {
742
+ return;
743
+ }
744
+ const releaseIfFocusLeft = () => {
745
+ if (!dialog.contains(dialog.ownerDocument.activeElement)) {
746
+ setIsExitFocusHeld(false);
747
+ }
748
+ };
749
+ const onFocusOut = () => queueMicrotask(releaseIfFocusLeft);
750
+ dialog.addEventListener('focusout', onFocusOut);
751
+ const unobserve = observeMutations(
752
+ dialog,
753
+ {
754
+ childList: true,
755
+ subtree: true,
756
+ attributeFilter: ['disabled', 'hidden', 'inert'],
757
+ },
758
+ releaseIfFocusLeft,
759
+ );
760
+ return () => {
761
+ dialog.removeEventListener('focusout', onFocusOut);
762
+ unobserve();
763
+ };
764
+ },
765
+ [isWatchingExitFocus],
766
+ );
767
+ const dialogComposedRef = useMergedRefs(dialogRef, modalityRef, exitFocusRef);
703
768
  useScrollLock(shouldPresent && hasScrim);
704
769
  useDevWarning(
705
770
  'BottomSheet',
@@ -769,7 +834,16 @@ function StandaloneBottomSheet({
769
834
  // that contains no presented sheet is marked inert and hidden as well, so
770
835
  // the state is explicit; one that does contain the top sheet is left alone,
771
836
  // because inert on it would reach the top sheet too.
772
- const isInert = isExiting || stackView.isBlocked;
837
+ // A sheet that closes with focus inside it is neither inert nor hidden
838
+ // while that focus stays: `inert` would drop the focus to the page, and
839
+ // `aria-hidden` would hide the focused control. When the exit ends, the
840
+ // dialog closes and focus returns in the same task. When focus leaves the
841
+ // sheet first (into a sheet opened above it, for example), the sheet goes
842
+ // inert at once. Until the close settles, no closing sheet is inert, so the
843
+ // first commit of a close never hides focus.
844
+ const isExitHoldingFocus =
845
+ isExiting && !isReplaced && (!isCloseSettled || isExitFocusHeld);
846
+ const isInert = (isExiting && !isExitHoldingFocus) || stackView.isBlocked;
773
847
 
774
848
  return (
775
849
  <dialog
@@ -784,6 +858,7 @@ function StandaloneBottomSheet({
784
858
  hasScrim && styles.scrim,
785
859
  hasScrim && isEntrySettled && styles.scrimSettled,
786
860
  hasScrim && isExiting && styles.scrimClosing,
861
+ isExitHoldingFocus && styles.exitHoldingFocus,
787
862
  !hasScrim && styles.dialogNonModal,
788
863
  )}
789
864
  ref={dialogComposedRef}
@@ -805,6 +880,11 @@ function StandaloneBottomSheet({
805
880
  hasScrim && stackView.depth > 0 ? stackView.depth : undefined
806
881
  }
807
882
  onCancel={handleCancel}
883
+ // A closing sheet that keeps focus takes no activation during its
884
+ // exit: no click and no key but Tab reaches the control that has focus.
885
+ onClickCapture={isExitHoldingFocus ? ignoreActivation : undefined}
886
+ onKeyDownCapture={isExitHoldingFocus ? ignoreKeyDuringExit : undefined}
887
+ onKeyUpCapture={isExitHoldingFocus ? ignoreKeyDuringExit : undefined}
808
888
  // Presses inside the sheet do not reach handlers around where it is
809
889
  // written. The dialog, not the panel, is the root, as in a switcher.
810
890
  {...layerClickBoundary({onClick: handleClick})}>
@@ -296,14 +296,19 @@ const styles = stylex.create({
296
296
  },
297
297
  content: {
298
298
  // Preserve the body's block formatting (including margin/float isolation)
299
- // and its definite percentage-height basis. min-content lets this real
299
+ // and its definite percentage-height basis. fit-content lets this real
300
300
  // observed box grow with async content beyond a fixed-height viewport.
301
301
  // This box is also the sheet's container: it carries the padding and
302
302
  // publishes it, so a lone Section child escapes it exactly as in Dialog.
303
303
  display: 'flow-root',
304
304
  boxSizing: 'border-box',
305
305
  height: '100%',
306
- minHeight: 'min-content',
306
+ // fit-content, not min-content: they are one size in the block axis, but
307
+ // WebKit ignores min-content on a block's min-height. There the box keeps
308
+ // its body's height on a budget-height sheet. Content placed directly in
309
+ // it then overflows the box, and its block-end padding leaves the scroll
310
+ // range.
311
+ minHeight: 'fit-content',
307
312
  },
308
313
  tallKeyboardBody: {
309
314
  scrollPaddingBlockEnd: MOBILE_KEYBOARD_BOTTOM_CLEARANCE,
@@ -413,6 +418,7 @@ function waitForTransition(
413
418
 
414
419
  let done = false;
415
420
  let timer: ReturnType<typeof setTimeout> | null = null;
421
+ let backstopMs = 0;
416
422
  const finish = () => {
417
423
  if (done) {
418
424
  return;
@@ -426,9 +432,24 @@ function waitForTransition(
426
432
  complete();
427
433
  };
428
434
  const handleTransitionEnd = (event: TransitionEvent) => {
429
- if (event.target === element && event.propertyName === propertyName) {
430
- finish();
435
+ if (event.target !== element || event.propertyName !== propertyName) {
436
+ return;
437
+ }
438
+ // The engine cancels a transition whose end value changes mid-flight. A
439
+ // percentage transform does when the closing frame moves the height by a
440
+ // fraction of a pixel. A replacement runs on from where it was. That one
441
+ // ends the motion, not the cancel.
442
+ if (
443
+ event.type === 'transitioncancel' &&
444
+ hasRunningTransition(element, propertyName)
445
+ ) {
446
+ if (timer != null) {
447
+ clearTimeout(timer);
448
+ timer = setTimeout(finish, backstopMs);
449
+ }
450
+ return;
431
451
  }
452
+ finish();
432
453
  };
433
454
  element.addEventListener('transitionend', handleTransitionEnd);
434
455
  element.addEventListener('transitioncancel', handleTransitionEnd);
@@ -479,7 +500,8 @@ function waitForTransition(
479
500
  return () => {};
480
501
  }
481
502
 
482
- timer = setTimeout(finish, transitionMs + TRANSITION_BACKSTOP_BUFFER_MS);
503
+ backstopMs = transitionMs + TRANSITION_BACKSTOP_BUFFER_MS;
504
+ timer = setTimeout(finish, backstopMs);
483
505
  return () => {
484
506
  if (timer != null) {
485
507
  clearTimeout(timer);
@@ -489,6 +511,19 @@ function waitForTransition(
489
511
  };
490
512
  }
491
513
 
514
+ // Whether a CSS transition of `propertyName` still runs on `element`.
515
+ function hasRunningTransition(
516
+ element: HTMLElement,
517
+ propertyName: string,
518
+ ): boolean {
519
+ return (element.getAnimations?.() ?? []).some(
520
+ animation =>
521
+ (animation as Animation & {transitionProperty?: string})
522
+ .transitionProperty === propertyName &&
523
+ (animation.playState === 'running' || animation.playState === 'paused'),
524
+ );
525
+ }
526
+
492
527
  function parseTransitionTime(value: string): number | null {
493
528
  const normalizedValue = value.trim();
494
529
  if (!/^-?(?:\d+|\d*\.\d+)(?:ms|s)$/.test(normalizedValue)) {
@@ -866,12 +866,16 @@ function openModalsOutside(element: Element): HTMLDialogElement[] {
866
866
  if (dialog === element || dialog.contains(element)) {
867
867
  continue;
868
868
  }
869
- // A modal on its way out (a closing sheet keeps its dialog modal through
870
- // the exit animation, inert and hidden) blocks nothing any more: it is
871
- // gone for this decision.
869
+ // A modal on its way out blocks nothing any more: it is gone for this
870
+ // decision. A closing sheet keeps its dialog modal through the exit
871
+ // animation; it is inert and hidden then, unless focus was inside it at
872
+ // the close, so the registry's own record of the close counts too.
872
873
  if (
873
874
  dialog.hasAttribute('inert') ||
874
- dialog.getAttribute('aria-hidden') === 'true'
875
+ dialog.getAttribute('aria-hidden') === 'true' ||
876
+ state.entries.some(
877
+ entry => !entry.isOpen && entry.getElement() === dialog,
878
+ )
875
879
  ) {
876
880
  continue;
877
881
  }
@@ -738,7 +738,9 @@ export function useSheetGestures({
738
738
  }
739
739
  return;
740
740
  }
741
- recordSheetHeight(node.getBoundingClientRect().height);
741
+ // No measure here: reading the height while the sheet mounts forces a
742
+ // layout in the middle of the open. The observer's first report comes
743
+ // after the browser's own layout, before the first paint.
742
744
  const ro = new ResizeObserver(entries => {
743
745
  const entry = entries[0];
744
746
  if (entry) {
@@ -19,6 +19,7 @@
19
19
  */
20
20
 
21
21
  import {
22
+ isValidElement,
22
23
  use,
23
24
  useEffect,
24
25
  useRef,
@@ -44,6 +45,7 @@ import {
44
45
  focusVars,
45
46
  } from '../theme/tokens.stylex';
46
47
  import {Spinner} from '../Spinner/Spinner';
48
+ import {Icon} from '../Icon/Icon';
47
49
  import {VisuallyHidden} from '../VisuallyHidden/VisuallyHidden';
48
50
  import {IconDefaultSizeProvider} from '../Icon/IconDefaultSizeContext';
49
51
  import {iconBoxSizeStyles, type IconSize} from '../Icon/IconSize.stylex';
@@ -51,6 +53,7 @@ import {iconBoxSizeStyles, type IconSize} from '../Icon/IconSize.stylex';
51
53
  import {EDGE_COMP_ATTR} from '../Layout/edgeCompensation.stylex';
52
54
  import {useSize} from '../SizeContext/SizeContext';
53
55
  import {useButtonGroup} from '../ButtonGroup/ButtonGroupContext';
56
+ import {PressGroupContext} from '../hooks/pressGroupContext';
54
57
  import {useInputGroup} from '../InputGroup/InputGroupContext';
55
58
  import {InputGroupInternalContext} from '../InputGroup/InputGroupInternalContext';
56
59
  import {
@@ -75,6 +78,12 @@ import {isRenderable} from '../utils/isRenderable';
75
78
 
76
79
  registerEnglishFallback(english);
77
80
 
81
+ /**
82
+ * Written by the press controller or a group's tracker while a press is
83
+ * believed. Typed `string`, so StyleX reads it as a computed key.
84
+ */
85
+ const PRESSED_ON: string = '[data-astryx-press="on"]';
86
+
78
87
  /**
79
88
  * Base button styles
80
89
  * Pseudo-classes are nested within properties per StyleX recommendation:
@@ -136,7 +145,19 @@ const styles = stylex.create({
136
145
  default: 'scale(0.98)',
137
146
  '@media (pointer: coarse)': 'scale(1)',
138
147
  },
139
- '[data-astryx-press="on"]': 'scale(0.98)',
148
+ [PRESSED_ON]: 'scale(0.98)',
149
+ },
150
+ },
151
+ // A tracked action's own `:active` image paints nothing: the tracker paints
152
+ // the action under the pointer, which need not be the one pressed first.
153
+ trackedOverlay: {
154
+ backgroundSize: {default: null, [`:active:not(${PRESSED_ON})`]: '0 0'},
155
+ },
156
+ // A tracked action's press is painted by its group's tracker alone.
157
+ pressableTracked: {
158
+ transform: {
159
+ default: 'scale(1)',
160
+ [PRESSED_ON]: 'scale(0.98)',
140
161
  },
141
162
  },
142
163
  inactive: {
@@ -145,7 +166,7 @@ const styles = stylex.create({
145
166
  transform: {
146
167
  default: 'none',
147
168
  ':active': 'none',
148
- '[data-astryx-press="on"]': 'none',
169
+ [PRESSED_ON]: 'none',
149
170
  },
150
171
  },
151
172
  disabled: {
@@ -158,7 +179,7 @@ const styles = stylex.create({
158
179
  backgroundImage: {
159
180
  default: 'none',
160
181
  ':active': 'none',
161
- '[data-astryx-press="on"]': 'none',
182
+ [PRESSED_ON]: 'none',
162
183
  },
163
184
  },
164
185
  iconOnly: {
@@ -559,6 +580,21 @@ const groupStyles = stylex.create({
559
580
  },
560
581
  });
561
582
 
583
+ /**
584
+ * Whether `node` adds nothing to a content name: nothing renders, or an Icon
585
+ * with no label. Any other node can add text.
586
+ */
587
+ function addsNoName(node: ReactNode): boolean {
588
+ if (!isRenderable(node)) {
589
+ return true;
590
+ }
591
+ if (!isValidElement<{label?: unknown}>(node) || node.type !== Icon) {
592
+ return false;
593
+ }
594
+ const {label} = node.props;
595
+ return typeof label !== 'string' || label.trim() === '';
596
+ }
597
+
562
598
  /**
563
599
  * A versatile button component with multiple variants.
564
600
  *
@@ -615,6 +651,7 @@ export function Button({
615
651
  const t = useComponentTranslator();
616
652
  const size = useSize(sizeProp, 'md');
617
653
  const buttonGroup = useButtonGroup();
654
+ const isGroupTracked = use(PressGroupContext);
618
655
  const inputGroup = useInputGroup();
619
656
 
620
657
  const [isPending, startTransition] = useTransition();
@@ -743,11 +780,13 @@ export function Button({
743
780
  sizeStyles[size],
744
781
  isIconOnly && styles.iconOnly,
745
782
  interactionOverlayStyles.backgroundImage,
783
+ isGroupTracked && styles.trackedOverlay,
746
784
  buttonDisabled && styles.inactive,
747
785
  isTrueDisabled && styles.disabled,
748
786
  useAriaDisabled && styles.ariaDisabled,
749
787
  renderAsLink && styles.link,
750
- !buttonGroup && styles.pressable,
788
+ !buttonGroup &&
789
+ (isGroupTracked ? styles.pressableTracked : styles.pressable),
751
790
  buttonGroup &&
752
791
  (buttonGroup.orientation === 'horizontal'
753
792
  ? groupStyles.horizontal
@@ -836,10 +875,29 @@ export function Button({
836
875
  (children != null && children !== label);
837
876
  const ariaLabelProp = needsAriaLabel ? {'aria-label': label} : null;
838
877
 
878
+ // The Button's accessible name when props decide it: the derived
879
+ // aria-label, else a caller aria-label, else the label text alone. It is
880
+ // not known here when a caller aria-labelledby names the Button from other
881
+ // elements, or when `icon` or `endContent` can add text to its content
882
+ // name (a count, for example). A decorative Icon in either slot adds none.
883
+ const accessibleName =
884
+ props['aria-labelledby'] != null
885
+ ? undefined
886
+ : needsAriaLabel
887
+ ? label
888
+ : (props['aria-label'] ??
889
+ (addsNoName(icon) && addsNoName(endContent) ? label : undefined));
890
+ // A tooltip that repeats the name adds nothing to the description, so a
891
+ // screen reader does not read the same words twice.
892
+ const isTooltipName =
893
+ tooltip != null &&
894
+ accessibleName != null &&
895
+ tooltip.trim() === accessibleName.trim();
896
+
839
897
  // When a tooltip is attached via the hook, point aria-describedby at the
840
898
  // tooltip content (composing with any consumer-provided value).
841
899
  const describedByProp =
842
- tooltip != null
900
+ tooltip != null && !isTooltipName
843
901
  ? {
844
902
  'aria-describedby':
845
903
  [props['aria-describedby'], tooltipHook.describedBy]
@@ -151,6 +151,15 @@ export const docs = {
151
151
  },
152
152
  {className: 'astryx-checkbox-label'},
153
153
  ],
154
+ vars: [
155
+ {
156
+ name: '--_indicator-press-image',
157
+ description:
158
+ "The row's touch press image, handed down to a first-party indicator that paints its own press. Set from the press paint only while the row itself is pressed; only this row declares it.",
159
+ default: 'none',
160
+ private: true,
161
+ },
162
+ ],
154
163
  },
155
164
  usage: {
156
165
  accessibility: [
@@ -390,6 +399,15 @@ export const docsZh = {
390
399
  },
391
400
  {className: 'astryx-checkbox-label'},
392
401
  ],
402
+ vars: [
403
+ {
404
+ name: '--_indicator-press-image',
405
+ description:
406
+ '复选框行的触摸按下图像,传递给自行绘制按下效果的第一方指示器。仅在该行自身被按下时根据按下涂层设置,只有该行声明它。',
407
+ default: 'none',
408
+ private: true,
409
+ },
410
+ ],
393
411
  },
394
412
  };
395
413
 
@@ -46,6 +46,7 @@ import {useTooltip} from '../Tooltip/useTooltip';
46
46
  import {mergeProps} from '../utils/mergeProps';
47
47
  import {rtlStyles} from '../utils/rtlStyles';
48
48
  import {indicatorScope} from '../Indicator/indicator.markers.stylex';
49
+ import {indicatorPressScope} from '../internal/indicatorPress.stylex';
49
50
  import {interactionOverlayStyles} from '../utils/interactionOverlay.stylex';
50
51
  import {useIndicatorFocusRing} from '../hooks/useIndicatorFocusRing';
51
52
  import {useBusyAnnouncement} from '../hooks/useBusyAnnouncement';
@@ -63,6 +64,11 @@ import english from '../i18n/generated-english/CheckboxInput/CheckboxInput.gener
63
64
 
64
65
  registerEnglishFallback(english);
65
66
 
67
+ /** Written by the touch press controller while the row is pressed. */
68
+ const PRESSED_ON = '[data-astryx-press="on"]';
69
+ /** Written by the touch press controller for the press's release. */
70
+ const PRESSED_FADING = '[data-astryx-press="fading"]';
71
+
66
72
  // The touch press's paint, declared by the shared overlay styles on the
67
73
  // element the controller writes to (`pressedAlpha`) at the press's strength,
68
74
  // 1 while on and 1 → 0 over the release, and inherited resolved by the layer
@@ -78,6 +84,20 @@ const styles = stylex.create({
78
84
  containerLabelHidden: {
79
85
  gap: 0,
80
86
  },
87
+ // An indicator in another package may not name this row's private press
88
+ // attribute. So the row hands its touch press image down to an indicator
89
+ // that paints its own press, but only in its own press states. A pressed
90
+ // ancestor row reaches it no more than it reaches the built-in box. The
91
+ // property has no default: a layered stylesheet ranks a default above the
92
+ // arms. Only this row declares it. A menu item paints its own press, so a
93
+ // mapped indicator there paints none.
94
+ indicatorPressImage: {
95
+ '--_indicator-press-image': {
96
+ default: null,
97
+ [PRESSED_ON]: 'var(--_press-paint-image)',
98
+ [PRESSED_FADING]: 'var(--_press-paint-image)',
99
+ },
100
+ },
81
101
  checkboxWrapper: {
82
102
  position: 'relative',
83
103
  display: 'flex',
@@ -86,15 +106,22 @@ const styles = stylex.create({
86
106
  flexShrink: 0,
87
107
  isolation: 'isolate',
88
108
  },
89
- // The owner paints this layer over the resolved indicator, so a theme
90
- // replacement cannot accidentally drop the component's pressed contract.
109
+ // The owner paints this layer over the resolved indicator, so a plain theme
110
+ // replacement keeps the pressed feedback. An indicator with
111
+ // `indicatorPressScope` paints its own press from what the row hands down,
112
+ // so this layer stays off over it.
91
113
  // Two pointers, two press models (see interactionOverlay.stylex.ts): a
92
114
  // mouse keeps `:active`; under a coarse pointer that arm is dropped and the
93
115
  // touch press controller writes `data-astryx-press` on the row, which this layer
94
116
  // reads off the same scope marker as the hover tint.
95
117
  indicatorPressOverlay: {
96
118
  '::after': {
97
- content: '""',
119
+ // `when.descendant` takes a pseudo-class; `:is(*)` matches any marked
120
+ // indicator.
121
+ content: {
122
+ default: '""',
123
+ [stylex.when.descendant(':is(*)', indicatorPressScope)]: 'none',
124
+ },
98
125
  position: 'absolute',
99
126
  inset: 0,
100
127
  borderRadius: radiusVars['--radius-inner'],
@@ -112,15 +139,14 @@ const styles = stylex.create({
112
139
  // what moves. Coarse pointers only, like the drop above.
113
140
  backgroundImage: {
114
141
  default: null,
115
- [stylex.when.ancestor('[data-astryx-press="on"]', indicatorScope)]: {
142
+ [stylex.when.ancestor(PRESSED_ON, indicatorScope)]: {
143
+ default: null,
144
+ '@media (pointer: coarse)': pressedOverlayImage,
145
+ },
146
+ [stylex.when.ancestor(PRESSED_FADING, indicatorScope)]: {
116
147
  default: null,
117
148
  '@media (pointer: coarse)': pressedOverlayImage,
118
149
  },
119
- [stylex.when.ancestor('[data-astryx-press="fading"]', indicatorScope)]:
120
- {
121
- default: null,
122
- '@media (pointer: coarse)': pressedOverlayImage,
123
- },
124
150
  },
125
151
  },
126
152
  },
@@ -450,6 +476,7 @@ export function CheckboxInput({
450
476
  // Hover and focus reach the checkbox visual through this ancestor
451
477
  // marker rather than props, so the whole row drives it.
452
478
  !isDisabled && indicatorScope,
479
+ !isDisabled && styles.indicatorPressImage,
453
480
  // The touch press's strength and release live on the row the
454
481
  // controller writes to; the layer over the checkbox box inherits
455
482
  // and paints it.
@@ -35,6 +35,7 @@ import {
35
35
  } from '../Field/fieldParts';
36
36
  import {useDevWarning} from '../hooks/useDevWarning';
37
37
  import {mergeRefs} from '../utils/mergeRefs';
38
+ import {isFocusLost} from '../utils/focusReturn';
38
39
  import {useMergedRefs} from '../hooks/useMergedRefs';
39
40
  import {useBusyAnnouncement} from '../hooks/useBusyAnnouncement';
40
41
  import {Field, inputWrapperStyles} from '../Field';
@@ -347,7 +348,10 @@ export interface ComplexSelectorRenderState {
347
348
  export interface ComplexSelectorHandle {
348
349
  /** Open the selector surface. No-op when disabled or already open. */
349
350
  open(): void;
350
- /** Close the selector surface. Restores focus to the trigger. */
351
+ /**
352
+ * Close the selector surface. Focus returns to the trigger only when it
353
+ * would otherwise be lost; a caller-rendered trigger always gets it back.
354
+ */
351
355
  close(): void;
352
356
  /** Toggle the selector surface open or closed. */
353
357
  toggle(): void;
@@ -630,12 +634,22 @@ export function ComplexSelector<Value>({
630
634
  onOpenChange?.(true);
631
635
  }, [onOpenChange]);
632
636
 
637
+ // The popup's content element, read when it closes.
638
+ const popupContentRef = useRef<HTMLElement | null>(null);
639
+ const hasCallerTrigger = renderTrigger != null;
633
640
  const handlePopoverHide = useCallback(() => {
634
- // Focus is restored first so a consumer that moves focus elsewhere from
635
- // the callback wins, instead of being overwritten a line later.
636
- triggerRef.current?.focus();
641
+ const trigger = triggerRef.current;
642
+ // A press that moved focus to another control keeps it there. A caller's
643
+ // control always gets focus back. Focus moves before the callback, so a
644
+ // consumer that moves it from the callback wins.
645
+ if (
646
+ hasCallerTrigger ||
647
+ isFocusLost(popupContentRef.current, trigger?.ownerDocument)
648
+ ) {
649
+ trigger?.focus();
650
+ }
637
651
  onOpenChange?.(false);
638
- }, [onOpenChange]);
652
+ }, [hasCallerTrigger, onOpenChange]);
639
653
 
640
654
  const popover = usePopover({
641
655
  dialogLabel: label,
@@ -645,6 +659,7 @@ export function ComplexSelector<Value>({
645
659
  onShow: handlePopoverShow,
646
660
  onHide: handlePopoverHide,
647
661
  });
662
+ popupContentRef.current = popover.contentRef.current;
648
663
 
649
664
  const isOpen = popover.isOpen;
650
665
 
@@ -712,14 +712,14 @@ export function ContextMenu({
712
712
  );
713
713
  const handleTriggerTouchEnd = useCallback(
714
714
  (event: React.TouchEvent<HTMLElement>) => {
715
- handleLongPressTouchEnd();
715
+ handleLongPressTouchEnd(event);
716
716
  onTouchEndProp?.(event);
717
717
  },
718
718
  [handleLongPressTouchEnd, onTouchEndProp],
719
719
  );
720
720
  const handleTriggerTouchCancel = useCallback(
721
721
  (event: React.TouchEvent<HTMLElement>) => {
722
- handleLongPressTouchCancel();
722
+ handleLongPressTouchCancel(event);
723
723
  onTouchCancelProp?.(event);
724
724
  },
725
725
  [handleLongPressTouchCancel, onTouchCancelProp],