@astryxdesign/core 0.7.7-canary.f9ac85f → 0.7.7

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 (198) hide show
  1. package/CHANGELOG.md +104 -0
  2. package/dist/AppShell/useCloseMobileDrawerOnActivate.d.ts +13 -0
  3. package/dist/AppShell/useCloseMobileDrawerOnActivate.d.ts.map +1 -0
  4. package/dist/AppShell/useCloseMobileDrawerOnActivate.js +45 -0
  5. package/dist/BottomSheet/BottomSheet.d.ts.map +1 -1
  6. package/dist/BottomSheet/BottomSheet.js +79 -9
  7. package/dist/BottomSheet/BottomSheetPanel.d.ts.map +1 -1
  8. package/dist/BottomSheet/BottomSheetPanel.js +1 -1
  9. package/dist/BottomSheet/bottomSheetStack.d.ts.map +1 -1
  10. package/dist/BottomSheet/bottomSheetStack.js +5 -4
  11. package/dist/Breadcrumbs/BreadcrumbItem.d.ts +2 -1
  12. package/dist/Breadcrumbs/BreadcrumbItem.d.ts.map +1 -1
  13. package/dist/Breadcrumbs/BreadcrumbItem.js +7 -3
  14. package/dist/Button/Button.d.ts.map +1 -1
  15. package/dist/Button/Button.js +30 -2
  16. package/dist/CheckboxList/CheckboxList.d.ts.map +1 -1
  17. package/dist/CheckboxList/CheckboxList.js +7 -1
  18. package/dist/CheckboxList/CheckboxListContext.d.ts +6 -0
  19. package/dist/CheckboxList/CheckboxListContext.d.ts.map +1 -1
  20. package/dist/CheckboxList/CheckboxListItem.d.ts.map +1 -1
  21. package/dist/CheckboxList/CheckboxListItem.js +10 -2
  22. package/dist/DateInput/DateInput.d.ts.map +1 -1
  23. package/dist/DateInput/DateInput.js +5 -2
  24. package/dist/DateInput/MonthScroller.d.ts.map +1 -1
  25. package/dist/DateInput/MonthScroller.js +13 -27
  26. package/dist/DateInput/Wheel.d.ts.map +1 -1
  27. package/dist/DateInput/Wheel.js +22 -41
  28. package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
  29. package/dist/DateTimeInput/DateTimeInput.js +5 -2
  30. package/dist/Dialog/Dialog.d.ts.map +1 -1
  31. package/dist/Dialog/Dialog.js +4 -3
  32. package/dist/Drawer/Drawer.js +0 -1
  33. package/dist/Drawer/useDrawerDialogPresence.d.ts.map +1 -1
  34. package/dist/Drawer/useDrawerDialogPresence.js +5 -1
  35. package/dist/Field/PanelSearchInput.d.ts +9 -1
  36. package/dist/Field/PanelSearchInput.d.ts.map +1 -1
  37. package/dist/Field/PanelSearchInput.js +3 -1
  38. package/dist/InfoTip/InfoTip.d.ts +2 -2
  39. package/dist/InfoTip/InfoTip.js +4 -4
  40. package/dist/Layer/layerAnimations.stylex.d.ts +7 -8
  41. package/dist/Layer/layerAnimations.stylex.d.ts.map +1 -1
  42. package/dist/Layer/layerAnimations.stylex.js +25 -20
  43. package/dist/Layer/usePressPin.d.ts +3 -5
  44. package/dist/Layer/usePressPin.d.ts.map +1 -1
  45. package/dist/Layer/usePressPin.js +4 -6
  46. package/dist/MobileNav/MobileNav.js +0 -1
  47. package/dist/MultiSelector/hooks.d.ts.map +1 -1
  48. package/dist/MultiSelector/hooks.js +2 -4
  49. package/dist/Outline/Outline.d.ts +2 -4
  50. package/dist/Outline/Outline.d.ts.map +1 -1
  51. package/dist/Outline/Outline.js +4 -52
  52. package/dist/Outline/useOutlineFromMarkdown.d.ts.map +1 -1
  53. package/dist/Outline/useOutlineFromMarkdown.js +1 -0
  54. package/dist/Outline/useScrollSpy.js +3 -3
  55. package/dist/PowerSearch/PowerSearch.d.ts.map +1 -1
  56. package/dist/PowerSearch/PowerSearch.js +134 -179
  57. package/dist/RadioList/RadioList.d.ts.map +1 -1
  58. package/dist/RadioList/RadioList.js +5 -0
  59. package/dist/Resizable/ResizeHandle.d.ts.map +1 -1
  60. package/dist/Resizable/ResizeHandle.js +118 -260
  61. package/dist/Resizable/resizeGesture.d.ts +91 -0
  62. package/dist/Resizable/resizeGesture.d.ts.map +1 -0
  63. package/dist/Resizable/resizeGesture.js +251 -0
  64. package/dist/SideNav/SideNavItem.d.ts.map +1 -1
  65. package/dist/SideNav/SideNavItem.js +3 -9
  66. package/dist/Token/Token.d.ts +2 -2
  67. package/dist/Token/Token.d.ts.map +1 -1
  68. package/dist/Token/Token.js +12 -4
  69. package/dist/Token/TokenNamingContext.d.ts +14 -0
  70. package/dist/Token/TokenNamingContext.d.ts.map +1 -0
  71. package/dist/Token/TokenNamingContext.js +23 -0
  72. package/dist/Tokenizer/Tokenizer.d.ts +46 -59
  73. package/dist/Tokenizer/Tokenizer.d.ts.map +1 -1
  74. package/dist/Tokenizer/Tokenizer.js +35 -58
  75. package/dist/Tokenizer/TokenizerBottomSheet.d.ts +25 -28
  76. package/dist/Tokenizer/TokenizerBottomSheet.d.ts.map +1 -1
  77. package/dist/Tokenizer/TokenizerBottomSheet.js +157 -54
  78. package/dist/Tokenizer/TokenizerItemRemovableContext.d.ts.map +1 -1
  79. package/dist/Tokenizer/TokenizerItemRemovableContext.js +5 -3
  80. package/dist/Tooltip/Tooltip.d.ts +25 -30
  81. package/dist/Tooltip/Tooltip.d.ts.map +1 -1
  82. package/dist/Tooltip/Tooltip.js +24 -5
  83. package/dist/Tooltip/index.d.ts +1 -1
  84. package/dist/Tooltip/index.d.ts.map +1 -1
  85. package/dist/Tooltip/useTooltip.d.ts +28 -29
  86. package/dist/Tooltip/useTooltip.d.ts.map +1 -1
  87. package/dist/Tooltip/useTooltip.js +20 -8
  88. package/dist/TopNav/TopNavItem.d.ts.map +1 -1
  89. package/dist/TopNav/TopNavItem.js +4 -7
  90. package/dist/TopNav/TopNavMegaMenuItem.d.ts.map +1 -1
  91. package/dist/TopNav/TopNavMegaMenuItem.js +4 -7
  92. package/dist/TopNav/TopNavMenu.d.ts.map +1 -1
  93. package/dist/TopNav/TopNavMenu.js +4 -7
  94. package/dist/astryx.css +5 -5
  95. package/dist/gestures/GestureHost.d.ts +27 -1
  96. package/dist/gestures/GestureHost.d.ts.map +1 -1
  97. package/dist/gestures/GestureHost.js +90 -8
  98. package/dist/hooks/useHighlightedOptionScroll.d.ts +11 -1
  99. package/dist/hooks/useHighlightedOptionScroll.d.ts.map +1 -1
  100. package/dist/hooks/useHighlightedOptionScroll.js +103 -5
  101. package/dist/hooks/useMenuPress.d.ts.map +1 -1
  102. package/dist/hooks/useMenuPress.js +4 -2
  103. package/dist/i18n/generated-english/PowerSearch/PowerSearch.generated.d.ts.map +1 -1
  104. package/dist/i18n/generated-english/PowerSearch/PowerSearch.generated.js +1 -0
  105. package/dist/i18n/generated-english/Tokenizer/TokenizerBottomSheet.generated.d.ts.map +1 -1
  106. package/dist/i18n/generated-english/Tokenizer/TokenizerBottomSheet.generated.js +7 -1
  107. package/dist/i18n/generated-locales/en.generated.d.ts.map +1 -1
  108. package/dist/i18n/generated-locales/en.generated.js +1 -0
  109. package/dist/i18n/generated-locales/pseudo.generated.d.ts.map +1 -1
  110. package/dist/i18n/generated-locales/pseudo.generated.js +1 -0
  111. package/dist/internal/SheetConfirmation.d.ts +11 -0
  112. package/dist/internal/SheetConfirmation.d.ts.map +1 -0
  113. package/dist/internal/SheetConfirmation.js +92 -0
  114. package/dist/internal/sheetDraft.d.ts +35 -0
  115. package/dist/internal/sheetDraft.d.ts.map +1 -0
  116. package/dist/internal/sheetDraft.js +65 -0
  117. package/dist/internal/touch-sheet.d.ts +21 -0
  118. package/dist/internal/touch-sheet.d.ts.map +1 -0
  119. package/dist/internal/touch-sheet.js +19 -0
  120. package/dist/internal/useDismissSearchOnTouchDrag.d.ts +24 -0
  121. package/dist/internal/useDismissSearchOnTouchDrag.d.ts.map +1 -0
  122. package/dist/internal/useDismissSearchOnTouchDrag.js +56 -0
  123. package/dist/motion/reducedMotion.d.ts +24 -0
  124. package/dist/motion/reducedMotion.d.ts.map +1 -1
  125. package/dist/motion/reducedMotion.js +78 -1
  126. package/dist/utils/clickGuard.d.ts +8 -1
  127. package/dist/utils/clickGuard.d.ts.map +1 -1
  128. package/dist/utils/clickGuard.js +24 -1
  129. package/locales/en.json +4 -0
  130. package/locales/pseudo.json +3 -0
  131. package/package.json +9 -9
  132. package/src/AppShell/useCloseMobileDrawerOnActivate.ts +52 -0
  133. package/src/BottomSheet/BottomSheet.tsx +95 -10
  134. package/src/BottomSheet/BottomSheetPanel.tsx +7 -2
  135. package/src/BottomSheet/bottomSheetStack.ts +8 -4
  136. package/src/Breadcrumbs/BreadcrumbItem.tsx +13 -3
  137. package/src/Button/Button.tsx +37 -1
  138. package/src/CheckboxList/CheckboxList.doc.mjs +4 -2
  139. package/src/CheckboxList/CheckboxList.tsx +6 -0
  140. package/src/CheckboxList/CheckboxListContext.tsx +6 -0
  141. package/src/CheckboxList/CheckboxListItem.tsx +11 -2
  142. package/src/DateInput/DateInput.tsx +8 -2
  143. package/src/DateInput/MonthScroller.tsx +18 -22
  144. package/src/DateInput/Wheel.tsx +30 -32
  145. package/src/DateTimeInput/DateTimeInput.tsx +8 -2
  146. package/src/Dialog/Dialog.tsx +19 -4
  147. package/src/Drawer/Drawer.tsx +1 -1
  148. package/src/Drawer/useDrawerDialogPresence.ts +5 -1
  149. package/src/Field/PanelSearchInput.tsx +11 -0
  150. package/src/InfoTip/InfoTip.doc.mjs +3 -3
  151. package/src/InfoTip/InfoTip.tsx +5 -5
  152. package/src/Layer/layerAnimations.stylex.ts +24 -16
  153. package/src/Layer/usePressPin.ts +5 -11
  154. package/src/MobileNav/MobileNav.tsx +1 -1
  155. package/src/MultiSelector/hooks.ts +2 -4
  156. package/src/Outline/Outline.doc.mjs +177 -51
  157. package/src/Outline/Outline.tsx +5 -57
  158. package/src/Outline/useOutlineFromMarkdown.doc.mjs +157 -0
  159. package/src/Outline/useOutlineFromMarkdown.ts +1 -0
  160. package/src/Outline/useScrollSpy.ts +3 -3
  161. package/src/PowerSearch/PowerSearch.doc.mjs +3 -3
  162. package/src/PowerSearch/PowerSearch.tsx +196 -183
  163. package/src/RadioList/RadioList.doc.mjs +4 -2
  164. package/src/RadioList/RadioList.tsx +4 -0
  165. package/src/Resizable/ResizeHandle.tsx +133 -328
  166. package/src/Resizable/resizeGesture.ts +368 -0
  167. package/src/SideNav/SideNavItem.tsx +3 -7
  168. package/src/Token/Token.tsx +13 -4
  169. package/src/Token/TokenNamingContext.ts +28 -0
  170. package/src/Tokenizer/Tokenizer.doc.mjs +16 -0
  171. package/src/Tokenizer/Tokenizer.tsx +81 -86
  172. package/src/Tokenizer/TokenizerBottomSheet.tsx +219 -93
  173. package/src/Tokenizer/TokenizerItemRemovableContext.ts +5 -3
  174. package/src/Tooltip/Tooltip.doc.mjs +123 -54
  175. package/src/Tooltip/Tooltip.tsx +36 -39
  176. package/src/Tooltip/index.ts +0 -1
  177. package/src/Tooltip/useTooltip.doc.mjs +147 -33
  178. package/src/Tooltip/useTooltip.tsx +39 -32
  179. package/src/TopNav/TopNavItem.tsx +6 -5
  180. package/src/TopNav/TopNavMegaMenuItem.tsx +6 -5
  181. package/src/TopNav/TopNavMenu.tsx +6 -5
  182. package/src/gestures/GestureHost.ts +105 -10
  183. package/src/hooks/useHighlightedOptionScroll.ts +113 -9
  184. package/src/hooks/useMenuPress.ts +8 -2
  185. package/src/i18n/generated-english/PowerSearch/PowerSearch.generated.ts +1 -0
  186. package/src/i18n/generated-english/Tokenizer/TokenizerBottomSheet.generated.ts +7 -1
  187. package/src/i18n/generated-locales/en.generated.ts +1 -0
  188. package/src/i18n/generated-locales/pseudo.generated.ts +1 -0
  189. package/src/internal/SheetConfirmation.tsx +101 -0
  190. package/src/internal/sheetDraft.ts +80 -0
  191. package/src/internal/touch-sheet.ts +21 -0
  192. package/src/internal/useDismissSearchOnTouchDrag.ts +69 -0
  193. package/src/motion/reducedMotion.ts +79 -1
  194. package/src/utils/clickGuard.ts +37 -1
  195. package/dist/internal/tokenizer-presentation.d.ts +0 -21
  196. package/dist/internal/tokenizer-presentation.d.ts.map +0 -1
  197. package/dist/internal/tokenizer-presentation.js +0 -21
  198. package/src/internal/tokenizer-presentation.ts +0 -25
@@ -37,7 +37,6 @@ import {
37
37
  } from 'react';
38
38
  import {flushSync} from 'react-dom';
39
39
  import * as stylex from '@stylexjs/stylex';
40
- import {AlertDialog} from '../AlertDialog/AlertDialog';
41
40
  import {BottomSheet} from '../BottomSheet/BottomSheet';
42
41
  import {Button} from '../Button/Button';
43
42
  import {EmptyState} from '../EmptyState/EmptyState';
@@ -50,9 +49,21 @@ import {useIsomorphicLayoutEffect} from '../hooks/useIsomorphicLayoutEffect';
50
49
  import {useAnnounce} from '../hooks/useAnnounce';
51
50
  import {useAnnounceRenderedText} from '../hooks/useAnnounceRenderedText';
52
51
  import {Text} from '../Text/Text';
52
+ import {SheetConfirmation} from '../internal/SheetConfirmation';
53
+ import {
54
+ hasSameSelection,
55
+ nameSheetDraftChange,
56
+ type SheetDraftChange,
57
+ } from '../internal/sheetDraft';
58
+ import {useDismissSearchOnTouchDrag} from '../internal/useDismissSearchOnTouchDrag';
53
59
  import {warnOnce} from '../utils/devWarning';
54
60
  import type {SearchableItem, SearchSource} from '../Typeahead/types';
55
- import {colorVars, radiusVars, spacingVars} from '../theme/tokens.stylex';
61
+ import {
62
+ colorVars,
63
+ radiusVars,
64
+ sizeVars,
65
+ spacingVars,
66
+ } from '../theme/tokens.stylex';
56
67
  import {
57
68
  registerEnglishFallback,
58
69
  useComponentTranslator,
@@ -88,12 +99,12 @@ const styles = stylex.create({
88
99
  searchRow: {
89
100
  paddingBlockEnd: spacingVars['--spacing-2'],
90
101
  },
91
- // PanelSearchInput sizes its field to the 32px option rows of a dropdown
92
- // panel. The sheet's rows are 44px thumb targets, so the field grows to
93
- // match them and the query reads as the first row of the list.
102
+ // A dropdown panel is already a bordered box, so its search field has no
103
+ // border. The sheet is not, so its field takes the shared input box at the
104
+ // `lg` height.
94
105
  searchField: {
95
- minHeight: 44,
96
- paddingBlock: spacingVars['--spacing-2'],
106
+ minHeight: sizeVars['--size-element-lg'],
107
+ paddingBlock: spacingVars['--spacing-1'],
97
108
  },
98
109
  list: {
99
110
  display: 'flex',
@@ -192,7 +203,10 @@ const styles = stylex.create({
192
203
  export interface TokenizerBottomSheetProps<T extends SearchableItem> {
193
204
  /** Whether the sheet is open. */
194
205
  isOpen: boolean;
195
- /** Called with `false` when the sheet dismisses or Done is pressed. */
206
+ /**
207
+ * Called with `false` when the sheet closes: Save, a clean dismissal, or a
208
+ * confirmed discard.
209
+ */
196
210
  onOpenChange: (isOpen: boolean) => void;
197
211
  /** The trigger, which takes focus back after the sheet closes. */
198
212
  finalFocusRef: RefObject<HTMLElement | null>;
@@ -208,10 +222,10 @@ export interface TokenizerBottomSheetProps<T extends SearchableItem> {
208
222
  /** The field label; names the sheet, the search field, and the list. */
209
223
  label: string;
210
224
  searchSource: SearchSource<T>;
211
- /** The committed selection. */
225
+ /** The committed selection; the draft the sheet edits starts from it. */
212
226
  value: T[];
213
- /** Whether `maxEntries` is reached. */
214
- isAtMax: boolean;
227
+ /** The selection limit, enforced on the draft. */
228
+ maxEntries: number | undefined;
215
229
  hasCreate: boolean;
216
230
  maxMenuItems: number;
217
231
  minQueryLength: number;
@@ -219,38 +233,35 @@ export interface TokenizerBottomSheetProps<T extends SearchableItem> {
219
233
  /** The no-results message; a string becomes the empty state's title. */
220
234
  emptySearchText: ReactNode;
221
235
  /**
222
- * Tokenizer's add path — duplicate checks and the create sentinel — asked
223
- * to stay silent: the sheet announces the change itself, from inside its
224
- * dialog, where `useAnnounce` routes it (`spec:AST-086` FR14). Returns
225
- * whether the item was added.
236
+ * Save's one report: the whole draft and the last edit that still holds.
237
+ * The sheet announces each edit itself when it happens, from inside its
238
+ * dialog, where `useAnnounce` routes it; nothing is announced on Save.
226
239
  */
227
- onAdd: (item: T, options: {silent: true}) => boolean;
228
- /** Tokenizer's remove path, silent for the same reason. */
229
- onRemove: (item: T, options: {silent: true}) => boolean;
230
- /** Tokenizer's clear-all path. */
231
- onClearAll: () => void;
240
+ onCommit: (items: T[], change: SheetDraftChange<T>) => void;
232
241
  onChangeQuery?: (query: string) => void;
233
242
  }
234
243
 
235
244
  /**
236
245
  * The touch surface of Tokenizer's `bottom-sheet` presentation.
237
246
  *
238
- * Rows keep their position while the sheet is open: the items selected when it
239
- * opened lead, in `value` order, then selected items the results do not carry,
240
- * then the results. Toggling a row changes its checkbox and nothing else; the
241
- * next open takes a fresh snapshot and regroups selected-first.
242
- */
243
- /**
244
- * The sheet: BottomSheet around the content. Split in two so the content —
247
+ * One open sheet edits one draft that starts as `value`; rows, the create row
248
+ * and Clear all change the draft, and Save reports it once. Rows keep their
249
+ * position while the sheet is open: the items selected when it opened lead,
250
+ * in `value` order, then draft-selected items the results do not carry, then
251
+ * the results; the next open takes a fresh snapshot and regroups
252
+ * selected-first.
253
+ *
254
+ * The sheet is BottomSheet around the content, split in two so the content —
245
255
  * search, rows, footer, and every announcement about them — is rendered
246
256
  * inside the sheet's dialog, where `useAnnounce` routes into it while it is
247
- * open (`spec:AST-086` FR14): a component that renders a modal and announces
248
- * about its content is inside that modal only when the announcing code is.
257
+ * open: a component that renders a modal and announces about its content is
258
+ * inside that modal only when the announcing code is.
249
259
  *
250
260
  * The outer part also tells the content how a close came about: a close the
251
- * person made (Done, swipe, scrim, Escape) clears the query so the next open
261
+ * person made (Save, swipe, scrim, Escape) clears the query so the next open
252
262
  * lists the bootstrap set, while a close the field forced (becoming disabled
253
- * while open) keeps it (`component:Tokenizer` FR18).
263
+ * while open) keeps it. A swipe, scrim or Escape close of a changed draft is
264
+ * routed to the content first, which asks before discarding.
254
265
  */
255
266
  export function TokenizerBottomSheet<T extends SearchableItem>({
256
267
  isOpen,
@@ -263,7 +274,9 @@ export function TokenizerBottomSheet<T extends SearchableItem>({
263
274
  // this handle, from the closing event, so a close the field forces by
264
275
  // becoming disabled (which never comes through here) keeps it.
265
276
  const clearQueryRef = useRef<(() => void) | null>(null);
266
- const requestClose = () => {
277
+ // Set by the content: a dismissal asks first while the draft is changed.
278
+ const requestDismissRef = useRef<(() => void) | null>(null);
279
+ const closeSheet = () => {
267
280
  clearQueryRef.current?.();
268
281
  onOpenChange(false);
269
282
  };
@@ -273,8 +286,10 @@ export function TokenizerBottomSheet<T extends SearchableItem>({
273
286
  onOpenChange={next => {
274
287
  if (next) {
275
288
  onOpenChange(true);
289
+ } else if (requestDismissRef.current != null) {
290
+ requestDismissRef.current();
276
291
  } else {
277
- requestClose();
292
+ closeSheet();
278
293
  }
279
294
  }}
280
295
  finalFocusRef={finalFocusRef}
@@ -286,7 +301,8 @@ export function TokenizerBottomSheet<T extends SearchableItem>({
286
301
  isOpen={isOpen}
287
302
  label={label}
288
303
  clearQueryRef={clearQueryRef}
289
- onRequestClose={requestClose}
304
+ requestDismissRef={requestDismissRef}
305
+ onCloseSheet={closeSheet}
290
306
  />
291
307
  </BottomSheet>
292
308
  );
@@ -300,27 +316,28 @@ type TokenizerSheetContentProps<T extends SearchableItem> = Omit<
300
316
  > & {
301
317
  /** Set by the content: clears and reports its query; called on a user close. */
302
318
  clearQueryRef: RefObject<(() => void) | null>;
303
- /** Close the sheet as the person's own act (Done). */
304
- onRequestClose: () => void;
319
+ /** Set by the content: a swipe, scrim or Escape close, which asks first while the draft is changed. */
320
+ requestDismissRef: RefObject<(() => void) | null>;
321
+ /** Close the sheet as the person's own act. */
322
+ onCloseSheet: () => void;
305
323
  };
306
324
 
307
325
  function TokenizerSheetContent<T extends SearchableItem>({
308
326
  isOpen,
309
327
  initialQuery,
310
328
  clearQueryRef,
311
- onRequestClose,
329
+ requestDismissRef,
330
+ onCloseSheet,
312
331
  label,
313
332
  searchSource,
314
333
  value,
315
- isAtMax,
334
+ maxEntries,
316
335
  hasCreate,
317
336
  maxMenuItems,
318
337
  minQueryLength,
319
338
  debounceMs,
320
339
  emptySearchText,
321
- onAdd,
322
- onRemove,
323
- onClearAll,
340
+ onCommit,
324
341
  onChangeQuery,
325
342
  }: TokenizerSheetContentProps<T>) {
326
343
  const t = useComponentTranslator();
@@ -348,6 +365,8 @@ function TokenizerSheetContent<T extends SearchableItem>({
348
365
  // sheet makes goes through `useAnnounce`, called inside the sheet's
349
366
  // dialog, so FR14 routes the words into it while the sheet is open.
350
367
  const [isClearConfirmationOpen, setIsClearConfirmationOpen] = useState(false);
368
+ const [isDiscardConfirmationOpen, setIsDiscardConfirmationOpen] =
369
+ useState(false);
351
370
  const clearAllRef = useRef<HTMLButtonElement>(null);
352
371
  const announce = useAnnounce();
353
372
  const closeClearConfirmation = (confirmed: boolean) => {
@@ -360,7 +379,28 @@ function TokenizerSheetContent<T extends SearchableItem>({
360
379
  clearAllRef.current?.focus();
361
380
  }
362
381
  };
363
- const selectedIds = useMemo(() => new Set(value.map(v => v.id)), [value]);
382
+ // Keep editing: the confirmation is gone, and focus returns to the search
383
+ // field, where the sheet's own reading order starts.
384
+ const closeDiscardConfirmation = () => {
385
+ // eslint-disable-next-line @eslint-react/dom-no-flush-sync -- the confirmation must be gone before focus moves back into the sheet
386
+ flushSync(() => setIsDiscardConfirmationOpen(false));
387
+ searchRef.current?.focus({preventScroll: true});
388
+ };
389
+
390
+ // ---- draft ----
391
+ // One open sheet edits one draft. The caller's `value` is unchanged until
392
+ // Save, so a dismissal can discard cleanly; `lastDraftChangeRef` is the
393
+ // edit Save names when it still holds.
394
+ const [valueAtOpen, setValueAtOpen] = useState<T[]>(() => [...value]);
395
+ const [draftValue, setDraftValue] = useState<T[]>(() => [...value]);
396
+ const lastDraftChangeRef = useRef<SheetDraftChange<T> | null>(null);
397
+ const isDraftDirty = !hasSameSelection(valueAtOpen, draftValue);
398
+ const selectedIds = useMemo(
399
+ () => new Set(draftValue.map(v => v.id)),
400
+ [draftValue],
401
+ );
402
+ const isAtMax = maxEntries != null && draftValue.length >= maxEntries;
403
+ const isDraftOverMax = maxEntries != null && draftValue.length > maxEntries;
364
404
 
365
405
  // ---- sheet session ----
366
406
  // Starts from the field's copy, which `onChangeQuery` keeps current, so the
@@ -421,7 +461,12 @@ function TokenizerSheetContent<T extends SearchableItem>({
421
461
  setWasOpen(isOpen);
422
462
  if (isOpen) {
423
463
  setSelectedItemsAtOpen(value);
464
+ setValueAtOpen([...value]);
465
+ setDraftValue([...value]);
466
+ lastDraftChangeRef.current = null;
424
467
  setRenderedItemCount(LIST_RENDER_BATCH_SIZE);
468
+ setIsClearConfirmationOpen(false);
469
+ setIsDiscardConfirmationOpen(false);
425
470
  // An open lists the bootstrap set, or searches a query kept across a
426
471
  // close the field forced (FR18), and that load is pending; a kept
427
472
  // query below the threshold leaves nothing pending, since no search
@@ -533,7 +578,7 @@ function TokenizerSheetContent<T extends SearchableItem>({
533
578
  selectedAtOpenMatches.map(item => item.id),
534
579
  );
535
580
  const resultIds = new Set(results.map(item => item.id));
536
- const selectedItemsMissingFromResults = value.filter(
581
+ const selectedItemsMissingFromResults = draftValue.filter(
537
582
  item =>
538
583
  !selectedAtOpenIds.has(item.id) &&
539
584
  !resultIds.has(item.id) &&
@@ -547,7 +592,7 @@ function TokenizerSheetContent<T extends SearchableItem>({
547
592
  ...selectedItemsMissingFromResults,
548
593
  ...results.filter(item => !selectedAtOpenIds.has(item.id)),
549
594
  ];
550
- }, [query, results, selectedItemsAtOpen, value]);
595
+ }, [query, results, selectedItemsAtOpen, draftValue]);
551
596
 
552
597
  const renderedItems = visibleItems.slice(0, renderedItemCount);
553
598
 
@@ -601,7 +646,7 @@ function TokenizerSheetContent<T extends SearchableItem>({
601
646
  const lowered = trimmedQuery.toLowerCase();
602
647
  if (
603
648
  selectedIds.has(trimmedQuery) ||
604
- value.some(item => item.label.toLowerCase() === lowered) ||
649
+ draftValue.some(item => item.label.toLowerCase() === lowered) ||
605
650
  results.some(item => item.label.toLowerCase() === lowered)
606
651
  ) {
607
652
  return null;
@@ -616,7 +661,7 @@ function TokenizerSheetContent<T extends SearchableItem>({
616
661
  isAtMax,
617
662
  isPending,
618
663
  selectedIds,
619
- value,
664
+ draftValue,
620
665
  results,
621
666
  t,
622
667
  ]);
@@ -642,6 +687,43 @@ function TokenizerSheetContent<T extends SearchableItem>({
642
687
  onChangeQuery?.(next);
643
688
  };
644
689
 
690
+ // A swipe, scrim or Escape close: asks first while the draft is changed.
691
+ useImperativeHandle(
692
+ requestDismissRef,
693
+ () => () => {
694
+ if (isDraftDirty) {
695
+ setIsDiscardConfirmationOpen(true);
696
+ } else {
697
+ onCloseSheet();
698
+ }
699
+ },
700
+ [isDraftDirty, onCloseSheet],
701
+ );
702
+
703
+ const handleSave = () => {
704
+ if (isDraftOverMax) {
705
+ return;
706
+ }
707
+ const change = isDraftDirty
708
+ ? nameSheetDraftChange(
709
+ valueAtOpen,
710
+ draftValue,
711
+ lastDraftChangeRef.current,
712
+ )
713
+ : null;
714
+ if (change != null) {
715
+ onCommit([...draftValue], change);
716
+ }
717
+ onCloseSheet();
718
+ };
719
+
720
+ // Focus moves to the sheet's panel, not the page body behind the modal.
721
+ // The keyboard then leaves and the query stays.
722
+ const listDragProps = useDismissSearchOnTouchDrag(
723
+ () => [searchRef.current],
724
+ () => listRef.current?.closest<HTMLElement>('[tabindex="-1"]'),
725
+ );
726
+
645
727
  const searchLabel = t('@astryx.tokenizer.search', {label});
646
728
  // An empty state only once the search has settled; while pending the busy
647
729
  // row above stands in for it.
@@ -725,6 +807,7 @@ function TokenizerSheetContent<T extends SearchableItem>({
725
807
  placeholder={t('@astryx.tokenizer.searchPlaceholder')}
726
808
  value={query}
727
809
  onValueChange={handleQueryChange}
810
+ hasInputBox
728
811
  fieldXstyle={styles.searchField}
729
812
  />
730
813
  </div>
@@ -736,6 +819,7 @@ function TokenizerSheetContent<T extends SearchableItem>({
736
819
  aria-busy={isPending || undefined}
737
820
  data-rendered-count={renderedItems.length}
738
821
  data-total-count={visibleItems.length}
822
+ {...listDragProps}
739
823
  onFocusCapture={event => {
740
824
  isRowFocusedRef.current =
741
825
  event.target instanceof HTMLElement &&
@@ -775,13 +859,18 @@ function TokenizerSheetContent<T extends SearchableItem>({
775
859
  variant="secondary"
776
860
  size="lg"
777
861
  onClick={() => {
778
- if (onAdd(createItem, {silent: true})) {
779
- // The created value, as the inline field announces it; the
780
- // item's label is the row's caption ("Create …").
781
- announce(
782
- t('@astryx.tokenizer.tokenAdded', {label: trimmedQuery}),
783
- );
784
- }
862
+ const base = {id: trimmedQuery, label: trimmedQuery};
863
+ const createdItem = base as T;
864
+ setDraftValue(current => [...current, createdItem]);
865
+ lastDraftChangeRef.current = {
866
+ item: createdItem,
867
+ type: 'create',
868
+ };
869
+ // The created value, as the inline field announces it; the
870
+ // item's label is the row's caption ("Create …").
871
+ announce(
872
+ t('@astryx.tokenizer.tokenAdded', {label: trimmedQuery}),
873
+ );
785
874
  // The same path a typed change takes, so the bootstrap
786
875
  // reload shows its pending state and reports the change.
787
876
  handleQueryChange('');
@@ -863,14 +952,18 @@ function TokenizerSheetContent<T extends SearchableItem>({
863
952
  return;
864
953
  }
865
954
  if (isSelected) {
866
- if (onRemove(item, {silent: true})) {
867
- announce(
868
- t('@astryx.tokenizer.tokenRemoved', {
869
- label: item.label,
870
- }),
871
- );
872
- }
873
- } else if (onAdd(item, {silent: true})) {
955
+ setDraftValue(current =>
956
+ current.filter(selected => selected.id !== item.id),
957
+ );
958
+ lastDraftChangeRef.current = {item, type: 'remove'};
959
+ announce(
960
+ t('@astryx.tokenizer.tokenRemoved', {
961
+ label: item.label,
962
+ }),
963
+ );
964
+ } else {
965
+ setDraftValue(current => [...current, item]);
966
+ lastDraftChangeRef.current = {item, type: 'add'};
874
967
  announce(
875
968
  t('@astryx.tokenizer.tokenAdded', {
876
969
  label: item.label,
@@ -903,38 +996,44 @@ function TokenizerSheetContent<T extends SearchableItem>({
903
996
  </div>
904
997
  )}
905
998
  </div>
906
- {isQueryEmpty && (
907
- <div {...stylex.props(styles.footer)}>
908
- <Button
909
- label={t('@astryx.tokenizer.clearAll')}
910
- variant="secondary"
911
- size="lg"
912
- isDisabled={value.length === 0}
913
- // With a tooltip, Button's disabled mode is `aria-disabled`: the
914
- // action keeps its focus order and says why it does nothing
915
- // (`spec:AST-085` FR1), instead of leaving the tab order as a
916
- // natively disabled button does — a confirmed Clear all returns
917
- // focus here before the search field takes it (FR24).
918
- tooltip={
919
- value.length === 0
920
- ? t('@astryx.tokenizer.nothingToClear')
921
- : undefined
922
- }
923
- ref={clearAllRef}
924
- onClick={() => setIsClearConfirmationOpen(true)}
925
- xstyle={styles.footerAction}
926
- />
927
- <Button
928
- label={t('@astryx.tokenizer.done')}
929
- variant="primary"
930
- size="lg"
931
- onClick={onRequestClose}
932
- xstyle={styles.footerAction}
933
- />
934
- </div>
935
- )}
999
+ <div {...stylex.props(styles.footer)}>
1000
+ <Button
1001
+ label={t('@astryx.tokenizer.clearAll')}
1002
+ variant="secondary"
1003
+ size="lg"
1004
+ isDisabled={draftValue.length === 0}
1005
+ // With a tooltip, Button's disabled mode is `aria-disabled`: the
1006
+ // action keeps its focus order and says why it does nothing
1007
+ // (`spec:AST-085` FR1), instead of leaving the tab order as a
1008
+ // natively disabled button does — a confirmed Clear all returns
1009
+ // focus here before the search field takes it (FR24).
1010
+ tooltip={
1011
+ draftValue.length === 0
1012
+ ? t('@astryx.tokenizer.nothingToClear')
1013
+ : undefined
1014
+ }
1015
+ ref={clearAllRef}
1016
+ onClick={() => setIsClearConfirmationOpen(true)}
1017
+ xstyle={styles.footerAction}
1018
+ />
1019
+ <Button
1020
+ label={t('@astryx.tokenizer.save')}
1021
+ variant="primary"
1022
+ size="lg"
1023
+ isDisabled={isDraftOverMax}
1024
+ // Over the limit (a `value` already over it at open, or a lowered
1025
+ // limit while open) Save keeps its tab stop and says why it waits.
1026
+ tooltip={
1027
+ isDraftOverMax
1028
+ ? t('@astryx.tokenizer.selectionLimit', {count: maxEntries ?? 0})
1029
+ : undefined
1030
+ }
1031
+ onClick={handleSave}
1032
+ xstyle={styles.footerAction}
1033
+ />
1034
+ </div>
936
1035
  {isClearConfirmationOpen && (
937
- <AlertDialog
1036
+ <SheetConfirmation
938
1037
  isOpen
939
1038
  onOpenChange={next => {
940
1039
  if (!next) {
@@ -943,13 +1042,40 @@ function TokenizerSheetContent<T extends SearchableItem>({
943
1042
  }}
944
1043
  title={t('@astryx.tokenizer.clearAllTitle')}
945
1044
  description={t('@astryx.tokenizer.clearAllDescription', {label})}
1045
+ cancelLabel={t('@astryx.alertDialog.cancel')}
946
1046
  actionLabel={t('@astryx.tokenizer.clearSelection')}
947
1047
  onAction={() => {
948
- onClearAll();
1048
+ // A clear is a removal of the draft's last item, for Save's
1049
+ // `remove` when it is the last edit that holds.
1050
+ const lastItem = draftValue[draftValue.length - 1];
1051
+ if (lastItem != null) {
1052
+ setDraftValue([]);
1053
+ lastDraftChangeRef.current = {item: lastItem, type: 'remove'};
1054
+ }
949
1055
  closeClearConfirmation(true);
950
1056
  }}
951
1057
  />
952
1058
  )}
1059
+ {isDiscardConfirmationOpen && (
1060
+ <SheetConfirmation
1061
+ isOpen
1062
+ onOpenChange={next => {
1063
+ if (!next) {
1064
+ closeDiscardConfirmation();
1065
+ }
1066
+ }}
1067
+ title={t('@astryx.tokenizer.discardChangesTitle')}
1068
+ description={t('@astryx.tokenizer.discardChangesDescription', {
1069
+ label,
1070
+ })}
1071
+ cancelLabel={t('@astryx.tokenizer.keepEditing')}
1072
+ actionLabel={t('@astryx.tokenizer.discardChanges')}
1073
+ onAction={() => {
1074
+ setIsDiscardConfirmationOpen(false);
1075
+ onCloseSheet();
1076
+ }}
1077
+ />
1078
+ )}
953
1079
  </div>
954
1080
  );
955
1081
  }
@@ -8,9 +8,11 @@
8
8
  * @output A context carrying a predicate that tells which tokens the user can
9
9
  * remove
10
10
  * @position Internal to Core: not exported from the Tokenizer barrel. A
11
- * component that composes Tokenizer and locks some tokens (PowerSearch: every
12
- * filter of a read-only field) provides it. Backspace then removes nothing
13
- * while the last token is locked. Without it, every token is removable.
11
+ * component that composes Tokenizer and locks some tokens (PowerSearch: its
12
+ * read-only filters, and every filter of a read-only field) provides it. In
13
+ * the popover presentation, Clear all then keeps a locked token, Backspace
14
+ * removes nothing while the last token is locked, and Clear all shows only
15
+ * while a removable token remains. Without it, every token is removable.
14
16
  */
15
17
 
16
18
  import {createContext} from 'react';