@astryxdesign/core 0.7.7-canary.4a8a76e → 0.7.7-canary.4e495ec

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/AppShell/useCloseMobileDrawerOnActivate.d.ts +13 -0
  2. package/dist/AppShell/useCloseMobileDrawerOnActivate.d.ts.map +1 -0
  3. package/dist/AppShell/useCloseMobileDrawerOnActivate.js +45 -0
  4. package/dist/BottomSheet/BottomSheet.d.ts.map +1 -1
  5. package/dist/BottomSheet/BottomSheet.js +79 -9
  6. package/dist/BottomSheet/BottomSheetPanel.d.ts.map +1 -1
  7. package/dist/BottomSheet/BottomSheetPanel.js +1 -1
  8. package/dist/BottomSheet/bottomSheetStack.d.ts.map +1 -1
  9. package/dist/BottomSheet/bottomSheetStack.js +5 -4
  10. package/dist/Breadcrumbs/BreadcrumbItem.d.ts +2 -1
  11. package/dist/Breadcrumbs/BreadcrumbItem.d.ts.map +1 -1
  12. package/dist/Breadcrumbs/BreadcrumbItem.js +7 -3
  13. package/dist/CheckboxList/CheckboxList.d.ts.map +1 -1
  14. package/dist/CheckboxList/CheckboxList.js +7 -1
  15. package/dist/CheckboxList/CheckboxListContext.d.ts +6 -0
  16. package/dist/CheckboxList/CheckboxListContext.d.ts.map +1 -1
  17. package/dist/CheckboxList/CheckboxListItem.d.ts.map +1 -1
  18. package/dist/CheckboxList/CheckboxListItem.js +10 -2
  19. package/dist/DateInput/DateInput.d.ts.map +1 -1
  20. package/dist/DateInput/DateInput.js +5 -2
  21. package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
  22. package/dist/DateTimeInput/DateTimeInput.js +5 -2
  23. package/dist/Dialog/Dialog.d.ts.map +1 -1
  24. package/dist/Dialog/Dialog.js +16 -14
  25. package/dist/Drawer/Drawer.d.ts.map +1 -1
  26. package/dist/Drawer/Drawer.js +15 -11
  27. package/dist/Drawer/useDrawerDialogPresence.d.ts.map +1 -1
  28. package/dist/Drawer/useDrawerDialogPresence.js +5 -1
  29. package/dist/Field/PanelSearchInput.d.ts +9 -1
  30. package/dist/Field/PanelSearchInput.d.ts.map +1 -1
  31. package/dist/Field/PanelSearchInput.js +3 -1
  32. package/dist/InfoTip/InfoTip.d.ts +2 -2
  33. package/dist/InfoTip/InfoTip.js +4 -4
  34. package/dist/Layer/layerAnimations.stylex.d.ts +7 -8
  35. package/dist/Layer/layerAnimations.stylex.d.ts.map +1 -1
  36. package/dist/Layer/layerAnimations.stylex.js +25 -20
  37. package/dist/Layer/useBackdropPress.d.ts +27 -0
  38. package/dist/Layer/useBackdropPress.d.ts.map +1 -0
  39. package/dist/Layer/useBackdropPress.js +53 -0
  40. package/dist/Layer/usePressPin.d.ts +3 -5
  41. package/dist/Layer/usePressPin.d.ts.map +1 -1
  42. package/dist/Layer/usePressPin.js +4 -6
  43. package/dist/MobileNav/MobileNav.js +0 -1
  44. package/dist/MultiSelector/hooks.d.ts.map +1 -1
  45. package/dist/MultiSelector/hooks.js +2 -4
  46. package/dist/Outline/Outline.d.ts +2 -4
  47. package/dist/Outline/Outline.d.ts.map +1 -1
  48. package/dist/Outline/Outline.js +4 -52
  49. package/dist/Outline/useOutlineFromMarkdown.d.ts.map +1 -1
  50. package/dist/Outline/useOutlineFromMarkdown.js +1 -0
  51. package/dist/Outline/useScrollSpy.js +3 -3
  52. package/dist/PowerSearch/PowerSearch.d.ts.map +1 -1
  53. package/dist/PowerSearch/PowerSearch.js +134 -179
  54. package/dist/RadioList/RadioList.d.ts.map +1 -1
  55. package/dist/RadioList/RadioList.js +5 -0
  56. package/dist/Resizable/ResizeHandle.d.ts.map +1 -1
  57. package/dist/Resizable/ResizeHandle.js +118 -260
  58. package/dist/Resizable/resizeGesture.d.ts +91 -0
  59. package/dist/Resizable/resizeGesture.d.ts.map +1 -0
  60. package/dist/Resizable/resizeGesture.js +251 -0
  61. package/dist/SegmentedControl/SegmentedControl.d.ts +3 -1
  62. package/dist/SegmentedControl/SegmentedControl.d.ts.map +1 -1
  63. package/dist/SegmentedControl/SegmentedControl.js +115 -8
  64. package/dist/SegmentedControl/SegmentedControlContext.d.ts +6 -0
  65. package/dist/SegmentedControl/SegmentedControlContext.d.ts.map +1 -1
  66. package/dist/SegmentedControl/SegmentedControlItem.d.ts +1 -0
  67. package/dist/SegmentedControl/SegmentedControlItem.d.ts.map +1 -1
  68. package/dist/SegmentedControl/SegmentedControlItem.js +8 -1
  69. package/dist/SegmentedControl/useSegmentedSelectionMotion.d.ts +357 -0
  70. package/dist/SegmentedControl/useSegmentedSelectionMotion.d.ts.map +1 -0
  71. package/dist/SegmentedControl/useSegmentedSelectionMotion.js +1349 -0
  72. package/dist/SegmentedControl/useSegmentedTouchTracking.d.ts +70 -0
  73. package/dist/SegmentedControl/useSegmentedTouchTracking.d.ts.map +1 -0
  74. package/dist/SegmentedControl/useSegmentedTouchTracking.js +264 -0
  75. package/dist/Selector/Selector.d.ts.map +1 -1
  76. package/dist/Selector/Selector.js +96 -67
  77. package/dist/Selector/optionShape.d.ts +24 -0
  78. package/dist/Selector/optionShape.d.ts.map +1 -0
  79. package/dist/Selector/optionShape.js +48 -0
  80. package/dist/Selector/selectableOptionEntries.d.ts +20 -0
  81. package/dist/Selector/selectableOptionEntries.d.ts.map +1 -0
  82. package/dist/Selector/selectableOptionEntries.js +38 -0
  83. package/dist/Selector/utils.d.ts +2 -17
  84. package/dist/Selector/utils.d.ts.map +1 -1
  85. package/dist/Selector/utils.js +3 -51
  86. package/dist/SideNav/SideNavItem.d.ts.map +1 -1
  87. package/dist/SideNav/SideNavItem.js +3 -9
  88. package/dist/Switch/switchTouch.d.ts +0 -7
  89. package/dist/Switch/switchTouch.d.ts.map +1 -1
  90. package/dist/Switch/switchTouch.js +2 -10
  91. package/dist/Switch/useSwitchTouch.d.ts +2 -11
  92. package/dist/Switch/useSwitchTouch.d.ts.map +1 -1
  93. package/dist/Switch/useSwitchTouch.js +26 -85
  94. package/dist/Token/Token.d.ts +2 -2
  95. package/dist/Token/Token.d.ts.map +1 -1
  96. package/dist/Token/Token.js +12 -4
  97. package/dist/Token/TokenNamingContext.d.ts +14 -0
  98. package/dist/Token/TokenNamingContext.d.ts.map +1 -0
  99. package/dist/Token/TokenNamingContext.js +23 -0
  100. package/dist/Tokenizer/Tokenizer.d.ts +46 -59
  101. package/dist/Tokenizer/Tokenizer.d.ts.map +1 -1
  102. package/dist/Tokenizer/Tokenizer.js +35 -58
  103. package/dist/Tokenizer/TokenizerBottomSheet.d.ts +25 -28
  104. package/dist/Tokenizer/TokenizerBottomSheet.d.ts.map +1 -1
  105. package/dist/Tokenizer/TokenizerBottomSheet.js +157 -54
  106. package/dist/Tokenizer/TokenizerItemRemovableContext.d.ts.map +1 -1
  107. package/dist/Tokenizer/TokenizerItemRemovableContext.js +5 -3
  108. package/dist/Tooltip/Tooltip.d.ts +25 -30
  109. package/dist/Tooltip/Tooltip.d.ts.map +1 -1
  110. package/dist/Tooltip/Tooltip.js +24 -5
  111. package/dist/Tooltip/index.d.ts +1 -1
  112. package/dist/Tooltip/index.d.ts.map +1 -1
  113. package/dist/Tooltip/useTooltip.d.ts +28 -29
  114. package/dist/Tooltip/useTooltip.d.ts.map +1 -1
  115. package/dist/Tooltip/useTooltip.js +20 -8
  116. package/dist/TopNav/TopNavItem.d.ts.map +1 -1
  117. package/dist/TopNav/TopNavItem.js +4 -7
  118. package/dist/TopNav/TopNavMegaMenuItem.d.ts.map +1 -1
  119. package/dist/TopNav/TopNavMegaMenuItem.js +4 -7
  120. package/dist/TopNav/TopNavMenu.d.ts.map +1 -1
  121. package/dist/TopNav/TopNavMenu.js +4 -7
  122. package/dist/astryx.css +76 -5
  123. package/dist/gestures/GestureHost.d.ts.map +1 -1
  124. package/dist/gestures/GestureHost.js +53 -5
  125. package/dist/hooks/actionGroupTracker.d.ts +3 -3
  126. package/dist/hooks/actionGroupTracker.d.ts.map +1 -1
  127. package/dist/hooks/actionGroupTracker.js +6 -53
  128. package/dist/hooks/useHighlightedOptionScroll.d.ts +11 -1
  129. package/dist/hooks/useHighlightedOptionScroll.d.ts.map +1 -1
  130. package/dist/hooks/useHighlightedOptionScroll.js +112 -11
  131. package/dist/hooks/useMediaQuery.d.ts +2 -1
  132. package/dist/hooks/useMediaQuery.d.ts.map +1 -1
  133. package/dist/hooks/useMediaQuery.js +7 -4
  134. package/dist/hooks/useMenuPress.d.ts.map +1 -1
  135. package/dist/hooks/useMenuPress.js +4 -2
  136. package/dist/i18n/generated-english/PowerSearch/PowerSearch.generated.d.ts.map +1 -1
  137. package/dist/i18n/generated-english/PowerSearch/PowerSearch.generated.js +1 -0
  138. package/dist/i18n/generated-english/Tokenizer/TokenizerBottomSheet.generated.d.ts.map +1 -1
  139. package/dist/i18n/generated-english/Tokenizer/TokenizerBottomSheet.generated.js +7 -1
  140. package/dist/i18n/generated-locales/en.generated.d.ts.map +1 -1
  141. package/dist/i18n/generated-locales/en.generated.js +1 -0
  142. package/dist/i18n/generated-locales/pseudo.generated.d.ts.map +1 -1
  143. package/dist/i18n/generated-locales/pseudo.generated.js +1 -0
  144. package/dist/internal/SheetConfirmation.d.ts +11 -0
  145. package/dist/internal/SheetConfirmation.d.ts.map +1 -0
  146. package/dist/internal/SheetConfirmation.js +92 -0
  147. package/dist/internal/sheetDraft.d.ts +35 -0
  148. package/dist/internal/sheetDraft.d.ts.map +1 -0
  149. package/dist/internal/sheetDraft.js +65 -0
  150. package/dist/internal/touch-sheet.d.ts +21 -0
  151. package/dist/internal/touch-sheet.d.ts.map +1 -0
  152. package/dist/internal/touch-sheet.js +19 -0
  153. package/dist/internal/useDismissSearchOnTouchDrag.d.ts +24 -0
  154. package/dist/internal/useDismissSearchOnTouchDrag.d.ts.map +1 -0
  155. package/dist/internal/useDismissSearchOnTouchDrag.js +56 -0
  156. package/dist/motion/roles.d.ts +7 -1
  157. package/dist/motion/roles.d.ts.map +1 -1
  158. package/dist/motion/roles.js +20 -1
  159. package/dist/motion/uikit/index.d.ts +6 -5
  160. package/dist/motion/uikit/index.d.ts.map +1 -1
  161. package/dist/motion/uikit/index.js +6 -5
  162. package/dist/motion/uikit/segmented.d.ts +55 -0
  163. package/dist/motion/uikit/segmented.d.ts.map +1 -0
  164. package/dist/motion/uikit/segmented.js +77 -0
  165. package/dist/motion/uikit/springs.d.ts +0 -18
  166. package/dist/motion/uikit/springs.d.ts.map +1 -1
  167. package/dist/motion/uikit/springs.js +5 -21
  168. package/dist/privateAttributes/segmentTitle.stylex.d.ts +5 -0
  169. package/dist/privateAttributes/segmentTitle.stylex.d.ts.map +1 -0
  170. package/dist/privateAttributes/segmentTitle.stylex.js +22 -0
  171. package/dist/utils/browserClickWait.d.ts +35 -0
  172. package/dist/utils/browserClickWait.d.ts.map +1 -0
  173. package/dist/utils/browserClickWait.js +123 -0
  174. package/dist/utils/clickGuard.d.ts +8 -1
  175. package/dist/utils/clickGuard.d.ts.map +1 -1
  176. package/dist/utils/clickGuard.js +24 -1
  177. package/locales/en.json +4 -0
  178. package/locales/pseudo.json +3 -0
  179. package/package.json +8 -8
  180. package/src/AppShell/useCloseMobileDrawerOnActivate.ts +52 -0
  181. package/src/BottomSheet/BottomSheet.tsx +95 -10
  182. package/src/BottomSheet/BottomSheetPanel.tsx +7 -2
  183. package/src/BottomSheet/bottomSheetStack.ts +8 -4
  184. package/src/Breadcrumbs/BreadcrumbItem.tsx +13 -3
  185. package/src/CheckboxList/CheckboxList.doc.mjs +4 -2
  186. package/src/CheckboxList/CheckboxList.tsx +6 -0
  187. package/src/CheckboxList/CheckboxListContext.tsx +6 -0
  188. package/src/CheckboxList/CheckboxListItem.tsx +11 -2
  189. package/src/DateInput/DateInput.tsx +8 -2
  190. package/src/DateTimeInput/DateTimeInput.tsx +8 -2
  191. package/src/Dialog/Dialog.tsx +44 -14
  192. package/src/Drawer/Drawer.tsx +28 -11
  193. package/src/Drawer/useDrawerDialogPresence.ts +5 -1
  194. package/src/Field/PanelSearchInput.tsx +11 -0
  195. package/src/InfoTip/InfoTip.doc.mjs +3 -3
  196. package/src/InfoTip/InfoTip.tsx +5 -5
  197. package/src/Layer/layerAnimations.stylex.ts +24 -16
  198. package/src/Layer/useBackdropPress.ts +81 -0
  199. package/src/Layer/usePressPin.ts +5 -11
  200. package/src/MobileNav/MobileNav.tsx +1 -1
  201. package/src/MultiSelector/hooks.ts +2 -4
  202. package/src/Outline/Outline.doc.mjs +177 -51
  203. package/src/Outline/Outline.tsx +5 -57
  204. package/src/Outline/useOutlineFromMarkdown.doc.mjs +157 -0
  205. package/src/Outline/useOutlineFromMarkdown.ts +1 -0
  206. package/src/Outline/useScrollSpy.ts +3 -3
  207. package/src/PowerSearch/PowerSearch.doc.mjs +3 -3
  208. package/src/PowerSearch/PowerSearch.tsx +196 -183
  209. package/src/RadioList/RadioList.doc.mjs +4 -2
  210. package/src/RadioList/RadioList.tsx +4 -0
  211. package/src/Resizable/ResizeHandle.tsx +133 -328
  212. package/src/Resizable/resizeGesture.ts +368 -0
  213. package/src/SegmentedControl/SegmentedControl.doc.mjs +3 -3
  214. package/src/SegmentedControl/SegmentedControl.tsx +179 -5
  215. package/src/SegmentedControl/SegmentedControlContext.ts +6 -0
  216. package/src/SegmentedControl/SegmentedControlItem.tsx +12 -1
  217. package/src/SegmentedControl/useSegmentedSelectionMotion.ts +1637 -0
  218. package/src/SegmentedControl/useSegmentedTouchTracking.ts +308 -0
  219. package/src/Selector/Selector.tsx +136 -89
  220. package/src/Selector/optionShape.ts +64 -0
  221. package/src/Selector/selectableOptionEntries.ts +39 -0
  222. package/src/Selector/utils.ts +9 -68
  223. package/src/SideNav/SideNavItem.tsx +3 -7
  224. package/src/Switch/switchTouch.ts +2 -10
  225. package/src/Switch/useSwitchTouch.ts +26 -96
  226. package/src/Token/Token.tsx +13 -4
  227. package/src/Token/TokenNamingContext.ts +28 -0
  228. package/src/Tokenizer/Tokenizer.doc.mjs +16 -0
  229. package/src/Tokenizer/Tokenizer.tsx +81 -86
  230. package/src/Tokenizer/TokenizerBottomSheet.tsx +219 -93
  231. package/src/Tokenizer/TokenizerItemRemovableContext.ts +5 -3
  232. package/src/Tooltip/Tooltip.doc.mjs +123 -54
  233. package/src/Tooltip/Tooltip.tsx +36 -39
  234. package/src/Tooltip/index.ts +0 -1
  235. package/src/Tooltip/useTooltip.doc.mjs +147 -33
  236. package/src/Tooltip/useTooltip.tsx +39 -32
  237. package/src/TopNav/TopNavItem.tsx +6 -5
  238. package/src/TopNav/TopNavMegaMenuItem.tsx +6 -5
  239. package/src/TopNav/TopNavMenu.tsx +6 -5
  240. package/src/gestures/GestureHost.ts +73 -4
  241. package/src/hooks/actionGroupTracker.ts +12 -56
  242. package/src/hooks/useHighlightedOptionScroll.ts +123 -15
  243. package/src/hooks/useMediaQuery.doc.mjs +2 -2
  244. package/src/hooks/useMediaQuery.ts +13 -4
  245. package/src/hooks/useMenuPress.ts +8 -2
  246. package/src/i18n/generated-english/PowerSearch/PowerSearch.generated.ts +1 -0
  247. package/src/i18n/generated-english/Tokenizer/TokenizerBottomSheet.generated.ts +7 -1
  248. package/src/i18n/generated-locales/en.generated.ts +1 -0
  249. package/src/i18n/generated-locales/pseudo.generated.ts +1 -0
  250. package/src/internal/SheetConfirmation.tsx +101 -0
  251. package/src/internal/sheetDraft.ts +80 -0
  252. package/src/internal/touch-sheet.ts +21 -0
  253. package/src/internal/useDismissSearchOnTouchDrag.ts +69 -0
  254. package/src/motion/MEASUREMENTS.md +7 -0
  255. package/src/motion/README.md +1 -0
  256. package/src/motion/roles.ts +20 -1
  257. package/src/motion/uikit/index.ts +5 -5
  258. package/src/motion/uikit/segmented.ts +74 -0
  259. package/src/motion/uikit/springs.ts +5 -21
  260. package/src/privateAttributes/segmentTitle.stylex.ts +22 -0
  261. package/src/utils/browserClickWait.ts +133 -0
  262. package/src/utils/clickGuard.ts +37 -1
  263. package/dist/internal/tokenizer-presentation.d.ts +0 -21
  264. package/dist/internal/tokenizer-presentation.d.ts.map +0 -1
  265. package/dist/internal/tokenizer-presentation.js +0 -21
  266. package/src/internal/tokenizer-presentation.ts +0 -25
@@ -5,7 +5,8 @@
5
5
  /**
6
6
  * @file useTooltip.tsx
7
7
  * @input Uses useLayer, usePressPin, React hooks
8
- * @output Exports useTooltip hook with stable trigger refs for hover/focus/tap tooltips
8
+ * @output Exports useTooltip hook with stable trigger refs for hover/focus/tap tooltips,
9
+ * and the module-only useTooltipWithTriggerBehavior that InfoTip's press-to-pin uses
9
10
  * @position Layer hook; builds on useLayer for tooltip behavior
10
11
  *
11
12
  * SYNC: When modified, update:
@@ -31,7 +32,7 @@ import {useAnnounceRenderedText} from '../hooks/useAnnounceRenderedText';
31
32
  import {useMergedRefs} from '../hooks/useMergedRefs';
32
33
  import {layerAnimations} from '../Layer/layerAnimations.stylex';
33
34
  import {registerLayerSurface} from '../Layer/layerSurfacePress';
34
- import {usePressPin} from '../Layer/usePressPin';
35
+ import {usePressPin, type LayerTriggerBehavior} from '../Layer/usePressPin';
35
36
  import {useLayerDismissal} from '../Layer/useLayerDismissal';
36
37
  import {themeProps} from '../utils/themeProps';
37
38
  import {
@@ -83,14 +84,6 @@ export type TooltipFocusTrigger = 'auto' | 'always' | 'never';
83
84
  */
84
85
  export type TooltipTouchTrigger = LayerTouchTrigger;
85
86
 
86
- /**
87
- * How the trigger opens the tooltip. `adaptive`, the default, previews it on
88
- * hover and focus, and a tap opens it unless the trigger acts. `hover`
89
- * previews it on hover and focus, and no touch or pen tap opens it. `toggle`
90
- * pins it on a click, tap, Enter, or Space on a button trigger.
91
- */
92
- export type TooltipTriggerBehavior = 'adaptive' | 'hover' | 'toggle';
93
-
94
87
  export interface TooltipOptions {
95
88
  /**
96
89
  * Position placement relative to anchor
@@ -135,32 +128,10 @@ export interface TooltipOptions {
135
128
  * - `tap`: tap always opens the tooltip, even on a trigger that acts
136
129
  * - `none`: touch never opens the tooltip
137
130
  *
138
- * It decides only under `triggerBehavior="adaptive"`, the default.
139
- *
140
131
  * @default 'auto'
141
132
  */
142
133
  touchTrigger?: TooltipTouchTrigger;
143
134
 
144
- /**
145
- * How the trigger opens the tooltip:
146
- * - `adaptive` (the default): hover and focus preview it. A tap opens it
147
- * unless the trigger performs an action of its own. While `touchTrigger`
148
- * remains, it decides those taps.
149
- * - `hover`: hover and focus preview it; no touch or pen tap opens it.
150
- * - `toggle`: on a button trigger, a click, a tap, Enter, or Space pins the
151
- * tooltip open, and the same press closes it. Hover and keyboard focus
152
- * still preview it. A pinned tooltip stays until Escape, a second press,
153
- * a press outside, or focus leaving. Astryx clickable rows and cards
154
- * ignore the pinning press, except an Item row on a trigger that is not
155
- * a native button. The `Tooltip` component also stops it at a child
156
- * trigger. A hook caller stops a plain `onClick` ancestor itself. The
157
- * trigger reports `aria-expanded` and `aria-controls`, and the tooltip's
158
- * text is announced once when it pins. `touchTrigger` is ignored.
159
- *
160
- * @default 'adaptive'
161
- */
162
- triggerBehavior?: TooltipTriggerBehavior;
163
-
164
135
  /**
165
136
  * Whether the tooltip is enabled.
166
137
  * When false, hover/focus triggers are disabled.
@@ -296,6 +267,42 @@ function isFocusable(element: HTMLElement): boolean {
296
267
  */
297
268
 
298
269
  export function useTooltip(options: TooltipOptions = {}): TooltipReturn {
270
+ // The option is fixed to `adaptive`, so an options object that carries it at
271
+ // runtime cannot reach the press-to-pin path.
272
+ return useTooltipWithTriggerBehavior({
273
+ ...options,
274
+ triggerBehavior: 'adaptive',
275
+ });
276
+ }
277
+
278
+ /**
279
+ * Module-only options for `useTooltipWithTriggerBehavior`. Not exported from
280
+ * the package.
281
+ */
282
+ export interface TooltipWithTriggerBehaviorOptions extends TooltipOptions {
283
+ /**
284
+ * How the trigger opens the tooltip:
285
+ * - `adaptive` (the default): hover and focus preview it. `touchTrigger`
286
+ * decides taps.
287
+ * - `toggle`: on a button trigger, a click, a tap, Enter, or Space pins the
288
+ * tooltip open, and the same press closes it. Hover and keyboard focus
289
+ * still preview it. A pinned tooltip stays until Escape, a second press,
290
+ * a press outside, or focus leaving. The trigger reports `aria-expanded`
291
+ * and `aria-controls`, and the tooltip's text is announced once when it
292
+ * pins. `touchTrigger` is ignored. InfoTip uses it.
293
+ *
294
+ * @default 'adaptive'
295
+ */
296
+ triggerBehavior?: LayerTriggerBehavior;
297
+ }
298
+
299
+ /**
300
+ * `useTooltip` with a trigger behavior. Module-only: InfoTip's press-to-pin
301
+ * goes through it, and the package does not export it.
302
+ */
303
+ export function useTooltipWithTriggerBehavior(
304
+ options: TooltipWithTriggerBehaviorOptions = {},
305
+ ): TooltipReturn {
299
306
  const {
300
307
  placement = 'above',
301
308
  alignment = 'center',
@@ -33,7 +33,7 @@ import type {LinkComponentType} from '../Link/types';
33
33
  import {useTopNavRenderMode} from './TopNavRenderContext';
34
34
  import {navItemStyles, type NavItemSize} from '../NavItem/navItemStyles.stylex';
35
35
  import {mergeProps} from '../utils';
36
- import {useAppShellMobile} from '../AppShell/AppShellMobileContext';
36
+ import {useCloseMobileDrawerOnActivate} from '../AppShell/useCloseMobileDrawerOnActivate';
37
37
  import {themeProps} from '../utils/themeProps';
38
38
  import {focusOutlineProps} from '../utils/focusOutline.stylex';
39
39
  import {interactionOverlayStyles} from '../utils/interactionOverlay.stylex';
@@ -199,7 +199,9 @@ export function TopNavItem({
199
199
  const pressable = usePressFeedback();
200
200
  const LinkComponent = useLinkComponent(as);
201
201
  const renderMode = useTopNavRenderMode();
202
- const {closeMobileNav} = useAppShellMobile();
202
+ const closeDrawerAfter = useCloseMobileDrawerOnActivate(
203
+ renderMode === 'drawer',
204
+ );
203
205
 
204
206
  // A disabled item renders as a plain <a> with no href: an href-less anchor
205
207
  // is not focusable and exposes no link affordance, so programmatic focus +
@@ -214,14 +216,13 @@ export function TopNavItem({
214
216
  // Drawer mode — render as a SideNavItem-style vertical list element
215
217
  // =========================================================================
216
218
  if (renderMode === 'drawer') {
219
+ const activate = closeDrawerAfter(onClick);
217
220
  const handleDrawerClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
218
221
  if (isDisabled) {
219
222
  preventDefaultClick(e);
220
223
  return;
221
224
  }
222
- // Forward the original onClick if present
223
- onClick?.(e);
224
- closeMobileNav();
225
+ activate?.(e);
225
226
  };
226
227
 
227
228
  return (
@@ -32,7 +32,7 @@ import type {LinkComponentType} from '../Link/types';
32
32
  import {useTopNavRenderMode} from './TopNavRenderContext';
33
33
  import {mergeProps} from '../utils';
34
34
  import type {BaseProps} from '../BaseProps';
35
- import {useAppShellMobile} from '../AppShell/AppShellMobileContext';
35
+ import {useCloseMobileDrawerOnActivate} from '../AppShell/useCloseMobileDrawerOnActivate';
36
36
  import {themeProps} from '../utils/themeProps';
37
37
  import {focusOutlineProps} from '../utils/focusOutline.stylex';
38
38
  import {interactionOverlayStyles} from '../utils/interactionOverlay.stylex';
@@ -200,7 +200,9 @@ export function TopNavMegaMenuItem({
200
200
  const pressable = usePressFeedback();
201
201
  const renderMode = useTopNavRenderMode();
202
202
  const LinkComponent = useLinkComponent(as);
203
- const {closeMobileNav} = useAppShellMobile();
203
+ const closeDrawerAfter = useCloseMobileDrawerOnActivate(
204
+ renderMode === 'drawer',
205
+ );
204
206
 
205
207
  // =========================================================================
206
208
  // Drawer mode — matches SideNavItem / TopNavMenu drawer appearance
@@ -208,10 +210,9 @@ export function TopNavMegaMenuItem({
208
210
  if (renderMode === 'drawer') {
209
211
  const Element = href ? LinkComponent : 'button';
210
212
  const elementProps = Element === 'button' ? {type: 'button' as const} : {};
211
- const handleDrawerClick = () => {
213
+ const handleDrawerClick = closeDrawerAfter(() => {
212
214
  onClick?.();
213
- closeMobileNav();
214
- };
215
+ });
215
216
  return (
216
217
  <Element
217
218
  {...restProps}
@@ -39,7 +39,7 @@ import type {BaseProps} from '../BaseProps';
39
39
  import {navItemStyles} from '../NavItem/navItemStyles.stylex';
40
40
  import {useTopNavSlot} from './TopNavContext';
41
41
  import {useTopNavRenderMode} from './TopNavRenderContext';
42
- import {useAppShellMobile} from '../AppShell/AppShellMobileContext';
42
+ import {useCloseMobileDrawerOnActivate} from '../AppShell/useCloseMobileDrawerOnActivate';
43
43
  import {useLinkComponent} from '../Link/useLinkComponent';
44
44
  import {isSafeUrl} from '../utils/safeUrl';
45
45
  import {themeProps} from '../utils/themeProps';
@@ -363,7 +363,9 @@ export function TopNavMenu({
363
363
  }: TopNavMenuProps) {
364
364
  const pressable = usePressFeedback();
365
365
  const renderMode = useTopNavRenderMode();
366
- const {closeMobileNav} = useAppShellMobile();
366
+ const closeDrawerAfter = useCloseMobileDrawerOnActivate(
367
+ renderMode === 'drawer',
368
+ );
367
369
  const LinkComponent = useLinkComponent();
368
370
  const [drawerExpanded, setDrawerExpanded] = useState(false);
369
371
  const menuId = useId();
@@ -524,10 +526,9 @@ export function TopNavMenu({
524
526
  key={getMenuItemKey(item)}
525
527
  href={item.href}
526
528
  {...pressable}
527
- onClick={(_e: React.MouseEvent) => {
529
+ onClick={closeDrawerAfter((_e: React.MouseEvent) => {
528
530
  item.onClick?.();
529
- closeMobileNav();
530
- }}
531
+ })}
531
532
  {...focusOutlineProps.focusVisible(
532
533
  navItemStyles.item,
533
534
  interactionOverlayStyles.backgroundColor,
@@ -475,6 +475,16 @@ interface WheelFollow {
475
475
  /** The fingers' speed at that move, content px per ms. */
476
476
  speedX: number;
477
477
  speedY: number;
478
+ /**
479
+ * The reading behind that speed: the fingers' travel where it began, and
480
+ * its interval in ms.
481
+ */
482
+ readFromX: number;
483
+ readFromY: number;
484
+ readSpan: number;
485
+ /** The fingers' speed before that reading, content px per ms. */
486
+ priorSpeedX: number;
487
+ priorSpeedY: number;
478
488
  }
479
489
 
480
490
  /**
@@ -503,6 +513,39 @@ function absorbedDebt(debt: number, moved: number): number {
503
513
  return Math.sign(debt) * Math.max(0, Math.abs(debt) - Math.abs(moved) / 2);
504
514
  }
505
515
 
516
+ /**
517
+ * A stall held a bunched wheel's frames back, and the move just before it took
518
+ * their time. So the wheel joins that move's reading. It can restore what the
519
+ * late move lowered, but never passes the speed before it.
520
+ */
521
+ function joinReading(f: WheelFollow, trueX: number, trueY: number): void {
522
+ if (f.readSpan <= 0) {
523
+ return;
524
+ }
525
+ f.speedX = joinedSpeed(
526
+ f.speedX,
527
+ (trueX - f.readFromX) / f.readSpan,
528
+ f.priorSpeedX,
529
+ );
530
+ f.speedY = joinedSpeed(
531
+ f.speedY,
532
+ (trueY - f.readFromY) / f.readSpan,
533
+ f.priorSpeedY,
534
+ );
535
+ }
536
+
537
+ /**
538
+ * A joined reading, held to the speed before the late move. It only ever
539
+ * raises the current speed, in the current direction.
540
+ */
541
+ function joinedSpeed(current: number, reading: number, prior: number): number {
542
+ const held =
543
+ Math.sign(reading) * Math.min(Math.abs(reading), Math.abs(prior));
544
+ return held * current >= 0 && Math.abs(held) > Math.abs(current)
545
+ ? held
546
+ : current;
547
+ }
548
+
506
549
  /**
507
550
  * `target` where it is ahead of `shown` in the direction of `speed`, else
508
551
  * `shown`: a reconcile that never steps back.
@@ -2276,9 +2319,22 @@ export class GestureHost {
2276
2319
  }
2277
2320
  if (this.wheelSegment?.decaying === true) {
2278
2321
  // Undecided: the real deltas wait for the splitter while the
2279
- // fingers' last speed carries the pan on.
2322
+ // fingers' last speed carries the pan on. A held wheel with its own
2323
+ // frame ends the reading a stall's bunch joins.
2280
2324
  f.heldX += dx;
2281
2325
  f.heldY += dy;
2326
+ if (this.wheelSegment.bunched) {
2327
+ // A raised speed moves on from where the pan is shown, so the
2328
+ // position never jumps inside the bunch.
2329
+ const since = time - f.lastMove;
2330
+ const speedX = f.speedX;
2331
+ const speedY = f.speedY;
2332
+ joinReading(f, f.trueX - f.heldX, f.trueY - f.heldY);
2333
+ f.baseX += (speedX - f.speedX) * since;
2334
+ f.baseY += (speedY - f.speedY) * since;
2335
+ } else {
2336
+ f.readSpan = 0;
2337
+ }
2282
2338
  this.followProvisionally(gesture, f, time);
2283
2339
  return;
2284
2340
  }
@@ -2291,10 +2347,18 @@ export class GestureHost {
2291
2347
  const debtY = absorbedDebt(gesture.y - beforeY, dy);
2292
2348
  const trueX = beforeX - dx;
2293
2349
  const trueY = beforeY - dy;
2294
- // A wheel the splitter reads as one stall's merge sets no speed. Its
2295
- // speed is one the fingers never had.
2350
+ // A wheel the splitter reads as one stall's merge has no speed of its
2351
+ // own. It joins the reading of the move before its bunch, whose
2352
+ // interval holds its time.
2296
2353
  const elapsed = time - f.lastMove;
2297
- if (elapsed > 0 && this.wheelSegment?.bunched !== true) {
2354
+ if (this.wheelSegment?.bunched === true) {
2355
+ joinReading(f, trueX, trueY);
2356
+ } else if (elapsed > 0) {
2357
+ f.priorSpeedX = f.speedX;
2358
+ f.priorSpeedY = f.speedY;
2359
+ f.readFromX = f.trueX;
2360
+ f.readFromY = f.trueY;
2361
+ f.readSpan = elapsed;
2298
2362
  f.speedX = (trueX - f.trueX) / elapsed;
2299
2363
  f.speedY = (trueY - f.trueY) / elapsed;
2300
2364
  }
@@ -2445,6 +2509,11 @@ export class GestureHost {
2445
2509
  baseY: kept.y,
2446
2510
  speedX: 0,
2447
2511
  speedY: 0,
2512
+ readFromX: kept.x,
2513
+ readFromY: kept.y,
2514
+ readSpan: 0,
2515
+ priorSpeedX: 0,
2516
+ priorSpeedY: 0,
2448
2517
  };
2449
2518
  }
2450
2519
  this.arena.began(
@@ -12,6 +12,8 @@
12
12
  * SYNC: When modified, update actionGroupTracker.test.ts
13
13
  */
14
14
 
15
+ import {clickAndReport, clickForLift} from '../utils/browserClickWait';
16
+ import {armClickGuard} from '../utils/clickGuard';
15
17
  import {PRESSED_ATTRIBUTE} from '../utils/pressFeedback';
16
18
  import {PRESS_FADE_MS} from '../utils/pressGesture';
17
19
 
@@ -70,9 +72,9 @@ export interface ActionGroupTrackerOptions {
70
72
  */
71
73
  onActivated?: (item: HTMLElement, cancelled: boolean) => void;
72
74
  /**
73
- * After the lift ran an action, take the browser's own click for that
74
- * touch (left to it by a recognizer that did not claim the touch), so the
75
- * action runs once. @default false
75
+ * After the lift ran an action, the shared click guard takes the browser's
76
+ * own click for that touch, so the action runs once. A recognizer that did
77
+ * not claim the touch leaves that click to the browser. @default false
76
78
  */
77
79
  swallowsNativeClick?: boolean;
78
80
  }
@@ -126,54 +128,6 @@ interface TrackedAction {
126
128
  bottom: number;
127
129
  }
128
130
 
129
- /**
130
- * Swallow the browser's own click for a touch that the lift already acted
131
- * on. The listeners exist only from that lift until the click, the next
132
- * press, or the next key. An activation with `detail === 0` (a key or
133
- * assistive technology) always passes, and the swallower keeps waiting
134
- * (`AST-094`).
135
- */
136
- function swallowNextClick(view: Window): void {
137
- const disarm = () => {
138
- view.removeEventListener('click', onClick, true);
139
- view.removeEventListener('pointerdown', disarm, true);
140
- view.removeEventListener('keydown', disarm, true);
141
- };
142
- const onClick = (event: MouseEvent) => {
143
- if (!event.isTrusted || event.detail === 0) {
144
- return;
145
- }
146
- disarm();
147
- event.preventDefault();
148
- event.stopImmediatePropagation();
149
- };
150
- view.addEventListener('click', onClick, true);
151
- view.addEventListener('pointerdown', disarm, true);
152
- view.addEventListener('keydown', disarm, true);
153
- }
154
-
155
- /**
156
- * Click `element` (its own `click()`, so its activation behavior and every
157
- * handler run as for any click) and report whether a handler cancelled it.
158
- * The event is read from a capture listener that exists only for the call.
159
- */
160
- function clickAndReport(element: HTMLElement): boolean {
161
- const view = element.ownerDocument.defaultView;
162
- let clicked: Event | null = null;
163
- const grab = (event: Event) => {
164
- if (clicked == null && event.target === element) {
165
- clicked = event;
166
- }
167
- };
168
- view?.addEventListener('click', grab, true);
169
- try {
170
- element.click();
171
- } finally {
172
- view?.removeEventListener('click', grab, true);
173
- }
174
- return (clicked as Event | null)?.defaultPrevented === true;
175
- }
176
-
177
131
  /**
178
132
  * Create a tracker. `getOptions` is read on every call, so a hook can pass
179
133
  * the latest props without recreating it.
@@ -358,14 +312,16 @@ export function createActionGroupTracker(
358
312
  const {onActivate, onActivated, swallowsNativeClick} = getOptions();
359
313
  if (onActivate != null) {
360
314
  onActivate(lifted);
315
+ if (swallowsNativeClick === true) {
316
+ armClickGuard(lifted.ownerDocument);
317
+ }
361
318
  } else {
362
- const cancelled = clickAndReport(lifted);
319
+ const cancelled =
320
+ swallowsNativeClick === true
321
+ ? clickForLift(lifted)
322
+ : clickAndReport(lifted);
363
323
  onActivated?.(lifted, cancelled);
364
324
  }
365
- const view = lifted.ownerDocument.defaultView;
366
- if (swallowsNativeClick === true && view != null) {
367
- swallowNextClick(view);
368
- }
369
325
  return lifted;
370
326
  },
371
327
  cancel() {
@@ -8,7 +8,8 @@
8
8
  * option-id resolver for the owning listbox
9
9
  * @output Exports useHighlightedOptionScroll: keeps the highlighted option
10
10
  * scrolled into view for keyboard navigation while hover-driven highlights
11
- * never scroll
11
+ * never scroll, and a hover after a keyboard move waits for the pointer to
12
+ * move or press; the hover callback is stable while `setHighlightedIndex` is
12
13
  * @position Internal hook; used by useCombobox, useMultiCombobox,
13
14
  * BaseTypeahead, and DateTimeInput — the owners of combobox highlight state
14
15
  */
@@ -27,6 +28,21 @@ interface UseHighlightedOptionScrollOptions {
27
28
  itemCount?: number;
28
29
  }
29
30
 
31
+ /**
32
+ * Highlights the hovered option at `index`. `onHighlight` runs when the hover
33
+ * takes effect: at once, or after the pointer moves or presses when a
34
+ * keyboard move holds the highlight.
35
+ */
36
+ export type HighlightFromHover = (
37
+ index: number,
38
+ onHighlight?: () => void,
39
+ ) => void;
40
+
41
+ interface WaitingHover {
42
+ index: number;
43
+ onHighlight?: () => void;
44
+ }
45
+
30
46
  /**
31
47
  * Keep the highlighted option visible during keyboard navigation — but not for
32
48
  * hover highlights. Hover must highlight only: scrollIntoView moves the next
@@ -34,6 +50,10 @@ interface UseHighlightedOptionScrollOptions {
34
50
  * scrolls again — a runaway auto-scroll loop with no user input.
35
51
  * Keyboard paths call the raw setter directly and keep their scrolling; hover
36
52
  * paths call the returned callback.
53
+ *
54
+ * A keyboard move holds the highlight. A key that scrolls the list moves rows
55
+ * under a still mouse, and the browser reports the row under it as entered;
56
+ * that row takes the highlight only once the pointer moves or presses.
37
57
  */
38
58
  export function useHighlightedOptionScroll({
39
59
  isOpen,
@@ -41,31 +61,119 @@ export function useHighlightedOptionScroll({
41
61
  setHighlightedIndex,
42
62
  getOptionId,
43
63
  itemCount,
44
- }: UseHighlightedOptionScrollOptions): (index: number) => void {
45
- const hoverHighlightRef = useRef(false);
64
+ }: UseHighlightedOptionScrollOptions): HighlightFromHover {
65
+ // The callback reads no render state, so memoized rows keep one function.
66
+ const hoveredIndexRef = useRef<number | null>(null);
67
+ const shownIndexRef = useRef(highlightedIndex);
68
+ // The hover that came while a keyboard move held the highlight.
69
+ const waitingHoverRef = useRef<WaitingHover | null>(null);
70
+ // Set only while a keyboard move holds the highlight.
71
+ const removeHoldListenersRef = useRef<(() => void) | null>(null);
72
+ const setHighlightedIndexRef = useRef(setHighlightedIndex);
73
+ // The pointer's last screen position a hold saw, kept across holds.
74
+ const lastPointerRef = useRef<{x: number; y: number} | null>(null);
75
+
76
+ const applyHover = useCallback((hover: WaitingHover) => {
77
+ hoveredIndexRef.current = hover.index;
78
+ setHighlightedIndexRef.current(hover.index);
79
+ hover.onHighlight?.();
80
+ }, []);
81
+
82
+ const endHold = useCallback(() => {
83
+ removeHoldListenersRef.current?.();
84
+ removeHoldListenersRef.current = null;
85
+ }, []);
86
+
87
+ const releaseHold = useCallback(() => {
88
+ endHold();
89
+ const waiting = waitingHoverRef.current;
90
+ waitingHoverRef.current = null;
91
+ if (waiting != null) {
92
+ applyHover(waiting);
93
+ }
94
+ }, [applyHover, endHold]);
95
+
96
+ // Holds the highlight until the pointer moves or presses. The listeners
97
+ // live only while the hold does. Movement is read by screen coordinates: a
98
+ // scroll moves the rows, not the pointer. The browser reports a row as
99
+ // entered before the move that reaches it, so the first real move gives
100
+ // that row the highlight. A press releases at once, so a held press lights
101
+ // the pressed row.
102
+ const startHold = useCallback(() => {
103
+ waitingHoverRef.current = null;
104
+ if (removeHoldListenersRef.current != null) {
105
+ return;
106
+ }
107
+ const onPointerMove = (event: PointerEvent) => {
108
+ const last = lastPointerRef.current;
109
+ lastPointerRef.current = {x: event.screenX, y: event.screenY};
110
+ if (
111
+ last != null &&
112
+ last.x === event.screenX &&
113
+ last.y === event.screenY
114
+ ) {
115
+ return;
116
+ }
117
+ releaseHold();
118
+ };
119
+ const options = {capture: true, passive: true} as const;
120
+ document.addEventListener('pointermove', onPointerMove, options);
121
+ document.addEventListener('pointerdown', releaseHold, options);
122
+ removeHoldListenersRef.current = () => {
123
+ document.removeEventListener('pointermove', onPointerMove, options);
124
+ document.removeEventListener('pointerdown', releaseHold, options);
125
+ };
126
+ }, [releaseHold]);
46
127
 
47
- const highlightFromHover = useCallback(
48
- (index: number) => {
49
- if (index !== highlightedIndex) {
50
- hoverHighlightRef.current = true;
128
+ const highlightFromHover = useCallback<HighlightFromHover>(
129
+ (index, onHighlight) => {
130
+ if (removeHoldListenersRef.current != null) {
131
+ waitingHoverRef.current = {index, onHighlight};
132
+ return;
51
133
  }
134
+ hoveredIndexRef.current = index;
52
135
  setHighlightedIndex(index);
136
+ onHighlight?.();
53
137
  },
54
- [highlightedIndex, setHighlightedIndex],
138
+ [setHighlightedIndex],
55
139
  );
56
140
 
141
+ // Effect: keeps the hold's document listeners calling the current setter.
142
+ useEffect(() => {
143
+ setHighlightedIndexRef.current = setHighlightedIndex;
144
+ }, [setHighlightedIndex]);
145
+
146
+ // Effect: removes the hold's document listeners on unmount.
147
+ useEffect(() => endHold, [endHold]);
148
+
149
+ // Effect: scrolls the highlighted option into view in the document, and
150
+ // starts or ends the hold on its document listeners.
57
151
  useEffect(() => {
58
- if (hoverHighlightRef.current) {
59
- hoverHighlightRef.current = false;
152
+ // Only a move to the hovered row came from the hover. A hover on the row
153
+ // already highlighted changes nothing, so a later run for the same row
154
+ // (a new list) still scrolls it into view.
155
+ const hasMoved = shownIndexRef.current !== highlightedIndex;
156
+ shownIndexRef.current = highlightedIndex;
157
+ const isFromHover =
158
+ hasMoved && hoveredIndexRef.current === highlightedIndex;
159
+ hoveredIndexRef.current = null;
160
+ if (isFromHover) {
60
161
  return;
61
162
  }
62
- if (!isOpen || highlightedIndex < 0) {
163
+ // A cleared highlight, a closed list, or a list that is not shown (a
164
+ // closed CommandPalette reports itself as open) has nothing to hold.
165
+ const option =
166
+ isOpen && highlightedIndex >= 0
167
+ ? document.getElementById(getOptionId(highlightedIndex))
168
+ : null;
169
+ if (option == null) {
170
+ waitingHoverRef.current = null;
171
+ endHold();
63
172
  return;
64
173
  }
65
- document
66
- .getElementById(getOptionId(highlightedIndex))
67
- ?.scrollIntoView?.({block: 'nearest'});
68
- }, [isOpen, highlightedIndex, getOptionId, itemCount]);
174
+ startHold();
175
+ option.scrollIntoView?.({block: 'nearest'});
176
+ }, [isOpen, highlightedIndex, getOptionId, itemCount, endHold, startHold]);
69
177
 
70
178
  return highlightFromHover;
71
179
  }
@@ -21,7 +21,7 @@ export const docs = {
21
21
  },
22
22
  ],
23
23
  usage: {
24
- description: 'SSR-safe media query hook that subscribes to window.matchMedia changes. Returns whether the given media query matches. Always returns false on first render for SSR compatibility.',
24
+ description: 'SSR-safe media query hook that subscribes to window.matchMedia changes. Returns whether the given media query matches. Always returns false on first render for SSR compatibility. Where `window.matchMedia` is missing, it returns its server default and never throws.',
25
25
  bestPractices: [
26
26
  { guidance: true, description: 'Use for responsive layout switching based on viewport width, color scheme, or motion preferences.' },
27
27
  { guidance: true, description: 'Prefer Astryx responsive tokens and component props over manual breakpoint logic when possible.' },
@@ -44,7 +44,7 @@ export const docsDense = {
44
44
  matches: 'whether media query currently matches. Always false on first render (SSR-safe).',
45
45
  },
46
46
  usage: {
47
- description: 'SSR-safe media query hook subscribing to window.matchMedia changes. Returns whether given media query matches. Always returns false on first render for SSR compatibility.',
47
+ description: 'SSR-safe media query hook subscribing to window.matchMedia changes. Returns whether given media query matches. Always returns false on first render for SSR compatibility. Where `window.matchMedia` is missing, returns its server default and never throws.',
48
48
  bestPractices: [
49
49
  { guidance: true, description: 'Use for responsive layout switching based on viewport width, color scheme, or motion preferences.' },
50
50
  { guidance: true, description: 'Prefer Astryx responsive tokens + component props over manual breakpoint logic when possible.' },
@@ -20,7 +20,8 @@ import {useCallback, useSyncExternalStore} from 'react';
20
20
 
21
21
  /**
22
22
  * SSR-safe media query hook.
23
- * Returns whether the given media query matches.
23
+ * Returns whether the given media query matches. Where the window has no
24
+ * `matchMedia`, as in a test DOM, it returns `serverDefault`.
24
25
  *
25
26
  * @param query - CSS media query string
26
27
  * @param serverDefault - Value to return during SSR. Pass a server-side hint
@@ -35,6 +36,10 @@ import {useCallback, useSyncExternalStore} from 'react';
35
36
  export function useMediaQuery(query: string, serverDefault = false): boolean {
36
37
  const subscribe = useCallback(
37
38
  (onStoreChange: () => void) => {
39
+ // A DOM without `matchMedia`, such as a test DOM, has nothing to watch.
40
+ if (typeof window.matchMedia !== 'function') {
41
+ return () => {};
42
+ }
38
43
  const mql = window.matchMedia(query);
39
44
  mql.addEventListener('change', onStoreChange);
40
45
  return () => mql.removeEventListener('change', onStoreChange);
@@ -42,9 +47,13 @@ export function useMediaQuery(query: string, serverDefault = false): boolean {
42
47
  [query],
43
48
  );
44
49
 
45
- const getSnapshot = useCallback(() => {
46
- return window.matchMedia(query).matches;
47
- }, [query]);
50
+ const getSnapshot = useCallback(
51
+ () =>
52
+ typeof window.matchMedia === 'function'
53
+ ? window.matchMedia(query).matches
54
+ : serverDefault,
55
+ [query, serverDefault],
56
+ );
48
57
 
49
58
  const getServerSnapshot = useCallback(() => serverDefault, [serverDefault]);
50
59