@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,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
|
|
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).
|
|
181
|
-
//
|
|
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
|
|
198
|
-
if (isDisabled
|
|
199
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
392
|
+
requestDismissRef,
|
|
393
|
+
onCloseSheet,
|
|
431
394
|
label,
|
|
432
395
|
searchSource,
|
|
433
396
|
value,
|
|
434
|
-
|
|
435
|
-
isAtMax,
|
|
397
|
+
maxEntries,
|
|
436
398
|
hasCreate,
|
|
437
399
|
maxMenuItems,
|
|
438
400
|
minQueryLength,
|
|
439
401
|
debounceMs,
|
|
440
402
|
emptySearchResultsText,
|
|
441
403
|
renderItem,
|
|
442
|
-
|
|
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
|
-
|
|
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 =
|
|
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,
|
|
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) ||
|
|
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,
|
|
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
|
-
// ----
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
//
|
|
636
|
-
//
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
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
|
-
|
|
751
|
-
|
|
752
|
-
|
|
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
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
}
|
|
777
|
-
|
|
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
|
-
}),
|
|
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:
|
|
818
|
-
|
|
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.
|
|
874
|
+
label: t('@astryx.tokenizer.save'),
|
|
828
875
|
variant: "primary",
|
|
829
876
|
size: "lg",
|
|
830
|
-
|
|
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(
|
|
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
|
-
|
|
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"}
|