@astryxdesign/lab 0.7.6-canary.f73623d → 0.7.6-canary.f77aaf2

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 (33) hide show
  1. package/dist/CommentPin/CommentPin.js +1 -1
  2. package/dist/Comments/CommentComposer.d.ts.map +1 -1
  3. package/dist/Comments/CommentComposer.js +12 -4
  4. package/dist/MobilePowerSearch/MobilePowerSearch.d.ts +3 -6
  5. package/dist/MobilePowerSearch/MobilePowerSearch.d.ts.map +1 -1
  6. package/dist/MobilePowerSearch/MobilePowerSearch.js +12 -76
  7. package/dist/MobileTokenizer/MobileTokenizer.d.ts +10 -7
  8. package/dist/MobileTokenizer/MobileTokenizer.d.ts.map +1 -1
  9. package/dist/MobileTokenizer/MobileTokenizer.js +191 -114
  10. package/dist/internal/MobileSheetConfirmation.d.ts +12 -0
  11. package/dist/internal/MobileSheetConfirmation.d.ts.map +1 -0
  12. package/dist/internal/MobileSheetConfirmation.js +88 -0
  13. package/dist/internal/useDismissSearchOnTouchDrag.d.ts +21 -0
  14. package/dist/internal/useDismissSearchOnTouchDrag.d.ts.map +1 -0
  15. package/dist/internal/useDismissSearchOnTouchDrag.js +43 -0
  16. package/package.json +4 -4
  17. package/src/CommentPin/CommentPin.tsx +1 -1
  18. package/src/Comments/CommentComposer.test.tsx +56 -0
  19. package/src/Comments/CommentComposer.tsx +18 -13
  20. package/src/MobilePowerSearch/MobilePowerSearch.doc.mjs +1 -1
  21. package/src/MobilePowerSearch/MobilePowerSearch.test.tsx +7 -2
  22. package/src/MobilePowerSearch/MobilePowerSearch.tsx +16 -83
  23. package/src/MobileTokenizer/MobileTokenizer.doc.mjs +3 -3
  24. package/src/MobileTokenizer/MobileTokenizer.test.tsx +287 -31
  25. package/src/MobileTokenizer/MobileTokenizer.tsx +242 -137
  26. package/src/Schedule/__tests__/ScheduleTimeDirection.a11y.chromium.spec.ts +4 -0
  27. package/src/TransferList/TransferList.doc.mjs +1 -1
  28. package/src/internal/MobileSheetConfirmation.test.tsx +68 -0
  29. package/src/internal/MobileSheetConfirmation.tsx +97 -0
  30. package/src/internal/useDismissSearchOnTouchDrag.test.tsx +94 -0
  31. package/src/internal/useDismissSearchOnTouchDrag.ts +57 -0
  32. package/src/CommentPin/CommentPin.spec.md +0 -319
  33. package/src/Schedule/Schedule.spec.md +0 -590
@@ -4,13 +4,12 @@
4
4
 
5
5
  /**
6
6
  * @file MobileTokenizer.tsx
7
- * @input Uses public @astryxdesign/core components and hooks only
8
- * (AlertDialog, BottomSheet, Button, CheckboxIndicator, EmptyState, Field,
9
- * Icon, Spinner, Text, TextInput, Token, VisuallyHidden, useAnnounce,
10
- * useMergedRefs, useTooltip, useTranslator)
7
+ * @input Public @astryxdesign/core components and hooks, plus private Lab
8
+ * mobile-sheet confirmation and search-drag dismissal owners
11
9
  * @output Exports MobileTokenizer — Lab prototype of the touch Tokenizer
12
- * flow with a single searchable sheet, stable in-session checkbox ordering,
13
- * and progressive long-list rendering
10
+ * flow with the inline-compatible closed trigger, a searchable draft-selection
11
+ * sheet, persistent Clear all / Save footer, dirty-dismiss confirmation,
12
+ * stable checkbox ordering, and progressive long-list rendering
14
13
  * @position Lab (canary) stack layer 1: the touch sheet's user-experience
15
14
  * pass happens here; Core's sheet presentation (`component:Tokenizer`
16
15
  * DEC-3, FR16–FR24, AR6) is implemented but not exposed. The behaviors
@@ -29,11 +28,14 @@
29
28
  * API mirrors Core Tokenizer (label/isLabelHidden/searchSource/value/onChange/
30
29
  * change, renderItem, hasCreate, maxEntries) so graduation is an import swap.
31
30
  * Lab deltas: the filter is a bordered TextInput (Core's PanelSearchInput is
32
- * not exported), and there is no theming/spec contract yet.
31
+ * not exported); row, Create, and Clear all changes stay in one draft until
32
+ * Save; the footer stays visible while searching; dirty dismissal asks before
33
+ * discarding; and directly dragging results blurs the search so the mobile
34
+ * keyboard leaves without clearing the query. There is no theming/spec contract
35
+ * yet.
33
36
  */
34
37
  import { useCallback, useEffect, useId, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState } from 'react';
35
38
  import * as stylex from '@stylexjs/stylex';
36
- import { AlertDialog } from '@astryxdesign/core/AlertDialog';
37
39
  import { BottomSheet } from '@astryxdesign/core/BottomSheet';
38
40
  import { Button } from '@astryxdesign/core/Button';
39
41
  import { EmptyState } from '@astryxdesign/core/EmptyState';
@@ -51,6 +53,8 @@ import { composeEventHandlers, mergeProps } from '@astryxdesign/core/utils';
51
53
  import { VisuallyHidden } from '@astryxdesign/core/VisuallyHidden';
52
54
  import '@astryxdesign/core/theme/tokens.stylex';
53
55
  import { colorVars, radiusVars, sizeVars, spacingVars } from '@astryxdesign/core/theme/tokens.stylex';
56
+ import { MobileSheetConfirmation } from "../internal/MobileSheetConfirmation.js";
57
+ import { useDismissSearchOnTouchDrag } from "../internal/useDismissSearchOnTouchDrag.js";
54
58
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
55
59
  const CREATABLE_ID_PREFIX = '__xds_create__';
56
60
  const LIST_RENDER_BATCH_SIZE = 50;
@@ -60,6 +64,9 @@ const LIST_LOAD_MORE_THRESHOLD_PX = 200;
60
64
  // concentric with the field's (radius-2) when inset = radius-2 − radius-1 −
61
65
  // border = 3px. The same number is the row gap, so wrapped rows read evenly.
62
66
  const TOKEN_ROW_INSET = `calc(${spacingVars['--spacing-1']} - 1px)`;
67
+ function hasSameSelection(a, b) {
68
+ return a.length === b.length && a.every(item => b.some(candidate => candidate.id === item.id));
69
+ }
63
70
  const styles = {
64
71
  trigger: {
65
72
  kzqmXN: "xh8yej3",
@@ -173,15 +180,9 @@ export function MobileTokenizer({
173
180
  sheetQueryRef.current = query;
174
181
  onChangeQuery?.(query);
175
182
  }, [onChangeQuery]);
176
- const selectedIds = useMemo(() => new Set(value.map(v => v.id)), [value]);
177
- const isAtMax = maxEntries != null && value.length >= maxEntries;
178
-
179
183
  // A field that becomes disabled cannot keep its sheet open, and must not
180
- // reopen it when re-enabled (FR17). Adjusted in the render that sees the
181
- // change, not an effect, so no frame shows both. A sheet that was open when
182
- // the field became disabled stays mounted, closed, so it runs its exit and
183
- // hands focus back to the trigger; a field disabled from the start mounts
184
- // no sheet.
184
+ // reopen it when re-enabled (FR17). A forced close bypasses the dirty-draft
185
+ // prompt, keeps the query, and discards the in-sheet draft on the next open.
185
186
  const [wasDisabled, setWasDisabled] = useState(isDisabled);
186
187
  const [keepsSheetWhileDisabled, setKeepsSheetWhileDisabled] = useState(false);
187
188
  if (isDisabled !== wasDisabled) {
@@ -194,51 +195,11 @@ export function MobileTokenizer({
194
195
  setKeepsSheetWhileDisabled(false);
195
196
  }
196
197
  }
197
- const handleAdd = useCallback(item => {
198
- if (isDisabled || isAtMax || selectedIds.has(item.id)) {
199
- return false;
200
- }
201
- if (hasCreate && typeof item.id === 'string' && item.id.startsWith(CREATABLE_ID_PREFIX)) {
202
- const created = item.id.slice(CREATABLE_ID_PREFIX.length);
203
- if (selectedIds.has(created)) {
204
- return false;
205
- }
206
- const createdItem = {
207
- id: created,
208
- label: created
209
- };
210
- const real = createdItem;
211
- onChange([...value, real], {
212
- item: real,
213
- type: 'create'
214
- });
215
- return true;
216
- }
217
- onChange([...value, item], {
218
- item,
219
- type: 'add'
220
- });
221
- return true;
222
- }, [hasCreate, isAtMax, isDisabled, onChange, selectedIds, value]);
223
- const handleRemove = useCallback(item => {
224
- if (isDisabled) {
225
- return false;
226
- }
227
- onChange(value.filter(v => v.id !== item.id), {
228
- item,
229
- type: 'remove'
230
- });
231
- return true;
232
- }, [isDisabled, onChange, value]);
233
- const handleClearAll = useCallback(() => {
234
- if (isDisabled || value.length === 0) {
235
- return;
198
+ const handleSheetCommit = useCallback((items, change) => {
199
+ if (!isDisabled) {
200
+ onChange(items, change);
236
201
  }
237
- onChange([], {
238
- item: value[value.length - 1],
239
- type: 'remove'
240
- });
241
- }, [isDisabled, onChange, value]);
202
+ }, [isDisabled, onChange]);
242
203
  const showsDisabledMessage = isDisabled && disabledMessage != null;
243
204
  // The disabled reason as a tooltip on the trigger, for hover, focus and
244
205
  // touch alike, and the element the trigger's description names
@@ -363,17 +324,14 @@ export function MobileTokenizer({
363
324
  label: label,
364
325
  searchSource: searchSource,
365
326
  value: value,
366
- selectedIds: selectedIds,
367
- isAtMax: isAtMax,
327
+ maxEntries: maxEntries,
368
328
  hasCreate: hasCreate,
369
329
  maxMenuItems: maxMenuItems,
370
330
  minQueryLength: minQueryLength,
371
331
  debounceMs: debounceMs,
372
332
  emptySearchResultsText: emptySearchResultsText,
373
333
  renderItem: renderItem,
374
- onAdd: handleAdd,
375
- onRemove: handleRemove,
376
- onClearAll: handleClearAll,
334
+ onCommit: handleSheetCommit,
377
335
  onChangeQuery: handleSheetQueryChange
378
336
  })]
379
337
  });
@@ -381,7 +339,7 @@ export function MobileTokenizer({
381
339
  MobileTokenizer.displayName = 'MobileTokenizer';
382
340
  /**
383
341
  * The sheet: BottomSheet around the content. The content owns the query; a
384
- * close the person makes (Done, swipe, scrim, Escape) clears it through a
342
+ * close the person makes (Save, swipe, scrim, Escape) clears it through a
385
343
  * handle from the closing event, so a close the field forces (becoming
386
344
  * disabled) keeps it (FR18).
387
345
  */
@@ -393,7 +351,8 @@ function MobileTokenizerSheet({
393
351
  ...content
394
352
  }) {
395
353
  const clearQueryRef = useRef(null);
396
- const requestClose = () => {
354
+ const requestDismissRef = useRef(null);
355
+ const closeSheet = () => {
397
356
  clearQueryRef.current?.();
398
357
  onOpenChange(false);
399
358
  };
@@ -402,8 +361,10 @@ function MobileTokenizerSheet({
402
361
  onOpenChange: next => {
403
362
  if (next) {
404
363
  onOpenChange(true);
364
+ } else if (requestDismissRef.current != null) {
365
+ requestDismissRef.current();
405
366
  } else {
406
- requestClose();
367
+ closeSheet();
407
368
  }
408
369
  },
409
370
  finalFocusRef: finalFocusRef,
@@ -415,7 +376,8 @@ function MobileTokenizerSheet({
415
376
  isOpen: isOpen,
416
377
  label: label,
417
378
  clearQueryRef: clearQueryRef,
418
- onRequestClose: requestClose
379
+ requestDismissRef: requestDismissRef,
380
+ onCloseSheet: closeSheet
419
381
  })
420
382
  });
421
383
  }
@@ -427,21 +389,19 @@ function MobileTokenizerSheetContent({
427
389
  isOpen,
428
390
  initialQuery,
429
391
  clearQueryRef,
430
- onRequestClose,
392
+ requestDismissRef,
393
+ onCloseSheet,
431
394
  label,
432
395
  searchSource,
433
396
  value,
434
- selectedIds,
435
- isAtMax,
397
+ maxEntries,
436
398
  hasCreate,
437
399
  maxMenuItems,
438
400
  minQueryLength,
439
401
  debounceMs,
440
402
  emptySearchResultsText,
441
403
  renderItem,
442
- onAdd,
443
- onRemove,
444
- onClearAll,
404
+ onCommit,
445
405
  onChangeQuery
446
406
  }) {
447
407
  const t = useTranslator();
@@ -449,6 +409,20 @@ function MobileTokenizerSheetContent({
449
409
  const searchRef = useRef(null);
450
410
  const clearAllRef = useRef(null);
451
411
 
412
+ // One open sheet owns one selection draft. The caller's controlled value is
413
+ // unchanged until Save, so a cancelled dismissal can discard cleanly.
414
+ const [valueAtOpen, setValueAtOpen] = useState(() => [...value]);
415
+ const [draftValue, setDraftValue] = useState(() => [...value]);
416
+ const lastDraftChangeRef = useRef(null);
417
+ const [isClearConfirmationOpen, setIsClearConfirmationOpen] = useState(false);
418
+ const [isDiscardConfirmationOpen, setIsDiscardConfirmationOpen] = useState(false);
419
+ const afterConfirmationRef = useRef(null);
420
+ const shouldRestoreDraftFocusRef = useRef(false);
421
+ const isDraftDirty = !hasSameSelection(valueAtOpen, draftValue);
422
+ const selectedIds = useMemo(() => new Set(draftValue.map(item => item.id)), [draftValue]);
423
+ const isAtMax = maxEntries != null && draftValue.length >= maxEntries;
424
+ const isDraftOverMax = maxEntries != null && draftValue.length > maxEntries;
425
+
452
426
  // ---- query and search ----
453
427
  const [query, setQuery] = useState(initialQuery);
454
428
  const trimmedQuery = query.trim();
@@ -544,7 +518,11 @@ function MobileTokenizerSheetContent({
544
518
  if (isOpen !== wasOpen) {
545
519
  setWasOpen(isOpen);
546
520
  if (isOpen) {
547
- setSelectedItemsAtOpen(value);
521
+ const snapshot = [...value];
522
+ setValueAtOpen(snapshot);
523
+ setDraftValue(snapshot);
524
+ lastDraftChangeRef.current = null;
525
+ setSelectedItemsAtOpen(snapshot);
548
526
  resetListWindow();
549
527
  // An open lists the bootstrap set, or searches a query kept across a
550
528
  // forced close (FR18); a kept query below the threshold leaves nothing
@@ -557,6 +535,12 @@ function MobileTokenizerSheetContent({
557
535
  } else {
558
536
  announcedSearchIdRef.current = searchIdRef.current;
559
537
  }
538
+ } else {
539
+ // Close both confirmations in the same render that closes the sheet.
540
+ afterConfirmationRef.current = null;
541
+ shouldRestoreDraftFocusRef.current = false;
542
+ setIsClearConfirmationOpen(false);
543
+ setIsDiscardConfirmationOpen(false);
560
544
  }
561
545
  }
562
546
  const handleQueryChange = next => {
@@ -587,16 +571,16 @@ function MobileTokenizerSheetContent({
587
571
  const selectedAtOpenMatches = selectedItemsAtOpen.filter(matchesQuery);
588
572
  const selectedAtOpenIds = new Set(selectedAtOpenMatches.map(item => item.id));
589
573
  const resultIds = new Set(results.map(item => item.id));
590
- const selectedItemsMissingFromResults = value.filter(item => !selectedAtOpenIds.has(item.id) && !resultIds.has(item.id) && matchesQuery(item));
574
+ const selectedItemsMissingFromResults = draftValue.filter(item => !selectedAtOpenIds.has(item.id) && !resultIds.has(item.id) && matchesQuery(item));
591
575
  return [...selectedAtOpenMatches, ...selectedItemsMissingFromResults, ...results.filter(item => !selectedAtOpenIds.has(item.id))];
592
- }, [trimmedQuery, results, selectedItemsAtOpen, value]);
576
+ }, [trimmedQuery, results, selectedItemsAtOpen, draftValue]);
593
577
  const renderedItems = visibleItems.slice(0, renderedItemCount);
594
578
  const createItem = useMemo(() => {
595
579
  if (!hasCreate || trimmedQuery === '' || isAtMax) {
596
580
  return null;
597
581
  }
598
582
  const lower = trimmedQuery.toLowerCase();
599
- if (selectedIds.has(trimmedQuery) || value.some(item => item.label.toLowerCase() === lower) || results.some(r => r.label.toLowerCase() === lower)) {
583
+ if (selectedIds.has(trimmedQuery) || draftValue.some(item => item.label.toLowerCase() === lower) || results.some(r => r.label.toLowerCase() === lower)) {
600
584
  return null;
601
585
  }
602
586
  return {
@@ -605,7 +589,7 @@ function MobileTokenizerSheetContent({
605
589
  value: trimmedQuery
606
590
  })
607
591
  };
608
- }, [hasCreate, trimmedQuery, results, selectedIds, isAtMax, value, t]);
592
+ }, [hasCreate, trimmedQuery, results, selectedIds, isAtMax, draftValue, t]);
609
593
  const isQueryEmpty = trimmedQuery === '';
610
594
  // An empty state is a searched query's answer. An empty query that lists
611
595
  // nothing — nothing selected and an empty bootstrap set — shows only the
@@ -627,21 +611,28 @@ function MobileTokenizerSheetContent({
627
611
  }
628
612
  }, [announce, createItem, isOpen, isPending, isQueryEmpty, isSettledEmpty, visibleItems.length, searchId, t]);
629
613
 
630
- // ---- Clear all ----
631
- // The confirmation is in the DOM only from the Clear all press until it
632
- // closes (INV7). Dialog restores focus only in its close effect, so the
633
- // sheet places focus itself once the modal is gone: on the search field
634
- // after a confirmed clear — the clear is spent, and the search field is
635
- // where the next selection starts (FR24, `spec:AST-085` FR4) — and back on
636
- // Clear all after a cancel. A confirmed clear is announced there too, after
637
- // the confirmation has gone, so the words are delivered inside the sheet
638
- // (`spec:AST-086` FR14).
639
- const [isClearConfirmationOpen, setIsClearConfirmationOpen] = useState(false);
640
- const afterConfirmationRef = useRef(null);
614
+ // ---- confirmations and dismissal ----
615
+ const closeDiscardConfirmation = () => {
616
+ shouldRestoreDraftFocusRef.current = true;
617
+ setIsDiscardConfirmationOpen(false);
618
+ };
619
+ // Synchronize DOM focus with the closed discard dialog. Dialog restores focus
620
+ // in its own close Effect, so focus the search only after that dialog closes.
621
+ useEffect(() => {
622
+ if (isDiscardConfirmationOpen || !shouldRestoreDraftFocusRef.current || !isOpen) {
623
+ return;
624
+ }
625
+ shouldRestoreDraftFocusRef.current = false;
626
+ searchRef.current?.focus({
627
+ preventScroll: true
628
+ });
629
+ }, [isDiscardConfirmationOpen, isOpen]);
641
630
  const closeClearConfirmation = confirmed => {
642
631
  afterConfirmationRef.current = confirmed ? 'search' : 'clear-all';
643
632
  setIsClearConfirmationOpen(false);
644
633
  };
634
+ // Synchronize DOM focus and the live-region announcement after the clear
635
+ // dialog closes, once Dialog's own close Effect has restored focus.
645
636
  useEffect(() => {
646
637
  if (isClearConfirmationOpen || afterConfirmationRef.current == null) {
647
638
  return;
@@ -655,10 +646,53 @@ function MobileTokenizerSheetContent({
655
646
  clearAllRef.current?.focus();
656
647
  }
657
648
  }, [announce, isClearConfirmationOpen, t]);
649
+ useImperativeHandle(requestDismissRef, () => () => {
650
+ if (isDraftDirty) {
651
+ setIsDiscardConfirmationOpen(true);
652
+ } else {
653
+ onCloseSheet();
654
+ }
655
+ }, [isDraftDirty, onCloseSheet]);
656
+ const handleSave = () => {
657
+ if (isDraftOverMax) {
658
+ return;
659
+ }
660
+ if (isDraftDirty) {
661
+ const added = draftValue.find(item => !valueAtOpen.some(openItem => openItem.id === item.id));
662
+ const removed = valueAtOpen.find(item => !draftValue.some(draftItem => draftItem.id === item.id));
663
+ const lastDraftChange = lastDraftChangeRef.current;
664
+ const lastChangeIsNet = lastDraftChange != null && (lastDraftChange.type === 'remove' ? valueAtOpen.some(item => item.id === lastDraftChange.item.id) && !draftValue.some(item => item.id === lastDraftChange.item.id) : draftValue.some(item => item.id === lastDraftChange.item.id) && !valueAtOpen.some(item => item.id === lastDraftChange.item.id));
665
+ let change = null;
666
+ if (lastChangeIsNet) {
667
+ change = lastDraftChange;
668
+ } else if (lastDraftChange?.type === 'remove' && removed != null) {
669
+ change = {
670
+ item: removed,
671
+ type: 'remove'
672
+ };
673
+ } else if (added != null) {
674
+ const type = lastDraftChange?.item.id === added.id && lastDraftChange.type === 'create' ? 'create' : 'add';
675
+ change = {
676
+ item: added,
677
+ type
678
+ };
679
+ } else if (removed != null) {
680
+ change = {
681
+ item: removed,
682
+ type: 'remove'
683
+ };
684
+ }
685
+ if (change != null) {
686
+ onCommit([...draftValue], change);
687
+ }
688
+ }
689
+ onCloseSheet();
690
+ };
658
691
  const searchLabel = t('@astryx.tokenizer.search', {
659
692
  label
660
693
  });
661
694
  const noResultsTitle = emptySearchResultsText ?? t('@astryx.typeahead.emptySearchResults');
695
+ const listDragProps = useDismissSearchOnTouchDrag(() => [searchRef.current]);
662
696
  return /*#__PURE__*/_jsxs("div", {
663
697
  ...{
664
698
  className: "x78zum5 xdt5ytf x5yr21d x2lwn1j xrrkdod xoxd1wu xvmdzux"
@@ -718,6 +752,7 @@ function MobileTokenizerSheetContent({
718
752
  "data-testid": "mobile-tokenizer-list",
719
753
  "data-rendered-count": renderedItems.length,
720
754
  "data-total-count": visibleItems.length,
755
+ ...listDragProps,
721
756
  onScroll: event => {
722
757
  const list = event.currentTarget;
723
758
  const distanceFromBottom = list.scrollHeight - list.scrollTop - list.clientHeight;
@@ -747,12 +782,21 @@ function MobileTokenizerSheetContent({
747
782
  variant: "secondary",
748
783
  size: "lg",
749
784
  onClick: () => {
750
- if (onAdd(createItem)) {
751
- announce(t('@astryx.tokenizer.tokenAdded', {
752
- label: trimmedQuery
753
- }));
754
- }
785
+ const item = {
786
+ id: trimmedQuery,
787
+ label: trimmedQuery
788
+ };
789
+ const createdItem = item;
790
+ setDraftValue(current => [...current, createdItem]);
791
+ lastDraftChangeRef.current = {
792
+ item: createdItem,
793
+ type: 'create'
794
+ };
795
+ announce(t('@astryx.tokenizer.tokenAdded', {
796
+ label: trimmedQuery
797
+ }));
755
798
  handleQueryChange('');
799
+ searchRef.current?.focus();
756
800
  }
757
801
  })]
758
802
  }), renderedItems.map(item => {
@@ -769,12 +813,20 @@ function MobileTokenizerSheetContent({
769
813
  return;
770
814
  }
771
815
  if (isSelected) {
772
- if (onRemove(item)) {
773
- announce(t('@astryx.tokenizer.tokenRemoved', {
774
- label: item.label
775
- }));
776
- }
777
- } else if (onAdd(item)) {
816
+ setDraftValue(current => current.filter(selected => selected.id !== item.id));
817
+ lastDraftChangeRef.current = {
818
+ item,
819
+ type: 'remove'
820
+ };
821
+ announce(t('@astryx.tokenizer.tokenRemoved', {
822
+ label: item.label
823
+ }));
824
+ } else {
825
+ setDraftValue(current => [...current, item]);
826
+ lastDraftChangeRef.current = {
827
+ item,
828
+ type: 'add'
829
+ };
778
830
  announce(t('@astryx.tokenizer.tokenAdded', {
779
831
  label: item.label
780
832
  }));
@@ -805,7 +857,7 @@ function MobileTokenizerSheetContent({
805
857
  })]
806
858
  }, item.id);
807
859
  })]
808
- }), isQueryEmpty && /*#__PURE__*/_jsxs("div", {
860
+ }), /*#__PURE__*/_jsxs("div", {
809
861
  ...{
810
862
  className: "x78zum5 x6s0dn4 x1txdalj x1vlblms xr1yuqi"
811
863
  },
@@ -814,23 +866,22 @@ function MobileTokenizerSheetContent({
814
866
  label: t('@astryx.tokenizer.clearAll'),
815
867
  variant: "secondary",
816
868
  size: "lg",
817
- isDisabled: value.length === 0
818
- // With a tooltip, Button's disabled mode is `aria-disabled`: the
819
- // action keeps its focus order and says why it does nothing
820
- // (`spec:AST-085` FR1), instead of leaving the tab order as a
821
- // natively disabled button does (FR24).
822
- ,
823
- tooltip: value.length === 0 ? t('@astryx.tokenizer.nothingToClear') : undefined,
869
+ isDisabled: draftValue.length === 0,
870
+ tooltip: draftValue.length === 0 ? t('@astryx.tokenizer.nothingToClear') : undefined,
824
871
  onClick: () => setIsClearConfirmationOpen(true),
825
872
  xstyle: styles.footerAction
826
873
  }), /*#__PURE__*/_jsx(Button, {
827
- label: t('@astryx.tokenizer.done'),
874
+ label: t('@astryx.tokenizer.save'),
828
875
  variant: "primary",
829
876
  size: "lg",
830
- onClick: onRequestClose,
877
+ isDisabled: isDraftOverMax,
878
+ tooltip: isDraftOverMax ? t('@astryx.tokenizer.selectionLimit', {
879
+ count: maxEntries ?? 0
880
+ }) : undefined,
881
+ onClick: handleSave,
831
882
  xstyle: styles.footerAction
832
883
  })]
833
- }), isClearConfirmationOpen && /*#__PURE__*/_jsx(AlertDialog, {
884
+ }), isClearConfirmationOpen && /*#__PURE__*/_jsx(MobileSheetConfirmation, {
834
885
  isOpen: true,
835
886
  onOpenChange: next => {
836
887
  if (!next) {
@@ -841,11 +892,37 @@ function MobileTokenizerSheetContent({
841
892
  description: t('@astryx.tokenizer.clearAllDescription', {
842
893
  label
843
894
  }),
895
+ cancelLabel: t('@astryx.alertDialog.cancel'),
844
896
  actionLabel: t('@astryx.tokenizer.clearSelection'),
845
897
  onAction: () => {
846
- onClearAll();
898
+ const lastItem = draftValue[draftValue.length - 1];
899
+ if (lastItem != null) {
900
+ setDraftValue([]);
901
+ lastDraftChangeRef.current = {
902
+ item: lastItem,
903
+ type: 'remove'
904
+ };
905
+ }
847
906
  closeClearConfirmation(true);
848
907
  }
908
+ }), isDiscardConfirmationOpen && /*#__PURE__*/_jsx(MobileSheetConfirmation, {
909
+ isOpen: true,
910
+ onOpenChange: next => {
911
+ if (!next) {
912
+ closeDiscardConfirmation();
913
+ }
914
+ },
915
+ title: t('@astryx.tokenizer.discardChangesTitle'),
916
+ description: t('@astryx.tokenizer.discardChangesDescription', {
917
+ label
918
+ }),
919
+ cancelLabel: t('@astryx.tokenizer.keepEditing'),
920
+ actionLabel: t('@astryx.tokenizer.discardChanges'),
921
+ onAction: () => {
922
+ shouldRestoreDraftFocusRef.current = false;
923
+ setIsDiscardConfirmationOpen(false);
924
+ onCloseSheet();
925
+ }
849
926
  })]
850
927
  });
851
928
  }
@@ -0,0 +1,12 @@
1
+ interface MobileSheetConfirmationProps {
2
+ isOpen: boolean;
3
+ onOpenChange: (isOpen: boolean) => void;
4
+ title: string;
5
+ description: string;
6
+ cancelLabel: string;
7
+ actionLabel: string;
8
+ onAction: () => void;
9
+ }
10
+ export declare function MobileSheetConfirmation({ isOpen, onOpenChange, title, description, cancelLabel, actionLabel, onAction, }: MobileSheetConfirmationProps): import("react").JSX.Element;
11
+ export {};
12
+ //# sourceMappingURL=MobileSheetConfirmation.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"MobileSheetConfirmation.d.ts","sourceRoot":"","sources":["../../src/internal/MobileSheetConfirmation.tsx"],"names":[],"mappings":"AA0BA,UAAU,4BAA4B;IACpC,MAAM,EAAE,OAAO,CAAC;IAChB,YAAY,EAAE,CAAC,MAAM,EAAE,OAAO,KAAK,IAAI,CAAC;IACxC,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE,MAAM,IAAI,CAAC;CACtB;AAMD,wBAAgB,uBAAuB,CAAC,EACtC,MAAM,EACN,YAAY,EACZ,KAAK,EACL,WAAW,EACX,WAAW,EACX,WAAW,EACX,QAAQ,GACT,EAAE,4BAA4B,+BAgD9B"}
@@ -0,0 +1,88 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ /**
6
+ * @file MobileSheetConfirmation.tsx
7
+ * @input Public Core Dialog, layout, text and action primitives; caller-owned copy
8
+ * @output Private confirmation shared by MobileTokenizer and MobilePowerSearch
9
+ * @position Lab-only mobile sheet confirmation owner; not a public export
10
+ *
11
+ * Core AlertDialog fixes its purpose to form and its safe action to ghost.
12
+ * These Lab flows instead allow scrim dismissal and use separated, full-width
13
+ * large actions with the secondary safe action focused first. Keep that
14
+ * difference here, while preserving the existing alert-dialog theme target.
15
+ */
16
+ import { useId } from 'react';
17
+ import * as stylex from '@stylexjs/stylex';
18
+ import { Button } from '@astryxdesign/core/Button';
19
+ import { Dialog } from '@astryxdesign/core/Dialog';
20
+ import { Heading } from '@astryxdesign/core/Heading';
21
+ import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout';
22
+ import { Stack } from '@astryxdesign/core/Stack';
23
+ import { Text } from '@astryxdesign/core/Text';
24
+ import { themeProps } from '@astryxdesign/core/utils';
25
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
26
+ const styles = {
27
+ action: {
28
+ kzqmXN: "xh8yej3",
29
+ $$css: true
30
+ }
31
+ };
32
+ export function MobileSheetConfirmation({
33
+ isOpen,
34
+ onOpenChange,
35
+ title,
36
+ description,
37
+ cancelLabel,
38
+ actionLabel,
39
+ onAction
40
+ }) {
41
+ const titleId = useId();
42
+ const descriptionId = useId();
43
+ return /*#__PURE__*/_jsx(Dialog, {
44
+ ...themeProps('alert-dialog'),
45
+ isOpen: isOpen,
46
+ onOpenChange: onOpenChange,
47
+ purpose: "info",
48
+ role: "alertdialog",
49
+ "aria-labelledby": titleId,
50
+ "aria-describedby": descriptionId,
51
+ width: 400,
52
+ children: /*#__PURE__*/_jsx(Layout, {
53
+ content: /*#__PURE__*/_jsxs(LayoutContent, {
54
+ children: [/*#__PURE__*/_jsx(Heading, {
55
+ level: 2,
56
+ id: titleId,
57
+ children: title
58
+ }), /*#__PURE__*/_jsx(Text, {
59
+ type: "body",
60
+ color: "secondary",
61
+ id: descriptionId,
62
+ children: description
63
+ })]
64
+ }),
65
+ footer: /*#__PURE__*/_jsx(LayoutFooter, {
66
+ children: /*#__PURE__*/_jsxs(Stack, {
67
+ direction: "vertical",
68
+ gap: 4,
69
+ hAlign: "stretch",
70
+ children: [/*#__PURE__*/_jsx(Button, {
71
+ label: actionLabel,
72
+ variant: "destructive",
73
+ size: "lg",
74
+ onClick: onAction,
75
+ xstyle: styles.action
76
+ }), /*#__PURE__*/_jsx(Button, {
77
+ label: cancelLabel,
78
+ variant: "secondary",
79
+ size: "lg",
80
+ onClick: () => onOpenChange(false),
81
+ "data-autofocus": true,
82
+ xstyle: styles.action
83
+ })]
84
+ })
85
+ })
86
+ })
87
+ });
88
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * @file useDismissSearchOnTouchDrag.ts
3
+ * @input Caller-owned search inputs and touch events from a separate results list
4
+ * @output Private touch handlers shared by the Lab mobile search sheets
5
+ * @position One owner for results-drag keyboard dismissal; not a public export
6
+ *
7
+ * A direct vertical touch drag dismisses only a focused caller-owned search.
8
+ * Scroll events are deliberately not observed: filtering can clamp scrollTop
9
+ * and fire scroll while someone is typing. Small moves, taps and multi-touch
10
+ * gestures leave focus alone. The hook acquires no external resource.
11
+ */
12
+ import { type TouchEvent } from 'react';
13
+ type SearchInput = HTMLInputElement | null | undefined;
14
+ export declare function useDismissSearchOnTouchDrag(getSearchInputs: () => readonly SearchInput[]): {
15
+ onTouchStart: (event: TouchEvent<HTMLElement>) => void;
16
+ onTouchMove: (event: TouchEvent<HTMLElement>) => void;
17
+ onTouchEnd: () => void;
18
+ onTouchCancel: () => void;
19
+ };
20
+ export {};
21
+ //# sourceMappingURL=useDismissSearchOnTouchDrag.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useDismissSearchOnTouchDrag.d.ts","sourceRoot":"","sources":["../../src/internal/useDismissSearchOnTouchDrag.ts"],"names":[],"mappings":"AAIA;;;;;;;;;;GAUG;AAEH,OAAO,EAAS,KAAK,UAAU,EAAC,MAAM,OAAO,CAAC;AAI9C,KAAK,WAAW,GAAG,gBAAgB,GAAG,IAAI,GAAG,SAAS,CAAC;AAEvD,wBAAgB,2BAA2B,CACzC,eAAe,EAAE,MAAM,SAAS,WAAW,EAAE;0BAQrB,UAAU,CAAC,WAAW,CAAC;yBAIxB,UAAU,CAAC,WAAW,CAAC;;;EAqB/C"}