@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,7 +28,11 @@
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
 
35
38
  import type React from 'react';
@@ -47,7 +50,6 @@ import {
47
50
  } from 'react';
48
51
  import * as stylex from '@stylexjs/stylex';
49
52
  import type {BaseProps} from '@astryxdesign/core';
50
- import {AlertDialog} from '@astryxdesign/core/AlertDialog';
51
53
  import {BottomSheet} from '@astryxdesign/core/BottomSheet';
52
54
  import {Button} from '@astryxdesign/core/Button';
53
55
  import {EmptyState} from '@astryxdesign/core/EmptyState';
@@ -70,6 +72,8 @@ import {
70
72
  sizeVars,
71
73
  spacingVars,
72
74
  } from '@astryxdesign/core/theme/tokens.stylex';
75
+ import {MobileSheetConfirmation} from '../internal/MobileSheetConfirmation';
76
+ import {useDismissSearchOnTouchDrag} from '../internal/useDismissSearchOnTouchDrag';
73
77
 
74
78
  export type MobileTokenizerChange<T extends SearchableItem> =
75
79
  | {item: T; type: 'add'}
@@ -126,6 +130,18 @@ const LIST_LOAD_MORE_THRESHOLD_PX = 200;
126
130
  // border = 3px. The same number is the row gap, so wrapped rows read evenly.
127
131
  const TOKEN_ROW_INSET = `calc(${spacingVars['--spacing-1']} - 1px)`;
128
132
 
133
+ type SheetDraftChange<T> = {
134
+ item: T;
135
+ type: 'add' | 'create' | 'remove';
136
+ };
137
+
138
+ function hasSameSelection<T extends SearchableItem>(a: T[], b: T[]): boolean {
139
+ return (
140
+ a.length === b.length &&
141
+ a.every(item => b.some(candidate => candidate.id === item.id))
142
+ );
143
+ }
144
+
129
145
  const styles = stylex.create({
130
146
  // The closed trigger is the inline Tokenizer field's own control box, as a
131
147
  // button — the same `inputWrapperStyles.base` box Core's sheet trigger
@@ -366,15 +382,9 @@ export function MobileTokenizer<T extends SearchableItem>({
366
382
  },
367
383
  [onChangeQuery],
368
384
  );
369
- const selectedIds = useMemo(() => new Set(value.map(v => v.id)), [value]);
370
- const isAtMax = maxEntries != null && value.length >= maxEntries;
371
-
372
385
  // A field that becomes disabled cannot keep its sheet open, and must not
373
- // reopen it when re-enabled (FR17). Adjusted in the render that sees the
374
- // change, not an effect, so no frame shows both. A sheet that was open when
375
- // the field became disabled stays mounted, closed, so it runs its exit and
376
- // hands focus back to the trigger; a field disabled from the start mounts
377
- // no sheet.
386
+ // reopen it when re-enabled (FR17). A forced close bypasses the dirty-draft
387
+ // prompt, keeps the query, and discards the in-sheet draft on the next open.
378
388
  const [wasDisabled, setWasDisabled] = useState(isDisabled);
379
389
  const [keepsSheetWhileDisabled, setKeepsSheetWhileDisabled] = useState(false);
380
390
  if (isDisabled !== wasDisabled) {
@@ -388,49 +398,14 @@ export function MobileTokenizer<T extends SearchableItem>({
388
398
  }
389
399
  }
390
400
 
391
- const handleAdd = useCallback(
392
- (item: T): boolean => {
393
- if (isDisabled || isAtMax || selectedIds.has(item.id)) {
394
- return false;
401
+ const handleSheetCommit = useCallback(
402
+ (items: T[], change: SheetDraftChange<T>) => {
403
+ if (!isDisabled) {
404
+ onChange(items, change);
395
405
  }
396
- if (
397
- hasCreate &&
398
- typeof item.id === 'string' &&
399
- item.id.startsWith(CREATABLE_ID_PREFIX)
400
- ) {
401
- const created = item.id.slice(CREATABLE_ID_PREFIX.length);
402
- if (selectedIds.has(created)) {
403
- return false;
404
- }
405
- const createdItem: SearchableItem = {id: created, label: created};
406
- const real = createdItem as T;
407
- onChange([...value, real], {item: real, type: 'create'});
408
- return true;
409
- }
410
- onChange([...value, item], {item, type: 'add'});
411
- return true;
412
406
  },
413
- [hasCreate, isAtMax, isDisabled, onChange, selectedIds, value],
407
+ [isDisabled, onChange],
414
408
  );
415
- const handleRemove = useCallback(
416
- (item: T): boolean => {
417
- if (isDisabled) {
418
- return false;
419
- }
420
- onChange(
421
- value.filter(v => v.id !== item.id),
422
- {item, type: 'remove'},
423
- );
424
- return true;
425
- },
426
- [isDisabled, onChange, value],
427
- );
428
- const handleClearAll = useCallback(() => {
429
- if (isDisabled || value.length === 0) {
430
- return;
431
- }
432
- onChange([], {item: value[value.length - 1], type: 'remove'});
433
- }, [isDisabled, onChange, value]);
434
409
 
435
410
  const showsDisabledMessage = isDisabled && disabledMessage != null;
436
411
  // The disabled reason as a tooltip on the trigger, for hover, focus and
@@ -606,17 +581,14 @@ export function MobileTokenizer<T extends SearchableItem>({
606
581
  label={label}
607
582
  searchSource={searchSource}
608
583
  value={value}
609
- selectedIds={selectedIds}
610
- isAtMax={isAtMax}
584
+ maxEntries={maxEntries}
611
585
  hasCreate={hasCreate}
612
586
  maxMenuItems={maxMenuItems}
613
587
  minQueryLength={minQueryLength}
614
588
  debounceMs={debounceMs}
615
589
  emptySearchResultsText={emptySearchResultsText}
616
590
  renderItem={renderItem}
617
- onAdd={handleAdd}
618
- onRemove={handleRemove}
619
- onClearAll={handleClearAll}
591
+ onCommit={handleSheetCommit}
620
592
  onChangeQuery={handleSheetQueryChange}
621
593
  />
622
594
  )}
@@ -635,23 +607,20 @@ interface MobileTokenizerSheetProps<T extends SearchableItem> {
635
607
  label: string;
636
608
  searchSource: SearchSource<T>;
637
609
  value: T[];
638
- selectedIds: Set<string>;
639
- isAtMax: boolean;
610
+ maxEntries?: number;
640
611
  hasCreate: boolean;
641
612
  maxMenuItems: number;
642
613
  minQueryLength: number;
643
614
  debounceMs: number;
644
615
  emptySearchResultsText?: string;
645
616
  renderItem?: (item: T) => ReactNode;
646
- onAdd: (item: T) => boolean;
647
- onRemove: (item: T) => boolean;
648
- onClearAll: () => void;
617
+ onCommit: (items: T[], change: SheetDraftChange<T>) => void;
649
618
  onChangeQuery: (query: string) => void;
650
619
  }
651
620
 
652
621
  /**
653
622
  * The sheet: BottomSheet around the content. The content owns the query; a
654
- * close the person makes (Done, swipe, scrim, Escape) clears it through a
623
+ * close the person makes (Save, swipe, scrim, Escape) clears it through a
655
624
  * handle from the closing event, so a close the field forces (becoming
656
625
  * disabled) keeps it (FR18).
657
626
  */
@@ -663,7 +632,8 @@ function MobileTokenizerSheet<T extends SearchableItem>({
663
632
  ...content
664
633
  }: MobileTokenizerSheetProps<T>) {
665
634
  const clearQueryRef = useRef<(() => void) | null>(null);
666
- const requestClose = () => {
635
+ const requestDismissRef = useRef<(() => void) | null>(null);
636
+ const closeSheet = () => {
667
637
  clearQueryRef.current?.();
668
638
  onOpenChange(false);
669
639
  };
@@ -673,8 +643,10 @@ function MobileTokenizerSheet<T extends SearchableItem>({
673
643
  onOpenChange={next => {
674
644
  if (next) {
675
645
  onOpenChange(true);
646
+ } else if (requestDismissRef.current != null) {
647
+ requestDismissRef.current();
676
648
  } else {
677
- requestClose();
649
+ closeSheet();
678
650
  }
679
651
  }}
680
652
  finalFocusRef={finalFocusRef}
@@ -686,7 +658,8 @@ function MobileTokenizerSheet<T extends SearchableItem>({
686
658
  isOpen={isOpen}
687
659
  label={label}
688
660
  clearQueryRef={clearQueryRef}
689
- onRequestClose={requestClose}
661
+ requestDismissRef={requestDismissRef}
662
+ onCloseSheet={closeSheet}
690
663
  />
691
664
  </BottomSheet>
692
665
  );
@@ -697,7 +670,8 @@ type MobileTokenizerSheetContentProps<T extends SearchableItem> = Omit<
697
670
  'onOpenChange' | 'finalFocusRef'
698
671
  > & {
699
672
  clearQueryRef: RefObject<(() => void) | null>;
700
- onRequestClose: () => void;
673
+ requestDismissRef: RefObject<(() => void) | null>;
674
+ onCloseSheet: () => void;
701
675
  };
702
676
 
703
677
  /**
@@ -708,21 +682,19 @@ function MobileTokenizerSheetContent<T extends SearchableItem>({
708
682
  isOpen,
709
683
  initialQuery,
710
684
  clearQueryRef,
711
- onRequestClose,
685
+ requestDismissRef,
686
+ onCloseSheet,
712
687
  label,
713
688
  searchSource,
714
689
  value,
715
- selectedIds,
716
- isAtMax,
690
+ maxEntries,
717
691
  hasCreate,
718
692
  maxMenuItems,
719
693
  minQueryLength,
720
694
  debounceMs,
721
695
  emptySearchResultsText,
722
696
  renderItem,
723
- onAdd,
724
- onRemove,
725
- onClearAll,
697
+ onCommit,
726
698
  onChangeQuery,
727
699
  }: MobileTokenizerSheetContentProps<T>) {
728
700
  const t = useTranslator();
@@ -730,6 +702,24 @@ function MobileTokenizerSheetContent<T extends SearchableItem>({
730
702
  const searchRef = useRef<HTMLInputElement>(null);
731
703
  const clearAllRef = useRef<HTMLButtonElement>(null);
732
704
 
705
+ // One open sheet owns one selection draft. The caller's controlled value is
706
+ // unchanged until Save, so a cancelled dismissal can discard cleanly.
707
+ const [valueAtOpen, setValueAtOpen] = useState<T[]>(() => [...value]);
708
+ const [draftValue, setDraftValue] = useState<T[]>(() => [...value]);
709
+ const lastDraftChangeRef = useRef<SheetDraftChange<T> | null>(null);
710
+ const [isClearConfirmationOpen, setIsClearConfirmationOpen] = useState(false);
711
+ const [isDiscardConfirmationOpen, setIsDiscardConfirmationOpen] =
712
+ useState(false);
713
+ const afterConfirmationRef = useRef<'search' | 'clear-all' | null>(null);
714
+ const shouldRestoreDraftFocusRef = useRef(false);
715
+ const isDraftDirty = !hasSameSelection(valueAtOpen, draftValue);
716
+ const selectedIds = useMemo(
717
+ () => new Set(draftValue.map(item => item.id)),
718
+ [draftValue],
719
+ );
720
+ const isAtMax = maxEntries != null && draftValue.length >= maxEntries;
721
+ const isDraftOverMax = maxEntries != null && draftValue.length > maxEntries;
722
+
733
723
  // ---- query and search ----
734
724
  const [query, setQuery] = useState(initialQuery);
735
725
  const trimmedQuery = query.trim();
@@ -838,7 +828,11 @@ function MobileTokenizerSheetContent<T extends SearchableItem>({
838
828
  if (isOpen !== wasOpen) {
839
829
  setWasOpen(isOpen);
840
830
  if (isOpen) {
841
- setSelectedItemsAtOpen(value);
831
+ const snapshot = [...value];
832
+ setValueAtOpen(snapshot);
833
+ setDraftValue(snapshot);
834
+ lastDraftChangeRef.current = null;
835
+ setSelectedItemsAtOpen(snapshot);
842
836
  resetListWindow();
843
837
  // An open lists the bootstrap set, or searches a query kept across a
844
838
  // forced close (FR18); a kept query below the threshold leaves nothing
@@ -851,6 +845,12 @@ function MobileTokenizerSheetContent<T extends SearchableItem>({
851
845
  } else {
852
846
  announcedSearchIdRef.current = searchIdRef.current;
853
847
  }
848
+ } else {
849
+ // Close both confirmations in the same render that closes the sheet.
850
+ afterConfirmationRef.current = null;
851
+ shouldRestoreDraftFocusRef.current = false;
852
+ setIsClearConfirmationOpen(false);
853
+ setIsDiscardConfirmationOpen(false);
854
854
  }
855
855
  }
856
856
  const handleQueryChange = (next: string) => {
@@ -890,7 +890,7 @@ function MobileTokenizerSheetContent<T extends SearchableItem>({
890
890
  selectedAtOpenMatches.map(item => item.id),
891
891
  );
892
892
  const resultIds = new Set(results.map(item => item.id));
893
- const selectedItemsMissingFromResults = value.filter(
893
+ const selectedItemsMissingFromResults = draftValue.filter(
894
894
  item =>
895
895
  !selectedAtOpenIds.has(item.id) &&
896
896
  !resultIds.has(item.id) &&
@@ -901,7 +901,7 @@ function MobileTokenizerSheetContent<T extends SearchableItem>({
901
901
  ...selectedItemsMissingFromResults,
902
902
  ...results.filter(item => !selectedAtOpenIds.has(item.id)),
903
903
  ];
904
- }, [trimmedQuery, results, selectedItemsAtOpen, value]);
904
+ }, [trimmedQuery, results, selectedItemsAtOpen, draftValue]);
905
905
  const renderedItems = visibleItems.slice(0, renderedItemCount);
906
906
 
907
907
  const createItem = useMemo<T | null>(() => {
@@ -911,7 +911,7 @@ function MobileTokenizerSheetContent<T extends SearchableItem>({
911
911
  const lower = trimmedQuery.toLowerCase();
912
912
  if (
913
913
  selectedIds.has(trimmedQuery) ||
914
- value.some(item => item.label.toLowerCase() === lower) ||
914
+ draftValue.some(item => item.label.toLowerCase() === lower) ||
915
915
  results.some(r => r.label.toLowerCase() === lower)
916
916
  ) {
917
917
  return null;
@@ -920,7 +920,7 @@ function MobileTokenizerSheetContent<T extends SearchableItem>({
920
920
  id: `${CREATABLE_ID_PREFIX}${trimmedQuery}`,
921
921
  label: t('@astryx.tokenizer.create', {value: trimmedQuery}),
922
922
  } as unknown as T;
923
- }, [hasCreate, trimmedQuery, results, selectedIds, isAtMax, value, t]);
923
+ }, [hasCreate, trimmedQuery, results, selectedIds, isAtMax, draftValue, t]);
924
924
 
925
925
  const isQueryEmpty = trimmedQuery === '';
926
926
  // An empty state is a searched query's answer. An empty query that lists
@@ -965,21 +965,30 @@ function MobileTokenizerSheetContent<T extends SearchableItem>({
965
965
  t,
966
966
  ]);
967
967
 
968
- // ---- Clear all ----
969
- // The confirmation is in the DOM only from the Clear all press until it
970
- // closes (INV7). Dialog restores focus only in its close effect, so the
971
- // sheet places focus itself once the modal is gone: on the search field
972
- // after a confirmed clear — the clear is spent, and the search field is
973
- // where the next selection starts (FR24, `spec:AST-085` FR4) — and back on
974
- // Clear all after a cancel. A confirmed clear is announced there too, after
975
- // the confirmation has gone, so the words are delivered inside the sheet
976
- // (`spec:AST-086` FR14).
977
- const [isClearConfirmationOpen, setIsClearConfirmationOpen] = useState(false);
978
- const afterConfirmationRef = useRef<'search' | 'clear-all' | null>(null);
968
+ // ---- confirmations and dismissal ----
969
+ const closeDiscardConfirmation = () => {
970
+ shouldRestoreDraftFocusRef.current = true;
971
+ setIsDiscardConfirmationOpen(false);
972
+ };
973
+ // Synchronize DOM focus with the closed discard dialog. Dialog restores focus
974
+ // in its own close Effect, so focus the search only after that dialog closes.
975
+ useEffect(() => {
976
+ if (
977
+ isDiscardConfirmationOpen ||
978
+ !shouldRestoreDraftFocusRef.current ||
979
+ !isOpen
980
+ ) {
981
+ return;
982
+ }
983
+ shouldRestoreDraftFocusRef.current = false;
984
+ searchRef.current?.focus({preventScroll: true});
985
+ }, [isDiscardConfirmationOpen, isOpen]);
979
986
  const closeClearConfirmation = (confirmed: boolean) => {
980
987
  afterConfirmationRef.current = confirmed ? 'search' : 'clear-all';
981
988
  setIsClearConfirmationOpen(false);
982
989
  };
990
+ // Synchronize DOM focus and the live-region announcement after the clear
991
+ // dialog closes, once Dialog's own close Effect has restored focus.
983
992
  useEffect(() => {
984
993
  if (isClearConfirmationOpen || afterConfirmationRef.current == null) {
985
994
  return;
@@ -993,10 +1002,63 @@ function MobileTokenizerSheetContent<T extends SearchableItem>({
993
1002
  clearAllRef.current?.focus();
994
1003
  }
995
1004
  }, [announce, isClearConfirmationOpen, t]);
1005
+ useImperativeHandle(
1006
+ requestDismissRef,
1007
+ () => () => {
1008
+ if (isDraftDirty) {
1009
+ setIsDiscardConfirmationOpen(true);
1010
+ } else {
1011
+ onCloseSheet();
1012
+ }
1013
+ },
1014
+ [isDraftDirty, onCloseSheet],
1015
+ );
1016
+
1017
+ const handleSave = () => {
1018
+ if (isDraftOverMax) {
1019
+ return;
1020
+ }
1021
+ if (isDraftDirty) {
1022
+ const added = draftValue.find(
1023
+ item => !valueAtOpen.some(openItem => openItem.id === item.id),
1024
+ );
1025
+ const removed = valueAtOpen.find(
1026
+ item => !draftValue.some(draftItem => draftItem.id === item.id),
1027
+ );
1028
+ const lastDraftChange = lastDraftChangeRef.current;
1029
+ const lastChangeIsNet =
1030
+ lastDraftChange != null &&
1031
+ (lastDraftChange.type === 'remove'
1032
+ ? valueAtOpen.some(item => item.id === lastDraftChange.item.id) &&
1033
+ !draftValue.some(item => item.id === lastDraftChange.item.id)
1034
+ : draftValue.some(item => item.id === lastDraftChange.item.id) &&
1035
+ !valueAtOpen.some(item => item.id === lastDraftChange.item.id));
1036
+ let change: SheetDraftChange<T> | null = null;
1037
+ if (lastChangeIsNet) {
1038
+ change = lastDraftChange;
1039
+ } else if (lastDraftChange?.type === 'remove' && removed != null) {
1040
+ change = {item: removed, type: 'remove'};
1041
+ } else if (added != null) {
1042
+ const type: SheetDraftChange<T>['type'] =
1043
+ lastDraftChange?.item.id === added.id &&
1044
+ lastDraftChange.type === 'create'
1045
+ ? 'create'
1046
+ : 'add';
1047
+ change = {item: added, type};
1048
+ } else if (removed != null) {
1049
+ change = {item: removed, type: 'remove'};
1050
+ }
1051
+ if (change != null) {
1052
+ onCommit([...draftValue], change);
1053
+ }
1054
+ }
1055
+ onCloseSheet();
1056
+ };
996
1057
 
997
1058
  const searchLabel = t('@astryx.tokenizer.search', {label});
998
1059
  const noResultsTitle =
999
1060
  emptySearchResultsText ?? t('@astryx.typeahead.emptySearchResults');
1061
+ const listDragProps = useDismissSearchOnTouchDrag(() => [searchRef.current]);
1000
1062
 
1001
1063
  return (
1002
1064
  <div {...stylex.props(styles.sheetBody)}>
@@ -1045,6 +1107,7 @@ function MobileTokenizerSheetContent<T extends SearchableItem>({
1045
1107
  data-testid="mobile-tokenizer-list"
1046
1108
  data-rendered-count={renderedItems.length}
1047
1109
  data-total-count={visibleItems.length}
1110
+ {...listDragProps}
1048
1111
  onScroll={event => {
1049
1112
  const list = event.currentTarget;
1050
1113
  const distanceFromBottom =
@@ -1074,12 +1137,21 @@ function MobileTokenizerSheetContent<T extends SearchableItem>({
1074
1137
  variant="secondary"
1075
1138
  size="lg"
1076
1139
  onClick={() => {
1077
- if (onAdd(createItem)) {
1078
- announce(
1079
- t('@astryx.tokenizer.tokenAdded', {label: trimmedQuery}),
1080
- );
1081
- }
1140
+ const item: SearchableItem = {
1141
+ id: trimmedQuery,
1142
+ label: trimmedQuery,
1143
+ };
1144
+ const createdItem = item as T;
1145
+ setDraftValue(current => [...current, createdItem]);
1146
+ lastDraftChangeRef.current = {
1147
+ item: createdItem,
1148
+ type: 'create',
1149
+ };
1150
+ announce(
1151
+ t('@astryx.tokenizer.tokenAdded', {label: trimmedQuery}),
1152
+ );
1082
1153
  handleQueryChange('');
1154
+ searchRef.current?.focus();
1083
1155
  }}
1084
1156
  />
1085
1157
  </div>
@@ -1100,14 +1172,16 @@ function MobileTokenizerSheetContent<T extends SearchableItem>({
1100
1172
  return;
1101
1173
  }
1102
1174
  if (isSelected) {
1103
- if (onRemove(item)) {
1104
- announce(
1105
- t('@astryx.tokenizer.tokenRemoved', {
1106
- label: item.label,
1107
- }),
1108
- );
1109
- }
1110
- } else if (onAdd(item)) {
1175
+ setDraftValue(current =>
1176
+ current.filter(selected => selected.id !== item.id),
1177
+ );
1178
+ lastDraftChangeRef.current = {item, type: 'remove'};
1179
+ announce(
1180
+ t('@astryx.tokenizer.tokenRemoved', {label: item.label}),
1181
+ );
1182
+ } else {
1183
+ setDraftValue(current => [...current, item]);
1184
+ lastDraftChangeRef.current = {item, type: 'add'};
1111
1185
  announce(
1112
1186
  t('@astryx.tokenizer.tokenAdded', {label: item.label}),
1113
1187
  );
@@ -1139,37 +1213,39 @@ function MobileTokenizerSheetContent<T extends SearchableItem>({
1139
1213
  })}
1140
1214
  </div>
1141
1215
  )}
1142
- {isQueryEmpty && (
1143
- <div {...stylex.props(styles.footer)}>
1144
- <Button
1145
- ref={clearAllRef}
1146
- label={t('@astryx.tokenizer.clearAll')}
1147
- variant="secondary"
1148
- size="lg"
1149
- isDisabled={value.length === 0}
1150
- // With a tooltip, Button's disabled mode is `aria-disabled`: the
1151
- // action keeps its focus order and says why it does nothing
1152
- // (`spec:AST-085` FR1), instead of leaving the tab order as a
1153
- // natively disabled button does (FR24).
1154
- tooltip={
1155
- value.length === 0
1156
- ? t('@astryx.tokenizer.nothingToClear')
1157
- : undefined
1158
- }
1159
- onClick={() => setIsClearConfirmationOpen(true)}
1160
- xstyle={styles.footerAction}
1161
- />
1162
- <Button
1163
- label={t('@astryx.tokenizer.done')}
1164
- variant="primary"
1165
- size="lg"
1166
- onClick={onRequestClose}
1167
- xstyle={styles.footerAction}
1168
- />
1169
- </div>
1170
- )}
1216
+ <div {...stylex.props(styles.footer)}>
1217
+ <Button
1218
+ ref={clearAllRef}
1219
+ label={t('@astryx.tokenizer.clearAll')}
1220
+ variant="secondary"
1221
+ size="lg"
1222
+ isDisabled={draftValue.length === 0}
1223
+ tooltip={
1224
+ draftValue.length === 0
1225
+ ? t('@astryx.tokenizer.nothingToClear')
1226
+ : undefined
1227
+ }
1228
+ onClick={() => setIsClearConfirmationOpen(true)}
1229
+ xstyle={styles.footerAction}
1230
+ />
1231
+ <Button
1232
+ label={t('@astryx.tokenizer.save')}
1233
+ variant="primary"
1234
+ size="lg"
1235
+ isDisabled={isDraftOverMax}
1236
+ tooltip={
1237
+ isDraftOverMax
1238
+ ? t('@astryx.tokenizer.selectionLimit', {
1239
+ count: maxEntries ?? 0,
1240
+ })
1241
+ : undefined
1242
+ }
1243
+ onClick={handleSave}
1244
+ xstyle={styles.footerAction}
1245
+ />
1246
+ </div>
1171
1247
  {isClearConfirmationOpen && (
1172
- <AlertDialog
1248
+ <MobileSheetConfirmation
1173
1249
  isOpen
1174
1250
  onOpenChange={next => {
1175
1251
  if (!next) {
@@ -1178,13 +1254,42 @@ function MobileTokenizerSheetContent<T extends SearchableItem>({
1178
1254
  }}
1179
1255
  title={t('@astryx.tokenizer.clearAllTitle')}
1180
1256
  description={t('@astryx.tokenizer.clearAllDescription', {label})}
1257
+ cancelLabel={t('@astryx.alertDialog.cancel')}
1181
1258
  actionLabel={t('@astryx.tokenizer.clearSelection')}
1182
1259
  onAction={() => {
1183
- onClearAll();
1260
+ const lastItem = draftValue[draftValue.length - 1];
1261
+ if (lastItem != null) {
1262
+ setDraftValue([]);
1263
+ lastDraftChangeRef.current = {
1264
+ item: lastItem,
1265
+ type: 'remove',
1266
+ };
1267
+ }
1184
1268
  closeClearConfirmation(true);
1185
1269
  }}
1186
1270
  />
1187
1271
  )}
1272
+ {isDiscardConfirmationOpen && (
1273
+ <MobileSheetConfirmation
1274
+ isOpen
1275
+ onOpenChange={next => {
1276
+ if (!next) {
1277
+ closeDiscardConfirmation();
1278
+ }
1279
+ }}
1280
+ title={t('@astryx.tokenizer.discardChangesTitle')}
1281
+ description={t('@astryx.tokenizer.discardChangesDescription', {
1282
+ label,
1283
+ })}
1284
+ cancelLabel={t('@astryx.tokenizer.keepEditing')}
1285
+ actionLabel={t('@astryx.tokenizer.discardChanges')}
1286
+ onAction={() => {
1287
+ shouldRestoreDraftFocusRef.current = false;
1288
+ setIsDiscardConfirmationOpen(false);
1289
+ onCloseSheet();
1290
+ }}
1291
+ />
1292
+ )}
1188
1293
  </div>
1189
1294
  );
1190
1295
  }
@@ -33,6 +33,10 @@ const STORIES = [
33
33
  const RTL_LOCALE = 'lab-schedule--right-to-left-locale';
34
34
 
35
35
  let evidence: Evidence;
36
+
37
+ // The stories show one fixed week, built in the Schedule's own
38
+ // America/Los_Angeles (Schedule.stories.tsx), so this contract reads the same
39
+ // events in any browser zone and on any day; it needs no zone or clock pin.
36
40
  test.beforeAll(async () => {
37
41
  evidence = await startEvidence();
38
42
  });
@@ -224,7 +224,7 @@ export const docs = {
224
224
  },
225
225
  {
226
226
  name: 'statusVariant',
227
- type: 'FieldStatusVariant',
227
+ type: "'attached' | 'detached' | 'tooltip'",
228
228
  description: 'Placement treatment for the status message.',
229
229
  },
230
230
  {