@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.
- package/dist/CommentPin/CommentPin.js +1 -1
- package/dist/Comments/CommentComposer.d.ts.map +1 -1
- package/dist/Comments/CommentComposer.js +12 -4
- package/dist/MobilePowerSearch/MobilePowerSearch.d.ts +3 -6
- package/dist/MobilePowerSearch/MobilePowerSearch.d.ts.map +1 -1
- package/dist/MobilePowerSearch/MobilePowerSearch.js +12 -76
- package/dist/MobileTokenizer/MobileTokenizer.d.ts +10 -7
- package/dist/MobileTokenizer/MobileTokenizer.d.ts.map +1 -1
- package/dist/MobileTokenizer/MobileTokenizer.js +191 -114
- package/dist/internal/MobileSheetConfirmation.d.ts +12 -0
- package/dist/internal/MobileSheetConfirmation.d.ts.map +1 -0
- package/dist/internal/MobileSheetConfirmation.js +88 -0
- package/dist/internal/useDismissSearchOnTouchDrag.d.ts +21 -0
- package/dist/internal/useDismissSearchOnTouchDrag.d.ts.map +1 -0
- package/dist/internal/useDismissSearchOnTouchDrag.js +43 -0
- package/package.json +4 -4
- package/src/CommentPin/CommentPin.tsx +1 -1
- package/src/Comments/CommentComposer.test.tsx +56 -0
- package/src/Comments/CommentComposer.tsx +18 -13
- package/src/MobilePowerSearch/MobilePowerSearch.doc.mjs +1 -1
- package/src/MobilePowerSearch/MobilePowerSearch.test.tsx +7 -2
- package/src/MobilePowerSearch/MobilePowerSearch.tsx +16 -83
- package/src/MobileTokenizer/MobileTokenizer.doc.mjs +3 -3
- package/src/MobileTokenizer/MobileTokenizer.test.tsx +287 -31
- package/src/MobileTokenizer/MobileTokenizer.tsx +242 -137
- package/src/Schedule/__tests__/ScheduleTimeDirection.a11y.chromium.spec.ts +4 -0
- package/src/TransferList/TransferList.doc.mjs +1 -1
- package/src/internal/MobileSheetConfirmation.test.tsx +68 -0
- package/src/internal/MobileSheetConfirmation.tsx +97 -0
- package/src/internal/useDismissSearchOnTouchDrag.test.tsx +94 -0
- package/src/internal/useDismissSearchOnTouchDrag.ts +57 -0
- package/src/CommentPin/CommentPin.spec.md +0 -319
- package/src/Schedule/Schedule.spec.md +0 -590
|
@@ -4,13 +4,12 @@
|
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* @file MobileTokenizer.tsx
|
|
7
|
-
* @input
|
|
8
|
-
*
|
|
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
|
|
13
|
-
*
|
|
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
|
|
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).
|
|
374
|
-
//
|
|
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
|
|
392
|
-
(
|
|
393
|
-
if (isDisabled
|
|
394
|
-
|
|
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
|
-
[
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
685
|
+
requestDismissRef,
|
|
686
|
+
onCloseSheet,
|
|
712
687
|
label,
|
|
713
688
|
searchSource,
|
|
714
689
|
value,
|
|
715
|
-
|
|
716
|
-
isAtMax,
|
|
690
|
+
maxEntries,
|
|
717
691
|
hasCreate,
|
|
718
692
|
maxMenuItems,
|
|
719
693
|
minQueryLength,
|
|
720
694
|
debounceMs,
|
|
721
695
|
emptySearchResultsText,
|
|
722
696
|
renderItem,
|
|
723
|
-
|
|
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
|
-
|
|
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 =
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
-
// ----
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
//
|
|
974
|
-
//
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
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
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
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
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
)
|
|
1109
|
-
|
|
1110
|
-
} else
|
|
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
|
-
{
|
|
1143
|
-
<
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
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
|
});
|