@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
@@ -0,0 +1,43 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ /**
6
+ * @file useDismissSearchOnTouchDrag.ts
7
+ * @input Caller-owned search inputs and touch events from a separate results list
8
+ * @output Private touch handlers shared by the Lab mobile search sheets
9
+ * @position One owner for results-drag keyboard dismissal; not a public export
10
+ *
11
+ * A direct vertical touch drag dismisses only a focused caller-owned search.
12
+ * Scroll events are deliberately not observed: filtering can clamp scrollTop
13
+ * and fire scroll while someone is typing. Small moves, taps and multi-touch
14
+ * gestures leave focus alone. The hook acquires no external resource.
15
+ */
16
+ import { useRef } from 'react';
17
+ const DRAG_THRESHOLD_PX = 8;
18
+ export function useDismissSearchOnTouchDrag(getSearchInputs) {
19
+ const startYRef = useRef(null);
20
+ const reset = () => {
21
+ startYRef.current = null;
22
+ };
23
+ return {
24
+ onTouchStart: event => {
25
+ startYRef.current = event.touches.length === 1 ? event.touches[0].clientY : null;
26
+ },
27
+ onTouchMove: event => {
28
+ if (event.touches.length !== 1) {
29
+ reset();
30
+ return;
31
+ }
32
+ const startY = startYRef.current;
33
+ if (startY == null || Math.abs(event.touches[0].clientY - startY) < DRAG_THRESHOLD_PX) {
34
+ return;
35
+ }
36
+ reset();
37
+ const focusedSearch = getSearchInputs().find(input => input != null && input === document.activeElement);
38
+ focusedSearch?.blur();
39
+ },
40
+ onTouchEnd: reset,
41
+ onTouchCancel: reset
42
+ };
43
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astryxdesign/lab",
3
- "version": "0.7.6-canary.f73623d",
3
+ "version": "0.7.6-canary.f77aaf2",
4
4
  "description": "Experimental Astryx components — published to npm only under the @canary dist-tag for early testing; never released as a stable (latest) version.",
5
5
  "author": "Meta Open Source",
6
6
  "license": "MIT",
@@ -49,7 +49,7 @@
49
49
  },
50
50
  "peerDependencies": {
51
51
  "@stylexjs/stylex": ">=0.10.0",
52
- "@astryxdesign/core": "0.7.6-canary.f73623d",
52
+ "@astryxdesign/core": "0.7.6-canary.f77aaf2",
53
53
  "react": ">=19.0.0",
54
54
  "react-dom": ">=19.0.0"
55
55
  },
@@ -59,8 +59,8 @@
59
59
  "d3-shape": "^3.2.0"
60
60
  },
61
61
  "devDependencies": {
62
- "@astryxdesign/a11y-spec": "0.7.6-canary.f73623d",
63
- "@astryxdesign/cli": "0.7.6-canary.f73623d",
62
+ "@astryxdesign/a11y-spec": "0.7.6-canary.f77aaf2",
63
+ "@astryxdesign/cli": "0.7.6-canary.f77aaf2",
64
64
  "@babel/cli": "^7.29.7",
65
65
  "@babel/core": "^7.29.7",
66
66
  "@babel/preset-react": "^7.29.7",
@@ -15,7 +15,7 @@
15
15
  *
16
16
  * SYNC: When modified, update these files to stay in sync:
17
17
  * - /packages/lab/src/CommentPin/CommentPin.doc.mjs
18
- * - /packages/lab/src/CommentPin/CommentPin.spec.md
18
+ * - /docs/specs/components/CommentPin/CommentPin.spec.md
19
19
  * - /packages/lab/src/CommentPin/CommentPin.test.tsx
20
20
  * - /packages/lab/src/CommentPin/index.ts
21
21
  * - /apps/storybook/stories/CommentPin.stories.tsx
@@ -200,6 +200,62 @@ describe('CommentComposer', () => {
200
200
  expect(screen.getByTestId('draft').textContent).toBe('Ship it 🎉');
201
201
  });
202
202
 
203
+ it('ends the upload busy state when onActionPendingChange is a new function each render', async () => {
204
+ let finishUpload: (images: {url: string; alt: string}[]) => void = () => {};
205
+ const onUploadImages = vi.fn(
206
+ () =>
207
+ new Promise<{url: string; alt: string}[]>(resolve => {
208
+ finishUpload = resolve;
209
+ }),
210
+ );
211
+ function Host() {
212
+ const [draft, setDraft] = useState('See this');
213
+ const [pending, setPending] = useState(false);
214
+ return (
215
+ <>
216
+ <CommentComposerBase
217
+ value={draft}
218
+ onChange={setDraft}
219
+ onSubmit={() => {}}
220
+ placeholder="Reply"
221
+ context={{kind: 'comment'}}
222
+ highlightColor="yellow"
223
+ triggers={[]}
224
+ agentCommands={[]}
225
+ hasEmojiPicker={false}
226
+ isSubmitting={false}
227
+ onUploadImages={onUploadImages}
228
+ // Inline: a new callback on every render of the host.
229
+ onActionPendingChange={isPending => setPending(isPending)}
230
+ />
231
+ <output data-testid="pending">{String(pending)}</output>
232
+ </>
233
+ );
234
+ }
235
+ const {container} = render(<Host />);
236
+ const input = container.querySelector('input[type="file"]');
237
+ if (!(input instanceof HTMLInputElement)) {
238
+ throw new Error('File input not found');
239
+ }
240
+ const file = new File(['png'], 'chart.png', {type: 'image/png'});
241
+ await act(async () => {
242
+ fireEvent.change(input, {target: {files: [file]}});
243
+ await Promise.resolve();
244
+ });
245
+ await settle();
246
+ expect(onUploadImages).toHaveBeenCalledWith([file]);
247
+ expect(screen.getByTestId('pending').textContent).toBe('true');
248
+
249
+ await act(async () => {
250
+ finishUpload([{url: 'https://example.com/a.png', alt: 'Chart'}]);
251
+ await Promise.resolve();
252
+ });
253
+ await settle();
254
+
255
+ expect(screen.getByTestId('pending').textContent).toBe('false');
256
+ expect(container.querySelector('[aria-busy="true"]')).toBeNull();
257
+ });
258
+
203
259
  it('uploads attached images and appends them as Markdown', async () => {
204
260
  const onChange = vi.fn();
205
261
  const onUploadImages = vi
@@ -239,6 +239,11 @@ export function CommentComposerBase({
239
239
  latestValueRef.current = value;
240
240
  const isMountedRef = useRef(true);
241
241
  const actionPendingRef = useRef(false);
242
+ // The latest callback, read when reporting. An inline callback is new on
243
+ // every render; depending on it would re-run the unmount cleanup below on
244
+ // each render and leave the composer busy after its upload ends.
245
+ const onActionPendingChangeRef = useRef(onActionPendingChange);
246
+ onActionPendingChangeRef.current = onActionPendingChange;
242
247
  const isSubmittingRef = useRef(isSubmitting);
243
248
  isSubmittingRef.current = isSubmitting;
244
249
  const imageInputRef = useRef<HTMLInputElement>(null);
@@ -269,27 +274,27 @@ export function CommentComposerBase({
269
274
  const [isActionPending, setIsActionPending] = useState(false);
270
275
  const [isPickerOpen, setIsPickerOpen] = useState(false);
271
276
 
272
- const updateActionPending = useCallback(
273
- (nextPending: boolean) => {
274
- if (actionPendingRef.current === nextPending) {
275
- return;
276
- }
277
- actionPendingRef.current = nextPending;
278
- setIsActionPending(nextPending);
279
- onActionPendingChange?.(nextPending);
280
- },
281
- [onActionPendingChange],
282
- );
277
+ const updateActionPending = useCallback((nextPending: boolean) => {
278
+ if (actionPendingRef.current === nextPending) {
279
+ return;
280
+ }
281
+ actionPendingRef.current = nextPending;
282
+ setIsActionPending(nextPending);
283
+ onActionPendingChangeRef.current?.(nextPending);
284
+ }, []);
285
+ // Tracks mount for the upload's async completion, and on unmount releases
286
+ // the host's pending-action hold, which the upload's finally skips once
287
+ // unmounted.
283
288
  useEffect(() => {
284
289
  isMountedRef.current = true;
285
290
  return () => {
286
291
  isMountedRef.current = false;
287
292
  if (actionPendingRef.current) {
288
293
  actionPendingRef.current = false;
289
- onActionPendingChange?.(false);
294
+ onActionPendingChangeRef.current?.(false);
290
295
  }
291
296
  };
292
- }, [onActionPendingChange]);
297
+ }, []);
293
298
 
294
299
  const uploadImages = async (files: readonly File[]) => {
295
300
  if (onUploadImages == null || files.length === 0) {
@@ -107,7 +107,7 @@ export const docs = {
107
107
  ],
108
108
  usage: {
109
109
  description:
110
- 'Lab prototype for trying the touch PowerSearch flow. Choose it explicitly on touch layouts (for example `const Search = isTouch ? MobilePowerSearch : PowerSearch`); it never switches on pointer type by itself. The whole field is one Manage filters trigger showing display-only tokens. Tapping it opens a tall Filters sheet listing each filter with its value in bold and a direct trailing remove action, an Add filter icon button at the top right, and sticky Clear all and Done actions. One-tap boolean filters use the same trailing remove action as every other removable filter. When there are no filters, the centered Add filter action stands alone; the duplicate header add and footer Done controls are hidden. Clear all asks for confirmation, removes editable filters without closing the sheet, and preserves read-only filters. Opening the rest of a row edits that filter with Cancel and Apply. Add filter opens directly when no filter exists and leaves search unfocused until the user chooses it; when the config names a content-search field it carries that search with inline suggestions. Creating a filter drills field → operator (radios, only when a field has more than one) → value. A value editor that is the sheet’s only section sits directly on the sheet surface; multiple sections use separate muted containers. The previous step remains open beneath the current standalone BottomSheet, so Escape, a scrim press, or a downward swipe dismisses the current sheet and reveals that retained step; the Add filter header stays action-free. the stack keeps one scrim and blocks covered sheets. Single-value choices use persistent radio indicators and multi-value choices use persistent checkbox indicators. Searchable string and entity lists and date calendars remain inline in the value sheet; date-only and date-range selections honor timezoneID, while date-time operators keep their date-and-time input. Announcements made while a sheet is open are delivered inside that dialog. Filters remain keyed by identity across controlled reorder, stale filters stay visible and removable, and controlled removals re-home focus. The scroll viewport reaches the sheet edge while padded inner content aligns with the fixed header. Fields whose only operators are nested are left out with a development warning.',
110
+ 'Lab prototype for trying the touch PowerSearch flow. Choose it explicitly on touch layouts (for example `const Search = isTouch ? MobilePowerSearch : PowerSearch`); it never switches on pointer type by itself. The whole field is one Manage filters trigger showing display-only tokens. Tapping it opens a tall Filters sheet listing each filter with its value in bold and a direct trailing remove action, an Add filter icon button at the top right, and sticky Clear all and Done actions. One-tap boolean filters use the same trailing remove action as every other removable filter. When there are no filters, the centered Add filter action stands alone; the duplicate header add and footer Done controls are hidden. Clear all asks for confirmation, removes editable filters without closing the sheet, and preserves read-only filters. Opening the rest of a row edits that filter with Cancel and Apply. Add filter opens directly when no filter exists and leaves search unfocused until the user chooses it; when the config names a content-search field it carries that search with inline suggestions. Creating a filter drills field → operator (radios, only when a field has more than one) → value. A value editor that is the sheet’s only section sits directly on the sheet surface; multiple sections use separate muted containers. The previous step remains open beneath the current standalone BottomSheet, so Escape, a scrim press, or a downward swipe dismisses the current sheet and reveals that retained step; the Add filter header stays action-free. the stack keeps one scrim and blocks covered sheets. Single-value choices use persistent radio indicators and multi-value choices use persistent checkbox indicators. Searchable string and entity lists and date calendars remain inline in the value sheet; date-only and date-range selections honor timezoneID, while date-time operators keep their date-and-time input. Announcements made while a sheet is open are delivered inside that dialog. Filters remain keyed by identity across controlled reorder, stale filters stay visible and removable, and controlled removals re-home focus. The scroll viewport reaches the sheet edge while padded inner content aligns with the fixed header. Dragging the results dismisses the search keyboard without clearing the query; filtering keeps search focused. Fields whose only operators are nested are left out with a development warning.',
111
111
  bestPractices: [
112
112
  {
113
113
  guidance: true,
@@ -1009,11 +1009,16 @@ describe('MobilePowerSearch Add filter', () => {
1009
1009
  sheet().querySelectorAll<HTMLElement>('div'),
1010
1010
  ).find(element => getComputedStyle(element).overflowY === 'auto')!;
1011
1011
  fireEvent.scroll(scrollBody);
1012
+ expect(search).toHaveFocus();
1013
+ fireEvent.touchStart(scrollBody, {touches: [{clientY: 100}]});
1014
+ fireEvent.touchMove(scrollBody, {touches: [{clientY: 107}]});
1015
+ expect(search).toHaveFocus();
1016
+ fireEvent.touchMove(scrollBody, {touches: [{clientY: 109}]});
1012
1017
  expect(search).not.toHaveFocus();
1013
1018
  search.focus();
1014
- fireEvent.touchMove(scrollBody);
1015
- expect(search).not.toHaveFocus();
1016
1019
  fireEvent.change(search, {target: {value: 'Field'}});
1020
+ fireEvent.scroll(scrollBody);
1021
+ expect(search).toHaveFocus();
1017
1022
  expect(
1018
1023
  within(sheet()).getAllByRole('button', {name: /^Field/}),
1019
1024
  ).toHaveLength(2);
@@ -5,11 +5,8 @@
5
5
  /**
6
6
  * @file MobilePowerSearch.tsx
7
7
  * @input Core PowerSearch config, filters and onChange (the same props as
8
- * @astryxdesign/core PowerSearch); public @astryxdesign/core components only
9
- * (BottomSheet, Button, Field, Heading, Icon, List,
10
- * RadioList, Text, TextInput, Token, Tooltip hooks, i18n, hooks —
11
- * useAnnounce for every announcement — utils, Layout container-padding
12
- * styles)
8
+ * @astryxdesign/core PowerSearch); public Core primitives and private Lab
9
+ * mobile-sheet confirmation and search-drag dismissal owners
13
10
  * @output Exports MobilePowerSearch — Lab prototype of the touch PowerSearch
14
11
  * flow: the whole field is one "Manage filters" trigger with display-only
15
12
  * tokens, a Filters sheet with editable rows and direct remove actions, Add
@@ -93,7 +90,6 @@ import * as stylex from '@stylexjs/stylex';
93
90
  import type {BaseProps} from '@astryxdesign/core';
94
91
  import {BottomSheet} from '@astryxdesign/core/BottomSheet';
95
92
  import {Button} from '@astryxdesign/core/Button';
96
- import {Dialog} from '@astryxdesign/core/Dialog';
97
93
  import {EmptyState} from '@astryxdesign/core/EmptyState';
98
94
  import {
99
95
  Field,
@@ -107,7 +103,6 @@ import {
107
103
  import {Heading} from '@astryxdesign/core/Heading';
108
104
  import {Icon, renderIconSlot, type IconType} from '@astryxdesign/core/Icon';
109
105
  import {IconButton} from '@astryxdesign/core/IconButton';
110
- import {Layout, LayoutContent, LayoutFooter} from '@astryxdesign/core/Layout';
111
106
  import {containerPaddingInlineVarStyles} from '@astryxdesign/core/Layout/padding.stylex';
112
107
  import {List, ListItem} from '@astryxdesign/core/List';
113
108
  import {resolveOperatorLabel} from '@astryxdesign/core/PowerSearch';
@@ -126,7 +121,6 @@ import {formatFilterValue} from '@astryxdesign/core/PowerSearch/utils';
126
121
  import type {InternalConfig} from '@astryxdesign/core/PowerSearch/utils';
127
122
  import {RadioList, RadioListItem} from '@astryxdesign/core/RadioList';
128
123
  import {useSize} from '@astryxdesign/core/SizeContext';
129
- import {Stack} from '@astryxdesign/core/Stack';
130
124
  import {Text} from '@astryxdesign/core/Text';
131
125
  import {TextInput} from '@astryxdesign/core/TextInput';
132
126
  import {Token} from '@astryxdesign/core/Token';
@@ -148,6 +142,8 @@ import {
148
142
  typographyVars,
149
143
  } from '@astryxdesign/core/theme/tokens.stylex';
150
144
  import {isImeKeyEvent, isRenderable, rtlStyles} from '@astryxdesign/core/utils';
145
+ import {MobileSheetConfirmation} from '../internal/MobileSheetConfirmation';
146
+ import {useDismissSearchOnTouchDrag} from '../internal/useDismissSearchOnTouchDrag';
151
147
  import {MobilePowerSearchValueEditor} from './MobilePowerSearchValueEditor';
152
148
  import {
153
149
  createContentSearchSource,
@@ -207,7 +203,7 @@ export interface MobilePowerSearchProps extends Omit<
207
203
  /** Validation status. */
208
204
  status?: InputStatus;
209
205
  /** How the status message is placed. @default 'attached' */
210
- statusVariant?: FieldStatusVariant;
206
+ statusVariant?: FieldStatusVariant | 'tooltip';
211
207
  /** Max display length for token values. @default 40 */
212
208
  maxTokenLength?: number;
213
209
  /** Max suggestions in string and entity value typeaheads. @default 10 */
@@ -257,63 +253,6 @@ function PlusIcon(props: SVGProps<SVGSVGElement>) {
257
253
  );
258
254
  }
259
255
 
260
- function ClearFiltersConfirmation({
261
- isOpen,
262
- onOpenChange,
263
- onConfirm,
264
- }: {
265
- isOpen: boolean;
266
- onOpenChange: (isOpen: boolean) => void;
267
- onConfirm: () => void;
268
- }) {
269
- const titleId = useId();
270
- const descriptionId = useId();
271
- return (
272
- <Dialog
273
- isOpen={isOpen}
274
- onOpenChange={onOpenChange}
275
- purpose="info"
276
- role="alertdialog"
277
- aria-labelledby={titleId}
278
- aria-describedby={descriptionId}
279
- width={400}>
280
- <Layout
281
- content={
282
- <LayoutContent>
283
- <Heading level={2} id={titleId}>
284
- Clear all filters?
285
- </Heading>
286
- <Text type="body" color="secondary" id={descriptionId}>
287
- This removes all editable filters from this search.
288
- </Text>
289
- </LayoutContent>
290
- }
291
- footer={
292
- <LayoutFooter>
293
- <Stack direction="vertical" gap={4} hAlign="stretch">
294
- <Button
295
- label="Clear filters"
296
- variant="destructive"
297
- size="lg"
298
- onClick={onConfirm}
299
- xstyle={styles.confirmationAction}
300
- />
301
- <Button
302
- label="Keep filters"
303
- variant="secondary"
304
- size="lg"
305
- onClick={() => onOpenChange(false)}
306
- data-autofocus
307
- xstyle={styles.confirmationAction}
308
- />
309
- </Stack>
310
- </LayoutFooter>
311
- }
312
- />
313
- </Dialog>
314
- );
315
- }
316
-
317
256
  const sizeStyles = stylex.create({
318
257
  sm: {minHeight: sizeVars['--size-element-sm']},
319
258
  md: {minHeight: sizeVars['--size-element-md']},
@@ -520,9 +459,6 @@ const styles = stylex.create({
520
459
  trailingControlRow: {
521
460
  flexDirection: 'row-reverse',
522
461
  },
523
- confirmationAction: {
524
- width: '100%',
525
- },
526
462
  rowValue: {
527
463
  fontWeight: fontWeightVars['--font-weight-bold'],
528
464
  },
@@ -953,14 +889,10 @@ export function MobilePowerSearch({
953
889
 
954
890
  // ------------------------------------------------------------------ focus --
955
891
 
956
- const blurFieldPickerSearch = useCallback(() => {
957
- const activeElement = document.activeElement;
958
- const contentSearch = contentSearchRef.current?.querySelector('input');
959
- const fieldSearch = fieldSearchRef.current?.querySelector('input');
960
- if (activeElement === contentSearch || activeElement === fieldSearch) {
961
- (activeElement as HTMLInputElement).blur();
962
- }
963
- }, []);
892
+ const fieldPickerDragProps = useDismissSearchOnTouchDrag(() => [
893
+ contentSearchRef.current?.querySelector('input'),
894
+ fieldSearchRef.current?.querySelector('input'),
895
+ ]);
964
896
 
965
897
  const focusPendingSheetTarget = useCallback(() => {
966
898
  const target = pendingSheetFocusRef.current;
@@ -2132,10 +2064,7 @@ export function MobilePowerSearch({
2132
2064
  </div>
2133
2065
  ) : null}
2134
2066
  </div>
2135
- <div
2136
- {...scrollViewportProps}
2137
- onScroll={blurFieldPickerSearch}
2138
- onTouchMove={blurFieldPickerSearch}>
2067
+ <div {...scrollViewportProps} {...fieldPickerDragProps}>
2139
2068
  <div {...scrollContentProps}>
2140
2069
  {hasContentSearch ? (
2141
2070
  contentResults.length > 0 ? (
@@ -2427,10 +2356,14 @@ export function MobilePowerSearch({
2427
2356
  </div>
2428
2357
  </BottomSheet>
2429
2358
 
2430
- <ClearFiltersConfirmation
2359
+ <MobileSheetConfirmation
2431
2360
  isOpen={isClearConfirmationOpen}
2432
2361
  onOpenChange={setIsClearConfirmationOpen}
2433
- onConfirm={() => {
2362
+ title="Clear all filters?"
2363
+ description="This removes all editable filters from this search."
2364
+ actionLabel="Clear filters"
2365
+ cancelLabel="Keep filters"
2366
+ onAction={() => {
2434
2367
  handleClearAll();
2435
2368
  setIsClearConfirmationOpen(false);
2436
2369
  }}
@@ -11,7 +11,7 @@ export const docs = {
11
11
  {name: 'isLabelHidden', type: 'boolean', description: 'Hide the visible label; the trigger and the sheet keep it as their accessible name.', default: 'false'},
12
12
  {name: 'searchSource', type: 'SearchSource<T>', description: 'Core Typeahead search source (search + bootstrap).', required: true},
13
13
  {name: 'value', type: 'T[]', description: 'Selected items (Core SearchableItem).', required: true},
14
- {name: 'onChange', type: '(items: T[], change: MobileTokenizerChange<T>) => void', description: 'Mirrors Core Tokenizer: single-item add/create/remove, or reorder.', required: true},
14
+ {name: 'onChange', type: '(items: T[], change: MobileTokenizerChange<T>) => void', description: 'Called by Save when the selection draft differs from the opening value. Receives the complete committed selection plus the latest add/create/remove metadata.', required: true},
15
15
  {name: 'renderItem', type: '(item: T) => ReactNode', description: 'Custom content for a result row, shown beside its checkbox. Default: the item label.'},
16
16
  {name: 'placeholder', type: 'string', description: 'Trigger text when nothing is selected.'},
17
17
  {name: 'description', type: 'string', description: 'Help text under the field, read with the trigger.'},
@@ -24,14 +24,14 @@ export const docs = {
24
24
  ],
25
25
  usage: {
26
26
  description:
27
- 'Lab prototype for trying the touch Tokenizer flow: tap the field to open one searchable sheet where selected and available items share a full-row checkbox list. Selected items are grouped first when the sheet opens, while checkbox changes keep every existing row in place until the sheet closes; the latest selection is regrouped the next time it opens. Selected custom values stay in the list, and long result sets render progressively in 50-item batches. Custom text uses a trailing Add action, and guarded Clear all plus Done actions finish the unfiltered flow. The footer is hidden while searching. Clear all stays in the focus order when nothing is selected and says why; a confirmed clear returns focus to the search field and is announced inside the sheet. Every announcement — additions, removals, result counts — is delivered inside the open sheet. The trigger describes its selection to assistive technology (count, then the tokens) after the field description, status, and disabled reason. Strings come from the shared Astryx catalog.',
27
+ 'Lab prototype for trying the touch Tokenizer flow: tap the field to open one searchable sheet where selected and available items share a full-row checkbox list. Selection changes remain in a draft until Save commits them; swipe, scrim, or Escape dismissal asks before discarding a dirty draft, while a clean dismissal closes directly. Selected items are grouped first when the sheet opens, while checkbox changes keep every existing row in place until the sheet closes; the latest selection is regrouped the next time it opens. Selected custom values stay in the list, and long result sets render progressively in 50-item batches. Custom text uses a trailing Add action. The bordered large search stays above the list, and scrolling or dragging results dismisses the software keyboard without clearing the query. Clear all and Save remain visible while searching. Clear all updates the draft only and requires confirmation; a confirmed clear returns focus to the search field and is announced inside the sheet. Every announcement — additions, removals, result counts — is delivered inside the open sheet. The trigger describes its committed selection to assistive technology (count, then the tokens) after the field description, status, and disabled reason. Strings come from the shared Astryx catalog.',
28
28
  bestPractices: [
29
29
  {guidance: true, description: 'Try this in Lab/canary to validate the flow; graduate via Core Tokenizer presentation="adaptive" when it ships.'},
30
30
  {guidance: false, description: 'Do not ship stable product on this Lab API; it has no theming or spec contract yet.'},
31
31
  ],
32
32
  anatomy: [
33
33
  {name: 'Trigger field', required: true, description: 'Button showing tokens as a summary, in the inline Tokenizer field\'s own box: same border, radius, surface, block size and token inset as the desktop field.'},
34
- {name: 'Management sheet', required: true, description: 'One searchable list with trailing checkboxes for existing items, a trailing Add action for custom text, and equal-width Clear all and Done footer actions when unfiltered. Clear all requires confirmation and stays focusable with its reason when nothing is selected; the footer is hidden while searching. Nothing of the sheet is in the page while it is closed.'},
34
+ {name: 'Management sheet', required: true, description: 'One searchable list with trailing checkboxes for existing items, a trailing Add action for custom text, and persistent equal-width Clear all and Save footer actions. Save commits the draft; dirty dismissal asks before discarding. Nothing of the sheet is in the page while it is closed.'},
35
35
  ],
36
36
  },
37
37
  };