@astryxdesign/core 0.7.7 → 0.7.8-canary.36cde5e

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 (581) 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 +46 -6
  4. package/dist/AppShell/AppShell.d.ts.map +1 -1
  5. package/dist/AppShell/AppShell.js +5 -0
  6. package/dist/BottomSheet/BottomSheet.d.ts.map +1 -1
  7. package/dist/BottomSheet/BottomSheet.js +1 -0
  8. package/dist/BottomSheet/BottomSheetPanel.d.ts.map +1 -1
  9. package/dist/BottomSheet/BottomSheetPanel.js +25 -4
  10. package/dist/BottomSheet/BottomSheetSwitcher.d.ts.map +1 -1
  11. package/dist/BottomSheet/BottomSheetSwitcher.js +5 -0
  12. package/dist/BottomSheet/useMobileKeyboard.d.ts.map +1 -1
  13. package/dist/BottomSheet/useMobileKeyboard.js +6 -0
  14. package/dist/BottomSheet/useSheetGestures.d.ts.map +1 -1
  15. package/dist/BottomSheet/useSheetGestures.js +12 -1
  16. package/dist/Button/Button.d.ts.map +1 -1
  17. package/dist/Button/Button.js +17 -1
  18. package/dist/Calendar/Calendar.d.ts.map +1 -1
  19. package/dist/Calendar/Calendar.js +15 -16
  20. package/dist/Carousel/Carousel.d.ts.map +1 -1
  21. package/dist/Carousel/Carousel.js +2 -0
  22. package/dist/Chat/ChatComposerInput.d.ts.map +1 -1
  23. package/dist/Chat/ChatComposerInput.js +4 -0
  24. package/dist/Chat/ChatMessageList.d.ts.map +1 -1
  25. package/dist/Chat/ChatMessageList.js +2 -1
  26. package/dist/Chat/useChatDictation.d.ts.map +1 -1
  27. package/dist/Chat/useChatDictation.js +2 -0
  28. package/dist/Chat/useChatNewMessages.js +1 -1
  29. package/dist/Chat/useChatStreamScroll.d.ts.map +1 -1
  30. package/dist/Chat/useChatStreamScroll.js +2 -0
  31. package/dist/Chat/useSpeechRecognition.d.ts.map +1 -1
  32. package/dist/Chat/useSpeechRecognition.js +3 -0
  33. package/dist/Chat/useTriggerMenu.js +1 -1
  34. package/dist/CheckboxInput/CheckboxInput.d.ts.map +1 -1
  35. package/dist/CheckboxInput/CheckboxInput.js +17 -12
  36. package/dist/CodeBlock/CodeBlock.d.ts +10 -1
  37. package/dist/CodeBlock/CodeBlock.d.ts.map +1 -1
  38. package/dist/CodeBlock/CodeBlock.js +28 -11
  39. package/dist/CommandPalette/CommandPalette.d.ts.map +1 -1
  40. package/dist/CommandPalette/CommandPalette.js +2 -0
  41. package/dist/CommandPalette/CommandPaletteItem.d.ts.map +1 -1
  42. package/dist/CommandPalette/CommandPaletteItem.js +2 -0
  43. package/dist/ComplexSelector/ComplexSelector.d.ts +4 -1
  44. package/dist/ComplexSelector/ComplexSelector.d.ts.map +1 -1
  45. package/dist/ComplexSelector/ComplexSelector.js +14 -4
  46. package/dist/ContextMenu/ContextMenu.js +2 -2
  47. package/dist/DateInput/MonthScroller.d.ts.map +1 -1
  48. package/dist/DateInput/MonthScroller.js +5 -2
  49. package/dist/DateInput/PickerWheel.d.ts +19 -0
  50. package/dist/DateInput/PickerWheel.d.ts.map +1 -0
  51. package/dist/DateInput/PickerWheel.js +625 -0
  52. package/dist/DateInput/TouchDateField.d.ts.map +1 -1
  53. package/dist/DateInput/TouchDateField.js +1 -0
  54. package/dist/DateInput/Wheel.d.ts +18 -37
  55. package/dist/DateInput/Wheel.d.ts.map +1 -1
  56. package/dist/DateInput/Wheel.js +84 -113
  57. package/dist/DateInput/useOwnScrollGesture.d.ts +10 -0
  58. package/dist/DateInput/useOwnScrollGesture.d.ts.map +1 -1
  59. package/dist/DateInput/useOwnScrollGesture.js +16 -3
  60. package/dist/DateInput/usePointerDragScroll.d.ts.map +1 -1
  61. package/dist/DateInput/usePointerDragScroll.js +2 -0
  62. package/dist/DateInput/useScrollSettle.d.ts.map +1 -1
  63. package/dist/DateInput/useScrollSettle.js +3 -0
  64. package/dist/DateInput/useWheelListbox.d.ts +59 -0
  65. package/dist/DateInput/useWheelListbox.d.ts.map +1 -0
  66. package/dist/DateInput/useWheelListbox.js +88 -0
  67. package/dist/DateInput/wheelOptions.d.ts +74 -0
  68. package/dist/DateInput/wheelOptions.d.ts.map +1 -0
  69. package/dist/DateInput/wheelOptions.js +98 -0
  70. package/dist/DateInput/wheelPhysics.d.ts +127 -0
  71. package/dist/DateInput/wheelPhysics.d.ts.map +1 -0
  72. package/dist/DateInput/wheelPhysics.js +341 -0
  73. package/dist/DateInput/wheelStrip.d.ts +92 -0
  74. package/dist/DateInput/wheelStrip.d.ts.map +1 -0
  75. package/dist/DateInput/wheelStrip.js +258 -0
  76. package/dist/DateTimeInput/TouchDateTimeField.d.ts.map +1 -1
  77. package/dist/DateTimeInput/TouchDateTimeField.js +2 -0
  78. package/dist/Dialog/Dialog.d.ts.map +1 -1
  79. package/dist/Dialog/Dialog.js +9 -32
  80. package/dist/Drawer/useDrawerDialogPresence.d.ts.map +1 -1
  81. package/dist/Drawer/useDrawerDialogPresence.js +3 -0
  82. package/dist/DropdownMenu/useMenuOverflow.d.ts.map +1 -1
  83. package/dist/DropdownMenu/useMenuOverflow.js +5 -0
  84. package/dist/Field/Field.js +2 -2
  85. package/dist/FieldStatus/useFieldStatusAnnouncement.d.ts.map +1 -1
  86. package/dist/FieldStatus/useFieldStatusAnnouncement.js +1 -0
  87. package/dist/FormLayout/FormLayout.d.ts +5 -1
  88. package/dist/FormLayout/FormLayout.d.ts.map +1 -1
  89. package/dist/HoverCard/HoverCard.d.ts +6 -4
  90. package/dist/HoverCard/HoverCard.d.ts.map +1 -1
  91. package/dist/HoverCard/HoverCard.js +48 -3
  92. package/dist/HoverCard/useHoverCard.d.ts +5 -4
  93. package/dist/HoverCard/useHoverCard.d.ts.map +1 -1
  94. package/dist/HoverCard/useHoverCard.js +20 -33
  95. package/dist/Icon/Icon.d.ts +5 -1
  96. package/dist/Icon/Icon.d.ts.map +1 -1
  97. package/dist/Icon/Icon.js +5 -2
  98. package/dist/Item/useSwipeAction.d.ts.map +1 -1
  99. package/dist/Item/useSwipeAction.js +3 -0
  100. package/dist/Layer/modalOutlet.d.ts.map +1 -1
  101. package/dist/Layer/modalOutlet.js +2 -0
  102. package/dist/Layer/outsidePress.d.ts.map +1 -1
  103. package/dist/Layer/outsidePress.js +1 -0
  104. package/dist/Layer/useLayer.d.ts.map +1 -1
  105. package/dist/Layer/useLayer.js +3 -0
  106. package/dist/Layer/useLayerDismissal.d.ts.map +1 -1
  107. package/dist/Layer/useLayerDismissal.js +3 -0
  108. package/dist/Layer/usePressPin.d.ts +17 -5
  109. package/dist/Layer/usePressPin.d.ts.map +1 -1
  110. package/dist/Layer/usePressPin.js +62 -12
  111. package/dist/Layer/useTouchTrigger.d.ts.map +1 -1
  112. package/dist/Layer/useTouchTrigger.js +3 -0
  113. package/dist/Lightbox/Lightbox.d.ts.map +1 -1
  114. package/dist/Lightbox/Lightbox.js +19 -0
  115. package/dist/MobileNav/MobileNav.d.ts +3 -2
  116. package/dist/MobileNav/MobileNav.d.ts.map +1 -1
  117. package/dist/MobileNav/MobileNav.js +9 -25
  118. package/dist/Outline/useOutlineFromDOM.d.ts.map +1 -1
  119. package/dist/Outline/useOutlineFromDOM.js +3 -0
  120. package/dist/Outline/useScrollSpy.d.ts.map +1 -1
  121. package/dist/Outline/useScrollSpy.js +3 -0
  122. package/dist/OverflowList/OverflowList.d.ts.map +1 -1
  123. package/dist/OverflowList/OverflowList.js +3 -0
  124. package/dist/Popover/Popover.d.ts.map +1 -1
  125. package/dist/Popover/Popover.js +6 -0
  126. package/dist/PowerSearch/PowerSearch.d.ts +43 -4
  127. package/dist/PowerSearch/PowerSearch.d.ts.map +1 -1
  128. package/dist/PowerSearch/PowerSearch.js +445 -134
  129. package/dist/PowerSearch/PowerSearchBottomSheet.d.ts +102 -0
  130. package/dist/PowerSearch/PowerSearchBottomSheet.d.ts.map +1 -0
  131. package/dist/PowerSearch/PowerSearchBottomSheet.js +1919 -0
  132. package/dist/PowerSearch/PowerSearchEditPopover.d.ts +35 -2
  133. package/dist/PowerSearch/PowerSearchEditPopover.d.ts.map +1 -1
  134. package/dist/PowerSearch/PowerSearchEditPopover.js +25 -6
  135. package/dist/PowerSearch/PowerSearchFilterEditor.d.ts +1 -1
  136. package/dist/PowerSearch/PowerSearchFilterEditor.d.ts.map +1 -1
  137. package/dist/PowerSearch/PowerSearchFilterEditor.js +4 -2
  138. package/dist/PowerSearch/PowerSearchToken.d.ts +1 -1
  139. package/dist/PowerSearch/PowerSearchToken.d.ts.map +1 -1
  140. package/dist/PowerSearch/PowerSearchToken.js +7 -2
  141. package/dist/PowerSearch/PowerSearchValueEditor.d.ts +38 -3
  142. package/dist/PowerSearch/PowerSearchValueEditor.d.ts.map +1 -1
  143. package/dist/PowerSearch/PowerSearchValueEditor.js +193 -83
  144. package/dist/PowerSearch/formatFilterValue.d.ts +18 -6
  145. package/dist/PowerSearch/formatFilterValue.d.ts.map +1 -1
  146. package/dist/PowerSearch/formatFilterValue.js +33 -12
  147. package/dist/PowerSearch/formatTokenValue.d.ts +28 -0
  148. package/dist/PowerSearch/formatTokenValue.d.ts.map +1 -0
  149. package/dist/PowerSearch/formatTokenValue.js +162 -0
  150. package/dist/PowerSearch/powerSearchDateZone.d.ts +21 -0
  151. package/dist/PowerSearch/powerSearchDateZone.d.ts.map +1 -0
  152. package/dist/PowerSearch/powerSearchDateZone.js +37 -0
  153. package/dist/PowerSearch/powerSearchSheetUtils.d.ts +65 -0
  154. package/dist/PowerSearch/powerSearchSheetUtils.d.ts.map +1 -0
  155. package/dist/PowerSearch/powerSearchSheetUtils.js +168 -0
  156. package/dist/PowerSearch/types.d.ts +2 -0
  157. package/dist/PowerSearch/types.d.ts.map +1 -1
  158. package/dist/ProgressBar/ProgressBarTouch.d.ts.map +1 -1
  159. package/dist/ProgressBar/ProgressBarTouch.js +2 -0
  160. package/dist/RadioList/RadioList.d.ts.map +1 -1
  161. package/dist/RadioList/RadioList.js +3 -2
  162. package/dist/RadioList/RadioListItem.d.ts +1 -1
  163. package/dist/RadioList/RadioListItem.d.ts.map +1 -1
  164. package/dist/RadioList/RadioListItem.js +5 -3
  165. package/dist/Resizable/ResizeHandle.d.ts +2 -1
  166. package/dist/Resizable/ResizeHandle.d.ts.map +1 -1
  167. package/dist/Resizable/ResizeHandle.js +101 -77
  168. package/dist/Resizable/createResizable.d.ts +145 -0
  169. package/dist/Resizable/createResizable.d.ts.map +1 -0
  170. package/dist/Resizable/createResizable.js +575 -0
  171. package/dist/Resizable/useResizable.d.ts +3 -3
  172. package/dist/Resizable/useResizable.d.ts.map +1 -1
  173. package/dist/Resizable/useResizable.js +252 -459
  174. package/dist/SegmentedControl/useSegmentedSelectionMotion.d.ts.map +1 -1
  175. package/dist/SegmentedControl/useSegmentedSelectionMotion.js +3 -3
  176. package/dist/SelectableCard/SelectableCard.d.ts +2 -0
  177. package/dist/SelectableCard/SelectableCard.d.ts.map +1 -1
  178. package/dist/SelectableCard/SelectableCard.js +36 -11
  179. package/dist/SelectableCard/selectableCardInput.d.ts +12 -0
  180. package/dist/SelectableCard/selectableCardInput.d.ts.map +1 -0
  181. package/dist/SelectableCard/selectableCardInput.js +20 -0
  182. package/dist/Selector/Selector.d.ts.map +1 -1
  183. package/dist/Selector/Selector.js +5 -8
  184. package/dist/Slider/sliderTouchController.js +2 -2
  185. package/dist/Spinner/Spinner.d.ts +1 -1
  186. package/dist/Spinner/Spinner.d.ts.map +1 -1
  187. package/dist/Spinner/Spinner.js +60 -58
  188. package/dist/Switch/Switch.d.ts.map +1 -1
  189. package/dist/Switch/Switch.js +4 -10
  190. package/dist/Switch/useSwitchTouch.js +4 -4
  191. package/dist/Table/BaseTable.d.ts.map +1 -1
  192. package/dist/Table/BaseTable.js +29 -8
  193. package/dist/Table/plugins/columnResize/useTableColumnResize.d.ts.map +1 -1
  194. package/dist/Table/plugins/columnResize/useTableColumnResize.js +3 -1
  195. package/dist/Table/plugins/filtering/useTableFiltering.d.ts.map +1 -1
  196. package/dist/Table/plugins/filtering/useTableFiltering.js +2 -1
  197. package/dist/Table/plugins/rowExpansion/useTableRowExpansion.d.ts.map +1 -1
  198. package/dist/Table/plugins/rowExpansion/useTableRowExpansion.js +4 -0
  199. package/dist/Table/plugins/stickyColumns/useTableStickyColumns.d.ts.map +1 -1
  200. package/dist/Table/plugins/stickyColumns/useTableStickyColumns.js +2 -0
  201. package/dist/TextInput/TextInput.d.ts +2 -2
  202. package/dist/TextInput/TextInput.d.ts.map +1 -1
  203. package/dist/TextInput/TextInput.js +10 -7
  204. package/dist/TimeInput/TimeInput.d.ts.map +1 -1
  205. package/dist/TimeInput/TimeInput.js +2 -0
  206. package/dist/TimeInput/TouchTimeField.d.ts.map +1 -1
  207. package/dist/TimeInput/TouchTimeField.js +2 -0
  208. package/dist/Timer/Timer.d.ts.map +1 -1
  209. package/dist/Timer/Timer.js +2 -0
  210. package/dist/Timestamp/Timestamp.js +1 -1
  211. package/dist/Toast/Toast.d.ts +11 -1
  212. package/dist/Toast/Toast.d.ts.map +1 -1
  213. package/dist/Toast/Toast.js +25 -15
  214. package/dist/Toast/ToastViewport.d.ts.map +1 -1
  215. package/dist/Toast/ToastViewport.js +97 -34
  216. package/dist/Toast/types.d.ts +9 -3
  217. package/dist/Toast/types.d.ts.map +1 -1
  218. package/dist/Toast/useToast.d.ts.map +1 -1
  219. package/dist/Toast/useToast.js +1 -0
  220. package/dist/Tokenizer/Tokenizer.d.ts +49 -37
  221. package/dist/Tokenizer/Tokenizer.d.ts.map +1 -1
  222. package/dist/Tokenizer/Tokenizer.js +22 -19
  223. package/dist/Tokenizer/TokenizerBottomSheet.d.ts +1 -1
  224. package/dist/Tokenizer/TokenizerBottomSheet.js +1 -1
  225. package/dist/Tokenizer/index.d.ts +1 -1
  226. package/dist/Tokenizer/index.d.ts.map +1 -1
  227. package/dist/Tooltip/useTooltip.d.ts +19 -1
  228. package/dist/Tooltip/useTooltip.d.ts.map +1 -1
  229. package/dist/Tooltip/useTooltip.js +34 -53
  230. package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
  231. package/dist/Typeahead/BaseTypeahead.js +27 -18
  232. package/dist/Typeahead/TypeaheadItem.d.ts +2 -1
  233. package/dist/Typeahead/TypeaheadItem.d.ts.map +1 -1
  234. package/dist/animator/createAnimator.d.ts +98 -0
  235. package/dist/animator/createAnimator.d.ts.map +1 -0
  236. package/dist/animator/createAnimator.js +259 -0
  237. package/dist/astryx.css +39 -6
  238. package/dist/gestures/GestureHost.d.ts +16 -1
  239. package/dist/gestures/GestureHost.d.ts.map +1 -1
  240. package/dist/gestures/GestureHost.js +1 -1
  241. package/dist/gestures/PressTracker.d.ts.map +1 -1
  242. package/dist/gestures/PressTracker.js +4 -3
  243. package/dist/hooks/ModalAnnounceProvider.d.ts.map +1 -1
  244. package/dist/hooks/ModalAnnounceProvider.js +1 -0
  245. package/dist/hooks/actionGroupTracker.d.ts +4 -1
  246. package/dist/hooks/actionGroupTracker.d.ts.map +1 -1
  247. package/dist/hooks/actionGroupTracker.js +13 -3
  248. package/dist/hooks/index.d.ts +1 -0
  249. package/dist/hooks/index.d.ts.map +1 -1
  250. package/dist/hooks/index.js +1 -0
  251. package/dist/hooks/longPressDetector.d.ts +66 -0
  252. package/dist/hooks/longPressDetector.d.ts.map +1 -0
  253. package/dist/hooks/longPressDetector.js +200 -0
  254. package/dist/hooks/pressGroupContext.d.ts +6 -0
  255. package/dist/hooks/pressGroupContext.d.ts.map +1 -0
  256. package/dist/hooks/pressGroupContext.js +20 -0
  257. package/dist/hooks/rootScrollStyles.d.ts +48 -0
  258. package/dist/hooks/rootScrollStyles.d.ts.map +1 -0
  259. package/dist/hooks/rootScrollStyles.js +207 -0
  260. package/dist/hooks/scrollLockTouchGuard.d.ts +13 -0
  261. package/dist/hooks/scrollLockTouchGuard.d.ts.map +1 -0
  262. package/dist/hooks/scrollLockTouchGuard.js +134 -0
  263. package/dist/hooks/scrollbarGutter.d.ts +4 -4
  264. package/dist/hooks/scrollbarGutter.d.ts.map +1 -1
  265. package/dist/hooks/scrollbarGutter.js +36 -23
  266. package/dist/hooks/useActionGroupTracking.d.ts +53 -0
  267. package/dist/hooks/useActionGroupTracking.d.ts.map +1 -0
  268. package/dist/hooks/useActionGroupTracking.js +174 -0
  269. package/dist/hooks/useAnimator.d.ts +41 -0
  270. package/dist/hooks/useAnimator.d.ts.map +1 -0
  271. package/dist/hooks/useAnimator.js +70 -0
  272. package/dist/hooks/useAnnounceRenderedText.d.ts.map +1 -1
  273. package/dist/hooks/useAnnounceRenderedText.js +1 -0
  274. package/dist/hooks/useAttachment.d.ts +1 -1
  275. package/dist/hooks/useAttachment.js +1 -1
  276. package/dist/hooks/useAutoMediaMode.d.ts.map +1 -1
  277. package/dist/hooks/useAutoMediaMode.js +1 -0
  278. package/dist/hooks/useBusyAnnouncement.d.ts.map +1 -1
  279. package/dist/hooks/useBusyAnnouncement.js +2 -0
  280. package/dist/hooks/useDevWarning.d.ts.map +1 -1
  281. package/dist/hooks/useDevWarning.js +2 -0
  282. package/dist/hooks/useFocusTrap.d.ts.map +1 -1
  283. package/dist/hooks/useFocusTrap.js +2 -0
  284. package/dist/hooks/useGridFocus.d.ts +8 -5
  285. package/dist/hooks/useGridFocus.d.ts.map +1 -1
  286. package/dist/hooks/useGridFocus.js +4 -3
  287. package/dist/hooks/useHotkeys.d.ts.map +1 -1
  288. package/dist/hooks/useHotkeys.js +3 -0
  289. package/dist/hooks/useImageMode.d.ts.map +1 -1
  290. package/dist/hooks/useImageMode.js +3 -0
  291. package/dist/hooks/useInputStatusIcon.d.ts +1 -1
  292. package/dist/hooks/useInputStatusIcon.d.ts.map +1 -1
  293. package/dist/hooks/useInputStatusIcon.js +2 -2
  294. package/dist/hooks/useKeyboardHint.js +1 -1
  295. package/dist/hooks/useLongPress.d.ts +8 -9
  296. package/dist/hooks/useLongPress.d.ts.map +1 -1
  297. package/dist/hooks/useLongPress.js +23 -106
  298. package/dist/hooks/useMenuHover.d.ts.map +1 -1
  299. package/dist/hooks/useMenuHover.js +2 -0
  300. package/dist/hooks/useMenuPress.d.ts.map +1 -1
  301. package/dist/hooks/useMenuPress.js +3 -0
  302. package/dist/hooks/useOverflow.d.ts.map +1 -1
  303. package/dist/hooks/useOverflow.js +2 -1
  304. package/dist/hooks/usePressFeedback.d.ts.map +1 -1
  305. package/dist/hooks/usePressFeedback.js +1 -0
  306. package/dist/hooks/useResolvedRequired.d.ts +3 -0
  307. package/dist/hooks/useResolvedRequired.d.ts.map +1 -1
  308. package/dist/hooks/useResolvedRequired.js +3 -0
  309. package/dist/hooks/useScrollLock.d.ts +7 -9
  310. package/dist/hooks/useScrollLock.d.ts.map +1 -1
  311. package/dist/hooks/useScrollLock.js +55 -48
  312. package/dist/hooks/useScrollableArea.d.ts.map +1 -1
  313. package/dist/hooks/useScrollableArea.js +5 -0
  314. package/dist/hooks/useStreamingText.d.ts.map +1 -1
  315. package/dist/hooks/useStreamingText.js +3 -0
  316. package/dist/i18n/generated-english/PowerSearch/PowerSearchBottomSheet.generated.d.ts +8 -0
  317. package/dist/i18n/generated-english/PowerSearch/PowerSearchBottomSheet.generated.d.ts.map +1 -0
  318. package/dist/i18n/generated-english/PowerSearch/PowerSearchBottomSheet.generated.js +42 -0
  319. package/dist/i18n/generated-english/PowerSearch/PowerSearchValueEditor.generated.d.ts.map +1 -1
  320. package/dist/i18n/generated-english/PowerSearch/PowerSearchValueEditor.generated.js +6 -0
  321. package/dist/i18n/generated-english/PowerSearch/formatTokenValue.generated.d.ts +8 -0
  322. package/dist/i18n/generated-english/PowerSearch/formatTokenValue.generated.d.ts.map +1 -0
  323. package/dist/i18n/generated-english/PowerSearch/formatTokenValue.generated.js +14 -0
  324. package/dist/i18n/generated-locales/en.generated.d.ts.map +1 -1
  325. package/dist/i18n/generated-locales/en.generated.js +30 -0
  326. package/dist/i18n/generated-locales/pseudo.generated.d.ts.map +1 -1
  327. package/dist/i18n/generated-locales/pseudo.generated.js +30 -0
  328. package/dist/index.d.ts +1 -0
  329. package/dist/index.d.ts.map +1 -1
  330. package/dist/index.js +4 -0
  331. package/dist/internal/indicatorPress.stylex.d.ts +20 -0
  332. package/dist/internal/indicatorPress.stylex.d.ts.map +1 -0
  333. package/dist/internal/indicatorPress.stylex.js +26 -0
  334. package/dist/internal/selectable-card.d.ts +21 -0
  335. package/dist/internal/selectable-card.d.ts.map +1 -0
  336. package/dist/internal/selectable-card.js +20 -0
  337. package/dist/internal/sheetDraft.d.ts +2 -2
  338. package/dist/internal/sheetDraft.js +2 -2
  339. package/dist/motion/animatedLayout.d.ts.map +1 -1
  340. package/dist/motion/animatedLayout.js +7 -7
  341. package/dist/motion/animator.d.ts +14 -14
  342. package/dist/motion/animator.d.ts.map +1 -1
  343. package/dist/motion/animator.js +45 -20
  344. package/dist/motion/progress.d.ts +88 -0
  345. package/dist/motion/progress.d.ts.map +1 -0
  346. package/dist/motion/progress.js +128 -0
  347. package/dist/motion/reducedMotion.d.ts.map +1 -1
  348. package/dist/motion/reducedMotion.js +3 -1
  349. package/dist/motion/uikit/alert.d.ts +41 -0
  350. package/dist/motion/uikit/alert.d.ts.map +1 -0
  351. package/dist/motion/uikit/alert.js +43 -0
  352. package/dist/motion/uikit/index.d.ts +4 -3
  353. package/dist/motion/uikit/index.d.ts.map +1 -1
  354. package/dist/motion/uikit/index.js +4 -3
  355. package/dist/motion/uikit/picker.d.ts +96 -0
  356. package/dist/motion/uikit/picker.d.ts.map +1 -0
  357. package/dist/motion/uikit/picker.js +117 -0
  358. package/dist/privateAttributes/pressGroupItem.stylex.d.ts +22 -0
  359. package/dist/privateAttributes/pressGroupItem.stylex.d.ts.map +1 -0
  360. package/dist/privateAttributes/pressGroupItem.stylex.js +36 -0
  361. package/dist/privateAttributes/reducedMotion.stylex.d.ts +23 -0
  362. package/dist/privateAttributes/reducedMotion.stylex.d.ts.map +1 -0
  363. package/dist/privateAttributes/reducedMotion.stylex.js +40 -0
  364. package/dist/theme/Theme.d.ts.map +1 -1
  365. package/dist/theme/Theme.js +29 -4
  366. package/dist/theme/motionKnobs.d.ts +4 -3
  367. package/dist/theme/motionKnobs.d.ts.map +1 -1
  368. package/dist/theme/motionKnobs.js +3 -2
  369. package/dist/theme/themeMotion.d.ts +6 -0
  370. package/dist/theme/themeMotion.d.ts.map +1 -1
  371. package/dist/theme/themeMotion.js +7 -0
  372. package/dist/utils/dateParser.d.ts.map +1 -1
  373. package/dist/utils/dateParser.js +12 -0
  374. package/dist/utils/focusReturn.d.ts +15 -0
  375. package/dist/utils/focusReturn.d.ts.map +1 -1
  376. package/dist/utils/focusReturn.js +28 -0
  377. package/dist/utils/interactionModality.d.ts.map +1 -1
  378. package/dist/utils/interactionModality.js +4 -0
  379. package/dist/utils/interactionOverlay.stylex.d.ts +3 -2
  380. package/dist/utils/interactionOverlay.stylex.d.ts.map +1 -1
  381. package/dist/utils/interactionOverlay.stylex.js +3 -2
  382. package/dist/utils/plainDate.d.ts +8 -0
  383. package/dist/utils/plainDate.d.ts.map +1 -1
  384. package/dist/utils/plainDate.js +19 -0
  385. package/dist/utils/pressFeedback.d.ts.map +1 -1
  386. package/dist/utils/pressFeedback.js +20 -6
  387. package/dist/utils/triggerFocus.d.ts +36 -0
  388. package/dist/utils/triggerFocus.d.ts.map +1 -0
  389. package/dist/utils/triggerFocus.js +65 -0
  390. package/locales/en.json +120 -0
  391. package/locales/pseudo.json +90 -0
  392. package/package.json +14 -9
  393. package/src/AlertDialog/AlertDialog.doc.mjs +2 -2
  394. package/src/AlertDialog/AlertDialog.tsx +46 -5
  395. package/src/AppShell/AppShell.tsx +4 -0
  396. package/src/BottomSheet/BottomSheet.tsx +1 -0
  397. package/src/BottomSheet/BottomSheetPanel.tsx +39 -3
  398. package/src/BottomSheet/BottomSheetSwitcher.tsx +5 -0
  399. package/src/BottomSheet/useMobileKeyboard.ts +4 -0
  400. package/src/BottomSheet/useSheetGestures.ts +10 -1
  401. package/src/Button/Button.tsx +26 -4
  402. package/src/Calendar/Calendar.tsx +36 -25
  403. package/src/Carousel/Carousel.tsx +1 -0
  404. package/src/Chat/ChatComposerInput.tsx +2 -0
  405. package/src/Chat/ChatMessageList.tsx +2 -1
  406. package/src/Chat/useChatDictation.ts +1 -0
  407. package/src/Chat/useChatNewMessages.ts +1 -1
  408. package/src/Chat/useChatStreamScroll.ts +2 -0
  409. package/src/Chat/useSpeechRecognition.ts +2 -0
  410. package/src/Chat/useTriggerMenu.tsx +1 -1
  411. package/src/CheckboxInput/CheckboxInput.doc.mjs +18 -0
  412. package/src/CheckboxInput/CheckboxInput.tsx +38 -15
  413. package/src/CodeBlock/CodeBlock.doc.mjs +9 -1
  414. package/src/CodeBlock/CodeBlock.tsx +41 -4
  415. package/src/CommandPalette/CommandPalette.tsx +1 -0
  416. package/src/CommandPalette/CommandPaletteItem.tsx +1 -0
  417. package/src/ComplexSelector/ComplexSelector.tsx +20 -5
  418. package/src/ContextMenu/ContextMenu.tsx +2 -2
  419. package/src/DateInput/MonthScroller.tsx +8 -2
  420. package/src/DateInput/PickerWheel.tsx +765 -0
  421. package/src/DateInput/TouchDateField.tsx +1 -0
  422. package/src/DateInput/Wheel.tsx +94 -150
  423. package/src/DateInput/useOwnScrollGesture.ts +25 -3
  424. package/src/DateInput/usePointerDragScroll.ts +1 -0
  425. package/src/DateInput/useScrollSettle.ts +2 -0
  426. package/src/DateInput/useWheelListbox.ts +112 -0
  427. package/src/DateInput/wheelOptions.ts +155 -0
  428. package/src/DateInput/wheelPhysics.ts +406 -0
  429. package/src/DateInput/wheelStrip.ts +341 -0
  430. package/src/DateTimeInput/TouchDateTimeField.tsx +2 -0
  431. package/src/Dialog/Dialog.tsx +12 -32
  432. package/src/Drawer/useDrawerDialogPresence.ts +2 -0
  433. package/src/DropdownMenu/useMenuOverflow.ts +3 -0
  434. package/src/Field/Field.tsx +2 -2
  435. package/src/FieldStatus/useFieldStatusAnnouncement.ts +1 -0
  436. package/src/FormLayout/FormLayout.doc.mjs +181 -46
  437. package/src/FormLayout/FormLayout.tsx +5 -1
  438. package/src/HoverCard/HoverCard.doc.mjs +10 -8
  439. package/src/HoverCard/HoverCard.tsx +58 -6
  440. package/src/HoverCard/useHoverCard.doc.mjs +178 -36
  441. package/src/HoverCard/useHoverCard.tsx +49 -54
  442. package/src/Icon/Icon.doc.mjs +49 -8
  443. package/src/Icon/Icon.tsx +15 -7
  444. package/src/IconButton/IconButton.doc.mjs +141 -21
  445. package/src/Item/useSwipeAction.ts +2 -0
  446. package/src/Layer/modalOutlet.ts +2 -0
  447. package/src/Layer/outsidePress.ts +1 -0
  448. package/src/Layer/useLayer.tsx +2 -0
  449. package/src/Layer/useLayerDismissal.ts +2 -0
  450. package/src/Layer/usePressPin.ts +81 -15
  451. package/src/Layer/useTouchTrigger.ts +2 -0
  452. package/src/Lightbox/Lightbox.tsx +19 -0
  453. package/src/MobileNav/MobileNav.tsx +9 -30
  454. package/src/Outline/useOutlineFromDOM.ts +2 -0
  455. package/src/Outline/useScrollSpy.ts +2 -0
  456. package/src/OverflowList/OverflowList.tsx +2 -0
  457. package/src/Popover/Popover.tsx +4 -0
  458. package/src/PowerSearch/PowerSearch.doc.mjs +60 -8
  459. package/src/PowerSearch/PowerSearch.tsx +627 -157
  460. package/src/PowerSearch/PowerSearchBottomSheet.tsx +2525 -0
  461. package/src/PowerSearch/PowerSearchEditPopover.tsx +39 -6
  462. package/src/PowerSearch/PowerSearchFilterEditor.tsx +2 -0
  463. package/src/PowerSearch/PowerSearchToken.tsx +7 -0
  464. package/src/PowerSearch/PowerSearchValueEditor.tsx +244 -58
  465. package/src/PowerSearch/formatFilterValue.ts +55 -17
  466. package/src/PowerSearch/formatTokenValue.ts +222 -0
  467. package/src/PowerSearch/powerSearchDateZone.ts +64 -0
  468. package/src/PowerSearch/powerSearchSheetUtils.ts +224 -0
  469. package/src/PowerSearch/types.ts +2 -0
  470. package/src/ProgressBar/ProgressBarTouch.ts +2 -0
  471. package/src/RadioList/RadioList.tsx +3 -2
  472. package/src/RadioList/RadioListItem.tsx +5 -3
  473. package/src/Resizable/Resizable.doc.mjs +5 -4
  474. package/src/Resizable/ResizeHandle.tsx +169 -109
  475. package/src/Resizable/createResizable.ts +831 -0
  476. package/src/Resizable/useResizable.ts +292 -623
  477. package/src/SegmentedControl/useSegmentedSelectionMotion.ts +6 -3
  478. package/src/SelectableCard/SelectableCard.tsx +39 -10
  479. package/src/SelectableCard/selectableCardInput.ts +30 -0
  480. package/src/Selector/Selector.tsx +5 -12
  481. package/src/Slider/sliderTouchController.ts +2 -2
  482. package/src/Spinner/Spinner.tsx +77 -63
  483. package/src/Switch/Switch.tsx +2 -6
  484. package/src/Switch/useSwitchTouch.ts +4 -4
  485. package/src/Table/BaseTable.tsx +46 -12
  486. package/src/Table/plugins/columnResize/useTableColumnResize.tsx +2 -1
  487. package/src/Table/plugins/filtering/useTableFiltering.tsx +1 -0
  488. package/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx +4 -0
  489. package/src/Table/plugins/stickyColumns/useTableStickyColumns.tsx +2 -0
  490. package/src/TextInput/TextInput.tsx +11 -10
  491. package/src/TimeInput/TimeInput.tsx +2 -0
  492. package/src/TimeInput/TouchTimeField.tsx +2 -0
  493. package/src/Timer/Timer.tsx +1 -0
  494. package/src/Timestamp/Timestamp.tsx +1 -1
  495. package/src/Toast/Toast.doc.mjs +4 -3
  496. package/src/Toast/Toast.tsx +52 -32
  497. package/src/Toast/ToastViewport.tsx +123 -35
  498. package/src/Toast/types.ts +9 -3
  499. package/src/Toast/useToast.tsx +1 -0
  500. package/src/Tokenizer/Tokenizer.doc.mjs +19 -3
  501. package/src/Tokenizer/Tokenizer.tsx +73 -51
  502. package/src/Tokenizer/TokenizerBottomSheet.tsx +1 -1
  503. package/src/Tokenizer/index.ts +1 -0
  504. package/src/Tooltip/useTooltip.doc.mjs +24 -0
  505. package/src/Tooltip/useTooltip.tsx +61 -56
  506. package/src/Typeahead/BaseTypeahead.tsx +27 -17
  507. package/src/Typeahead/TypeaheadItem.doc.mjs +6 -3
  508. package/src/Typeahead/TypeaheadItem.tsx +2 -1
  509. package/src/animator/createAnimator.doc.mjs +153 -0
  510. package/src/animator/createAnimator.ts +324 -0
  511. package/src/gestures/GestureHost.ts +1 -1
  512. package/src/gestures/PressTracker.ts +4 -3
  513. package/src/gestures/README.md +10 -4
  514. package/src/hooks/ModalAnnounceProvider.tsx +1 -0
  515. package/src/hooks/actionGroupTracker.ts +20 -5
  516. package/src/hooks/index.ts +2 -0
  517. package/src/hooks/longPressDetector.ts +273 -0
  518. package/src/hooks/pressGroupContext.ts +21 -0
  519. package/src/hooks/rootScrollStyles.ts +247 -0
  520. package/src/hooks/scrollLockTouchGuard.ts +182 -0
  521. package/src/hooks/scrollbarGutter.ts +41 -26
  522. package/src/hooks/useActionGroupTracking.ts +224 -0
  523. package/src/hooks/useAnimator.doc.mjs +109 -0
  524. package/src/hooks/useAnimator.ts +67 -0
  525. package/src/hooks/useAnnounceRenderedText.ts +1 -0
  526. package/src/hooks/useAttachment.ts +1 -1
  527. package/src/hooks/useAutoMediaMode.ts +1 -0
  528. package/src/hooks/useBusyAnnouncement.ts +2 -0
  529. package/src/hooks/useDevWarning.ts +2 -0
  530. package/src/hooks/useFocusTrap.ts +2 -0
  531. package/src/hooks/useGridFocus.doc.mjs +6 -6
  532. package/src/hooks/useGridFocus.ts +10 -7
  533. package/src/hooks/useHotkeys.ts +2 -0
  534. package/src/hooks/useImageMode.ts +2 -0
  535. package/src/hooks/useInputStatusIcon.tsx +3 -2
  536. package/src/hooks/useKeyboardHint.tsx +1 -1
  537. package/src/hooks/useLongPress.doc.mjs +2 -2
  538. package/src/hooks/useLongPress.ts +32 -122
  539. package/src/hooks/useMenuHover.ts +1 -0
  540. package/src/hooks/useMenuPress.ts +2 -0
  541. package/src/hooks/useOverflow.ts +2 -1
  542. package/src/hooks/usePressFeedback.ts +1 -0
  543. package/src/hooks/useResolvedRequired.ts +3 -0
  544. package/src/hooks/useScrollLock.doc.mjs +51 -12
  545. package/src/hooks/useScrollLock.ts +57 -60
  546. package/src/hooks/useScrollableArea.ts +4 -0
  547. package/src/hooks/useStreamingText.ts +2 -0
  548. package/src/i18n/generated-english/PowerSearch/PowerSearchBottomSheet.generated.ts +43 -0
  549. package/src/i18n/generated-english/PowerSearch/PowerSearchValueEditor.generated.ts +6 -0
  550. package/src/i18n/generated-english/PowerSearch/formatTokenValue.generated.ts +15 -0
  551. package/src/i18n/generated-locales/en.generated.ts +41 -0
  552. package/src/i18n/generated-locales/pseudo.generated.ts +42 -0
  553. package/src/index.ts +13 -0
  554. package/src/internal/indicatorPress.stylex.ts +24 -0
  555. package/src/internal/selectable-card.ts +21 -0
  556. package/src/internal/sheetDraft.ts +2 -2
  557. package/src/motion/MEASUREMENTS.md +18 -0
  558. package/src/motion/README.md +10 -5
  559. package/src/motion/animatedLayout.ts +10 -5
  560. package/src/motion/animator.ts +66 -31
  561. package/src/motion/progress.ts +155 -0
  562. package/src/motion/reducedMotion.ts +3 -1
  563. package/src/motion/uikit/alert.ts +43 -0
  564. package/src/motion/uikit/index.ts +4 -3
  565. package/src/motion/uikit/picker.ts +116 -0
  566. package/src/privateAttributes/pressGroupItem.stylex.ts +44 -0
  567. package/src/privateAttributes/reducedMotion.stylex.ts +45 -0
  568. package/src/theme/Theme.tsx +37 -3
  569. package/src/theme/motionKnobs.ts +4 -3
  570. package/src/theme/themeMotion.ts +7 -0
  571. package/src/utils/dateParser.ts +16 -0
  572. package/src/utils/focusReturn.ts +30 -0
  573. package/src/utils/interactionModality.ts +4 -0
  574. package/src/utils/interactionOverlay.stylex.ts +3 -2
  575. package/src/utils/plainDate.ts +21 -0
  576. package/src/utils/pressFeedback.ts +24 -6
  577. package/src/utils/triggerFocus.ts +80 -0
  578. package/dist/internal/touch-sheet.d.ts +0 -21
  579. package/dist/internal/touch-sheet.d.ts.map +0 -1
  580. package/dist/internal/touch-sheet.js +0 -19
  581. package/src/internal/touch-sheet.ts +0 -21
@@ -0,0 +1,2525 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ /**
6
+ * @file PowerSearchBottomSheet.tsx
7
+ * @input The field's InternalConfig, filters, onChange, the field typeahead
8
+ * source, and the sheet options PowerSearch forwards; BottomSheet,
9
+ * Button, EmptyState, Heading, Icon, IconButton, Indicator, List,
10
+ * RadioList, Text, TextInput, PowerSearchValueEditor, the internal sheet
11
+ * confirmation and search-drag owners, i18n
12
+ * @output Exports PowerSearchBottomSheet — the touch surface of
13
+ * `PowerSearch experimental.presentation="bottom-sheet"`: a Filters sheet
14
+ * with editable rows and trailing remove actions, Add filter in its fixed
15
+ * header and Clear all / Done in its fixed footer, an Add filter sheet with
16
+ * grouped field rows or the content search with inline suggestions, and a
17
+ * value step that stages field → operator → value and confirms from its
18
+ * footer
19
+ * @position Internal presentation surface of PowerSearch. Loaded on demand by
20
+ * PowerSearch.tsx so the popover presentation never carries it. The
21
+ * trigger lives in PowerSearch.tsx.
22
+ *
23
+ * The three steps are sibling standalone BottomSheets that stack: each step
24
+ * opens above the sheet that led to it, which stays open beneath as a covered
25
+ * sheet, so no sheet needs a Back action. A dismissal closes only the top
26
+ * sheet and reveals the step beneath; dismissing the bottom sheet ends the
27
+ * flow. A list value's Tokenizer opens its own sheet above the value step, as
28
+ * the top sheet of the stack.
29
+ *
30
+ * The sheets pad their own content (`padding={0}` on BottomSheet). The
31
+ * Filters and Add filter sheets keep their header and footer fixed around a
32
+ * scroll viewport that reaches the sheet edge; its content publishes the
33
+ * padding as container padding so `edge="bleed"` lists run edge to edge with
34
+ * row text on the header's inset. The sheets are pinned tall: the field list
35
+ * resizes as it is searched and the editor changes with the operator, and
36
+ * only a fully expanded tall sheet gives mobile-keyboard accommodation.
37
+ *
38
+ * SYNC: When modified, update:
39
+ * - /packages/core/src/PowerSearch/PowerSearch.tsx (the trigger and the lazy host)
40
+ * - /docs/specs/components/PowerSearch/PowerSearch.spec.md (FR10–FR21, AR4–AR6, PR5)
41
+ */
42
+
43
+ import React, {
44
+ useCallback,
45
+ useEffect,
46
+ useId,
47
+ useLayoutEffect,
48
+ useMemo,
49
+ useRef,
50
+ useState,
51
+ type ReactNode,
52
+ type RefObject,
53
+ type SVGProps,
54
+ } from 'react';
55
+ import {flushSync} from 'react-dom';
56
+ import * as stylex from '@stylexjs/stylex';
57
+ import {BottomSheet} from '../BottomSheet/BottomSheet';
58
+ import {Button} from '../Button/Button';
59
+ import {EmptyState} from '../EmptyState/EmptyState';
60
+ import {Heading} from '../Heading/Heading';
61
+ import {Icon} from '../Icon/Icon';
62
+ import {IconButton} from '../IconButton/IconButton';
63
+ import {CheckboxIndicator} from '../Indicator/CheckboxIndicator';
64
+ import {RadioIndicator} from '../Indicator/RadioIndicator';
65
+ import {focusOutlineStyles} from '../utils/focusOutline.stylex';
66
+ import {isRenderable} from '../utils/isRenderable';
67
+ import {containerPaddingInlineVarStyles} from '../Layout/padding.stylex';
68
+ import {List} from '../List/List';
69
+ import {ListItem} from '../List/ListItem';
70
+ import {RadioList} from '../RadioList/RadioList';
71
+ import {RadioListItem} from '../RadioList/RadioListItem';
72
+ import {Text} from '../Text/Text';
73
+ import {TextInput} from '../TextInput/TextInput';
74
+ import type {SearchSource} from '../Typeahead/types';
75
+ import {useAnnounce, type AnnounceFn} from '../hooks/useAnnounce';
76
+ import type {TranslatorFn} from '../i18n';
77
+ import {useListFormatter} from '../i18n/useListFormatter';
78
+ import {useLocale} from '../i18n/useLocale';
79
+ import {SheetConfirmation} from '../internal/SheetConfirmation';
80
+ import {useDismissSearchOnTouchDrag} from '../internal/useDismissSearchOnTouchDrag';
81
+ import {
82
+ colorVars,
83
+ fontWeightVars,
84
+ radiusVars,
85
+ spacingVars,
86
+ } from '../theme/tokens.stylex';
87
+ import {isImeKeyEvent} from '../utils/ime';
88
+ import {rtlStyles} from '../utils/rtlStyles';
89
+ import {formatTokenValueText} from './formatTokenValue';
90
+
91
+ function resolveRelativeDateLabel(
92
+ operatorValue: OperatorValue & {type: 'date_relative'},
93
+ value: string,
94
+ t: TranslatorFn,
95
+ buildRelativeDateOptions: PowerSearchSheetEditors['buildRelativeDateOptions'],
96
+ ): string {
97
+ return (
98
+ buildRelativeDateOptions(operatorValue, t).find(
99
+ option => option.value === value,
100
+ )?.label ?? value
101
+ );
102
+ }
103
+
104
+ // A placeholder the Filters row formats `labelValue` with, then splits on, so
105
+ // the value segment can render bold inside the catalog's ordering. A private
106
+ // use code point: never part of a label or value.
107
+ const ROW_VALUE_SLOT = '\uE000';
108
+ import type {
109
+ buildRelativeDateOptions,
110
+ PowerSearchValueEditor,
111
+ } from './PowerSearchValueEditor';
112
+ import type {NestedEditor} from './PowerSearchEditPopover';
113
+ import {useAnnounceRenderedText} from '../hooks/useAnnounceRenderedText';
114
+ import {useInternalListFocus} from '../hooks/useListFocus';
115
+ import {useMergedRefs} from '../hooks/useMergedRefs';
116
+ import {groupItems} from '../utils/groupItems';
117
+ import {
118
+ createSheetSuggestionSource,
119
+ fieldMatchesQuery,
120
+ createFilterIdentities,
121
+ filterSignature,
122
+ isSupportedOperator,
123
+ nearestEditableIndex,
124
+ resolveFilterIdentityIndex,
125
+ } from './powerSearchSheetUtils';
126
+ import {resolveOperatorLabel} from './resolveOperatorLabel';
127
+ import type {InternalConfig} from './useInternalConfig';
128
+ import type {
129
+ FilterValue,
130
+ OperatorValue,
131
+ PartialFilter,
132
+ PowerSearchChangeType,
133
+ PowerSearchComponents,
134
+ PowerSearchConfig,
135
+ PowerSearchField,
136
+ PowerSearchFilter,
137
+ PowerSearchItem,
138
+ PowerSearchOperator,
139
+ } from './types';
140
+ import {
141
+ registerEnglishFallback,
142
+ useComponentTranslator,
143
+ } from '../i18n/useComponentTranslator';
144
+ import english from '../i18n/generated-english/PowerSearch/PowerSearchBottomSheet.generated';
145
+
146
+ registerEnglishFallback(english);
147
+
148
+ // =============================================================================
149
+ // Props
150
+ // =============================================================================
151
+
152
+ /**
153
+ * The popover presentation's editors, which the value step reuses. They come
154
+ * from PowerSearch.tsx, whose module graph already loads them for the popover
155
+ * presentation. Imported here, they and every component they reach would join
156
+ * this lazily imported graph, whose modules must import Core directly and
157
+ * never through a barrel.
158
+ */
159
+ export interface PowerSearchSheetEditors {
160
+ NestedEditor: typeof NestedEditor;
161
+ PowerSearchValueEditor: typeof PowerSearchValueEditor;
162
+ buildRelativeDateOptions: typeof buildRelativeDateOptions;
163
+ }
164
+
165
+ export interface PowerSearchBottomSheetProps {
166
+ /** Whether the flow is open; the trigger in PowerSearch.tsx owns it. */
167
+ isOpen: boolean;
168
+ onOpenChange: (isOpen: boolean) => void;
169
+ /**
170
+ * The trigger: where focus returns when the flow closes, whichever step
171
+ * closed it and however it opened (a tap on a token included).
172
+ */
173
+ finalFocusRef: RefObject<HTMLElement | null>;
174
+ /**
175
+ * The field's formatted result count, announced from inside the open
176
+ * sheet's dialog when it changes while the flow is open. `useAnnounce`
177
+ * routes a message by where its caller is, so the owner above the sheets
178
+ * cannot reach the dialog's region.
179
+ */
180
+ resultCountText?: string | null;
181
+ config: InternalConfig;
182
+ /** The caller's config, handed to Editor overrides as they expect it. */
183
+ configProp: PowerSearchConfig;
184
+ filters: ReadonlyArray<PowerSearchFilter>;
185
+ onChange: (
186
+ filters: ReadonlyArray<PowerSearchFilter>,
187
+ changeType: PowerSearchChangeType,
188
+ index: number,
189
+ ) => void;
190
+ /** The field typeahead source; the content search lists its suggestions. */
191
+ searchSource: SearchSource<PowerSearchItem>;
192
+ /** Placeholder of the content search. */
193
+ placeholder: string;
194
+ isReadOnly: boolean;
195
+ isDisabled: boolean;
196
+ hasClear: boolean;
197
+ maxOperatorMenuItems?: number;
198
+ /** The zone a date-only value is read and written in; see PowerSearch. */
199
+ timezoneID?: string;
200
+ maxSearchResults: number;
201
+ /** The caller's confirm label; the step picks a default per mode. */
202
+ saveButtonLabel?: string;
203
+ components?: PowerSearchComponents;
204
+ editors: PowerSearchSheetEditors;
205
+ }
206
+
207
+ // =============================================================================
208
+ // Styles
209
+ // =============================================================================
210
+
211
+ // The trailing plus a field row shows when choosing it adds the filter at
212
+ // once, and the Add filter actions' glyph. Core's default icon set has no plus.
213
+ function PlusIcon(props: SVGProps<SVGSVGElement>) {
214
+ return (
215
+ <svg
216
+ viewBox="0 0 24 24"
217
+ fill="none"
218
+ stroke="currentColor"
219
+ strokeWidth={1.5}
220
+ strokeLinecap="round"
221
+ strokeLinejoin="round"
222
+ {...props}>
223
+ <path d="M5 12h14" />
224
+ <path d="M12 5v14" />
225
+ </svg>
226
+ );
227
+ }
228
+
229
+ const styles = stylex.create({
230
+ // 44px touch targets for every sheet action, without resizing inputs.
231
+ touchAction: {
232
+ minHeight: 44,
233
+ },
234
+ // A sheet action drawn smaller than 44px keeps its drawn size and grows
235
+ // only its hit area to 44px, under a coarse pointer. The custom properties
236
+ // carry the media condition because StyleX allows plain values inside a
237
+ // pseudo-element. On a fine pointer the overlay is not generated, so it
238
+ // never takes hover from the action's own content.
239
+ touchHitArea: {
240
+ '--_power-search-sheet-hit-inset': {
241
+ default: '0px',
242
+ '@media (pointer: coarse)': 'min(0px, calc((100% - 44px) / 2))',
243
+ },
244
+ '--_power-search-sheet-hit-content': {
245
+ default: 'none',
246
+ '@media (pointer: coarse)': '""',
247
+ },
248
+ '::after': {
249
+ content: 'var(--_power-search-sheet-hit-content)',
250
+ position: 'absolute',
251
+ inset: 'var(--_power-search-sheet-hit-inset)',
252
+ },
253
+ },
254
+ // Scopes the sticky header's and footer's layer to this sheet's content:
255
+ // the List rows are `position: relative` and come after the header, so
256
+ // without a layer of its own the header is painted under the rows that
257
+ // scroll beneath it. Isolating the content keeps that z-index inside it, so
258
+ // the sheet's grab handle (z-index 1 on the panel, outside this wrapper)
259
+ // still paints above.
260
+ sheet: {
261
+ display: 'flex',
262
+ flexDirection: 'column',
263
+ minHeight: '100%',
264
+ isolation: 'isolate',
265
+ },
266
+ // The Filters and Add filter sheets fill the panel and scroll only their
267
+ // viewport, so the header and footer stay fixed.
268
+ sheetWithFixedFooter: {
269
+ height: '100%',
270
+ overflow: 'hidden',
271
+ },
272
+ header: {
273
+ position: 'sticky',
274
+ insetBlockStart: 0,
275
+ // Above the rows that scroll under it, within the isolated sheet content.
276
+ zIndex: 1,
277
+ display: 'flex',
278
+ flexDirection: 'column',
279
+ gap: spacingVars['--spacing-3'],
280
+ // Clears the sheet's floating grab handle.
281
+ paddingBlockStart: spacingVars['--spacing-6'],
282
+ paddingBlockEnd: spacingVars['--spacing-3'],
283
+ paddingInline: spacingVars['--spacing-4'],
284
+ backgroundColor: colorVars['--color-background-surface'],
285
+ },
286
+ headerRow: {
287
+ display: 'flex',
288
+ alignItems: 'center',
289
+ gap: spacingVars['--spacing-2'],
290
+ },
291
+ headerText: {
292
+ minWidth: 0,
293
+ flexGrow: 1,
294
+ display: 'flex',
295
+ flexDirection: 'column',
296
+ },
297
+ // The body pads the sheet's content and publishes that inline padding as
298
+ // the container padding its lists read: with `edge="bleed"` their rows run
299
+ // edge to edge (full-bleed dividers) while row text keeps this inset, so
300
+ // row text, group headings and the sheet title share one edge.
301
+ body: {
302
+ flexGrow: 1,
303
+ display: 'flex',
304
+ flexDirection: 'column',
305
+ gap: spacingVars['--spacing-4'],
306
+ paddingInline: spacingVars['--spacing-4'],
307
+ paddingBlockEnd: spacingVars['--spacing-4'],
308
+ },
309
+ // The viewport reaches the sheet edge, so its scrollbar does too; the
310
+ // content inside it carries the body's padding.
311
+ scrollViewport: {
312
+ flexGrow: 1,
313
+ minHeight: 0,
314
+ overflowY: 'auto',
315
+ overscrollBehaviorY: 'contain',
316
+ },
317
+ scrollContent: {
318
+ minHeight: '100%',
319
+ display: 'flex',
320
+ flexDirection: 'column',
321
+ gap: spacingVars['--spacing-4'],
322
+ paddingInline: spacingVars['--spacing-4'],
323
+ paddingBlockEnd: spacingVars['--spacing-4'],
324
+ },
325
+ footer: {
326
+ position: 'sticky',
327
+ insetBlockEnd: 0,
328
+ zIndex: 1,
329
+ display: 'flex',
330
+ alignItems: 'center',
331
+ justifyContent: 'space-between',
332
+ gap: spacingVars['--spacing-2'],
333
+ paddingBlock: spacingVars['--spacing-3'],
334
+ paddingInline: spacingVars['--spacing-4'],
335
+ backgroundColor: colorVars['--color-background-surface'],
336
+ },
337
+ fixedFooter: {
338
+ position: 'relative',
339
+ insetBlockEnd: 'auto',
340
+ flexShrink: 0,
341
+ },
342
+ footerEqualAction: {
343
+ flexGrow: 1,
344
+ flexBasis: 0,
345
+ minWidth: 0,
346
+ minHeight: 44,
347
+ },
348
+ footerSoleAction: {
349
+ flexGrow: 1,
350
+ },
351
+ empty: {
352
+ paddingBlock: spacingVars['--spacing-6'],
353
+ textAlign: 'center',
354
+ },
355
+ emptyState: {
356
+ flexGrow: 1,
357
+ display: 'flex',
358
+ alignItems: 'center',
359
+ justifyContent: 'center',
360
+ },
361
+ // With more than one section in the value step, each section is a muted
362
+ // container. Its inline padding is published as container padding, so
363
+ // choice rows run edge to edge inside the section.
364
+ controlSection: {
365
+ padding: spacingVars['--spacing-3'],
366
+ borderRadius: radiusVars['--radius-container'],
367
+ backgroundColor: colorVars['--color-background-muted'],
368
+ },
369
+ // The operator radios put their indicator at the row's end, as the choice
370
+ // rows do.
371
+ trailingControlRow: {
372
+ flexDirection: 'row-reverse',
373
+ },
374
+ // The built-in nested editor's sub-filter rows collapse from one line to
375
+ // wrapped lines under a container query on the popover's own container;
376
+ // the sheet renders no such container, so the editor's tree root is the
377
+ // query container here, and at phone widths the rows wrap instead of
378
+ // running past the sheet's edge.
379
+ nestedEditorContainer: {
380
+ containerType: 'inline-size',
381
+ overflow: 'hidden',
382
+ },
383
+ rowValue: {
384
+ fontWeight: fontWeightVars['--font-weight-bold'],
385
+ },
386
+ // Choice rows run edge to edge on the inset of the surface that holds them,
387
+ // the body or a muted section, with no dividers: the group reaches into
388
+ // that padding and each row pads back out to it.
389
+ choiceList: {
390
+ display: 'flex',
391
+ flexDirection: 'column',
392
+ marginInlineStart: 'calc(-1 * var(--container-padding-inline-start, 0px))',
393
+ marginInlineEnd: 'calc(-1 * var(--container-padding-inline-end, 0px))',
394
+ width:
395
+ 'calc(100% + var(--container-padding-inline-start, 0px) + var(--container-padding-inline-end, 0px))',
396
+ },
397
+ choiceRow: {
398
+ display: 'flex',
399
+ alignItems: 'center',
400
+ gap: spacingVars['--spacing-3'],
401
+ width: '100%',
402
+ minHeight: 44,
403
+ paddingBlock: spacingVars['--spacing-3'],
404
+ paddingInlineStart: 'var(--container-padding-inline-start, 0px)',
405
+ paddingInlineEnd: 'var(--container-padding-inline-end, 0px)',
406
+ borderWidth: 0,
407
+ borderRadius: 0,
408
+ backgroundColor: 'transparent',
409
+ color: colorVars['--color-text-primary'],
410
+ fontFamily: 'inherit',
411
+ textAlign: 'start',
412
+ cursor: {
413
+ default: 'pointer',
414
+ ':is([aria-disabled="true"])': 'default',
415
+ },
416
+ },
417
+ // An aria-disabled row gets no system disabled paint in forced colors;
418
+ // GrayText is the platform's disabled affordance there.
419
+ choiceRowDisabled: {
420
+ color: {
421
+ default: colorVars['--color-text-secondary'],
422
+ '@media (forced-colors: active)': 'GrayText',
423
+ },
424
+ },
425
+ choiceIcon: {
426
+ display: 'inline-flex',
427
+ flexShrink: 0,
428
+ },
429
+ choiceLabel: {
430
+ flexGrow: 1,
431
+ minWidth: 0,
432
+ },
433
+ editorInput: {
434
+ paddingBlockStart: spacingVars['--spacing-1'],
435
+ },
436
+ });
437
+
438
+ // =============================================================================
439
+ // Draft state
440
+ // =============================================================================
441
+
442
+ type SheetStep = 'manage' | 'fields' | 'value';
443
+
444
+ type PendingSheetFocus =
445
+ | {readonly type: 'manager-add'}
446
+ | {readonly type: 'content-search'}
447
+ | {
448
+ readonly type: 'manager-filter';
449
+ readonly sourceFilter: PowerSearchFilter;
450
+ readonly sourceSignature: string;
451
+ readonly preferredIndex: number;
452
+ /**
453
+ * After a removal, focus goes to the row's trailing remove action, so a
454
+ * run of removals needs no re-aiming; otherwise to the row.
455
+ */
456
+ readonly part?: 'remove';
457
+ }
458
+ | {readonly type: 'field'; readonly fieldKey: string};
459
+
460
+ interface FilterDraft {
461
+ readonly mode: 'create' | 'edit';
462
+ /**
463
+ * Counts the value steps opened in this flow. Part of the editor's `key`:
464
+ * the step's sheet stays mounted between openings, and an editor that
465
+ * reads its filter on mount (the built-in nested editor, a caller's
466
+ * override) must remount per opened filter, not per field and operator —
467
+ * two groups on one field would otherwise share one editor's state.
468
+ */
469
+ readonly generation: number;
470
+ /** Control within the Add filter sheet that launched this draft. */
471
+ readonly returnFocus?: 'field' | 'content-search';
472
+ /** Index in `filters` when edit mode opened. */
473
+ readonly filterIndex?: number;
474
+ /** Original controlled filter identity, used to follow immutable reorders. */
475
+ readonly sourceFilter?: PowerSearchFilter;
476
+ /** Structural snapshot, used when controlled parents clone their values. */
477
+ readonly sourceSignature?: string;
478
+ readonly field: string;
479
+ readonly operator?: string;
480
+ readonly value?: FilterValue;
481
+ }
482
+
483
+ function resolveDraftFilterIndex(
484
+ filters: ReadonlyArray<PowerSearchFilter>,
485
+ draft: FilterDraft,
486
+ ): number | null {
487
+ if (
488
+ draft.mode !== 'edit' ||
489
+ draft.filterIndex == null ||
490
+ draft.sourceFilter == null ||
491
+ draft.sourceSignature == null
492
+ ) {
493
+ return null;
494
+ }
495
+ return resolveFilterIdentityIndex(
496
+ filters,
497
+ draft.sourceFilter,
498
+ draft.sourceSignature,
499
+ draft.filterIndex,
500
+ );
501
+ }
502
+
503
+ // Below this many fields a search box is noise: the whole list is on screen.
504
+ const SEARCHABLE_FIELD_COUNT = 8;
505
+ // Empty-query browsing keeps the popover presentation's safety ceiling.
506
+ const MAX_BROWSE_MENU_ITEMS = 1000;
507
+
508
+ // =============================================================================
509
+ // Choice rows (enum, enum_list, date_relative)
510
+ // =============================================================================
511
+
512
+ /**
513
+ * Rendered inside a step's BottomSheet so that `useAnnounce` resolves the
514
+ * sheet's dialog as the enclosing modal; publishes that
515
+ * dialog's `announce` to the flow above through `onAnnounceChange` while
516
+ * mounted. Renders nothing.
517
+ */
518
+ function SheetAnnouncer({
519
+ onAnnounceChange,
520
+ }: {
521
+ onAnnounceChange: (announce: AnnounceFn | null) => void;
522
+ }) {
523
+ const announce = useAnnounce();
524
+ // Hands this dialog's announce function up; the cleanup withdraws it when
525
+ // the step changes or the flow closes.
526
+ useEffect(() => {
527
+ onAnnounceChange(announce);
528
+ return () => {
529
+ onAnnounceChange(null);
530
+ };
531
+ }, [announce, onAnnounceChange]);
532
+ return null;
533
+ }
534
+
535
+ /**
536
+ * The value step's "This filter is no longer available" message, rendered
537
+ * inside the step's dialog. The shared own-content module reads the rendered
538
+ * text and speaks it once per opened filter while the step is the open one.
539
+ * Called from inside the sheet's dialog, `useAnnounce` routes it into the
540
+ * dialog. The message is plain text, not an EmptyState, so nothing announces
541
+ * it a second time.
542
+ */
543
+ function SheetEmptyMessage({
544
+ id,
545
+ message,
546
+ occasion,
547
+ isShown,
548
+ }: {
549
+ id?: string;
550
+ message: string;
551
+ occasion: string;
552
+ /**
553
+ * Whether the message's step is the open one. A sheet beneath another, or
554
+ * closed with its body retained, speaks nothing, and the hook re-arms so
555
+ * the same occasion is spoken again when the step reopens on it.
556
+ */
557
+ isShown: boolean;
558
+ }) {
559
+ const ref = useRef<HTMLDivElement>(null);
560
+ useAnnounceRenderedText(ref, isShown, occasion);
561
+ return (
562
+ <div ref={ref} id={id} {...stylex.props(styles.empty)}>
563
+ <Text type="supporting" color="secondary">
564
+ {message}
565
+ </Text>
566
+ </div>
567
+ );
568
+ }
569
+
570
+ interface ChoiceItem {
571
+ readonly value: string;
572
+ readonly label: string;
573
+ readonly icon?: ReactNode;
574
+ }
575
+
576
+ // A radio group's one tab stop: the checked row, else the first.
577
+ function radioTabStopOrder(list: HTMLElement): ReadonlyArray<HTMLElement> {
578
+ const rows = Array.from(list.querySelectorAll<HTMLElement>('[role="radio"]'));
579
+ const checked = rows.find(row => row.getAttribute('aria-checked') === 'true');
580
+ return checked == null
581
+ ? rows
582
+ : [checked, ...rows.filter(row => row !== checked)];
583
+ }
584
+
585
+ /**
586
+ * Closed vocabularies render as full-width rows with a trailing check instead
587
+ * of the popover presentation's Selector or Tokenizer: a dropdown layer inside
588
+ * a sheet stacks a second overlay on a surface the person is already inside.
589
+ */
590
+ function ChoiceRows({
591
+ items,
592
+ selected,
593
+ isDisabled,
594
+ isMultiple,
595
+ label,
596
+ onToggle,
597
+ }: {
598
+ items: ReadonlyArray<ChoiceItem>;
599
+ selected: ReadonlySet<string>;
600
+ isDisabled?: boolean;
601
+ /** `enum_list`: checkbox rows that toggle. Otherwise radio rows. */
602
+ isMultiple: boolean;
603
+ /** The group's accessible name: the field label. */
604
+ label: string;
605
+ onToggle: (value: string, isSelected: boolean) => void;
606
+ }) {
607
+ // The rows carry their state as state, not as a name suffix: `radio` rows
608
+ // in a `radiogroup` for a single choice, `checkbox` rows in a `group` for
609
+ // `enum_list`, each with `aria-checked`, as the Tokenizer's sheet rows do.
610
+ // Disabled rows stay focusable with `aria-disabled`.
611
+ //
612
+ // Keyboard: a radio group is one tab stop — the checked row, else the
613
+ // first — and the arrow keys move focus and check the row they land on,
614
+ // wrapping; Home and End move to and check the first and last row. The
615
+ // shared list focus owns the tab stop and the moves; a row that takes focus
616
+ // from an arrow, Home or End is checked here. Space and Enter check the
617
+ // focused radio row and toggle a checkbox row: each row is a native
618
+ // `button`, whose activation fires `onClick` for both keys. A checkbox row
619
+ // is its own tab stop.
620
+ const isKeyMoveRef = useRef(false);
621
+ const {listRef, handleKeyDown, handleFocus} =
622
+ useInternalListFocus<HTMLDivElement>({
623
+ itemSelector: '[role="radio"]',
624
+ orientation: 'both',
625
+ hasRovingTabIndex: !isMultiple,
626
+ tabStopOrder: radioTabStopOrder,
627
+ });
628
+ const handleRadioKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
629
+ isKeyMoveRef.current = true;
630
+ try {
631
+ handleKeyDown(event);
632
+ } finally {
633
+ isKeyMoveRef.current = false;
634
+ }
635
+ };
636
+ const handleRadioFocus = (event: React.FocusEvent<HTMLDivElement>) => {
637
+ handleFocus(event);
638
+ if (!isKeyMoveRef.current || isDisabled) {
639
+ return;
640
+ }
641
+ const value = (event.target as HTMLElement).closest<HTMLElement>(
642
+ '[role="radio"]',
643
+ )?.dataset.value;
644
+ if (value != null && !selected.has(value)) {
645
+ onToggle(value, false);
646
+ }
647
+ };
648
+ return (
649
+ <div
650
+ ref={listRef}
651
+ role={isMultiple ? 'group' : 'radiogroup'}
652
+ aria-label={label}
653
+ onKeyDown={isMultiple ? undefined : handleRadioKeyDown}
654
+ onFocus={isMultiple ? undefined : handleRadioFocus}
655
+ {...stylex.props(styles.choiceList)}>
656
+ {items.map(item => {
657
+ const isSelected = selected.has(item.value);
658
+ return (
659
+ <button
660
+ key={item.value}
661
+ type="button"
662
+ role={isMultiple ? 'checkbox' : 'radio'}
663
+ aria-checked={isSelected}
664
+ aria-disabled={isDisabled || undefined}
665
+ data-value={item.value}
666
+ onClick={() => {
667
+ if (!isDisabled) {
668
+ onToggle(item.value, isSelected);
669
+ }
670
+ }}
671
+ {...stylex.props(
672
+ focusOutlineStyles.focusVisible,
673
+ styles.choiceRow,
674
+ isDisabled && styles.choiceRowDisabled,
675
+ )}>
676
+ {isRenderable(item.icon) && (
677
+ <span aria-hidden="true" {...stylex.props(styles.choiceIcon)}>
678
+ {item.icon}
679
+ </span>
680
+ )}
681
+ <Text
682
+ type="body"
683
+ color={isDisabled ? 'inherit' : undefined}
684
+ xstyle={styles.choiceLabel}>
685
+ {item.label}
686
+ </Text>
687
+ {isMultiple ? (
688
+ <CheckboxIndicator
689
+ state={isSelected ? 'checked' : 'unchecked'}
690
+ size="md"
691
+ isDisabled={isDisabled}
692
+ />
693
+ ) : (
694
+ <RadioIndicator
695
+ state={isSelected ? 'checked' : 'unchecked'}
696
+ size="md"
697
+ isDisabled={isDisabled}
698
+ />
699
+ )}
700
+ </button>
701
+ );
702
+ })}
703
+ </div>
704
+ );
705
+ }
706
+
707
+ /**
708
+ * The value control for one filter inside the value step. Closed vocabularies
709
+ * are rows here; every other type is the popover presentation's own editor in
710
+ * its sheet presentation.
711
+ */
712
+ function SheetValueEditor({
713
+ operatorValue,
714
+ filterValue,
715
+ onChange,
716
+ onUnstage,
717
+ config,
718
+ fieldLabel,
719
+ isDisabled,
720
+ maxMenuItems,
721
+ timezoneID,
722
+ editors,
723
+ }: {
724
+ operatorValue: OperatorValue;
725
+ filterValue: FilterValue | undefined;
726
+ onChange: (value: FilterValue) => void;
727
+ onUnstage: () => void;
728
+ config: InternalConfig;
729
+ fieldLabel: string;
730
+ isDisabled: boolean;
731
+ maxMenuItems?: number;
732
+ timezoneID?: string;
733
+ editors: PowerSearchSheetEditors;
734
+ }) {
735
+ const {buildRelativeDateOptions, PowerSearchValueEditor} = editors;
736
+ const t = useComponentTranslator();
737
+ const selectedValues = useMemo<ReadonlySet<string>>(() => {
738
+ if (filterValue?.type === 'enum' || filterValue?.type === 'date_relative') {
739
+ return new Set([filterValue.value]);
740
+ }
741
+ if (filterValue?.type === 'enum_list') {
742
+ return new Set(filterValue.value);
743
+ }
744
+ return new Set();
745
+ }, [filterValue]);
746
+
747
+ switch (operatorValue.type) {
748
+ case 'empty':
749
+ case 'nested':
750
+ return null;
751
+ case 'enum':
752
+ return (
753
+ <ChoiceRows
754
+ items={operatorValue.values}
755
+ selected={selectedValues}
756
+ isDisabled={isDisabled}
757
+ isMultiple={false}
758
+ label={fieldLabel}
759
+ onToggle={value => onChange({type: 'enum', value})}
760
+ />
761
+ );
762
+ case 'enum_list':
763
+ return (
764
+ <ChoiceRows
765
+ items={operatorValue.values}
766
+ selected={selectedValues}
767
+ isDisabled={isDisabled}
768
+ isMultiple
769
+ label={fieldLabel}
770
+ onToggle={(value, isSelected) => {
771
+ const existing =
772
+ filterValue?.type === 'enum_list' ? filterValue.value : [];
773
+ onChange({
774
+ type: 'enum_list',
775
+ value: isSelected
776
+ ? existing.filter(v => v !== value)
777
+ : [...existing, value],
778
+ });
779
+ }}
780
+ />
781
+ );
782
+ case 'date_relative':
783
+ return (
784
+ <ChoiceRows
785
+ items={buildRelativeDateOptions(operatorValue, t)}
786
+ selected={selectedValues}
787
+ isDisabled={isDisabled}
788
+ isMultiple={false}
789
+ label={fieldLabel}
790
+ onToggle={value => onChange({type: 'date_relative', value})}
791
+ />
792
+ );
793
+ case 'string':
794
+ case 'string_list':
795
+ case 'integer':
796
+ case 'float':
797
+ case 'time':
798
+ case 'date_absolute':
799
+ case 'date_range':
800
+ case 'entity_list':
801
+ case 'custom':
802
+ return (
803
+ <div {...stylex.props(styles.editorInput)}>
804
+ <PowerSearchValueEditor
805
+ operatorValue={operatorValue}
806
+ filterValue={filterValue}
807
+ onChange={onChange}
808
+ onUnstage={onUnstage}
809
+ config={config}
810
+ maxMenuItems={maxMenuItems}
811
+ isDisabled={isDisabled}
812
+ presentation="bottom-sheet"
813
+ fieldLabel={fieldLabel}
814
+ timezoneID={timezoneID}
815
+ />
816
+ </div>
817
+ );
818
+ }
819
+ }
820
+
821
+ // =============================================================================
822
+ // Component
823
+ // =============================================================================
824
+
825
+ /**
826
+ * The sheet flow of PowerSearch's touch presentation: Filters, Add filter,
827
+ * and the value step, as standalone sheets that stack, each open beneath the
828
+ * step it led to.
829
+ */
830
+ export function PowerSearchBottomSheet({
831
+ isOpen,
832
+ onOpenChange,
833
+ finalFocusRef,
834
+ resultCountText,
835
+ config,
836
+ configProp,
837
+ filters,
838
+ onChange,
839
+ searchSource,
840
+ placeholder,
841
+ isReadOnly,
842
+ isDisabled,
843
+ hasClear,
844
+ maxOperatorMenuItems,
845
+ timezoneID,
846
+ maxSearchResults,
847
+ saveButtonLabel: saveButtonLabelFromProps,
848
+ components: componentOverrides,
849
+ editors,
850
+ }: PowerSearchBottomSheetProps) {
851
+ const {NestedEditor} = editors;
852
+ const t = useComponentTranslator();
853
+ const locale = useLocale();
854
+ const formatList = useListFormatter();
855
+ const isInteractive = !isDisabled && !isReadOnly;
856
+
857
+ const contentSearchFieldKey = config.config.contentSearchFieldKey;
858
+ const contentSearchOperator =
859
+ contentSearchFieldKey == null
860
+ ? undefined
861
+ : config.getDefaultOperator(contentSearchFieldKey);
862
+ const hasContentSearch = contentSearchOperator?.value.type === 'string';
863
+ const suggestionSource = useMemo(
864
+ () => createSheetSuggestionSource(config, searchSource),
865
+ [config, searchSource],
866
+ );
867
+
868
+ const contentResultsId = useId();
869
+ const managerAddButtonRef = useRef<HTMLButtonElement>(null);
870
+ const contentSearchRef = useRef<HTMLDivElement>(null);
871
+ const fieldSearchRef = useRef<HTMLDivElement>(null);
872
+ // The Add filter sheet's panel: the fallback focus target when the control
873
+ // a step came from is gone — its first focusable control, which is the
874
+ // search field when one shows, else the first field row.
875
+ const fieldsSheetRef = useRef<HTMLDivElement>(null);
876
+ // The content search and its suggestion rows are one vertical list for the
877
+ // arrow keys: ArrowDown from the searchbox reaches the first suggestion,
878
+ // ArrowUp from the first suggestion returns to the searchbox, and the ends
879
+ // do not wrap. Home and End stay with the searchbox's caret.
880
+ const contentListFocus = useInternalListFocus<HTMLDivElement>({
881
+ itemSelector: `[role="searchbox"], [id="${contentResultsId}"] button`,
882
+ wrap: false,
883
+ hasHomeEnd: false,
884
+ });
885
+ const fieldsSheetCallbackRef = useMergedRefs(
886
+ fieldsSheetRef,
887
+ contentListFocus.listRef,
888
+ );
889
+ const handleContentListKeyDown = (event: React.KeyboardEvent) => {
890
+ if (!isImeKeyEvent(event.nativeEvent)) {
891
+ contentListFocus.handleKeyDown(event);
892
+ }
893
+ };
894
+ const manageSheetRef = useRef<HTMLDivElement>(null);
895
+ // Where focus was inside the Filters or Add filter sheet, read on every
896
+ // focus change there, so a controlled update that unmounts the focused
897
+ // control (a filter's row, a dropped field's row, Clear all after the last
898
+ // filter) can put focus on the nearest remaining row by that index, else
899
+ // the sheet's first control, else the sheet panel. A removed element
900
+ // fires no blur, so the position is what the effect below reads.
901
+ const lastSheetFocusRef = useRef<{
902
+ sheet: 'manage' | 'fields';
903
+ rowIndex: number | null;
904
+ } | null>(null);
905
+ // One index space for the record and the recovery: rows that are buttons
906
+ // (a plain read-only row is passed over, as the removal and Apply/Cancel
907
+ // rules pass it over), in document order.
908
+ const buttonRowsOf = (panel: HTMLElement): HTMLElement[] =>
909
+ Array.from(panel.querySelectorAll<HTMLLIElement>('li'))
910
+ .map(row => row.querySelector<HTMLElement>('button'))
911
+ .filter((button): button is HTMLElement => button != null);
912
+ const noteSheetFocus = useCallback(
913
+ (sheet: 'manage' | 'fields') => (event: React.FocusEvent) => {
914
+ const row = (event.target as HTMLElement).closest('li');
915
+ const button = row?.querySelector<HTMLElement>('button') ?? null;
916
+ const rowIndex =
917
+ button == null
918
+ ? -1
919
+ : buttonRowsOf(event.currentTarget as HTMLElement).indexOf(button);
920
+ lastSheetFocusRef.current = {
921
+ sheet,
922
+ rowIndex: rowIndex === -1 ? null : rowIndex,
923
+ };
924
+ },
925
+ [],
926
+ );
927
+ const managerRowsRef = useRef(new Map<number, HTMLLIElement>());
928
+ const fieldRowsRef = useRef(new Map<string, HTMLLIElement>());
929
+ const latestFiltersRef = useRef(filters);
930
+ const pendingSheetFocusRef = useRef<PendingSheetFocus | null>(null);
931
+ const pendingMutationFocusRef = useRef(false);
932
+
933
+ // The step an open starts on: Add filter directly when an interactive field
934
+ // has nothing to manage yet, otherwise Filters. Neither search field takes
935
+ // focus on open, so the software keyboard stays down until the person taps
936
+ // the search. Add filter opened directly is the flow's entry point: no
937
+ // Filters sheet beneath it, and Done in its footer.
938
+ const isEntryAddFilter = filters.length === 0 && isInteractive;
939
+ const [step, setStep] = useState<SheetStep | null>(() =>
940
+ isOpen ? (isEntryAddFilter ? 'fields' : 'manage') : null,
941
+ );
942
+ const [fieldPickerOrigin, setFieldPickerOrigin] = useState<
943
+ 'manage' | 'trigger'
944
+ >(() => (isOpen && isEntryAddFilter ? 'trigger' : 'manage'));
945
+ // The current step, read by the sheets' close reports and by a removal that
946
+ // decides where to speak.
947
+ const stepRef = useRef(step);
948
+ useLayoutEffect(() => {
949
+ stepRef.current = step;
950
+ }, [step]);
951
+ // The draft outlives a step change on purpose: a closing value step stays
952
+ // presented through its exit, and clearing the draft with the step would
953
+ // blank that sheet mid-transition.
954
+ const [draft, setDraft] = useState<FilterDraft | null>(null);
955
+ const draftGenerationRef = useRef(0);
956
+ const nextDraftGeneration = useCallback(() => {
957
+ draftGenerationRef.current += 1;
958
+ return draftGenerationRef.current;
959
+ }, []);
960
+ const [fieldQuery, setFieldQuery] = useState('');
961
+ const [contentQuery, setContentQuery] = useState('');
962
+ const [isClearConfirmationOpen, setIsClearConfirmationOpen] = useState(false);
963
+ const clearAllButtonRef = useRef<HTMLButtonElement>(null);
964
+ // Set while Clear all commits, so the result count that changes in that
965
+ // commit is spoken with Clear all's outcome, as one message.
966
+ const isClearingRef = useRef(false);
967
+
968
+ // `useAnnounce` delivers inside the open Astryx modal that encloses its
969
+ // caller, and this component renders above the
970
+ // sheets, so it cannot call the hook itself. Each step's body renders a
971
+ // `SheetAnnouncer` inside its BottomSheet, which publishes the dialog's own
972
+ // `announce` here while that step is the active one; the effects below
973
+ // speak through it, so every message about a sheet's content is delivered
974
+ // in that sheet's dialog.
975
+ const announceRef = useRef<AnnounceFn | null>(null);
976
+ const handleAnnounceChange = useCallback((next: AnnounceFn | null) => {
977
+ announceRef.current = next;
978
+ }, []);
979
+ const announce = useCallback((message: string) => {
980
+ announceRef.current?.(message);
981
+ }, []);
982
+ const pendingAnnouncementRef = useRef<string | null>(null);
983
+
984
+ // The controlled `isOpen` opens and closes the flow; the step follows it in
985
+ // the render that sees the change, not an effect, so the entry step's sheet
986
+ // opens in the same commit as the flow (React's adjust-state-on-
987
+ // prop-change pattern). Closing from outside — the field became disabled —
988
+ // ends the flow wherever it was, the Clear all confirmation included.
989
+ const [wasOpen, setWasOpen] = useState(isOpen);
990
+ if (isOpen !== wasOpen) {
991
+ setWasOpen(isOpen);
992
+ if (isOpen) {
993
+ setFieldQuery('');
994
+ setContentQuery('');
995
+ setStep(isEntryAddFilter ? 'fields' : 'manage');
996
+ setFieldPickerOrigin(isEntryAddFilter ? 'trigger' : 'manage');
997
+ } else {
998
+ setStep(null);
999
+ setIsClearConfirmationOpen(false);
1000
+ }
1001
+ }
1002
+
1003
+ const closeFlow = useCallback(() => {
1004
+ pendingSheetFocusRef.current = null;
1005
+ setStep(null);
1006
+ onOpenChange(false);
1007
+ }, [onOpenChange]);
1008
+
1009
+ // Whether a Filters row is a button: the field interactive and the
1010
+ // filter not read-only. The row opens the value step — the editor for its
1011
+ // operator's value type, the built-in nested editor for a `nested` one, or
1012
+ // for a filter the config no longer knows the unavailable state — and its
1013
+ // trailing remove action removes it, as the popover's token has its × for
1014
+ // it.
1015
+ const isRowButton = useCallback(
1016
+ (filter: PowerSearchFilter): boolean => !filter.isReadOnly && isInteractive,
1017
+ [isInteractive],
1018
+ );
1019
+
1020
+ // ------------------------------------------------------------------ focus --
1021
+
1022
+ // The control a step change named also becomes where the closing step
1023
+ // sheets return focus when their exit ends: a closing sheet that held focus
1024
+ // keeps it until then, and BottomSheet then returns it, so the named control
1025
+ // receives it even when the first move could not land. With no control
1026
+ // named, the closing steps return focus as the flow does.
1027
+ const stepReturnTargetRef = useRef<HTMLElement | null>(null);
1028
+ const stepFinalFocusRef = useMemo<React.RefObject<HTMLElement | null>>(
1029
+ () => ({
1030
+ get current() {
1031
+ return stepReturnTargetRef.current ?? finalFocusRef.current;
1032
+ },
1033
+ }),
1034
+ [finalFocusRef],
1035
+ );
1036
+ const placeFocus = (element: HTMLElement | null | undefined) => {
1037
+ if (element == null) {
1038
+ return;
1039
+ }
1040
+ stepReturnTargetRef.current = element;
1041
+ element.focus({preventScroll: true});
1042
+ };
1043
+ const focusPendingSheetTarget = useCallback(() => {
1044
+ const target = pendingSheetFocusRef.current;
1045
+ if (target == null) {
1046
+ return;
1047
+ }
1048
+ pendingSheetFocusRef.current = null;
1049
+ if (target.type === 'manager-add') {
1050
+ placeFocus(managerAddButtonRef.current);
1051
+ return;
1052
+ }
1053
+ // Add filter's fallback: a controlled update may have removed the field
1054
+ // whose row opened the step, or the content-search field itself; the
1055
+ // sheet's first focusable control then takes focus — the search field
1056
+ // when one shows, else the first field row.
1057
+ const firstFieldsControl = () => {
1058
+ const panel = fieldsSheetRef.current;
1059
+ return (
1060
+ panel?.querySelector<HTMLElement>('input') ??
1061
+ panel?.querySelector<HTMLElement>('li button') ??
1062
+ panel?.querySelector<HTMLElement>('button') ??
1063
+ null
1064
+ );
1065
+ };
1066
+ if (target.type === 'content-search') {
1067
+ placeFocus(
1068
+ contentSearchRef.current?.querySelector<HTMLInputElement>('input') ??
1069
+ firstFieldsControl(),
1070
+ );
1071
+ return;
1072
+ }
1073
+ if (target.type === 'field') {
1074
+ placeFocus(
1075
+ fieldRowsRef.current
1076
+ .get(target.fieldKey)
1077
+ ?.querySelector<HTMLButtonElement>('button') ?? firstFieldsControl(),
1078
+ );
1079
+ return;
1080
+ }
1081
+ // The row the step came from, followed across a controlled reorder or
1082
+ // clone; when a controlled update removed it, or left two filters it
1083
+ // could be, the nearest remaining row that is a button, as after a
1084
+ // removal; else Add filter.
1085
+ const index =
1086
+ resolveFilterIdentityIndex(
1087
+ latestFiltersRef.current,
1088
+ target.sourceFilter,
1089
+ target.sourceSignature,
1090
+ target.preferredIndex,
1091
+ ) ??
1092
+ nearestEditableIndex(
1093
+ latestFiltersRef.current,
1094
+ target.preferredIndex,
1095
+ isRowButton,
1096
+ );
1097
+ const row = index == null ? null : managerRowsRef.current.get(index);
1098
+ const rowButton =
1099
+ target.part === 'remove'
1100
+ ? (row?.querySelector<HTMLButtonElement>('[data-sheet-remove]') ??
1101
+ row?.querySelector<HTMLButtonElement>('button'))
1102
+ : row?.querySelector<HTMLButtonElement>('button');
1103
+ placeFocus(rowButton ?? managerAddButtonRef.current);
1104
+ }, [isRowButton]);
1105
+
1106
+ // A controlled update that unmounts the focused control of the Filters or
1107
+ // Add filter sheet leaves focus on the page body inside the open modal.
1108
+ // Read before paint on every commit of an open step: when focus is no
1109
+ // longer inside the sheet's dialog, the nearest remaining row by the last
1110
+ // focused index takes it, else the sheet's first control, else its panel.
1111
+ // The pending-target effects below run after and win when a step
1112
+ // change named a control.
1113
+ useLayoutEffect(() => {
1114
+ const last = lastSheetFocusRef.current;
1115
+ if (step !== 'manage' && step !== 'fields') {
1116
+ return;
1117
+ }
1118
+ if (last == null || last.sheet !== step) {
1119
+ return;
1120
+ }
1121
+ const panel =
1122
+ step === 'manage' ? manageSheetRef.current : fieldsSheetRef.current;
1123
+ const dialog = panel?.closest('dialog');
1124
+ const active = document.activeElement;
1125
+ if (
1126
+ panel == null ||
1127
+ dialog == null ||
1128
+ (active != null && active !== document.body && dialog.contains(active))
1129
+ ) {
1130
+ return;
1131
+ }
1132
+ const rowButtons = buttonRowsOf(panel);
1133
+ let target: HTMLElement | null = null;
1134
+ if (last.rowIndex != null && rowButtons.length > 0) {
1135
+ // The following button row, else the last.
1136
+ target = rowButtons[Math.min(last.rowIndex, rowButtons.length - 1)];
1137
+ }
1138
+ target ??=
1139
+ panel.querySelector<HTMLElement>('input') ??
1140
+ rowButtons[0] ??
1141
+ panel.querySelector<HTMLElement>('button') ??
1142
+ null;
1143
+ if (target != null) {
1144
+ target.focus({preventScroll: true});
1145
+ } else {
1146
+ const focusable = dialog.querySelector<HTMLElement>('[tabindex="-1"]');
1147
+ (focusable ?? dialog).focus({preventScroll: true});
1148
+ }
1149
+ });
1150
+
1151
+ // Both Effects move focus inside the entering sheet — the external system
1152
+ // is the document's focus — to the control the step named. They run after
1153
+ // the entering sheet's own
1154
+ // panel focus (child effects commit first), so the intended control wins,
1155
+ // and they acquire nothing, so they need no cleanup. The filters effect is
1156
+ // declared first so a mutation that lands in the same commit as the step
1157
+ // change resolves its row against the new filters.
1158
+ useEffect(() => {
1159
+ latestFiltersRef.current = filters;
1160
+ if (step === 'manage' && pendingMutationFocusRef.current) {
1161
+ pendingMutationFocusRef.current = false;
1162
+ focusPendingSheetTarget();
1163
+ }
1164
+ }, [filters, focusPendingSheetTarget, step]);
1165
+
1166
+ // A target still pending when the flow closes, such as a commit that
1167
+ // disabled the field, never carries into the next open.
1168
+ useEffect(() => {
1169
+ if (step == null) {
1170
+ stepReturnTargetRef.current = null;
1171
+ pendingSheetFocusRef.current = null;
1172
+ return;
1173
+ }
1174
+ // A step that opens starts with no named return target; a return to
1175
+ // Filters keeps the one its commit, or its removal, has just named.
1176
+ if (step !== 'manage') {
1177
+ stepReturnTargetRef.current = null;
1178
+ }
1179
+ focusPendingSheetTarget();
1180
+ }, [focusPendingSheetTarget, step]);
1181
+
1182
+ // An announcement queued by a step change — a suggestion or a removal that
1183
+ // changed the filters — is spoken in the commit that shows the next step,
1184
+ // so it lands in that step's dialog region rather than the one about to
1185
+ // close. It synchronizes the live region named above and acquires nothing.
1186
+ useEffect(() => {
1187
+ const message = pendingAnnouncementRef.current;
1188
+ if (step != null && message != null) {
1189
+ pendingAnnouncementRef.current = null;
1190
+ announce(message);
1191
+ }
1192
+ }, [announce, step]);
1193
+
1194
+ // ----------------------------------------------------------------- fields --
1195
+
1196
+ // Every visible field lists: the value step edits every operator's value,
1197
+ // `nested` through the popover's built-in editor.
1198
+ const fields = useMemo(() => config.getVisibleFields(), [config]);
1199
+
1200
+ const normalizedFieldQuery = fieldQuery.trim().toLowerCase();
1201
+ const matchingFields = useMemo(() => {
1202
+ const matches = fields.filter(field =>
1203
+ fieldMatchesQuery(field, normalizedFieldQuery),
1204
+ );
1205
+ return normalizedFieldQuery === ''
1206
+ ? matches
1207
+ : matches.slice(0, maxSearchResults);
1208
+ }, [fields, maxSearchResults, normalizedFieldQuery]);
1209
+
1210
+ // Ungrouped fields first, then each group in first-appearance order — the
1211
+ // same grouping the popover presentation's field suggestions use
1212
+ // (`groupItems` with `ungroupedFirst`), so the two presentations
1213
+ // list a config's fields in one order.
1214
+ const groupedFields = useMemo(
1215
+ () =>
1216
+ matchingFields.length === 0
1217
+ ? []
1218
+ : groupItems(
1219
+ matchingFields.map(field => ({
1220
+ id: field.key,
1221
+ label: field.label,
1222
+ auxiliaryData: {group: field.group},
1223
+ field,
1224
+ })),
1225
+ {ungroupedFirst: true},
1226
+ ).map(
1227
+ group =>
1228
+ [
1229
+ group.heading ?? '',
1230
+ group.items.map(item => item.field),
1231
+ ] as const,
1232
+ ),
1233
+ [matchingFields],
1234
+ );
1235
+
1236
+ const isFieldSearchShown = fields.length >= SEARCHABLE_FIELD_COUNT;
1237
+
1238
+ // A field whose only operator takes an `empty` value is a one-tap filter:
1239
+ // its row adds it with no value step, and shows a plus, not a chevron.
1240
+ const isImmediateAddField = useCallback(
1241
+ (field: PowerSearchField): boolean => {
1242
+ const operators = config.getVisibleOperators(field.key);
1243
+ return operators.length === 1 && operators[0].value.type === 'empty';
1244
+ },
1245
+ [config],
1246
+ );
1247
+
1248
+ // The steps stack: each opens above the sheet that led to it, which stays
1249
+ // open beneath. Add filter entered directly has no Filters sheet beneath.
1250
+ const isFieldPickerDirectEntry = fieldPickerOrigin === 'trigger';
1251
+ const isManageSheetOpen =
1252
+ step === 'manage' ||
1253
+ (step === 'fields' && !isFieldPickerDirectEntry) ||
1254
+ (step === 'value' && (draft?.mode === 'edit' || !isFieldPickerDirectEntry));
1255
+ const isFieldsSheetOpen =
1256
+ step === 'fields' || (step === 'value' && draft?.mode === 'create');
1257
+
1258
+ // The suggestions derive synchronously from the config, so a list never
1259
+ // waits on a request: an empty list means an empty result, not a pending
1260
+ // one. They stay listed while the value step covers Add filter.
1261
+ const contentResults = useMemo(() => {
1262
+ if (!isFieldsSheetOpen || !hasContentSearch || !isInteractive) {
1263
+ return [];
1264
+ }
1265
+ const results =
1266
+ contentQuery.trim() === ''
1267
+ ? suggestionSource.bootstrap()
1268
+ : suggestionSource.search(contentQuery);
1269
+ return results.slice(0, MAX_BROWSE_MENU_ITEMS);
1270
+ }, [
1271
+ contentQuery,
1272
+ hasContentSearch,
1273
+ isFieldsSheetOpen,
1274
+ isInteractive,
1275
+ suggestionSource,
1276
+ ]);
1277
+
1278
+ // The field's result count, while a sheet is open: the owner's own Effect
1279
+ // is quiet then, so the change is spoken here, inside the dialog. Only a
1280
+ // change after the flow opened is spoken.
1281
+ const announcedResultCountRef = useRef(resultCountText);
1282
+ // The latest result count, for Clear all's handler to read after its
1283
+ // commit has flushed.
1284
+ const latestResultCountRef = useRef(resultCountText);
1285
+ useLayoutEffect(() => {
1286
+ latestResultCountRef.current = resultCountText;
1287
+ }, [resultCountText]);
1288
+ useEffect(() => {
1289
+ if (
1290
+ isClearingRef.current ||
1291
+ resultCountText === announcedResultCountRef.current
1292
+ ) {
1293
+ return;
1294
+ }
1295
+ announcedResultCountRef.current = resultCountText;
1296
+ if (step != null && resultCountText != null) {
1297
+ announce(resultCountText);
1298
+ }
1299
+ }, [announce, resultCountText, step]);
1300
+
1301
+ // ------------------------------------------------------------------ draft --
1302
+
1303
+ const draftField = draft ? config.getField(draft.field) : undefined;
1304
+ const draftOperator =
1305
+ draft?.operator != null
1306
+ ? config.getOperator(draft.field, draft.operator)
1307
+ : undefined;
1308
+ const draftOperators = useMemo(
1309
+ () => (draft ? config.getVisibleOperators(draft.field) : []),
1310
+ [config, draft],
1311
+ );
1312
+
1313
+ // The two parts of a Filters row's text for `filter`: "{field} {operator}"
1314
+ // as one catalog message, and the value as the popover presentation's
1315
+ // token shows it — the same function, so the same words and the same zone
1316
+ // handling, before the token's width budget. The rows render these, and
1317
+ // the removal announcement joins them, so the three can never
1318
+ // disagree.
1319
+ const describeFilterParts = useCallback(
1320
+ (filter: PowerSearchFilter): {label: string; value: string | null} => {
1321
+ const field = config.getField(filter.field);
1322
+ const operator = config.getOperator(filter.field, filter.operator);
1323
+ // A filter whose field or operator the config no longer has still
1324
+ // lists, by its raw keys, as its token does on the trigger; its row
1325
+ // opens the unavailable step, and its trailing remove action removes
1326
+ // it.
1327
+ const fieldLabel = field?.label ?? filter.field;
1328
+ const operatorLabel =
1329
+ operator == null ? filter.operator : resolveOperatorLabel(operator, t);
1330
+ const label = operatorLabel
1331
+ ? t('@astryx.powersearch.sheet.fieldOperator', {
1332
+ field: fieldLabel,
1333
+ operator: operatorLabel,
1334
+ })
1335
+ : fieldLabel;
1336
+ if (operator == null) {
1337
+ return {label, value: null};
1338
+ }
1339
+ // A relative-date value is stored as a key (`3d_ago`); the row reads it
1340
+ // as the value step's choice row does ("3 days ago"), through the
1341
+ // catalog's relative-date messages, so the sheet agrees with itself. The popover token
1342
+ // shows the stored key (a known gap of the record).
1343
+ const value =
1344
+ operator.value.type === 'date_relative' &&
1345
+ filter.value.type === 'date_relative'
1346
+ ? resolveRelativeDateLabel(
1347
+ operator.value,
1348
+ filter.value.value,
1349
+ t,
1350
+ editors.buildRelativeDateOptions,
1351
+ )
1352
+ : formatTokenValueText(
1353
+ operator.value,
1354
+ filter.value,
1355
+ Number.MAX_SAFE_INTEGER,
1356
+ locale,
1357
+ t,
1358
+ formatList,
1359
+ timezoneID,
1360
+ );
1361
+ return {label, value};
1362
+ },
1363
+ [config, editors, formatList, locale, t, timezoneID],
1364
+ );
1365
+ const describeFilter = useCallback(
1366
+ (filter: PowerSearchFilter): string => {
1367
+ const parts = describeFilterParts(filter);
1368
+ return parts.value
1369
+ ? t('@astryx.powersearch.sheet.labelValue', {
1370
+ label: parts.label,
1371
+ value: parts.value,
1372
+ })
1373
+ : parts.label;
1374
+ },
1375
+ [describeFilterParts, t],
1376
+ );
1377
+
1378
+ const openFieldList = useCallback(() => {
1379
+ if (!isInteractive) {
1380
+ return;
1381
+ }
1382
+ setFieldPickerOrigin('manage');
1383
+ setFieldQuery('');
1384
+ setContentQuery('');
1385
+ pendingSheetFocusRef.current = null;
1386
+ setStep('fields');
1387
+ }, [isInteractive]);
1388
+
1389
+ const queueManagerAddFocus = useCallback(() => {
1390
+ pendingSheetFocusRef.current = {type: 'manager-add'};
1391
+ }, []);
1392
+
1393
+ const commitSavedFilter = useCallback(
1394
+ (next: FilterDraft, filter: PowerSearchFilter) => {
1395
+ if (!isInteractive) {
1396
+ queueManagerAddFocus();
1397
+ setStep('manage');
1398
+ return;
1399
+ }
1400
+ if (next.mode === 'edit') {
1401
+ const filterIndex = resolveDraftFilterIndex(filters, next);
1402
+ const currentFilter =
1403
+ filterIndex == null ? undefined : filters[filterIndex];
1404
+ if (
1405
+ filterIndex == null ||
1406
+ currentFilter == null ||
1407
+ currentFilter.isReadOnly
1408
+ ) {
1409
+ // A controlled update removed the edited filter, or left two it
1410
+ // could be, or made it read-only: nothing is written, and focus
1411
+ // returns to the nearest row that is a button around where it was,
1412
+ // else Add filter.
1413
+ pendingSheetFocusRef.current =
1414
+ next.sourceFilter != null &&
1415
+ next.sourceSignature != null &&
1416
+ next.filterIndex != null
1417
+ ? {
1418
+ type: 'manager-filter',
1419
+ sourceFilter: next.sourceFilter,
1420
+ sourceSignature: next.sourceSignature,
1421
+ preferredIndex: next.filterIndex,
1422
+ }
1423
+ : {type: 'manager-add'};
1424
+ setStep('manage');
1425
+ return;
1426
+ }
1427
+ pendingSheetFocusRef.current = {
1428
+ type: 'manager-filter',
1429
+ sourceFilter: filter,
1430
+ sourceSignature: filterSignature(filter),
1431
+ preferredIndex: filterIndex,
1432
+ };
1433
+ const updated = [...filters];
1434
+ updated[filterIndex] = filter;
1435
+ onChange(updated, 'edit', filterIndex);
1436
+ } else {
1437
+ queueManagerAddFocus();
1438
+ onChange([...filters, filter], 'add', filters.length);
1439
+ }
1440
+ setStep('manage');
1441
+ },
1442
+ [filters, isInteractive, onChange, queueManagerAddFocus],
1443
+ );
1444
+
1445
+ // Opening a new filter can come from a field row or a content-search
1446
+ // suggestion. The origin is kept so Cancel returns to the control the person
1447
+ // came from.
1448
+ const openCreateEditor = useCallback(
1449
+ (
1450
+ field: PowerSearchField,
1451
+ operator: PowerSearchOperator,
1452
+ returnFocus: 'field' | 'content-search' = 'field',
1453
+ ) => {
1454
+ setDraft({
1455
+ mode: 'create',
1456
+ generation: nextDraftGeneration(),
1457
+ returnFocus,
1458
+ field: field.key,
1459
+ operator: operator.key,
1460
+ value: operator.value.type === 'empty' ? {type: 'empty'} : undefined,
1461
+ });
1462
+ pendingSheetFocusRef.current = null;
1463
+ setStep('value');
1464
+ },
1465
+ [nextDraftGeneration],
1466
+ );
1467
+
1468
+ const handleFieldSelect = useCallback(
1469
+ (field: PowerSearchField) => {
1470
+ if (!isInteractive) {
1471
+ return;
1472
+ }
1473
+ // A one-tap filter has nothing to stage: choosing its field adds it
1474
+ // and returns to Filters.
1475
+ if (isImmediateAddField(field)) {
1476
+ const [operator] = config.getVisibleOperators(field.key);
1477
+ const nextFilter: PowerSearchFilter = {
1478
+ field: field.key,
1479
+ operator: operator.key,
1480
+ value: {type: 'empty'},
1481
+ };
1482
+ pendingSheetFocusRef.current = {
1483
+ type: 'manager-filter',
1484
+ sourceFilter: nextFilter,
1485
+ sourceSignature: filterSignature(nextFilter),
1486
+ preferredIndex: filters.length,
1487
+ };
1488
+ pendingMutationFocusRef.current = true;
1489
+ onChange([...filters, nextFilter], 'add', filters.length);
1490
+ pendingAnnouncementRef.current = t('@astryx.tokenizer.tokenAdded', {
1491
+ label: describeFilter(nextFilter),
1492
+ });
1493
+ setStep('manage');
1494
+ return;
1495
+ }
1496
+ const operator =
1497
+ config.getDefaultOperator(field.key) ?? field.operators[0];
1498
+ if (operator == null) {
1499
+ return;
1500
+ }
1501
+ openCreateEditor(field, operator);
1502
+ },
1503
+ [
1504
+ config,
1505
+ describeFilter,
1506
+ filters,
1507
+ isImmediateAddField,
1508
+ isInteractive,
1509
+ onChange,
1510
+ openCreateEditor,
1511
+ t,
1512
+ ],
1513
+ );
1514
+
1515
+ const handleContentSuggestionSelect = useCallback(
1516
+ (item: PowerSearchItem | null) => {
1517
+ if (!isInteractive || item?.auxiliaryData == null) {
1518
+ return;
1519
+ }
1520
+ const {fieldKey, filterValue, operatorKey} = item.auxiliaryData;
1521
+ const field = config.getField(fieldKey);
1522
+ const operator =
1523
+ operatorKey == null
1524
+ ? config.getDefaultOperator(fieldKey)
1525
+ : config.getOperator(fieldKey, operatorKey);
1526
+ if (field == null || operator == null) {
1527
+ return;
1528
+ }
1529
+ setContentQuery('');
1530
+ if (filterValue != null) {
1531
+ const nextFilter: PowerSearchFilter = {
1532
+ field: field.key,
1533
+ operator: operator.key,
1534
+ value: filterValue,
1535
+ };
1536
+ pendingSheetFocusRef.current = {
1537
+ type: 'manager-filter',
1538
+ sourceFilter: nextFilter,
1539
+ sourceSignature: filterSignature(nextFilter),
1540
+ preferredIndex: filters.length,
1541
+ };
1542
+ pendingMutationFocusRef.current = true;
1543
+ onChange([...filters, nextFilter], 'add', filters.length);
1544
+ // The same announcement the popover presentation's typeahead add
1545
+ // makes, so assistive technology hears one phrase for one outcome;
1546
+ // spoken once the Filters sheet is the open dialog.
1547
+ pendingAnnouncementRef.current = t('@astryx.tokenizer.tokenAdded', {
1548
+ label: item.label,
1549
+ });
1550
+ setStep('manage');
1551
+ return;
1552
+ }
1553
+ openCreateEditor(field, operator, 'content-search');
1554
+ },
1555
+ [config, filters, isInteractive, onChange, openCreateEditor, t],
1556
+ );
1557
+
1558
+ const handleFilterEdit = useCallback(
1559
+ (index: number) => {
1560
+ const filter = filters[index];
1561
+ if (!isInteractive || filter == null || filter.isReadOnly) {
1562
+ return;
1563
+ }
1564
+ setDraft({
1565
+ mode: 'edit',
1566
+ generation: nextDraftGeneration(),
1567
+ filterIndex: index,
1568
+ sourceFilter: filter,
1569
+ sourceSignature: filterSignature(filter),
1570
+ field: filter.field,
1571
+ operator: filter.operator,
1572
+ value: filter.value,
1573
+ });
1574
+ pendingSheetFocusRef.current = null;
1575
+ setStep('value');
1576
+ },
1577
+ [filters, isInteractive, nextDraftGeneration],
1578
+ );
1579
+
1580
+ const handleRemoveFilter = useCallback(
1581
+ (index: number) => {
1582
+ const filter = filters[index];
1583
+ if (!isInteractive || filter == null || filter.isReadOnly) {
1584
+ return;
1585
+ }
1586
+ const nextFilters = filters.filter((_, i) => i !== index);
1587
+ const nextIndex = nearestEditableIndex(nextFilters, index, isRowButton);
1588
+ if (nextIndex == null) {
1589
+ pendingSheetFocusRef.current = {type: 'manager-add'};
1590
+ } else {
1591
+ const nextFilter = nextFilters[nextIndex];
1592
+ pendingSheetFocusRef.current = {
1593
+ type: 'manager-filter',
1594
+ sourceFilter: nextFilter,
1595
+ sourceSignature: filterSignature(nextFilter),
1596
+ preferredIndex: nextIndex,
1597
+ part: 'remove',
1598
+ };
1599
+ }
1600
+ pendingMutationFocusRef.current = true;
1601
+ onChange(nextFilters, 'remove', index);
1602
+ // The popover presentation's token × announces the same phrase. A
1603
+ // removal from a Filters row stays in that sheet and speaks at once; a
1604
+ // caller's Editor that removes while returning to Filters speaks once
1605
+ // the Filters sheet is the open dialog.
1606
+ const message = t('@astryx.tokenizer.tokenRemoved', {
1607
+ label: describeFilter(filter),
1608
+ });
1609
+ if (stepRef.current === 'manage') {
1610
+ announce(message);
1611
+ } else {
1612
+ pendingAnnouncementRef.current = message;
1613
+ }
1614
+ },
1615
+ [
1616
+ announce,
1617
+ describeFilter,
1618
+ filters,
1619
+ isInteractive,
1620
+ isRowButton,
1621
+ onChange,
1622
+ t,
1623
+ ],
1624
+ );
1625
+
1626
+ // Closing Clear all's confirmation. Kept, focus returns to Clear all.
1627
+ // Confirmed, Clear all removes the filters the person can edit and keeps
1628
+ // read-only ones, which are the consumer's: one `remove` at the first
1629
+ // removed index. The Filters sheet stays open for further editing. The
1630
+ // confirmation must be gone before focus moves into the sheet and the
1631
+ // outcome is routed into the sheet's region, so its close is flushed
1632
+ // first. The outcome is "Editable filters removed" while a read-only filter
1633
+ // remains, otherwise "All filters removed", joined with a result count that
1634
+ // changed in the same commit as one catalog message; focus moves to the
1635
+ // sheet's first remaining control: a kept read-only row has none, so Add
1636
+ // filter.
1637
+ const closeClearConfirmation = (confirmed: boolean) => {
1638
+ const kept = filters.filter(filter => filter.isReadOnly);
1639
+ const firstRemovedIndex = filters.findIndex(filter => !filter.isReadOnly);
1640
+ const isClearing = confirmed && isInteractive && firstRemovedIndex >= 0;
1641
+ isClearingRef.current = isClearing;
1642
+ try {
1643
+ // eslint-disable-next-line @eslint-react/dom-no-flush-sync -- the confirmation must be gone before focus moves into the sheet and the words are routed into it
1644
+ flushSync(() => {
1645
+ setIsClearConfirmationOpen(false);
1646
+ if (isClearing) {
1647
+ onChange(kept, 'remove', firstRemovedIndex);
1648
+ }
1649
+ });
1650
+ } finally {
1651
+ isClearingRef.current = false;
1652
+ }
1653
+ if (!isClearing) {
1654
+ clearAllButtonRef.current?.focus();
1655
+ return;
1656
+ }
1657
+ managerAddButtonRef.current?.focus({preventScroll: true});
1658
+ const resultCount = latestResultCountRef.current;
1659
+ const changedCount =
1660
+ resultCount !== announcedResultCountRef.current ? resultCount : null;
1661
+ announcedResultCountRef.current = resultCount;
1662
+ if (kept.length > 0) {
1663
+ announce(
1664
+ changedCount == null
1665
+ ? t('@astryx.powersearch.editableFiltersRemoved')
1666
+ : t('@astryx.powersearch.sheet.editableFiltersClearedWithCount', {
1667
+ resultCount: changedCount,
1668
+ }),
1669
+ );
1670
+ } else {
1671
+ announce(
1672
+ changedCount == null
1673
+ ? t('@astryx.powersearch.allFiltersRemoved')
1674
+ : t('@astryx.powersearch.sheet.filtersClearedWithCount', {
1675
+ resultCount: changedCount,
1676
+ }),
1677
+ );
1678
+ }
1679
+ };
1680
+
1681
+ const handleOperatorSelect = useCallback((operator: PowerSearchOperator) => {
1682
+ setDraft(current => {
1683
+ if (current == null) {
1684
+ return current;
1685
+ }
1686
+ // A value survives an operator change only when the new operator
1687
+ // reads the same value type.
1688
+ const keepsValue =
1689
+ current.value != null && current.value.type === operator.value.type;
1690
+ return {
1691
+ ...current,
1692
+ operator: operator.key,
1693
+ value:
1694
+ operator.value.type === 'empty'
1695
+ ? {type: 'empty'}
1696
+ : keepsValue
1697
+ ? current.value
1698
+ : undefined,
1699
+ };
1700
+ });
1701
+ }, []);
1702
+
1703
+ const handleDraftValueChange = useCallback((value: FilterValue) => {
1704
+ setDraft(current => (current == null ? current : {...current, value}));
1705
+ }, []);
1706
+ // The number input's draft was cleared or stopped parsing: nothing stands
1707
+ // to confirm, so the confirm action disables again.
1708
+ const handleDraftUnstage = useCallback(() => {
1709
+ setDraft(current =>
1710
+ current == null ? current : {...current, value: undefined},
1711
+ );
1712
+ }, []);
1713
+
1714
+ const handleSave = useCallback(() => {
1715
+ if (draft?.value == null || draft.operator == null) {
1716
+ return;
1717
+ }
1718
+ commitSavedFilter(draft, {
1719
+ field: draft.field,
1720
+ operator: draft.operator,
1721
+ value: draft.value,
1722
+ });
1723
+ }, [commitSavedFilter, draft]);
1724
+
1725
+ const returnFromValue = useCallback(() => {
1726
+ if (draft == null) {
1727
+ queueManagerAddFocus();
1728
+ setStep('manage');
1729
+ return;
1730
+ }
1731
+ if (
1732
+ draft.mode === 'edit' &&
1733
+ draft.sourceFilter != null &&
1734
+ draft.sourceSignature != null &&
1735
+ draft.filterIndex != null
1736
+ ) {
1737
+ pendingSheetFocusRef.current = {
1738
+ type: 'manager-filter',
1739
+ sourceFilter: draft.sourceFilter,
1740
+ sourceSignature: draft.sourceSignature,
1741
+ preferredIndex:
1742
+ resolveDraftFilterIndex(filters, draft) ?? draft.filterIndex,
1743
+ };
1744
+ setStep('manage');
1745
+ return;
1746
+ }
1747
+ if (draft.returnFocus === 'content-search') {
1748
+ pendingSheetFocusRef.current = {type: 'content-search'};
1749
+ setStep('fields');
1750
+ return;
1751
+ }
1752
+ pendingSheetFocusRef.current = {type: 'field', fieldKey: draft.field};
1753
+ setStep('fields');
1754
+ }, [draft, filters, queueManagerAddFocus]);
1755
+
1756
+ const handleRemove = useCallback(() => {
1757
+ if (draft?.mode !== 'edit') {
1758
+ return;
1759
+ }
1760
+ const filterIndex = resolveDraftFilterIndex(filters, draft);
1761
+ if (filterIndex == null || filters[filterIndex]?.isReadOnly) {
1762
+ queueManagerAddFocus();
1763
+ setStep('manage');
1764
+ return;
1765
+ }
1766
+ handleRemoveFilter(filterIndex);
1767
+ setStep('manage');
1768
+ }, [draft, filters, handleRemoveFilter, queueManagerAddFocus]);
1769
+
1770
+ // Each step sheet is controlled by `step`; a sheet only ever asks to close
1771
+ // (a dismissal its `purpose` allows: Escape, the scrim, a swipe, a platform
1772
+ // close request), which only the top sheet of the stack receives. Closing
1773
+ // the top step reveals the step beneath it; closing the bottom one ends the
1774
+ // flow. A stale report from a step that is no longer current is ignored.
1775
+ // Every step change goes through the handlers above.
1776
+ const handleManageOpenChange = useCallback(
1777
+ (isSheetOpen: boolean) => {
1778
+ if (!isSheetOpen && stepRef.current === 'manage') {
1779
+ closeFlow();
1780
+ }
1781
+ },
1782
+ [closeFlow],
1783
+ );
1784
+ const handleFieldsOpenChange = useCallback(
1785
+ (isSheetOpen: boolean) => {
1786
+ if (isSheetOpen || stepRef.current !== 'fields') {
1787
+ return;
1788
+ }
1789
+ if (fieldPickerOrigin === 'trigger') {
1790
+ closeFlow();
1791
+ return;
1792
+ }
1793
+ queueManagerAddFocus();
1794
+ setStep('manage');
1795
+ },
1796
+ [closeFlow, fieldPickerOrigin, queueManagerAddFocus],
1797
+ );
1798
+ const handleValueOpenChange = useCallback(
1799
+ (isSheetOpen: boolean) => {
1800
+ if (!isSheetOpen && stepRef.current === 'value') {
1801
+ returnFromValue();
1802
+ }
1803
+ },
1804
+ [returnFromValue],
1805
+ );
1806
+
1807
+ // ---------------------------------------------------------------- render --
1808
+
1809
+ // Rows are keyed by filter identity, not position: a parent's reorder or a
1810
+ // removal above the focused row then moves elements instead of remounting
1811
+ // them, and focus stays on the same filter. Only a filter that is
1812
+ // itself removed loses its element, and the re-home effect above takes
1813
+ // over.
1814
+ const [filterIdentities] = useState(createFilterIdentities);
1815
+ const filterIds = filterIdentities.next(filters);
1816
+ const filterRows = filters.map((filter, index) => {
1817
+ const {label, value} = describeFilterParts(filter);
1818
+ const isEditable = isRowButton(filter);
1819
+ // The row reads from the same catalog message the removal announcement
1820
+ // uses (`labelValue`), so a locale that reorders it reorders both: the
1821
+ // message is formatted with a placeholder for the value, and the text
1822
+ // either side of that placeholder wraps the bold value.
1823
+ let displayLabel: ReactNode = label;
1824
+ if (value) {
1825
+ const [before, after] = t('@astryx.powersearch.sheet.labelValue', {
1826
+ label,
1827
+ value: ROW_VALUE_SLOT,
1828
+ }).split(ROW_VALUE_SLOT);
1829
+ displayLabel = (
1830
+ <>
1831
+ {before}
1832
+ <span {...stylex.props(styles.rowValue)}>{value}</span>
1833
+ {after}
1834
+ </>
1835
+ );
1836
+ }
1837
+ return {
1838
+ displayLabel,
1839
+ filter,
1840
+ index,
1841
+ isEditable,
1842
+ key: `filter-${filterIds[index]}`,
1843
+ };
1844
+ });
1845
+
1846
+ const hasRemovableFilter = filters.some(filter => !filter.isReadOnly);
1847
+ const isClearAllShown = hasClear && isInteractive && hasRemovableFilter;
1848
+
1849
+ const EditorOverride =
1850
+ draftOperator != null
1851
+ ? componentOverrides?.[draftOperator.value.type]?.Editor
1852
+ : undefined;
1853
+
1854
+ const draftOperatorLabel =
1855
+ draftOperator == null ? '' : resolveOperatorLabel(draftOperator, t);
1856
+ // The title names the filter's own operator when the step offers no
1857
+ // Operator group: one visible operator, or an operator the config no longer
1858
+ // lists as visible. The step then never shows another operator's name.
1859
+ const showsOperatorGroup =
1860
+ draftOperators.length > 1 &&
1861
+ draftOperator != null &&
1862
+ draftOperators.includes(draftOperator);
1863
+ // The title — the dialog's name — reads the field and operator as
1864
+ // the Filters row does: by their labels, or, for a filter whose field or
1865
+ // operator the config no longer has, by the raw keys the row shows.
1866
+ const editorTitle =
1867
+ draft == null
1868
+ ? t('@astryx.powersearch.sheet.addFilter')
1869
+ : draftField == null || (draftOperator == null && draft.operator != null)
1870
+ ? t('@astryx.powersearch.sheet.fieldOperator', {
1871
+ field: draftField?.label ?? draft.field,
1872
+ operator:
1873
+ draftOperatorLabel !== ''
1874
+ ? draftOperatorLabel
1875
+ : (draft.operator ?? ''),
1876
+ }).trim()
1877
+ : !showsOperatorGroup && draftOperatorLabel !== ''
1878
+ ? t('@astryx.powersearch.sheet.fieldOperator', {
1879
+ field: draftField.label,
1880
+ operator: draftOperatorLabel,
1881
+ })
1882
+ : draftField.label;
1883
+ const saveButtonLabel =
1884
+ saveButtonLabelFromProps ??
1885
+ (draft?.mode === 'edit'
1886
+ ? t('@astryx.powersearch.editor.apply')
1887
+ : t('@astryx.powersearch.sheet.addFilter'));
1888
+
1889
+ // Every sheet body: padded, and publishing that padding to its lists.
1890
+ const bodyProps = stylex.props(
1891
+ styles.body,
1892
+ containerPaddingInlineVarStyles[4],
1893
+ );
1894
+ const scrollContentProps = stylex.props(
1895
+ styles.scrollContent,
1896
+ containerPaddingInlineVarStyles[4],
1897
+ );
1898
+ const controlSectionProps = stylex.props(
1899
+ styles.controlSection,
1900
+ containerPaddingInlineVarStyles[3],
1901
+ );
1902
+
1903
+ // A vertical touch drag on the Add filter rows moves focus off a focused
1904
+ // search to the sheet, so the software keyboard leaves and the rows can be
1905
+ // read.
1906
+ const fieldPickerDragProps = useDismissSearchOnTouchDrag(
1907
+ () => [
1908
+ contentSearchRef.current?.querySelector('input'),
1909
+ fieldSearchRef.current?.querySelector('input'),
1910
+ ],
1911
+ () => fieldsSheetRef.current?.closest<HTMLElement>('[tabindex="-1"]'),
1912
+ );
1913
+
1914
+ const isEditorDisabled = isDisabled || isReadOnly;
1915
+ const isSaveDisabled =
1916
+ isEditorDisabled || draft?.operator == null || draft.value == null;
1917
+ // The step has something to edit when the field and operator still exist
1918
+ // (a controlled config update can remove either). A caller's `Editor`
1919
+ // override replaces the body for its value type with the props the popover
1920
+ // hands it; otherwise a `nested` value takes the popover's built-in
1921
+ // NestedEditor and every other type the sheet's own control.
1922
+ const isDraftRenderable =
1923
+ draft != null && draftField != null && draftOperator != null;
1924
+ const isDraftNested =
1925
+ isDraftRenderable && !isSupportedOperator(draftOperator);
1926
+ // A controlled config update that removes the field or operator being
1927
+ // edited replaces the step's body with the unavailable state while focus
1928
+ // may be on one of the body's controls, which unmount without a blur. Focus
1929
+ // then moves to Cancel, the way back to the Filters row whose remove action
1930
+ // is the removal path, so it never falls to the page body inside the open
1931
+ // sheet. The message itself is announced by its rendered element.
1932
+ const cancelButtonRef = useRef<HTMLButtonElement>(null);
1933
+ const wasDraftRenderableRef = useRef(isDraftRenderable);
1934
+ useLayoutEffect(() => {
1935
+ const becameUnavailable =
1936
+ wasDraftRenderableRef.current && !isDraftRenderable && draft != null;
1937
+ wasDraftRenderableRef.current = isDraftRenderable;
1938
+ if (!becameUnavailable || step !== 'value') {
1939
+ return;
1940
+ }
1941
+ const active = document.activeElement;
1942
+ const dialog = cancelButtonRef.current?.closest('dialog');
1943
+ if (dialog != null && active != null && dialog.contains(active)) {
1944
+ return;
1945
+ }
1946
+ cancelButtonRef.current?.focus();
1947
+ }, [draft, isDraftRenderable, step]);
1948
+ // The operator group stays outside either editor, so switching operators
1949
+ // swaps the body — a `nested` operator beside a plain one included.
1950
+ const operatorGroup = isDraftRenderable && showsOperatorGroup && (
1951
+ <RadioList
1952
+ label={t('@astryx.powersearch.editor.operator')}
1953
+ isLabelHidden
1954
+ value={draft.operator ?? ''}
1955
+ onChange={operatorKey => {
1956
+ const operator = draftOperators.find(
1957
+ candidate => candidate.key === operatorKey,
1958
+ );
1959
+ if (operator != null) {
1960
+ handleOperatorSelect(operator);
1961
+ }
1962
+ }}
1963
+ isDisabled={isEditorDisabled}>
1964
+ {draftOperators.map(operator => (
1965
+ <RadioListItem
1966
+ key={operator.key}
1967
+ label={t('@astryx.powersearch.sheet.fieldOperator', {
1968
+ field: draftField.label,
1969
+ operator: resolveOperatorLabel(operator, t),
1970
+ })}
1971
+ value={operator.key}
1972
+ xstyle={styles.trailingControlRow}
1973
+ />
1974
+ ))}
1975
+ </RadioList>
1976
+ );
1977
+ // The step's Operator group owns the operator choice, so the built-in
1978
+ // nested editor's root operator picker is hidden and its root shows the
1979
+ // checked operator's label.
1980
+ const nestedOperatorOptions = useMemo(
1981
+ () =>
1982
+ draftOperator == null
1983
+ ? []
1984
+ : [
1985
+ {
1986
+ value: draftOperator.key,
1987
+ label: resolveOperatorLabel(draftOperator, t),
1988
+ },
1989
+ ],
1990
+ [draftOperator, t],
1991
+ );
1992
+ const handleNestedDraftChange = useCallback((next: PartialFilter) => {
1993
+ setDraft(current =>
1994
+ current == null ? current : {...current, value: next.value},
1995
+ );
1996
+ }, []);
1997
+
1998
+ const valueEditor =
1999
+ isDraftRenderable &&
2000
+ (isDraftNested ? (
2001
+ <NestedEditor
2002
+ key={`${draft.generation}-${draft.field}-${draft.operator}`}
2003
+ config={config}
2004
+ partialFilter={{
2005
+ field: draft.field,
2006
+ operator: draft.operator,
2007
+ value: draft.value,
2008
+ }}
2009
+ operatorOptions={nestedOperatorOptions}
2010
+ hasRootOperatorPicker={false}
2011
+ onOperatorChange={() => {}}
2012
+ onPartialFilterChange={handleNestedDraftChange}
2013
+ maxMenuItems={maxOperatorMenuItems}
2014
+ timezoneID={timezoneID}
2015
+ isReadOnly={isEditorDisabled}
2016
+ xstyle={styles.nestedEditorContainer}
2017
+ />
2018
+ ) : (
2019
+ <SheetValueEditor
2020
+ key={`${draft.generation}-${draft.field}-${draft.operator}`}
2021
+ operatorValue={draftOperator.value}
2022
+ filterValue={draft.value}
2023
+ onChange={handleDraftValueChange}
2024
+ onUnstage={handleDraftUnstage}
2025
+ config={config}
2026
+ fieldLabel={draftField.label}
2027
+ isDisabled={isEditorDisabled}
2028
+ maxMenuItems={maxOperatorMenuItems}
2029
+ timezoneID={timezoneID}
2030
+ editors={editors}
2031
+ />
2032
+ ));
2033
+
2034
+ return (
2035
+ <>
2036
+ <BottomSheet
2037
+ isOpen={isManageSheetOpen}
2038
+ onOpenChange={handleManageOpenChange}
2039
+ finalFocusRef={finalFocusRef}
2040
+ label={t('@astryx.powersearch.sheet.filtersTitle')}
2041
+ height="tall"
2042
+ padding={0}>
2043
+ <div
2044
+ ref={manageSheetRef}
2045
+ onFocus={noteSheetFocus('manage')}
2046
+ {...stylex.props(styles.sheet, styles.sheetWithFixedFooter)}>
2047
+ {step === 'manage' && (
2048
+ <SheetAnnouncer onAnnounceChange={handleAnnounceChange} />
2049
+ )}
2050
+ <div {...stylex.props(styles.header)}>
2051
+ <div {...stylex.props(styles.headerRow)}>
2052
+ <div {...stylex.props(styles.headerText)}>
2053
+ <Heading level={3}>
2054
+ {t('@astryx.powersearch.sheet.filtersTitle')}
2055
+ </Heading>
2056
+ </div>
2057
+ {isInteractive && filterRows.length > 0 && (
2058
+ <Button
2059
+ ref={managerAddButtonRef}
2060
+ label={t('@astryx.powersearch.sheet.addFilter')}
2061
+ icon={<Icon icon={PlusIcon} size="sm" />}
2062
+ isIconOnly
2063
+ variant="secondary"
2064
+ size="md"
2065
+ xstyle={styles.touchHitArea}
2066
+ onClick={openFieldList}
2067
+ />
2068
+ )}
2069
+ </div>
2070
+ </div>
2071
+ <div {...stylex.props(styles.scrollViewport)}>
2072
+ <div {...scrollContentProps}>
2073
+ {filterRows.length === 0 ? (
2074
+ // With no filter the single thing to do is the only visible
2075
+ // action. Mounted only while the sheet is open, so its text
2076
+ // is spoken in this sheet when it appears.
2077
+ isManageSheetOpen && (
2078
+ <EmptyState
2079
+ title={t('@astryx.powersearch.sheet.noFilters')}
2080
+ description={t(
2081
+ '@astryx.powersearch.sheet.noFiltersDescription',
2082
+ )}
2083
+ headingLevel={4}
2084
+ icon={<Icon icon="funnel" size="lg" color="secondary" />}
2085
+ xstyle={styles.emptyState}
2086
+ actions={
2087
+ isInteractive ? (
2088
+ <Button
2089
+ ref={managerAddButtonRef}
2090
+ label={t('@astryx.powersearch.sheet.addFilter')}
2091
+ icon={<Icon icon={PlusIcon} size="sm" />}
2092
+ variant="primary"
2093
+ size="lg"
2094
+ xstyle={styles.touchHitArea}
2095
+ onClick={openFieldList}
2096
+ />
2097
+ ) : undefined
2098
+ }
2099
+ />
2100
+ )
2101
+ ) : (
2102
+ <List hasDividers density="spacious" edge="bleed">
2103
+ {filterRows.map(row => {
2104
+ const operator = config.getOperator(
2105
+ row.filter.field,
2106
+ row.filter.operator,
2107
+ );
2108
+ const fieldLabel =
2109
+ config.getField(row.filter.field)?.label ??
2110
+ row.filter.field;
2111
+ // A one-tap filter has no value to edit: its row removes
2112
+ // it.
2113
+ const isOneTap = operator?.value.type === 'empty';
2114
+ const removeLabel = t(
2115
+ '@astryx.powersearch.sheet.removeFilter',
2116
+ {field: fieldLabel},
2117
+ );
2118
+ return (
2119
+ <ListItem
2120
+ key={row.key}
2121
+ ref={node => {
2122
+ if (node == null) {
2123
+ managerRowsRef.current.delete(row.index);
2124
+ } else {
2125
+ managerRowsRef.current.set(row.index, node);
2126
+ }
2127
+ }}
2128
+ label={row.displayLabel}
2129
+ onClick={
2130
+ row.isEditable
2131
+ ? isOneTap
2132
+ ? () => handleRemoveFilter(row.index)
2133
+ : () => handleFilterEdit(row.index)
2134
+ : undefined
2135
+ }
2136
+ endContent={
2137
+ row.isEditable ? (
2138
+ <IconButton
2139
+ data-sheet-remove=""
2140
+ label={removeLabel}
2141
+ tooltip={removeLabel}
2142
+ icon={<Icon icon="close" size="sm" />}
2143
+ variant="ghost"
2144
+ size="md"
2145
+ xstyle={styles.touchHitArea}
2146
+ onClick={event => {
2147
+ event.stopPropagation();
2148
+ handleRemoveFilter(row.index);
2149
+ }}
2150
+ />
2151
+ ) : undefined
2152
+ }
2153
+ />
2154
+ );
2155
+ })}
2156
+ </List>
2157
+ )}
2158
+ </div>
2159
+ </div>
2160
+ {filterRows.length > 0 && (
2161
+ <div {...stylex.props(styles.footer, styles.fixedFooter)}>
2162
+ {isClearAllShown && (
2163
+ <Button
2164
+ ref={clearAllButtonRef}
2165
+ label={t('@astryx.powersearch.sheet.clearAll')}
2166
+ variant="secondary"
2167
+ size="lg"
2168
+ xstyle={styles.footerEqualAction}
2169
+ onClick={() => setIsClearConfirmationOpen(true)}
2170
+ />
2171
+ )}
2172
+ <Button
2173
+ label={t('@astryx.powersearch.sheet.done')}
2174
+ variant="primary"
2175
+ size="lg"
2176
+ xstyle={
2177
+ isClearAllShown
2178
+ ? styles.footerEqualAction
2179
+ : [styles.footerSoleAction, styles.touchHitArea]
2180
+ }
2181
+ onClick={closeFlow}
2182
+ width={isClearAllShown ? undefined : '100%'}
2183
+ />
2184
+ </div>
2185
+ )}
2186
+ </div>
2187
+ </BottomSheet>
2188
+
2189
+ <BottomSheet
2190
+ isOpen={isFieldsSheetOpen}
2191
+ onOpenChange={handleFieldsOpenChange}
2192
+ finalFocusRef={stepFinalFocusRef}
2193
+ label={t('@astryx.powersearch.sheet.addFilter')}
2194
+ height="tall"
2195
+ padding={0}>
2196
+ <div
2197
+ ref={fieldsSheetCallbackRef}
2198
+ onFocus={noteSheetFocus('fields')}
2199
+ onKeyDown={hasContentSearch ? handleContentListKeyDown : undefined}
2200
+ {...stylex.props(styles.sheet, styles.sheetWithFixedFooter)}>
2201
+ {step === 'fields' && (
2202
+ <SheetAnnouncer onAnnounceChange={handleAnnounceChange} />
2203
+ )}
2204
+ <div {...stylex.props(styles.header)}>
2205
+ <div {...stylex.props(styles.headerRow)}>
2206
+ <div {...stylex.props(styles.headerText)}>
2207
+ <Heading level={3}>
2208
+ {t('@astryx.powersearch.sheet.addFilter')}
2209
+ </Heading>
2210
+ </div>
2211
+ </div>
2212
+ {hasContentSearch ? (
2213
+ <div ref={contentSearchRef}>
2214
+ <TextInput
2215
+ label={t('@astryx.powersearch.sheet.search')}
2216
+ isLabelHidden
2217
+ role="searchbox"
2218
+ aria-controls={contentResultsId}
2219
+ value={contentQuery}
2220
+ onChange={setContentQuery}
2221
+ onKeyDown={event => {
2222
+ if (isImeKeyEvent(event.nativeEvent)) {
2223
+ return;
2224
+ }
2225
+ if (event.key === 'Enter' && contentResults[0] != null) {
2226
+ event.preventDefault();
2227
+ handleContentSuggestionSelect(contentResults[0]);
2228
+ }
2229
+ }}
2230
+ placeholder={placeholder}
2231
+ hasClear
2232
+ startIcon={<Icon icon="search" size="sm" color="secondary" />}
2233
+ isDisabled={!isInteractive}
2234
+ size="lg"
2235
+ width="100%"
2236
+ />
2237
+ </div>
2238
+ ) : isFieldSearchShown ? (
2239
+ <div ref={fieldSearchRef}>
2240
+ <TextInput
2241
+ label={t('@astryx.powersearch.sheet.searchFields')}
2242
+ isLabelHidden
2243
+ placeholder={t(
2244
+ '@astryx.powersearch.sheet.searchFieldsPlaceholder',
2245
+ )}
2246
+ value={fieldQuery}
2247
+ onChange={setFieldQuery}
2248
+ startIcon={<Icon icon="search" size="sm" color="secondary" />}
2249
+ isDisabled={!isInteractive}
2250
+ size="lg"
2251
+ width="100%"
2252
+ />
2253
+ </div>
2254
+ ) : null}
2255
+ </div>
2256
+ <div
2257
+ {...stylex.props(styles.scrollViewport)}
2258
+ {...fieldPickerDragProps}>
2259
+ <div {...scrollContentProps}>
2260
+ {hasContentSearch ? (
2261
+ contentResults.length > 0 ? (
2262
+ <List
2263
+ id={contentResultsId}
2264
+ aria-label={t('@astryx.powersearch.sheet.searchResults')}
2265
+ hasDividers
2266
+ density="spacious"
2267
+ edge="bleed">
2268
+ {contentResults.map(item => (
2269
+ <ListItem
2270
+ key={item.id}
2271
+ label={item.label}
2272
+ isDisabled={!isInteractive}
2273
+ onClick={() => handleContentSuggestionSelect(item)}
2274
+ />
2275
+ ))}
2276
+ </List>
2277
+ ) : contentQuery.trim() !== '' ? (
2278
+ // The searchbox's `aria-controls` target while nothing
2279
+ // lists. The empty state mounts only while this step is the
2280
+ // open one and is spoken from its rendered text, once per
2281
+ // query that matches nothing.
2282
+ <div id={contentResultsId}>
2283
+ {step === 'fields' && (
2284
+ <EmptyState
2285
+ key={contentQuery}
2286
+ icon={
2287
+ <Icon icon="search" size="lg" color="secondary" />
2288
+ }
2289
+ title={t('@astryx.powersearch.sheet.noResults')}
2290
+ description={t(
2291
+ '@astryx.powersearch.sheet.tryDifferentSearch',
2292
+ )}
2293
+ headingLevel={4}
2294
+ isCompact
2295
+ xstyle={styles.emptyState}
2296
+ />
2297
+ )}
2298
+ </div>
2299
+ ) : (
2300
+ // An empty query with no bootstrap suggestion: the results
2301
+ // region exists, empty, so the searchbox's `aria-controls`
2302
+ // always names an element in the document.
2303
+ <div id={contentResultsId} />
2304
+ )
2305
+ ) : groupedFields.length === 0 ? (
2306
+ // A search outcome: only a non-empty query that matches
2307
+ // nothing shows it; a config with no field lists nothing.
2308
+ normalizedFieldQuery !== '' &&
2309
+ step === 'fields' && (
2310
+ <EmptyState
2311
+ key={normalizedFieldQuery}
2312
+ icon={<Icon icon="search" size="lg" color="secondary" />}
2313
+ title={t('@astryx.powersearch.sheet.noMatchingFields')}
2314
+ description={t(
2315
+ '@astryx.powersearch.sheet.tryDifferentSearch',
2316
+ )}
2317
+ headingLevel={4}
2318
+ isCompact
2319
+ xstyle={styles.emptyState}
2320
+ />
2321
+ )
2322
+ ) : (
2323
+ groupedFields.map(([group, groupFields]) => (
2324
+ <List
2325
+ key={group}
2326
+ hasDividers
2327
+ density="spacious"
2328
+ header={
2329
+ <Heading level={4}>
2330
+ {group === ''
2331
+ ? t('@astryx.powersearch.sheet.ungroupedFields')
2332
+ : group}
2333
+ </Heading>
2334
+ }
2335
+ edge="bleed">
2336
+ {groupFields.map(field => (
2337
+ <ListItem
2338
+ key={field.key}
2339
+ ref={node => {
2340
+ if (node == null) {
2341
+ fieldRowsRef.current.delete(field.key);
2342
+ } else {
2343
+ fieldRowsRef.current.set(field.key, node);
2344
+ }
2345
+ }}
2346
+ label={field.label}
2347
+ isDisabled={!isInteractive}
2348
+ startContent={field.icon}
2349
+ endContent={
2350
+ isImmediateAddField(field) ? (
2351
+ <Icon icon={PlusIcon} size="sm" color="secondary" />
2352
+ ) : (
2353
+ <Icon
2354
+ icon="chevronRight"
2355
+ size="sm"
2356
+ color="secondary"
2357
+ xstyle={rtlStyles.mirror}
2358
+ />
2359
+ )
2360
+ }
2361
+ onClick={() => handleFieldSelect(field)}
2362
+ />
2363
+ ))}
2364
+ </List>
2365
+ ))
2366
+ )}
2367
+ </div>
2368
+ </div>
2369
+ {isFieldPickerDirectEntry && (
2370
+ <div {...stylex.props(styles.footer, styles.fixedFooter)}>
2371
+ <Button
2372
+ label={t('@astryx.powersearch.sheet.done')}
2373
+ variant="primary"
2374
+ width="100%"
2375
+ xstyle={styles.touchAction}
2376
+ onClick={closeFlow}
2377
+ />
2378
+ </div>
2379
+ )}
2380
+ </div>
2381
+ </BottomSheet>
2382
+
2383
+ <BottomSheet
2384
+ isOpen={step === 'value'}
2385
+ onOpenChange={handleValueOpenChange}
2386
+ finalFocusRef={stepFinalFocusRef}
2387
+ label={editorTitle}
2388
+ height="tall"
2389
+ padding={0}
2390
+ purpose="info">
2391
+ <div {...stylex.props(styles.sheet)}>
2392
+ <div {...stylex.props(styles.header)}>
2393
+ <div {...stylex.props(styles.headerRow)}>
2394
+ <div {...stylex.props(styles.headerText)}>
2395
+ <Heading level={3} maxLines={1}>
2396
+ {editorTitle}
2397
+ </Heading>
2398
+ </div>
2399
+ </div>
2400
+ </div>
2401
+ {isDraftRenderable && EditorOverride != null ? (
2402
+ <div {...bodyProps}>
2403
+ {step === 'value' && (
2404
+ <SheetAnnouncer onAnnounceChange={handleAnnounceChange} />
2405
+ )}
2406
+ {operatorGroup && (
2407
+ <div {...controlSectionProps}>{operatorGroup}</div>
2408
+ )}
2409
+ <EditorOverride
2410
+ key={`${draft.generation}-${draft.field}-${draft.operator}`}
2411
+ config={configProp}
2412
+ filter={{
2413
+ field: draft.field,
2414
+ operator: draft.operator,
2415
+ value: draft.value,
2416
+ }}
2417
+ mode={draft.mode}
2418
+ onSave={saved => {
2419
+ if (saved == null) {
2420
+ if (draft.mode === 'edit') {
2421
+ handleRemove();
2422
+ } else {
2423
+ queueManagerAddFocus();
2424
+ setStep('manage');
2425
+ }
2426
+ return;
2427
+ }
2428
+ commitSavedFilter(draft, saved);
2429
+ }}
2430
+ onCancel={returnFromValue}
2431
+ saveButtonLabel={saveButtonLabel}
2432
+ isReadOnly={isEditorDisabled}
2433
+ timezoneID={timezoneID}
2434
+ />
2435
+ </div>
2436
+ ) : (
2437
+ <>
2438
+ <div {...bodyProps}>
2439
+ {step === 'value' && (
2440
+ <SheetAnnouncer onAnnounceChange={handleAnnounceChange} />
2441
+ )}
2442
+ {isDraftRenderable ? (
2443
+ // A lone editor sits on the sheet's surface; with the
2444
+ // Operator group above it, each section is a muted
2445
+ // container.
2446
+ operatorGroup ? (
2447
+ <>
2448
+ <div {...controlSectionProps}>{operatorGroup}</div>
2449
+ <div {...controlSectionProps}>{valueEditor}</div>
2450
+ </>
2451
+ ) : (
2452
+ valueEditor
2453
+ )
2454
+ ) : (
2455
+ // A controlled config update removed the field or operator
2456
+ // being edited: say so (the element's rendered text is
2457
+ // announced, once per opened filter, while this step is the
2458
+ // open one) and keep a way out.
2459
+ draft != null && (
2460
+ <SheetEmptyMessage
2461
+ message={t('@astryx.powersearch.sheet.filterUnavailable')}
2462
+ occasion={String(draft.generation)}
2463
+ isShown={step === 'value'}
2464
+ />
2465
+ )
2466
+ )}
2467
+ </div>
2468
+ <div {...stylex.props(styles.footer)}>
2469
+ {isReadOnly ? (
2470
+ <Button
2471
+ ref={cancelButtonRef}
2472
+ label={t('@astryx.powersearch.editor.cancel')}
2473
+ variant="secondary"
2474
+ width="100%"
2475
+ xstyle={[styles.footerSoleAction, styles.touchHitArea]}
2476
+ onClick={returnFromValue}
2477
+ />
2478
+ ) : (
2479
+ <>
2480
+ <Button
2481
+ ref={cancelButtonRef}
2482
+ label={t('@astryx.powersearch.editor.cancel')}
2483
+ variant="secondary"
2484
+ size="lg"
2485
+ xstyle={styles.footerEqualAction}
2486
+ onClick={returnFromValue}
2487
+ />
2488
+ {isDraftRenderable && (
2489
+ <Button
2490
+ label={saveButtonLabel}
2491
+ variant="primary"
2492
+ size="lg"
2493
+ isDisabled={isSaveDisabled}
2494
+ xstyle={styles.footerEqualAction}
2495
+ onClick={handleSave}
2496
+ />
2497
+ )}
2498
+ </>
2499
+ )}
2500
+ </div>
2501
+ </>
2502
+ )}
2503
+ </div>
2504
+ </BottomSheet>
2505
+
2506
+ {isClearConfirmationOpen && (
2507
+ <SheetConfirmation
2508
+ isOpen
2509
+ onOpenChange={isConfirmationOpen => {
2510
+ if (!isConfirmationOpen) {
2511
+ closeClearConfirmation(false);
2512
+ }
2513
+ }}
2514
+ title={t('@astryx.powersearch.sheet.clearAllTitle')}
2515
+ description={t('@astryx.powersearch.sheet.clearAllDescription')}
2516
+ actionLabel={t('@astryx.powersearch.sheet.clearAllConfirm')}
2517
+ cancelLabel={t('@astryx.powersearch.sheet.clearAllCancel')}
2518
+ onAction={() => closeClearConfirmation(true)}
2519
+ />
2520
+ )}
2521
+ </>
2522
+ );
2523
+ }
2524
+
2525
+ PowerSearchBottomSheet.displayName = 'PowerSearchBottomSheet';