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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (266) hide show
  1. package/dist/AlertDialog/AlertDialog.d.ts +4 -1
  2. package/dist/AlertDialog/AlertDialog.d.ts.map +1 -1
  3. package/dist/AlertDialog/AlertDialog.js +36 -6
  4. package/dist/BottomSheet/BottomSheet.d.ts.map +1 -1
  5. package/dist/BottomSheet/BottomSheet.js +76 -6
  6. package/dist/BottomSheet/BottomSheetPanel.d.ts.map +1 -1
  7. package/dist/BottomSheet/BottomSheetPanel.js +23 -4
  8. package/dist/BottomSheet/bottomSheetStack.d.ts.map +1 -1
  9. package/dist/BottomSheet/bottomSheetStack.js +5 -4
  10. package/dist/BottomSheet/useSheetGestures.d.ts.map +1 -1
  11. package/dist/BottomSheet/useSheetGestures.js +3 -1
  12. package/dist/Button/Button.d.ts.map +1 -1
  13. package/dist/Button/Button.js +47 -3
  14. package/dist/CheckboxInput/CheckboxInput.d.ts.map +1 -1
  15. package/dist/CheckboxInput/CheckboxInput.js +13 -2
  16. package/dist/ComplexSelector/ComplexSelector.d.ts +4 -1
  17. package/dist/ComplexSelector/ComplexSelector.d.ts.map +1 -1
  18. package/dist/ComplexSelector/ComplexSelector.js +14 -4
  19. package/dist/ContextMenu/ContextMenu.js +2 -2
  20. package/dist/DateInput/MonthScroller.d.ts.map +1 -1
  21. package/dist/DateInput/MonthScroller.js +13 -27
  22. package/dist/DateInput/Wheel.d.ts.map +1 -1
  23. package/dist/DateInput/Wheel.js +22 -41
  24. package/dist/Dialog/Dialog.d.ts.map +1 -1
  25. package/dist/Dialog/Dialog.js +7 -31
  26. package/dist/Field/Field.js +2 -2
  27. package/dist/HoverCard/HoverCard.d.ts +4 -3
  28. package/dist/HoverCard/HoverCard.d.ts.map +1 -1
  29. package/dist/HoverCard/HoverCard.js +27 -2
  30. package/dist/HoverCard/useHoverCard.d.ts +5 -4
  31. package/dist/HoverCard/useHoverCard.d.ts.map +1 -1
  32. package/dist/HoverCard/useHoverCard.js +17 -31
  33. package/dist/Icon/Icon.d.ts +5 -1
  34. package/dist/Icon/Icon.d.ts.map +1 -1
  35. package/dist/Icon/Icon.js +5 -2
  36. package/dist/Layer/layerAnimations.stylex.d.ts +7 -8
  37. package/dist/Layer/layerAnimations.stylex.d.ts.map +1 -1
  38. package/dist/Layer/layerAnimations.stylex.js +25 -20
  39. package/dist/Layer/usePressPin.d.ts +17 -5
  40. package/dist/Layer/usePressPin.d.ts.map +1 -1
  41. package/dist/Layer/usePressPin.js +62 -12
  42. package/dist/Lightbox/Lightbox.d.ts.map +1 -1
  43. package/dist/Lightbox/Lightbox.js +18 -0
  44. package/dist/PowerSearch/PowerSearch.d.ts +36 -3
  45. package/dist/PowerSearch/PowerSearch.d.ts.map +1 -1
  46. package/dist/PowerSearch/PowerSearch.js +522 -269
  47. package/dist/PowerSearch/PowerSearchBottomSheet.d.ts +100 -0
  48. package/dist/PowerSearch/PowerSearchBottomSheet.d.ts.map +1 -0
  49. package/dist/PowerSearch/PowerSearchBottomSheet.js +1913 -0
  50. package/dist/PowerSearch/PowerSearchEditPopover.d.ts +32 -1
  51. package/dist/PowerSearch/PowerSearchEditPopover.d.ts.map +1 -1
  52. package/dist/PowerSearch/PowerSearchEditPopover.js +17 -6
  53. package/dist/PowerSearch/PowerSearchValueEditor.d.ts +38 -3
  54. package/dist/PowerSearch/PowerSearchValueEditor.d.ts.map +1 -1
  55. package/dist/PowerSearch/PowerSearchValueEditor.js +170 -61
  56. package/dist/PowerSearch/formatFilterValue.d.ts +7 -0
  57. package/dist/PowerSearch/formatFilterValue.d.ts.map +1 -1
  58. package/dist/PowerSearch/formatFilterValue.js +11 -1
  59. package/dist/PowerSearch/formatTokenValue.d.ts +28 -0
  60. package/dist/PowerSearch/formatTokenValue.d.ts.map +1 -0
  61. package/dist/PowerSearch/formatTokenValue.js +160 -0
  62. package/dist/PowerSearch/powerSearchSheetUtils.d.ts +65 -0
  63. package/dist/PowerSearch/powerSearchSheetUtils.d.ts.map +1 -0
  64. package/dist/PowerSearch/powerSearchSheetUtils.js +168 -0
  65. package/dist/RadioList/RadioList.d.ts.map +1 -1
  66. package/dist/RadioList/RadioList.js +3 -2
  67. package/dist/RadioList/RadioListItem.d.ts +1 -1
  68. package/dist/RadioList/RadioListItem.d.ts.map +1 -1
  69. package/dist/RadioList/RadioListItem.js +5 -3
  70. package/dist/SegmentedControl/useSegmentedSelectionMotion.d.ts.map +1 -1
  71. package/dist/SegmentedControl/useSegmentedSelectionMotion.js +3 -3
  72. package/dist/Slider/sliderTouchController.js +2 -2
  73. package/dist/Spinner/Spinner.d.ts +1 -1
  74. package/dist/Spinner/Spinner.d.ts.map +1 -1
  75. package/dist/Spinner/Spinner.js +60 -58
  76. package/dist/Switch/useSwitchTouch.js +4 -4
  77. package/dist/Table/BaseTable.d.ts.map +1 -1
  78. package/dist/Table/BaseTable.js +29 -8
  79. package/dist/TextInput/TextInput.d.ts +2 -2
  80. package/dist/TextInput/TextInput.d.ts.map +1 -1
  81. package/dist/TextInput/TextInput.js +10 -7
  82. package/dist/Toast/Toast.d.ts +11 -1
  83. package/dist/Toast/Toast.d.ts.map +1 -1
  84. package/dist/Toast/Toast.js +25 -15
  85. package/dist/Toast/ToastViewport.d.ts.map +1 -1
  86. package/dist/Toast/ToastViewport.js +94 -34
  87. package/dist/Toast/types.d.ts +9 -3
  88. package/dist/Toast/types.d.ts.map +1 -1
  89. package/dist/Token/Token.d.ts +2 -2
  90. package/dist/Token/Token.d.ts.map +1 -1
  91. package/dist/Token/Token.js +12 -4
  92. package/dist/Token/TokenNamingContext.d.ts +14 -0
  93. package/dist/Token/TokenNamingContext.d.ts.map +1 -0
  94. package/dist/Token/TokenNamingContext.js +23 -0
  95. package/dist/Tooltip/useTooltip.d.ts +19 -1
  96. package/dist/Tooltip/useTooltip.d.ts.map +1 -1
  97. package/dist/Tooltip/useTooltip.js +34 -53
  98. package/dist/Typeahead/TypeaheadItem.d.ts +2 -1
  99. package/dist/Typeahead/TypeaheadItem.d.ts.map +1 -1
  100. package/dist/animator/createAnimator.d.ts +98 -0
  101. package/dist/animator/createAnimator.d.ts.map +1 -0
  102. package/dist/animator/createAnimator.js +259 -0
  103. package/dist/astryx.css +22 -6
  104. package/dist/gestures/GestureHost.d.ts +27 -1
  105. package/dist/gestures/GestureHost.d.ts.map +1 -1
  106. package/dist/gestures/GestureHost.js +90 -8
  107. package/dist/gestures/PressTracker.d.ts.map +1 -1
  108. package/dist/gestures/PressTracker.js +4 -3
  109. package/dist/hooks/actionGroupTracker.d.ts +4 -1
  110. package/dist/hooks/actionGroupTracker.d.ts.map +1 -1
  111. package/dist/hooks/actionGroupTracker.js +13 -3
  112. package/dist/hooks/index.d.ts +1 -0
  113. package/dist/hooks/index.d.ts.map +1 -1
  114. package/dist/hooks/index.js +1 -0
  115. package/dist/hooks/pressGroupContext.d.ts +6 -0
  116. package/dist/hooks/pressGroupContext.d.ts.map +1 -0
  117. package/dist/hooks/pressGroupContext.js +20 -0
  118. package/dist/hooks/useActionGroupTracking.d.ts +53 -0
  119. package/dist/hooks/useActionGroupTracking.d.ts.map +1 -0
  120. package/dist/hooks/useActionGroupTracking.js +174 -0
  121. package/dist/hooks/useAnimator.d.ts +41 -0
  122. package/dist/hooks/useAnimator.d.ts.map +1 -0
  123. package/dist/hooks/useAnimator.js +70 -0
  124. package/dist/hooks/useAttachment.d.ts +1 -1
  125. package/dist/hooks/useAttachment.js +1 -1
  126. package/dist/hooks/useInputStatusIcon.d.ts +1 -1
  127. package/dist/hooks/useInputStatusIcon.d.ts.map +1 -1
  128. package/dist/hooks/useInputStatusIcon.js +2 -2
  129. package/dist/hooks/useLongPress.d.ts +6 -2
  130. package/dist/hooks/useLongPress.d.ts.map +1 -1
  131. package/dist/hooks/useLongPress.js +95 -15
  132. package/dist/hooks/useMenuPress.d.ts.map +1 -1
  133. package/dist/hooks/useMenuPress.js +4 -2
  134. package/dist/i18n/generated-english/PowerSearch/PowerSearchBottomSheet.generated.d.ts +8 -0
  135. package/dist/i18n/generated-english/PowerSearch/PowerSearchBottomSheet.generated.d.ts.map +1 -0
  136. package/dist/i18n/generated-english/PowerSearch/PowerSearchBottomSheet.generated.js +42 -0
  137. package/dist/i18n/generated-english/PowerSearch/PowerSearchValueEditor.generated.d.ts.map +1 -1
  138. package/dist/i18n/generated-english/PowerSearch/PowerSearchValueEditor.generated.js +6 -0
  139. package/dist/i18n/generated-english/PowerSearch/formatTokenValue.generated.d.ts +8 -0
  140. package/dist/i18n/generated-english/PowerSearch/formatTokenValue.generated.d.ts.map +1 -0
  141. package/dist/i18n/generated-english/PowerSearch/formatTokenValue.generated.js +14 -0
  142. package/dist/i18n/generated-locales/en.generated.d.ts.map +1 -1
  143. package/dist/i18n/generated-locales/en.generated.js +30 -0
  144. package/dist/i18n/generated-locales/pseudo.generated.d.ts.map +1 -1
  145. package/dist/i18n/generated-locales/pseudo.generated.js +30 -0
  146. package/dist/index.d.ts +1 -0
  147. package/dist/index.d.ts.map +1 -1
  148. package/dist/index.js +4 -0
  149. package/dist/internal/indicatorPress.stylex.d.ts +20 -0
  150. package/dist/internal/indicatorPress.stylex.d.ts.map +1 -0
  151. package/dist/internal/indicatorPress.stylex.js +26 -0
  152. package/dist/motion/animator.d.ts +14 -14
  153. package/dist/motion/animator.d.ts.map +1 -1
  154. package/dist/motion/animator.js +41 -18
  155. package/dist/motion/reducedMotion.d.ts +24 -0
  156. package/dist/motion/reducedMotion.d.ts.map +1 -1
  157. package/dist/motion/reducedMotion.js +78 -1
  158. package/dist/motion/uikit/alert.d.ts +41 -0
  159. package/dist/motion/uikit/alert.d.ts.map +1 -0
  160. package/dist/motion/uikit/alert.js +43 -0
  161. package/dist/privateAttributes/pressGroupItem.stylex.d.ts +15 -0
  162. package/dist/privateAttributes/pressGroupItem.stylex.d.ts.map +1 -0
  163. package/dist/privateAttributes/pressGroupItem.stylex.js +23 -0
  164. package/dist/utils/clickGuard.d.ts +8 -1
  165. package/dist/utils/clickGuard.d.ts.map +1 -1
  166. package/dist/utils/clickGuard.js +24 -1
  167. package/dist/utils/dateParser.d.ts.map +1 -1
  168. package/dist/utils/dateParser.js +12 -0
  169. package/dist/utils/focusReturn.d.ts +15 -0
  170. package/dist/utils/focusReturn.d.ts.map +1 -1
  171. package/dist/utils/focusReturn.js +28 -0
  172. package/dist/utils/interactionOverlay.stylex.d.ts +3 -2
  173. package/dist/utils/interactionOverlay.stylex.d.ts.map +1 -1
  174. package/dist/utils/interactionOverlay.stylex.js +3 -2
  175. package/dist/utils/pressFeedback.d.ts.map +1 -1
  176. package/dist/utils/pressFeedback.js +19 -6
  177. package/dist/utils/triggerFocus.d.ts +36 -0
  178. package/dist/utils/triggerFocus.d.ts.map +1 -0
  179. package/dist/utils/triggerFocus.js +65 -0
  180. package/locales/en.json +120 -0
  181. package/locales/pseudo.json +90 -0
  182. package/package.json +9 -4
  183. package/src/AlertDialog/AlertDialog.doc.mjs +2 -2
  184. package/src/AlertDialog/AlertDialog.tsx +37 -5
  185. package/src/BottomSheet/BottomSheet.tsx +85 -5
  186. package/src/BottomSheet/BottomSheetPanel.tsx +40 -5
  187. package/src/BottomSheet/bottomSheetStack.ts +8 -4
  188. package/src/BottomSheet/useSheetGestures.ts +3 -1
  189. package/src/Button/Button.tsx +63 -5
  190. package/src/CheckboxInput/CheckboxInput.doc.mjs +18 -0
  191. package/src/CheckboxInput/CheckboxInput.tsx +36 -9
  192. package/src/ComplexSelector/ComplexSelector.tsx +20 -5
  193. package/src/ContextMenu/ContextMenu.tsx +2 -2
  194. package/src/DateInput/MonthScroller.tsx +18 -22
  195. package/src/DateInput/Wheel.tsx +30 -32
  196. package/src/Dialog/Dialog.tsx +10 -31
  197. package/src/Field/Field.tsx +2 -2
  198. package/src/HoverCard/HoverCard.doc.mjs +4 -2
  199. package/src/HoverCard/HoverCard.tsx +35 -4
  200. package/src/HoverCard/useHoverCard.doc.mjs +178 -36
  201. package/src/HoverCard/useHoverCard.tsx +46 -52
  202. package/src/Icon/Icon.doc.mjs +49 -8
  203. package/src/Icon/Icon.tsx +15 -7
  204. package/src/Layer/layerAnimations.stylex.ts +24 -16
  205. package/src/Layer/usePressPin.ts +81 -15
  206. package/src/Lightbox/Lightbox.tsx +18 -0
  207. package/src/PowerSearch/PowerSearch.doc.mjs +54 -5
  208. package/src/PowerSearch/PowerSearch.tsx +734 -288
  209. package/src/PowerSearch/PowerSearchBottomSheet.tsx +2515 -0
  210. package/src/PowerSearch/PowerSearchEditPopover.tsx +28 -6
  211. package/src/PowerSearch/PowerSearchValueEditor.tsx +203 -38
  212. package/src/PowerSearch/formatFilterValue.ts +18 -1
  213. package/src/PowerSearch/formatTokenValue.ts +215 -0
  214. package/src/PowerSearch/powerSearchSheetUtils.ts +224 -0
  215. package/src/RadioList/RadioList.tsx +3 -2
  216. package/src/RadioList/RadioListItem.tsx +5 -3
  217. package/src/SegmentedControl/useSegmentedSelectionMotion.ts +6 -3
  218. package/src/Slider/sliderTouchController.ts +2 -2
  219. package/src/Spinner/Spinner.tsx +77 -63
  220. package/src/Switch/useSwitchTouch.ts +4 -4
  221. package/src/Table/BaseTable.tsx +46 -12
  222. package/src/TextInput/TextInput.tsx +11 -10
  223. package/src/Toast/Toast.doc.mjs +4 -3
  224. package/src/Toast/Toast.tsx +52 -32
  225. package/src/Toast/ToastViewport.tsx +121 -35
  226. package/src/Toast/types.ts +9 -3
  227. package/src/Token/Token.tsx +13 -4
  228. package/src/Token/TokenNamingContext.ts +28 -0
  229. package/src/Tooltip/useTooltip.doc.mjs +24 -0
  230. package/src/Tooltip/useTooltip.tsx +61 -56
  231. package/src/Typeahead/TypeaheadItem.doc.mjs +6 -3
  232. package/src/Typeahead/TypeaheadItem.tsx +2 -1
  233. package/src/animator/createAnimator.doc.mjs +153 -0
  234. package/src/animator/createAnimator.ts +324 -0
  235. package/src/gestures/GestureHost.ts +105 -10
  236. package/src/gestures/PressTracker.ts +4 -3
  237. package/src/gestures/README.md +7 -1
  238. package/src/hooks/actionGroupTracker.ts +20 -5
  239. package/src/hooks/index.ts +2 -0
  240. package/src/hooks/pressGroupContext.ts +21 -0
  241. package/src/hooks/useActionGroupTracking.ts +224 -0
  242. package/src/hooks/useAnimator.doc.mjs +109 -0
  243. package/src/hooks/useAnimator.ts +67 -0
  244. package/src/hooks/useAttachment.ts +1 -1
  245. package/src/hooks/useInputStatusIcon.tsx +3 -2
  246. package/src/hooks/useLongPress.doc.mjs +2 -2
  247. package/src/hooks/useLongPress.ts +136 -15
  248. package/src/hooks/useMenuPress.ts +8 -2
  249. package/src/i18n/generated-english/PowerSearch/PowerSearchBottomSheet.generated.ts +43 -0
  250. package/src/i18n/generated-english/PowerSearch/PowerSearchValueEditor.generated.ts +6 -0
  251. package/src/i18n/generated-english/PowerSearch/formatTokenValue.generated.ts +15 -0
  252. package/src/i18n/generated-locales/en.generated.ts +41 -0
  253. package/src/i18n/generated-locales/pseudo.generated.ts +42 -0
  254. package/src/index.ts +13 -0
  255. package/src/internal/indicatorPress.stylex.ts +24 -0
  256. package/src/motion/README.md +8 -5
  257. package/src/motion/animator.ts +61 -29
  258. package/src/motion/reducedMotion.ts +79 -1
  259. package/src/motion/uikit/alert.ts +43 -0
  260. package/src/privateAttributes/pressGroupItem.stylex.ts +23 -0
  261. package/src/utils/clickGuard.ts +37 -1
  262. package/src/utils/dateParser.ts +16 -0
  263. package/src/utils/focusReturn.ts +30 -0
  264. package/src/utils/interactionOverlay.stylex.ts +3 -2
  265. package/src/utils/pressFeedback.ts +19 -6
  266. package/src/utils/triggerFocus.ts +80 -0
@@ -4,19 +4,25 @@
4
4
 
5
5
  /**
6
6
  * @file PowerSearch.tsx
7
- * @input PowerSearchConfig, filters, onChange
7
+ * @input PowerSearchConfig, filters, onChange; Tokenizer, Field parts, Token,
8
+ * useAdaptivePresentation, and PowerSearchBottomSheet (lazy)
8
9
  * @output Structured filter bar with token-based filter management
9
10
  * @position Main component; forwards DOM ref and exposes tokenizer control via
10
- * handleRef
11
+ * handleRef. The experimental `presentation` option swaps the tokenizer
12
+ * field and editor popover for one trigger that opens PowerSearchBottomSheet.
11
13
  *
12
14
  * SYNC: When modified, update:
13
15
  * - /packages/core/src/PowerSearch/index.ts
16
+ * - /packages/core/src/PowerSearch/PowerSearchBottomSheet.tsx (sheet presentation)
14
17
  * - /packages/cli/assets/templates/blocks/components/PowerSearch/ (showcase blocks)
15
18
  */
16
19
 
17
20
  import React, {
21
+ lazy,
22
+ Suspense,
18
23
  useCallback,
19
24
  useEffect,
25
+ useId,
20
26
  useImperativeHandle,
21
27
  useMemo,
22
28
  useRef,
@@ -34,32 +40,48 @@ import {TokenizerClearAllMessageContext} from '../Tokenizer/TokenizerClearAllMes
34
40
  import {TokenizerItemRemovableContext} from '../Tokenizer/TokenizerItemRemovableContext';
35
41
  import {TypeaheadItem} from '../Typeahead/TypeaheadItem';
36
42
  import {Token} from '../Token';
43
+ import {TokenNamingContext} from '../Token/TokenNamingContext';
37
44
  import {Avatar} from '../Avatar';
38
45
  import {layerAnimations} from '../Layer/layerAnimations.stylex';
39
- import {Icon} from '../Icon';
46
+ import {Icon, renderIconSlot} from '../Icon';
40
47
  import type {IconType} from '../Icon';
41
48
  import type {IconName} from '../Icon/globalIconRegistry';
42
49
  import type {InputStatus} from '../Field';
50
+ import {Field} from '../Field/Field';
51
+ import {
52
+ inputStatusBorderStyles,
53
+ inputStatusFocusWithinStyles,
54
+ inputStatusHoverShadowStyles,
55
+ inputWrapperStyles,
56
+ } from '../Field/inputStyles.stylex';
43
57
  import {usePopover} from '../Popover/usePopover';
58
+ import {useTooltip} from '../Tooltip/useTooltip';
44
59
  import {layerViewportInset} from '../Layer/layerViewportInset.stylex';
45
60
  import {useAnnounce} from '../hooks/useAnnounce';
61
+ import {useAdaptivePresentation} from '../hooks/useAdaptivePresentation';
62
+ import {useDevWarning} from '../hooks/useDevWarning';
63
+ import {useIsHydrating} from '../hooks/useIsHydrating';
46
64
  import {
47
65
  spacingVars,
48
66
  colorVars,
67
+ sizeVars,
49
68
  typeScaleVars,
69
+ typographyVars,
50
70
  fontWeightVars,
51
71
  } from '../theme/tokens.stylex';
52
72
  import {useSize} from '../SizeContext/SizeContext';
73
+ import {isFocusDetached} from '../utils/focusReturn';
74
+ import {isRenderable} from '../utils/isRenderable';
53
75
  import {useInternalConfig} from './useInternalConfig';
54
76
  import {usePowerSearchSource} from './usePowerSearchSource';
77
+ import {formatFilterValue} from './formatFilterValue';
78
+ import {NestedEditor, PowerSearchEditPopover} from './PowerSearchEditPopover';
55
79
  import {
56
- formatFilterValue,
57
- formatDateAbsoluteCompact,
58
- } from './formatFilterValue';
59
- import {PowerSearchEditPopover} from './PowerSearchEditPopover';
80
+ buildRelativeDateOptions,
81
+ PowerSearchValueEditor,
82
+ } from './PowerSearchValueEditor';
60
83
  import {resolveOperatorLabel} from './resolveOperatorLabel';
61
84
  import {themeProps} from '../utils/themeProps';
62
- import {truncateCharacters} from '../utils/characters';
63
85
  import {useLocale} from '../i18n/useLocale';
64
86
  import type {Locale} from '../i18n/types';
65
87
  import type {SearchableItem} from '../Typeahead/types';
@@ -73,9 +95,14 @@ import type {
73
95
  PowerSearchHandle,
74
96
  FilterValue,
75
97
  OperatorValue,
76
- EnumItem,
77
98
  PowerSearchComponents,
99
+ PowerSearchOperator,
78
100
  } from './types';
101
+ import type {
102
+ PowerSearchBottomSheetProps,
103
+ PowerSearchSheetEditors,
104
+ } from './PowerSearchBottomSheet';
105
+ import {formatTokenValueText} from './formatTokenValue';
79
106
 
80
107
  import {useMergedRefs} from '../hooks/useMergedRefs';
81
108
  import type {FieldStatusPlacement} from '../Field/fieldStatusPlacement';
@@ -148,217 +175,131 @@ const resultCountStyles = stylex.create({
148
175
  },
149
176
  });
150
177
 
151
- function truncateString(value: string, limit: number): string {
152
- // Same semantics as before — strings within limit + 3 pass through, longer
153
- // ones cut to limit + '...' — but counted in characters, not code units.
154
- return truncateCharacters(value, limit + 3, '...');
155
- }
156
-
157
- function getEnumLabel(values: ReadonlyArray<EnumItem>, value: string): string {
158
- return values.find(v => v.value === value)?.label ?? value;
159
- }
160
-
161
- function PowerSearchTokenValue({
162
- operatorValue,
163
- filterValue,
164
- maxLength,
165
- locale,
166
- }: {
167
- operatorValue: OperatorValue;
168
- filterValue: FilterValue;
169
- maxLength: number;
170
- locale: Locale;
171
- }) {
172
- switch (filterValue.type) {
173
- case 'empty':
174
- return null;
175
-
176
- case 'string':
177
- return (
178
- <span {...stylex.props(tokenValueStyles.value)}>
179
- {truncateString(filterValue.value, maxLength)}
180
- </span>
181
- );
182
-
183
- case 'integer':
184
- case 'float':
185
- return (
186
- <span {...stylex.props(tokenValueStyles.value)}>
187
- {filterValue.value}
188
- </span>
189
- );
190
-
191
- case 'enum':
192
- if (operatorValue.type === 'enum') {
193
- return (
194
- <span {...stylex.props(tokenValueStyles.value)}>
195
- {truncateString(
196
- getEnumLabel(operatorValue.values, filterValue.value),
197
- maxLength,
198
- )}
199
- </span>
200
- );
201
- }
202
- return (
203
- <span {...stylex.props(tokenValueStyles.value)}>
204
- {truncateString(filterValue.value, maxLength)}
205
- </span>
206
- );
207
-
208
- case 'string_list': {
209
- const items = filterValue.value;
210
- if (items.length === 0) {
211
- return null;
212
- }
213
- if (items.length === 1) {
214
- return (
215
- <span {...stylex.props(tokenValueStyles.value)}>
216
- {truncateString(items[0], maxLength)}
217
- </span>
218
- );
219
- }
220
- const totalLength = items.reduce((sum, s) => sum + s.length, 0);
221
- if (totalLength > maxLength) {
222
- return (
223
- <span {...stylex.props(tokenValueStyles.value)}>
224
- {items.length} items
225
- </span>
226
- );
227
- }
228
- return (
229
- <span {...stylex.props(tokenValueStyles.value)}>
230
- {items.join(', ')}
231
- </span>
232
- );
233
- }
234
-
235
- case 'enum_list': {
236
- const items = filterValue.value;
237
- if (items.length === 0) {
238
- return null;
239
- }
240
- if (operatorValue.type === 'enum_list') {
241
- const labels = items.map(v => getEnumLabel(operatorValue.values, v));
242
- if (labels.length === 1) {
243
- return (
244
- <span {...stylex.props(tokenValueStyles.value)}>
245
- {truncateString(labels[0], maxLength)}
246
- </span>
247
- );
248
- }
249
- const totalLength = labels.reduce((sum, s) => sum + s.length, 0);
250
- if (totalLength > maxLength) {
251
- return (
252
- <span {...stylex.props(tokenValueStyles.value)}>
253
- {labels.length} items
254
- </span>
255
- );
256
- }
257
- return (
258
- <span {...stylex.props(tokenValueStyles.value)}>
259
- {labels.join(', ')}
260
- </span>
261
- );
262
- }
263
- if (items.length === 1) {
264
- return (
265
- <span {...stylex.props(tokenValueStyles.value)}>
266
- {truncateString(items[0], maxLength)}
267
- </span>
268
- );
269
- }
270
- return (
271
- <span {...stylex.props(tokenValueStyles.value)}>
272
- {items.length} items
273
- </span>
274
- );
275
- }
276
-
277
- case 'entity_list': {
278
- const entities = filterValue.value;
279
- if (entities.length === 0) {
280
- return null;
281
- }
282
- if (entities.length === 1) {
283
- return (
284
- <span {...stylex.props(tokenValueStyles.value)}>
285
- {truncateString(entities[0].label, maxLength)}
286
- </span>
287
- );
288
- }
289
- const totalLength = entities.reduce((sum, e) => sum + e.label.length, 0);
290
- if (totalLength > maxLength) {
291
- return (
292
- <span {...stylex.props(tokenValueStyles.value)}>
293
- {entities.length} items
294
- </span>
295
- );
296
- }
297
- return (
298
- <span {...stylex.props(tokenValueStyles.value)}>
299
- {entities.map(e => e.label).join(', ')}
300
- </span>
301
- );
302
- }
303
-
304
- case 'time':
305
- return (
306
- <span {...stylex.props(tokenValueStyles.value)}>
307
- {filterValue.value}
308
- </span>
309
- );
310
-
311
- case 'date_absolute': {
312
- const formatted = formatDateAbsoluteCompact(
313
- filterValue.unixSeconds,
314
- locale,
315
- );
316
- return (
317
- <span {...stylex.props(tokenValueStyles.value)}>
318
- {truncateString(formatted, maxLength)}
319
- </span>
320
- );
321
- }
322
-
323
- case 'date_relative':
324
- return (
325
- <span {...stylex.props(tokenValueStyles.value)}>
326
- {truncateString(filterValue.value, maxLength)}
327
- </span>
328
- );
329
-
330
- case 'date_range':
331
- return <span {...stylex.props(tokenValueStyles.value)}>date range</span>;
178
+ // The sheet presentation's trigger field: the same control box as the
179
+ // tokenizer field, with display-only tokens and one button as its only tab
180
+ // stop.
181
+ const sheetTriggerSizeStyles = stylex.create({
182
+ sm: {minHeight: sizeVars['--size-element-sm']},
183
+ md: {minHeight: sizeVars['--size-element-md']},
184
+ lg: {minHeight: sizeVars['--size-element-lg']},
185
+ });
332
186
 
333
- case 'custom':
334
- if (operatorValue.type === 'custom') {
335
- return (
336
- <span {...stylex.props(tokenValueStyles.value)}>
337
- {truncateString(
338
- operatorValue.getString(filterValue.value),
339
- maxLength,
340
- )}
341
- </span>
342
- );
343
- }
344
- return (
345
- <span {...stylex.props(tokenValueStyles.value)}>
346
- {filterValue.value}
347
- </span>
348
- );
187
+ const sheetTriggerStyles = stylex.create({
188
+ wrapper: {
189
+ position: 'relative',
190
+ flexWrap: 'nowrap',
191
+ gap: spacingVars['--spacing-1'],
192
+ height: 'auto',
193
+ cursor: {
194
+ default: 'pointer',
195
+ ':is([aria-disabled="true"])': 'default',
196
+ },
197
+ },
198
+ wrapperWithTokens: {
199
+ paddingBlock: `calc(${spacingVars['--spacing-1']} - 1px)`,
200
+ paddingInline: `calc(${spacingVars['--spacing-1']} - 1px)`,
201
+ },
202
+ contentSection: {
203
+ position: 'relative',
204
+ minWidth: 0,
205
+ flexGrow: 1,
206
+ display: 'flex',
207
+ alignItems: 'center',
208
+ flexWrap: 'wrap',
209
+ columnGap: spacingVars['--spacing-1'],
210
+ rowGap: `calc(${spacingVars['--spacing-1']} - 1px)`,
211
+ },
212
+ startIconWithTokens: {
213
+ marginInlineStart: `calc(${spacingVars['--spacing-2']} - ${spacingVars['--spacing-1']} + 1px)`,
214
+ },
215
+ // The only tab stop in the field. It stays visually borderless while the
216
+ // wrapper delegates taps on tokens and empty space to it.
217
+ trigger: {
218
+ appearance: 'none',
219
+ flex: '1 1 40px',
220
+ minWidth: '40px',
221
+ alignSelf: 'stretch',
222
+ display: 'flex',
223
+ alignItems: 'center',
224
+ paddingBlock: 0,
225
+ paddingInline: spacingVars['--spacing-1'],
226
+ margin: 0,
227
+ borderWidth: 0,
228
+ borderStyle: 'none',
229
+ backgroundColor: 'transparent',
230
+ color: colorVars['--color-text-secondary'],
231
+ fontFamily: typographyVars['--font-family-body'],
232
+ fontSize: typeScaleVars['--text-body-size'],
233
+ lineHeight: typeScaleVars['--text-body-leading'],
234
+ textAlign: 'start',
235
+ whiteSpace: 'nowrap',
236
+ cursor: {
237
+ default: 'pointer',
238
+ ':is([aria-disabled="true"])': 'default',
239
+ },
240
+ outline: 'none',
241
+ },
242
+ // Once a token is present the empty trigger covers the token lane instead
243
+ // of reserving 40px or wrapping onto an otherwise blank row.
244
+ triggerWithTokens: {
245
+ position: 'absolute',
246
+ inset: 0,
247
+ minWidth: 0,
248
+ padding: 0,
249
+ },
250
+ endSection: {
251
+ display: 'flex',
252
+ alignItems: 'center',
253
+ gap: spacingVars['--spacing-2'],
254
+ flexShrink: 0,
255
+ paddingInlineEnd: spacingVars['--spacing-1'],
256
+ },
257
+ });
349
258
 
350
- case 'nested': {
351
- const count = filterValue.value.length;
352
- return (
353
- <span {...stylex.props(tokenValueStyles.value)}>
354
- {count === 1 ? '1 filter' : `${count} filters`}
355
- </span>
356
- );
357
- }
259
+ // The sheet flow (three standalone sheets, the sheet editors) only
260
+ // renders for the bottom-sheet presentation. Loading it on demand keeps it out
261
+ // of pages whose PowerSearch stays a tokenizer field.
262
+ // One import promise, shared by the preload and the lazy element: the sheet
263
+ // presentation loads the flow's chunk as soon as it is active, so the first
264
+ // press does not wait for the network, while nothing of the flow is rendered
265
+ // until a press.
266
+ const loadPowerSearchBottomSheet = async () =>
267
+ import('./PowerSearchBottomSheet');
268
+ const LazyPowerSearchBottomSheet = lazy(async () =>
269
+ loadPowerSearchBottomSheet().then(module => ({
270
+ default: module.PowerSearchBottomSheet,
271
+ })),
272
+ );
273
+ // The value step reuses the popover presentation's editors, which this
274
+ // module already loads; see PowerSearchSheetEditors.
275
+ const SHEET_EDITORS: PowerSearchSheetEditors = {
276
+ NestedEditor,
277
+ PowerSearchValueEditor,
278
+ buildRelativeDateOptions,
279
+ };
358
280
 
359
- default:
360
- return null;
281
+ /**
282
+ * Hosts the lazily loaded sheet flow. Renders nothing on the server or in the
283
+ * render that hydrates server HTML: a lazy component that has not loaded yet
284
+ * would leave server markup incomplete and abort hydration. The closed flow
285
+ * has no visible markup, so it first renders right after hydration.
286
+ */
287
+ function PowerSearchSheetHost(
288
+ props: Omit<PowerSearchBottomSheetProps, 'editors'>,
289
+ ) {
290
+ const isHydrating = useIsHydrating();
291
+ if (isHydrating) {
292
+ return null;
361
293
  }
294
+ return (
295
+ <Suspense fallback={null}>
296
+ <LazyPowerSearchBottomSheet {...props} editors={SHEET_EDITORS} />
297
+ </Suspense>
298
+ );
299
+ }
300
+
301
+ function PowerSearchTokenValue({text}: {text: string}) {
302
+ return <span {...stylex.props(tokenValueStyles.value)}>{text}</span>;
362
303
  }
363
304
 
364
305
  // =============================================================================
@@ -469,6 +410,37 @@ export interface PowerSearchProps extends Omit<
469
410
  * Keys are operator value types (e.g. 'string', 'enum', 'date_absolute').
470
411
  */
471
412
  components?: PowerSearchComponents;
413
+
414
+ /**
415
+ * Experimental options, outside the stable compatibility promise: anything
416
+ * in this object may change or be removed in a patch release. Omit it for
417
+ * the stable behavior.
418
+ * @experimental
419
+ */
420
+ experimental?: {
421
+ /**
422
+ * How the field presents filter editing.
423
+ * - `'popover'`: the tokenizer field with its typeahead and the editor
424
+ * popover — the stable behavior.
425
+ * - `'bottom-sheet'`: the whole field is one trigger showing display-only
426
+ * filter tokens; it opens a stack of sheets. Filters lists the filters
427
+ * as rows that edit or remove one, with Add filter, Done, and Clear
428
+ * all, which asks first and keeps read-only filters. Add filter opens
429
+ * above it with the fields (or the content search with inline
430
+ * suggestions), and a value step opens above that, stages operator →
431
+ * value, and confirms from its footer. Dismissing a step reveals the
432
+ * one beneath. Suited to compact touch screens, where the editor
433
+ * popover competes with the software keyboard.
434
+ * - `'adaptive'`: the bottom sheet on compact coarse-pointer screens,
435
+ * otherwise the popover — the same resolution Selector uses.
436
+ *
437
+ * In the sheet presentation `menuWidth` and `tokenOverflowBehavior` have
438
+ * no surface to act on and warn in development.
439
+ * @experimental
440
+ * @default 'popover'
441
+ */
442
+ presentation?: 'popover' | 'bottom-sheet' | 'adaptive';
443
+ };
472
444
  }
473
445
 
474
446
  // =============================================================================
@@ -491,6 +463,20 @@ type PopoverState =
491
463
  // Component
492
464
  // =============================================================================
493
465
 
466
+ /**
467
+ * Whether a press on a token with this operator commits the filter instead of
468
+ * opening its editor: an `empty` operator with no `empty` Editor override.
469
+ */
470
+ function commitsWithoutEditor(
471
+ operator: PowerSearchOperator | undefined,
472
+ componentOverrides: PowerSearchComponents | undefined,
473
+ ): boolean {
474
+ return (
475
+ operator?.value.type === 'empty' &&
476
+ componentOverrides?.empty?.Editor == null
477
+ );
478
+ }
479
+
494
480
  /**
495
481
  * Structured filter bar where each token represents a filter (field + operator + value).
496
482
  *
@@ -591,6 +577,7 @@ export function PowerSearch({
591
577
  className,
592
578
  style,
593
579
  components: componentOverrides,
580
+ experimental,
594
581
  }: PowerSearchProps) {
595
582
  const size = useSize(sizeProp, 'md');
596
583
  const config = useInternalConfig(configProp);
@@ -604,12 +591,164 @@ export function PowerSearch({
604
591
  popoverSaveButtonLabelFromProps ?? t('@astryx.powersearch.editor.apply');
605
592
  const tokenizerRef = useRef<TokenizerHandle>(null);
606
593
 
594
+ // Experimental presentation. `adaptive`
595
+ // resolves through the policy the selection controls share; an open flow
596
+ // keeps its presentation until it closes, so a resolution change mid-task
597
+ // cannot pull the surface out from under the person.
598
+ const presentation = experimental?.presentation ?? 'popover';
599
+ const resolvedPresentation = useAdaptivePresentation(presentation);
600
+ const [isSheetOpen, setIsSheetOpen] = useState(false);
601
+ // Latched for the flow's lifetime: the flow stays presented after it closes
602
+ // until the sheets have run their exit and handed focus back to the trigger.
603
+ // Without the latch, `adaptive` resolving to `popover` mid-session (a phone
604
+ // turned to landscape) would unmount the sheets and the trigger they return
605
+ // focus to in the same render, dropping focus to the page body.
606
+ const [isSheetPresented, setIsSheetPresented] = useState(false);
607
+ const isSheetPresentation =
608
+ isSheetOpen || isSheetPresented || resolvedPresentation === 'bottom-sheet';
609
+ const triggerRef = useRef<HTMLButtonElement>(null);
610
+ // `hasAutoFocus` applies only to the control the field first mounts. A
611
+ // presentation swap remounts the control (combobox input or trigger), and
612
+ // React's `autoFocus` on the new one would pull focus from wherever on the
613
+ // page it was; the roots' callback refs hand over the focus the field held
614
+ // instead. A field that never swaps keeps `autoFocus` on every render, so a
615
+ // Dialog that keeps it mounted still finds its `data-autofocus` on open.
616
+ const [autoFocusState, setAutoFocusState] = useState({
617
+ presentation: isSheetPresentation,
618
+ isFirstControl: true,
619
+ });
620
+ if (autoFocusState.presentation !== isSheetPresentation) {
621
+ setAutoFocusState({
622
+ presentation: isSheetPresentation,
623
+ isFirstControl: false,
624
+ });
625
+ }
626
+ const autoFocusOnMount =
627
+ hasAutoFocus &&
628
+ autoFocusState.isFirstControl &&
629
+ autoFocusState.presentation === isSheetPresentation;
630
+ // A focus the swap moves continues the same visit to the field: the
631
+ // control that held focus is gone (no blur fires for a removed element), so
632
+ // the caller is told of neither a blur nor a new focus.
633
+ const isSwapFocusRef = useRef(false);
634
+ const focusAfterSwap = useCallback((control: {focus(): void} | null) => {
635
+ if (control == null) {
636
+ return;
637
+ }
638
+ isSwapFocusRef.current = true;
639
+ try {
640
+ control.focus();
641
+ } finally {
642
+ isSwapFocusRef.current = false;
643
+ }
644
+ }, []);
645
+ const handleFieldFocus = useCallback(
646
+ (event: React.FocusEvent) => {
647
+ if (!isSwapFocusRef.current) {
648
+ onFocus?.(event);
649
+ }
650
+ },
651
+ [onFocus],
652
+ );
653
+ const handleFieldBlur = useCallback(
654
+ (event: React.FocusEvent) => {
655
+ onBlur?.(event);
656
+ },
657
+ [onBlur],
658
+ );
659
+ // While the flow is open, the sheet announces its own content from inside
660
+ // its dialog; this component announces only with the flow closed, and
661
+ // `useAnnounce` routes those messages by where the field is — into a Dialog
662
+ // or modal Drawer that holds it, or the shared channel when no modal does.
663
+ // A result count that changed during the flow's exit is spoken once the
664
+ // flow has fully closed and focus is back on the trigger: the closing
665
+ // dialog stays modal through its exit, so neither its region nor the
666
+ // page-body channel is heard before.
667
+ const afterCloseResultCountRef = useRef<string | null>(null);
668
+ const triggerId = useId();
669
+ const triggerLabelId = useId();
670
+ const triggerContentId = useId();
671
+ const statusMessageId = useId();
672
+ // A field that becomes disabled cannot keep its flow open, and must not
673
+ // reopen it when re-enabled. Adjusted in the
674
+ // render that sees the change, not an effect, so no frame shows both.
675
+ const [wasDisabled, setWasDisabled] = useState(isDisabled);
676
+ if (isDisabled !== wasDisabled) {
677
+ setWasDisabled(isDisabled);
678
+ if (isDisabled && isSheetOpen) {
679
+ setIsSheetOpen(false);
680
+ }
681
+ }
682
+ useDevWarning(
683
+ 'PowerSearch',
684
+ '`menuWidth` has no surface in the bottom-sheet presentation: the sheet flow has no typeahead popover to size. Scope it to the popover presentation or leave it out.',
685
+ isSheetPresentation && menuWidth != null,
686
+ );
687
+ useDevWarning(
688
+ 'PowerSearch',
689
+ '`tokenOverflowBehavior` has no surface in the bottom-sheet presentation: the trigger wraps its display-only tokens. Scope it to the popover presentation or leave it out.',
690
+ isSheetPresentation && tokenOverflowBehavior != null,
691
+ );
692
+
607
693
  const [popoverState, setPopoverStateRaw] = useState<PopoverState>({
608
694
  type: 'idle',
609
695
  });
610
696
 
697
+ // A default filter token's visible text: its label (field and operator)
698
+ // and its value. The token's remove button and editor dialog are named by
699
+ // the same text.
700
+ const tokenText = useCallback(
701
+ (filter: PowerSearchFilter) => {
702
+ const field = config.getField(filter.field);
703
+ const operator = config.getOperator(filter.field, filter.operator);
704
+ const fieldLabel = field?.label ?? '';
705
+ const operatorLabel = operator ? resolveOperatorLabel(operator, t) : '';
706
+ const label = `${fieldLabel}: ${operatorLabel}`.trim();
707
+ const value = operator
708
+ ? formatTokenValueText(
709
+ operator.value,
710
+ filter.value,
711
+ Math.max(
712
+ maxTokenLength - fieldLabel.length - operatorLabel.length,
713
+ 10,
714
+ ),
715
+ locale,
716
+ )
717
+ : null;
718
+ return {label, value, full: value != null ? `${label} ${value}` : label};
719
+ },
720
+ [config, maxTokenLength, t, locale],
721
+ );
722
+ // The built-in token being edited. A token from a `components` override
723
+ // names itself, so its editor keeps the plain popover.
724
+ const editedFilter =
725
+ popoverState.type === 'editing'
726
+ ? filters[popoverState.filterIndex]
727
+ : undefined;
728
+ const editedOperator =
729
+ editedFilter != null
730
+ ? config.getOperator(editedFilter.field, editedFilter.operator)
731
+ : undefined;
732
+ const editedBuiltInFilter =
733
+ editedOperator == null ||
734
+ componentOverrides?.[editedOperator.value.type]?.Token == null
735
+ ? editedFilter
736
+ : undefined;
737
+ // Whether the editor popover is open, for the hand-off below, which runs
738
+ // in the commit that swaps presentations.
739
+ const isPopoverOpenRef = useRef(false);
740
+ // When `adaptive` flips to the sheet while the editor popover is open (a
741
+ // phone rotated), the editing ends with the popover: its state returns to
742
+ // idle (through the raw setter, since the tokenizer input is gone), in the
743
+ // render that sees the swap, so a later switch back does not reopen a stale
744
+ // editor.
745
+ if (isSheetPresentation && popoverState.type !== 'idle') {
746
+ setPopoverStateRaw({type: 'idle'});
747
+ }
748
+
611
749
  // Layer for positioning the edit popover anchored to the tokenizer
612
750
  const handleLayerHide = useCallback(() => {
751
+ isPopoverOpenRef.current = false;
613
752
  setPopoverStateRaw({type: 'idle'});
614
753
  }, []);
615
754
 
@@ -618,14 +757,66 @@ export function PowerSearch({
618
757
  hasLightDismiss: true,
619
758
  hasCloseButton: false,
620
759
  hasAutoFocus: false,
621
- // The popup's own listbox/menu content is the exposed semantics; focus
622
- // stays on the tokenizer input, so a modal dialog wrapper is incorrect.
623
- role: 'none',
760
+ // Adding: the popup's own listbox/menu content is the exposed semantics,
761
+ // and focus stays on the tokenizer input, so a dialog wrapper is
762
+ // incorrect. Editing a token: focus moves into the editor's controls, so
763
+ // the editor is a non-modal dialog named by the token's visible text.
764
+ role: editedBuiltInFilter != null ? 'dialog' : 'none',
765
+ isModal: false,
766
+ dialogLabel:
767
+ editedBuiltInFilter != null
768
+ ? tokenText(editedBuiltInFilter).full
769
+ : undefined,
624
770
  });
625
771
 
772
+ // A presentation swap removes the focused control, so the other
773
+ // presentation's primary control (the combobox input or the trigger) takes
774
+ // focus and focus never falls to the page body. Each presentation keys its
775
+ // root, so a swap mounts a new root. The detaching root notes that it held
776
+ // focus, which also covers an input focused by server-rendered `autofocus`
777
+ // before hydration. The editor popover is portaled outside the root, so an
778
+ // open editor counts as held focus. The attaching root gives focus to its
779
+ // primary control when focus has fallen to the page body; the sheet root
780
+ // also hides a popover layer left open.
781
+ const isFocusPendingHandOffRef = useRef(false);
782
+ const hidePopover = popover.hide;
783
+ const handOffFocusRef = useCallback(
784
+ (node: HTMLDivElement | null) => {
785
+ if (node == null) {
786
+ return;
787
+ }
788
+ const trigger = triggerRef.current;
789
+ const isSheetRoot = trigger != null && node.contains(trigger);
790
+ // The sheet presentation preloads the flow's chunk, so the first press
791
+ // opens without waiting for the network; nothing renders.
792
+ if (isSheetRoot) {
793
+ void loadPowerSearchBottomSheet();
794
+ }
795
+ if (isSheetRoot && isPopoverOpenRef.current) {
796
+ isPopoverOpenRef.current = false;
797
+ hidePopover();
798
+ }
799
+ const takesFocus = isFocusPendingHandOffRef.current;
800
+ isFocusPendingHandOffRef.current = false;
801
+ if (takesFocus && isFocusDetached(node.ownerDocument)) {
802
+ focusAfterSwap(isSheetRoot ? trigger : tokenizerRef.current);
803
+ }
804
+ return () => {
805
+ if (
806
+ node.contains(node.ownerDocument.activeElement) ||
807
+ isPopoverOpenRef.current
808
+ ) {
809
+ isFocusPendingHandOffRef.current = true;
810
+ }
811
+ };
812
+ },
813
+ [focusAfterSwap, hidePopover],
814
+ );
815
+
626
816
  // Wrapper that manages layer visibility and tokenizer focus alongside state
627
817
  const setPopoverState = useCallback(
628
818
  (state: PopoverState) => {
819
+ isPopoverOpenRef.current = state.type !== 'idle';
629
820
  setPopoverStateRaw(state);
630
821
  if (state.type !== 'idle') {
631
822
  // Schedule after the current frame so React can render the popover
@@ -642,13 +833,22 @@ export function PowerSearch({
642
833
  [popover],
643
834
  );
644
835
 
645
- // Expose imperative handle
836
+ // Expose imperative handle; in the sheet presentation the trigger is the
837
+ // field's focusable control.
646
838
  useImperativeHandle(handleRef, () => ({
647
839
  focusTypeahead() {
648
- tokenizerRef.current?.focus();
840
+ if (isSheetPresentation) {
841
+ triggerRef.current?.focus();
842
+ } else {
843
+ tokenizerRef.current?.focus();
844
+ }
649
845
  },
650
846
  blurTypeahead() {
651
- tokenizerRef.current?.blur();
847
+ if (isSheetPresentation) {
848
+ triggerRef.current?.blur();
849
+ } else {
850
+ tokenizerRef.current?.blur();
851
+ }
652
852
  },
653
853
  }));
654
854
 
@@ -789,10 +989,7 @@ export function PowerSearch({
789
989
  // edit: commit it as that editor's save would, the way adding one does,
790
990
  // instead of opening it. A consumer's `empty` Editor override still opens.
791
991
  const operator = config.getOperator(filter.field, filter.operator);
792
- if (
793
- operator?.value.type === 'empty' &&
794
- componentOverrides?.empty?.Editor == null
795
- ) {
992
+ if (commitsWithoutEditor(operator, componentOverrides)) {
796
993
  const newFilters = [...filters];
797
994
  newFilters[index] = {
798
995
  field: filter.field,
@@ -855,10 +1052,12 @@ export function PowerSearch({
855
1052
  setPopoverState({type: 'idle'});
856
1053
  }, [setPopoverState]);
857
1054
 
858
- // Custom token renderer
859
- // Custom token renderer
860
- const renderToken = useCallback(
861
- (item: PowerSearchItem, onRemove: () => void) => {
1055
+ // Token renderer for both presentations. Given `onRemove`, the token is
1056
+ // the tokenizer field's: click edits, × removes. Without it (the sheet
1057
+ // presentation's trigger) the token is display-only; the field as a whole
1058
+ // opens the flow.
1059
+ const renderFilterToken = useCallback(
1060
+ (item: PowerSearchItem, onRemove: (() => void) | undefined) => {
862
1061
  const auxData = item.auxiliaryData;
863
1062
  const filterIndex = auxData?.filterIndex ?? -1;
864
1063
  const filter = filterIndex >= 0 ? filters[filterIndex] : undefined;
@@ -868,7 +1067,8 @@ export function PowerSearch({
868
1067
  ? config.getOperator(auxData.fieldKey, auxData.operatorKey)
869
1068
  : undefined;
870
1069
 
871
- const canInteract = !isReadOnly && !isDisabled && !filter?.isReadOnly;
1070
+ const canInteract =
1071
+ onRemove != null && !isReadOnly && !isDisabled && !filter?.isReadOnly;
872
1072
  const handleClick = canInteract
873
1073
  ? () => handleTokenClick(filterIndex)
874
1074
  : undefined;
@@ -897,20 +1097,16 @@ export function PowerSearch({
897
1097
  // Default token rendering
898
1098
  const fieldLabel = field?.label ?? '';
899
1099
  const operatorLabel = operator ? resolveOperatorLabel(operator, t) : '';
900
- const tokenLabel = `${fieldLabel}: ${operatorLabel}`.trim();
901
- const adjustedMaxLength = Math.max(
902
- maxTokenLength - fieldLabel.length - operatorLabel.length,
903
- 10,
904
- );
905
-
1100
+ const text =
1101
+ filter != null
1102
+ ? tokenText(filter)
1103
+ : {label: `${fieldLabel}: ${operatorLabel}`.trim(), value: null};
1104
+ const tokenLabel = text.label;
1105
+ const fullText =
1106
+ text.value != null ? `${text.label} ${text.value}` : text.label;
906
1107
  const valueContent =
907
- operator && filter ? (
908
- <PowerSearchTokenValue
909
- operatorValue={operator.value}
910
- filterValue={filter.value}
911
- maxLength={adjustedMaxLength}
912
- locale={locale}
913
- />
1108
+ text.value != null ? (
1109
+ <PowerSearchTokenValue text={text.value} />
914
1110
  ) : undefined;
915
1111
 
916
1112
  // Show entity photo as token icon for single-entity filters
@@ -924,32 +1120,49 @@ export function PowerSearch({
924
1120
  tokenIcon = <Avatar src={entity.photo} name={entity.label} size={16} />;
925
1121
  }
926
1122
 
1123
+ // A token that opens its editor says that it opens a dialog, and the
1124
+ // naming context names it by all its visible text.
1125
+ const editorProps =
1126
+ handleClick != null &&
1127
+ !commitsWithoutEditor(operator, componentOverrides)
1128
+ ? {
1129
+ 'aria-haspopup': 'dialog' as const,
1130
+ 'aria-expanded':
1131
+ popoverState.type === 'editing' &&
1132
+ popoverState.filterIndex === filterIndex,
1133
+ }
1134
+ : null;
1135
+
927
1136
  return (
928
- <Token
929
- label={tokenLabel}
930
- size={size}
931
- icon={tokenIcon}
932
- endContent={valueContent}
933
- onMouseDown={
934
- handleClick
935
- ? e => {
936
- // Keep a token press from entering Tokenizer's input-focus
937
- // redirect. Click still edits/removes; Tab entry is unchanged.
938
- e.preventDefault();
939
- }
940
- : undefined
941
- }
942
- onClick={
943
- handleClick
944
- ? (e: React.MouseEvent) => {
945
- e.stopPropagation();
946
- handleClick();
947
- }
948
- : undefined
949
- }
950
- onRemove={handleRemove}
951
- isDisabled={isDisabled}
952
- />
1137
+ <TokenNamingContext
1138
+ value={{actionName: fullText, removeName: fullText}}>
1139
+ <Token
1140
+ label={tokenLabel}
1141
+ size={size}
1142
+ icon={tokenIcon}
1143
+ {...editorProps}
1144
+ endContent={valueContent}
1145
+ onMouseDown={
1146
+ handleClick
1147
+ ? e => {
1148
+ // Keep a token press from entering Tokenizer's input-focus
1149
+ // redirect. Click still edits/removes; Tab entry is unchanged.
1150
+ e.preventDefault();
1151
+ }
1152
+ : undefined
1153
+ }
1154
+ onClick={
1155
+ handleClick
1156
+ ? (e: React.MouseEvent) => {
1157
+ e.stopPropagation();
1158
+ handleClick();
1159
+ }
1160
+ : undefined
1161
+ }
1162
+ onRemove={handleRemove}
1163
+ isDisabled={isDisabled}
1164
+ />
1165
+ </TokenNamingContext>
953
1166
  );
954
1167
  },
955
1168
  [
@@ -957,16 +1170,23 @@ export function PowerSearch({
957
1170
  config,
958
1171
  configProp,
959
1172
  maxTokenLength,
960
- locale,
961
1173
  size,
962
1174
  isReadOnly,
963
1175
  isDisabled,
964
1176
  handleTokenClick,
965
1177
  componentOverrides,
966
1178
  t,
1179
+ popoverState,
1180
+ tokenText,
967
1181
  ],
968
1182
  );
969
1183
 
1184
+ const renderToken = useCallback(
1185
+ (item: PowerSearchItem, onRemove: () => void) =>
1186
+ renderFilterToken(item, onRemove),
1187
+ [renderFilterToken],
1188
+ );
1189
+
970
1190
  // Custom typeahead item renderer — adds field icon and description
971
1191
  const renderItem = useCallback(
972
1192
  (item: PowerSearchItem) => {
@@ -1084,13 +1304,18 @@ export function PowerSearch({
1084
1304
  return resultCount;
1085
1305
  }, [resultCount, t]);
1086
1306
 
1087
- // Announce result-count changes to screen readers through a polite live
1088
- // region, mirroring the way Typeahead announces its dropdown result count.
1089
- // The count is otherwise only shown visually and stays silent to assistive
1090
- // tech. Only a change after mount is spoken: the guard compares against the
1091
- // last announced text rather than a mounted flag, so StrictMode's effect
1092
- // replay (which resets a flag and would speak the count present on mount
1093
- // unprompted) announces nothing until the count changes.
1307
+ // This Effect synchronizes a polite live region with the result count,
1308
+ // mirroring the way Typeahead announces its dropdown result count: the count
1309
+ // is otherwise only shown visually and stays silent to assistive tech. The
1310
+ // region follows where the field is: the modal that holds the field (a
1311
+ // Dialog, a modal Drawer), else the shared channel. While a sheet of the flow is open the sheet itself announces the
1312
+ // count from inside its dialog, and this Effect stays quiet. Only a
1313
+ // change after mount is spoken — the count present on mount is not
1314
+ // announced unprompted — and the guard compares against the last announced
1315
+ // text rather than a mounted flag, so StrictMode's effect replay announces
1316
+ // nothing either. A change that lands while the flow is closing waits for
1317
+ // the trigger focus, since the dialog is still modal through its exit. The
1318
+ // announcement acquires no resource, so there is no cleanup.
1094
1319
  const announce = useAnnounce();
1095
1320
  // PowerSearch words its Clear all as its record does; the field announces
1096
1321
  // these words instead of Tokenizer's (component:Tokenizer AR7).
@@ -1103,15 +1328,20 @@ export function PowerSearch({
1103
1328
  [hasReadOnlyFilter, t],
1104
1329
  );
1105
1330
  const announcedResultCountRef = useRef(resultCountText);
1331
+ const isSheetExiting = isSheetPresented && !isSheetOpen;
1106
1332
  useEffect(() => {
1107
1333
  if (resultCountText === announcedResultCountRef.current) {
1108
1334
  return;
1109
1335
  }
1110
1336
  announcedResultCountRef.current = resultCountText;
1111
1337
  if (resultCountText != null) {
1112
- announce(resultCountText);
1338
+ if (isSheetExiting) {
1339
+ afterCloseResultCountRef.current = resultCountText;
1340
+ } else if (!isSheetOpen) {
1341
+ announce(resultCountText);
1342
+ }
1113
1343
  }
1114
- }, [resultCountText, announce]);
1344
+ }, [resultCountText, announce, isSheetExiting, isSheetOpen]);
1115
1345
 
1116
1346
  // Build combined endContent from resultCount + endContent props
1117
1347
  const combinedEndContent = useMemo((): React.ReactNode => {
@@ -1132,14 +1362,230 @@ export function PowerSearch({
1132
1362
  return resultCountNode || endContent || undefined;
1133
1363
  }, [resultCountText, endContent]);
1134
1364
 
1365
+ // ------------------------------------------------------- sheet trigger --
1366
+
1367
+ // Disabled-reason tooltip for the sheet trigger. Disabled controls swallow
1368
+ // pointer events, so the listeners attach to the field's control box; the
1369
+ // trigger stays perceivable through aria-disabled.
1370
+ const showsDisabledMessage = isDisabled && !!disabledMessage;
1371
+ const disabledMessageTooltip = useTooltip({
1372
+ placement: 'above',
1373
+ focusTrigger: 'always',
1374
+ isEnabled: isSheetPresentation && showsDisabledMessage,
1375
+ });
1376
+ const rootRef = useMergedRefs(
1377
+ ref,
1378
+ popover.triggerRef as React.Ref<HTMLDivElement>,
1379
+ handOffFocusRef,
1380
+ );
1381
+
1382
+ const openSheet = useCallback(() => {
1383
+ if (isDisabled) {
1384
+ return;
1385
+ }
1386
+ // The flow restores focus to the element that was active when it opened;
1387
+ // make sure that is the trigger even for a tap on a token.
1388
+ triggerRef.current?.focus({preventScroll: true});
1389
+ setIsSheetPresented(true);
1390
+ setIsSheetOpen(true);
1391
+ }, [isDisabled]);
1392
+
1393
+ // The field's end lane — the result count and `endContent` — sits outside
1394
+ // the trigger button: a caller's action button there keeps its own tab stop
1395
+ // and taps, and an interactive control must not nest in a component
1396
+ // button, so a tap on it must not open the flow.
1397
+ const endLaneRef = useRef<HTMLDivElement>(null);
1398
+ const handleTriggerWrapperClick = useCallback(
1399
+ (event: React.MouseEvent<HTMLDivElement>) => {
1400
+ // Tokens are display-only; a tap on them or the empty space around them
1401
+ // opens the flow. The trigger button handles its own click, and the end
1402
+ // lane handles its own.
1403
+ if (
1404
+ event.target instanceof Node &&
1405
+ (triggerRef.current?.contains(event.target) ||
1406
+ endLaneRef.current?.contains(event.target))
1407
+ ) {
1408
+ return;
1409
+ }
1410
+ openSheet();
1411
+ },
1412
+ [openSheet],
1413
+ );
1414
+
1415
+ const handleTriggerFocusWithin = useCallback(
1416
+ (event: React.FocusEvent<HTMLDivElement>) => {
1417
+ // The flow focuses the trigger only after its exit completes; that focus
1418
+ // is the signal that the retained sheets can leave. If the resolution
1419
+ // moved to the popover meanwhile, the tokenizer input takes over the
1420
+ // focus once it mounts (the hand-off above).
1421
+ if (!isSheetOpen && isSheetPresented) {
1422
+ setIsSheetPresented(false);
1423
+ // The dialogs have closed: the page-body channel is exposed again.
1424
+ const resultCount = afterCloseResultCountRef.current;
1425
+ afterCloseResultCountRef.current = null;
1426
+ if (resultCount != null) {
1427
+ announce(resultCount);
1428
+ }
1429
+ }
1430
+ if (
1431
+ !(event.relatedTarget instanceof Node) ||
1432
+ !event.currentTarget.contains(event.relatedTarget)
1433
+ ) {
1434
+ handleFieldFocus(event);
1435
+ }
1436
+ },
1437
+ [announce, handleFieldFocus, isSheetOpen, isSheetPresented],
1438
+ );
1439
+ const handleTriggerBlurWithin = useCallback(
1440
+ (event: React.FocusEvent<HTMLDivElement>) => {
1441
+ if (
1442
+ !(event.relatedTarget instanceof Node) ||
1443
+ !event.currentTarget.contains(event.relatedTarget)
1444
+ ) {
1445
+ handleFieldBlur(event);
1446
+ }
1447
+ },
1448
+ [handleFieldBlur],
1449
+ );
1450
+
1451
+ if (isSheetPresentation) {
1452
+ const triggerDescribedBy =
1453
+ [
1454
+ status?.message ? statusMessageId : null,
1455
+ showsDisabledMessage ? disabledMessageTooltip.describedBy : null,
1456
+ ]
1457
+ .filter(Boolean)
1458
+ .join(' ') || undefined;
1459
+
1460
+ return (
1461
+ <div key="sheet" ref={rootRef} {...themeProps('power-search')}>
1462
+ <Field
1463
+ label={label}
1464
+ isLabelHidden={isLabelHidden}
1465
+ inputID={triggerId}
1466
+ labelID={triggerLabelId}
1467
+ isDisabled={isDisabled}
1468
+ status={
1469
+ status
1470
+ ? {
1471
+ type: status.type,
1472
+ message: status.message,
1473
+ messageID: status.message ? statusMessageId : undefined,
1474
+ }
1475
+ : undefined
1476
+ }
1477
+ statusVariant={statusVariant}
1478
+ xstyle={xstyle}
1479
+ className={className}
1480
+ style={style}>
1481
+ <div
1482
+ ref={disabledMessageTooltip.ref}
1483
+ onClick={handleTriggerWrapperClick}
1484
+ onFocus={handleTriggerFocusWithin}
1485
+ onBlur={handleTriggerBlurWithin}
1486
+ aria-disabled={isDisabled ? true : undefined}
1487
+ {...stylex.props(
1488
+ inputWrapperStyles.base,
1489
+ sheetTriggerStyles.wrapper,
1490
+ filters.length > 0 && sheetTriggerStyles.wrapperWithTokens,
1491
+ sheetTriggerSizeStyles[size],
1492
+ isDisabled && inputWrapperStyles.disabled,
1493
+ status && inputStatusBorderStyles[status.type],
1494
+ status &&
1495
+ !isDisabled &&
1496
+ inputStatusHoverShadowStyles[status.type],
1497
+ status && inputStatusFocusWithinStyles[status.type],
1498
+ )}>
1499
+ {/* The trigger's name is the label plus everything visible here:
1500
+ the placeholder, or the tokens, so speech input can say what
1501
+ it sees and assistive technology hears the selected filters
1502
+ (WCAG 2.5.3). */}
1503
+ <div
1504
+ id={triggerContentId}
1505
+ {...stylex.props(sheetTriggerStyles.contentSection)}>
1506
+ {isRenderable(startIcon as React.ReactNode) && (
1507
+ <span
1508
+ {...stylex.props(
1509
+ filters.length > 0 &&
1510
+ sheetTriggerStyles.startIconWithTokens,
1511
+ )}>
1512
+ {renderIconSlot(startIcon, {size: 'sm', color: 'secondary'})}
1513
+ </span>
1514
+ )}
1515
+ {tokenizerValue.map(item => (
1516
+ <React.Fragment key={item.id}>
1517
+ {renderFilterToken(item, undefined)}
1518
+ </React.Fragment>
1519
+ ))}
1520
+ <button
1521
+ type="button"
1522
+ id={triggerId}
1523
+ ref={triggerRef}
1524
+ // The field's primary control: `data-testid` lands here, as it
1525
+ // lands on the combobox input in the popover presentation.
1526
+ data-testid={testId}
1527
+ autoFocus={autoFocusOnMount}
1528
+ onClick={openSheet}
1529
+ aria-disabled={isDisabled ? true : undefined}
1530
+ aria-haspopup="dialog"
1531
+ aria-expanded={isSheetOpen}
1532
+ aria-labelledby={`${triggerLabelId} ${triggerContentId}`}
1533
+ aria-describedby={triggerDescribedBy}
1534
+ {...stylex.props(
1535
+ sheetTriggerStyles.trigger,
1536
+ filters.length > 0 && sheetTriggerStyles.triggerWithTokens,
1537
+ )}>
1538
+ {filters.length === 0 ? placeholder : null}
1539
+ </button>
1540
+ </div>
1541
+ {(endContent || isRenderable(resultCountText)) && (
1542
+ <div
1543
+ ref={endLaneRef}
1544
+ {...stylex.props(sheetTriggerStyles.endSection)}>
1545
+ {isRenderable(resultCountText) && (
1546
+ <span {...stylex.props(resultCountStyles.text)}>
1547
+ {resultCountText}
1548
+ </span>
1549
+ )}
1550
+ {endContent}
1551
+ </div>
1552
+ )}
1553
+ </div>
1554
+ {showsDisabledMessage &&
1555
+ disabledMessageTooltip.renderTooltip(disabledMessage)}
1556
+ </Field>
1557
+ {/* Mounted from the press until the flow's exit has completed and
1558
+ focus is back on the trigger (`isSheetPresented`): at rest the
1559
+ field renders its trigger alone — no closed dialogs, rows, search
1560
+ field or editors in the DOM. */}
1561
+ {(isSheetOpen || isSheetPresented) && (
1562
+ <PowerSearchSheetHost
1563
+ isOpen={isSheetOpen}
1564
+ onOpenChange={setIsSheetOpen}
1565
+ finalFocusRef={triggerRef}
1566
+ resultCountText={resultCountText}
1567
+ config={config}
1568
+ configProp={configProp}
1569
+ filters={filters}
1570
+ onChange={onChange}
1571
+ searchSource={searchSource}
1572
+ placeholder={placeholder}
1573
+ isReadOnly={isReadOnly}
1574
+ isDisabled={isDisabled}
1575
+ hasClear={hasClear}
1576
+ maxOperatorMenuItems={maxOperatorMenuItems}
1577
+ maxSearchResults={maxSearchResults}
1578
+ saveButtonLabel={popoverSaveButtonLabelFromProps}
1579
+ components={componentOverrides}
1580
+ />
1581
+ )}
1582
+ </div>
1583
+ );
1584
+ }
1585
+
1135
1586
  return (
1136
1587
  <>
1137
- <div
1138
- ref={useMergedRefs(
1139
- ref,
1140
- popover.triggerRef as React.Ref<HTMLDivElement>,
1141
- )}
1142
- {...themeProps('power-search')}>
1588
+ <div key="popover" ref={rootRef} {...themeProps('power-search')}>
1143
1589
  {/* The field's Clear all announces PowerSearch's own words
1144
1590
  (component:PowerSearch; component:Tokenizer AR7). */}
1145
1591
  <TokenizerClearAllMessageContext value={clearAllMessage}>
@@ -1156,7 +1602,7 @@ export function PowerSearch({
1156
1602
  maxMenuItems={MAX_BROWSE_MENU_ITEMS}
1157
1603
  menuWidth={menuWidth}
1158
1604
  placeholder={filters.length === 0 ? placeholder : ''}
1159
- hasAutoFocus={hasAutoFocus}
1605
+ hasAutoFocus={autoFocusOnMount}
1160
1606
  hasClear={hasClear && !isReadOnly}
1161
1607
  startIcon={startIcon}
1162
1608
  endContent={combinedEndContent}
@@ -1168,8 +1614,8 @@ export function PowerSearch({
1168
1614
  debounceMs={0}
1169
1615
  status={status}
1170
1616
  statusVariant={statusVariant}
1171
- onFocus={onFocus}
1172
- onBlur={onBlur}
1617
+ onFocus={handleFieldFocus}
1618
+ onBlur={handleFieldBlur}
1173
1619
  containerXstyle={xstyle}
1174
1620
  containerClassName={className}
1175
1621
  containerStyle={style}