panelui-native 0.86.1 → 0.88.0

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 (63) hide show
  1. package/README.md +4 -2
  2. package/lib/module/components/animated-badge/index.js +451 -0
  3. package/lib/module/components/animated-badge/index.js.map +1 -0
  4. package/lib/module/components/bottom-sheet/index.js +17 -6
  5. package/lib/module/components/bottom-sheet/index.js.map +1 -1
  6. package/lib/module/components/bubble-chart/index.js +1782 -0
  7. package/lib/module/components/bubble-chart/index.js.map +1 -0
  8. package/lib/module/components/feedback-dialog/index.js +474 -0
  9. package/lib/module/components/feedback-dialog/index.js.map +1 -0
  10. package/lib/module/components/pyramid-chart/index.js +1143 -0
  11. package/lib/module/components/pyramid-chart/index.js.map +1 -0
  12. package/lib/module/components/scroll-blur/index.js +363 -0
  13. package/lib/module/components/scroll-blur/index.js.map +1 -0
  14. package/lib/module/components/scroll-canvas/index.js +2 -0
  15. package/lib/module/components/scroll-canvas/index.js.map +1 -1
  16. package/lib/module/components/scroll-text/index.js +3 -0
  17. package/lib/module/components/scroll-text/index.js.map +1 -1
  18. package/lib/module/components/search-bar/index.js +753 -25
  19. package/lib/module/components/search-bar/index.js.map +1 -1
  20. package/lib/module/hooks/use-keyboard-avoidance.js +21 -1
  21. package/lib/module/hooks/use-keyboard-avoidance.js.map +1 -1
  22. package/lib/module/hooks/use-reveal-progress.js +21 -1
  23. package/lib/module/hooks/use-reveal-progress.js.map +1 -1
  24. package/lib/module/index.js +5 -0
  25. package/lib/module/index.js.map +1 -1
  26. package/lib/module/utils/chart.js +24 -0
  27. package/lib/module/utils/chart.js.map +1 -1
  28. package/lib/typescript/src/components/animated-badge/index.d.ts +224 -0
  29. package/lib/typescript/src/components/animated-badge/index.d.ts.map +1 -0
  30. package/lib/typescript/src/components/bottom-sheet/index.d.ts +1 -15
  31. package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -1
  32. package/lib/typescript/src/components/bubble-chart/index.d.ts +481 -0
  33. package/lib/typescript/src/components/bubble-chart/index.d.ts.map +1 -0
  34. package/lib/typescript/src/components/feedback-dialog/index.d.ts +156 -0
  35. package/lib/typescript/src/components/feedback-dialog/index.d.ts.map +1 -0
  36. package/lib/typescript/src/components/pyramid-chart/index.d.ts +328 -0
  37. package/lib/typescript/src/components/pyramid-chart/index.d.ts.map +1 -0
  38. package/lib/typescript/src/components/scroll-blur/index.d.ts +113 -0
  39. package/lib/typescript/src/components/scroll-blur/index.d.ts.map +1 -0
  40. package/lib/typescript/src/components/scroll-canvas/index.d.ts.map +1 -1
  41. package/lib/typescript/src/components/search-bar/index.d.ts +350 -6
  42. package/lib/typescript/src/components/search-bar/index.d.ts.map +1 -1
  43. package/lib/typescript/src/hooks/use-keyboard-avoidance.d.ts.map +1 -1
  44. package/lib/typescript/src/hooks/use-reveal-progress.d.ts +6 -27
  45. package/lib/typescript/src/hooks/use-reveal-progress.d.ts.map +1 -1
  46. package/lib/typescript/src/index.d.ts +6 -1
  47. package/lib/typescript/src/index.d.ts.map +1 -1
  48. package/lib/typescript/src/utils/chart.d.ts +14 -0
  49. package/lib/typescript/src/utils/chart.d.ts.map +1 -1
  50. package/package.json +1 -1
  51. package/src/components/animated-badge/index.tsx +513 -0
  52. package/src/components/bottom-sheet/index.tsx +23 -8
  53. package/src/components/bubble-chart/index.tsx +2145 -0
  54. package/src/components/feedback-dialog/index.tsx +557 -0
  55. package/src/components/pyramid-chart/index.tsx +1360 -0
  56. package/src/components/scroll-blur/index.tsx +466 -0
  57. package/src/components/scroll-canvas/index.tsx +2 -1
  58. package/src/components/scroll-text/index.tsx +3 -3
  59. package/src/components/search-bar/index.tsx +933 -45
  60. package/src/hooks/use-keyboard-avoidance.ts +24 -1
  61. package/src/hooks/use-reveal-progress.ts +30 -1
  62. package/src/index.ts +72 -1
  63. package/src/utils/chart.ts +27 -0
@@ -1,13 +1,80 @@
1
1
  /**
2
2
  * SearchBar — a text field for querying a list, with the two controls a search
3
- * needs and an ordinary field does not.
3
+ * needs and an ordinary field does not, and a panel of results that opens out
4
+ * of the field itself.
4
5
  *
5
6
  * ```tsx
6
7
  * <SearchBar placeholder="Search orders" onSubmit={run} />
7
8
  * <SearchBar variant="filled" shape="pill" cancel="focus" />
8
- * <SearchBar debounce={250} onDebouncedChange={filter} loading={pending} />
9
+ * <SearchBar avoidKeyboard value={query} onChangeText={setQuery}>
10
+ * <SearchBar.Section label="Suggested">
11
+ * <SearchBar.Item trailing={<AddButton />} onPress={add}>Claude</SearchBar.Item>
12
+ * </SearchBar.Section>
13
+ * </SearchBar>
9
14
  * ```
10
15
  *
16
+ * ## The results are above the field, and the field is above the keyboard
17
+ *
18
+ * A search that is being typed into has a keyboard under it, and a list drawn
19
+ * below the field is a list drawn behind the keyboard. So `avoidKeyboard`
20
+ * lifts the field until it sits `keyboardOffset` points clear of the keyboard's
21
+ * top edge, and the panel opens *upward* out of it into the space that is
22
+ * actually free.
23
+ *
24
+ * That puts the first result nearest the field and the last one furthest away,
25
+ * which is the order a reader walking away from the caret expects. Pass
26
+ * `panelPlacement="bottom"` for a search bar in a header, where the space is
27
+ * the other way round.
28
+ *
29
+ * The panel is positioned absolutely rather than laid out in the flow, so
30
+ * opening it never moves the page underneath — a list that pushes the field it
31
+ * belongs to is a field that walks away from the finger typing into it.
32
+ *
33
+ * ## Touches inside the panel must not close the keyboard
34
+ *
35
+ * The panel scrolls with `keyboardShouldPersistTaps="always"`, and every press
36
+ * inside it holds the field's focus open for a moment afterwards. Both are
37
+ * needed, because a search closes the instant the field blurs and there are
38
+ * two separate ways for a touch in the panel to blur it.
39
+ *
40
+ * `"handled"` only spares presses a child takes responsibility for, which
41
+ * leaves the panel's own padding, the gaps between rows, a section heading and
42
+ * the whole of `SearchBar.Status` as live dismiss surfaces — tapping the word
43
+ * "Searching …" would end the search. `"always"` gives the panel back.
44
+ *
45
+ * The focus guard covers the other way: a control inside a row — an add
46
+ * button, a remove ✕ — takes focus with the press on Android, and returning it
47
+ * a frame later is not enough on its own, because the blur has already closed
48
+ * the panel the control was drawn in. So a press in the panel marks the field
49
+ * as still being used, and a blur arriving under that mark is answered by
50
+ * asking for focus back rather than by ending the search.
51
+ *
52
+ * That guard only knows about presses that go through this component's own
53
+ * parts, and a caller's `Pressable` in a row's `trailing` slot takes the touch
54
+ * itself. So the panel also waits before believing any blur, and asks the
55
+ * keyboard: it is still up, because nothing in the panel dismisses it, and a
56
+ * search whose keyboard is still up has not ended. Focus goes back instead.
57
+ *
58
+ * ## The space kept for the field is not a target
59
+ *
60
+ * The card is one box around the results *and* the field, so it carries a
61
+ * spacer where the field sits. That spacer is a plain view drawn over a
62
+ * focused field, and a touch on a plain view is the platform's cue to dismiss
63
+ * the keyboard — so winning one blurred the field and closed the panel drawn
64
+ * out of that focus. Tapping the search box shut the results, which is exactly
65
+ * backwards. The card and its spacer take no touches at all now.
66
+ *
67
+ * ## What has already been picked goes in the field
68
+ *
69
+ * `tokens` puts the choices made so far inside the field, before the caret, so
70
+ * the query and what it has produced are one control rather than a control and
71
+ * a list somewhere above it. `SearchBar.Token` is the chip; backspace on an
72
+ * empty field fires `onRemoveLastToken`, which is what a token field does
73
+ * everywhere else.
74
+ *
75
+ * They scroll rather than wrap: the field is one line tall, and a row of chips
76
+ * that grew it would move the caret every time something was picked.
77
+ *
11
78
  * ## The clear button, and why it is not the platform's
12
79
  *
13
80
  * A ✕ appears inside the field as soon as there is something to clear, and
@@ -44,29 +111,47 @@
44
111
  * a return key is somebody saying they are done waiting.
45
112
  */
46
113
  import {
114
+ Children,
115
+ createContext,
47
116
  forwardRef,
117
+ isValidElement,
118
+ useContext,
48
119
  useCallback,
49
120
  useEffect,
50
121
  useImperativeHandle,
122
+ useMemo,
51
123
  useRef,
52
124
  useState,
53
125
  type ReactNode,
54
126
  } from 'react';
55
127
  import {
128
+ ScrollView,
129
+ StyleSheet,
56
130
  View,
131
+ useWindowDimensions,
57
132
  type LayoutChangeEvent,
58
133
  type NativeSyntheticEvent,
59
134
  type TextInput,
135
+ type TextInputKeyPressEventData,
60
136
  type TextInputSubmitEditingEventData,
137
+ type ViewProps,
138
+ type ViewStyle,
61
139
  } from 'react-native';
62
140
  import Animated, {
141
+ FadeIn,
142
+ FadeOut,
63
143
  useAnimatedStyle,
64
144
  useSharedValue,
65
145
  withTiming,
66
146
  } from 'react-native-reanimated';
67
147
  import { tv, type VariantProps } from 'tailwind-variants';
148
+ import { useKeyboard } from '../../hooks/use-keyboard';
68
149
  import { SearchIcon, XIcon } from '../../icons';
69
- import { AnimatedPressable } from '../../primitives/animated-pressable';
150
+ import {
151
+ AnimatedPressable,
152
+ type AnimatedPressableProps,
153
+ } from '../../primitives/animated-pressable';
154
+ import { KeyboardAvoider } from '../../primitives/keyboard-avoider';
70
155
  import { Text } from '../../primitives/text';
71
156
  import { Input, type InputProps } from '../input';
72
157
  import { Spinner } from '../spinner';
@@ -89,10 +174,123 @@ const CANCEL_GAP = 8;
89
174
  */
90
175
  const CLEAR_HIT_SLOP = 12;
91
176
 
177
+ /** The panel's crossfade, matching every other anchored list in the library. */
178
+ const PANEL_IN = 140;
179
+ const PANEL_OUT = 120;
180
+
181
+ /** Gap left between the panel's far edge and the edge of the screen. */
182
+ const PANEL_EDGE_GAP = 24;
183
+
184
+ /** Floor for the derived height — below this a list is not worth opening. */
185
+ const PANEL_MIN_HEIGHT = 160;
186
+
187
+ /**
188
+ * Ceiling for the derived height, in points. About six rows and a heading.
189
+ *
190
+ * The room above a lifted field is most of the screen, and a panel that takes
191
+ * all of it is a full-screen list that happens to have a search box under it —
192
+ * the page it was opened over stops being visible, so the search stops reading
193
+ * as something laid over the app. Past this the results scroll, which is what
194
+ * a long list should do anyway.
195
+ *
196
+ * `panelMaxHeight` overrides it for a screen that really is nothing but the
197
+ * search.
198
+ */
199
+ const PANEL_MAX_HEIGHT = 320;
200
+
201
+ /**
202
+ * The field's height per size, matching `Input`'s own `h-10 / h-12 / h-14`.
203
+ *
204
+ * It is the fallback for the slot the panel keeps for the field, which is
205
+ * otherwise the measured height and therefore zero on the frame the panel
206
+ * first opens. A zero slot puts the card's bottom edge at the field's, so the
207
+ * last row is drawn underneath the field — which is painted after the card and
208
+ * takes the touch. The press then reads as a tap on the input.
209
+ */
210
+ const FIELD_HEIGHT = { sm: 40, md: 48, lg: 56 } as const;
211
+
212
+ /**
213
+ * How long a press inside the panel keeps the field's focus. Long enough to
214
+ * cover the blur Android sends with the press and the re-render that follows
215
+ * it, short enough that a real dismissal is never held open.
216
+ */
217
+ const FOCUS_GUARD = 400;
218
+
219
+ /**
220
+ * How long the panel waits after a blur before believing the search is over.
221
+ *
222
+ * The guard above only covers presses that go through this component's own
223
+ * parts. A caller's own `Pressable` in a row's `trailing` slot takes the touch
224
+ * itself and this component never hears about it — so the catch-all is the
225
+ * keyboard: it is still up, because nothing in the panel dismisses it, and a
226
+ * search whose keyboard is still up has not ended. The pause is long enough
227
+ * for focus to come back on its own and short enough that a real dismissal
228
+ * does not leave the panel hanging.
229
+ */
230
+ const BLUR_GRACE = 220;
231
+
232
+ /** Share of the field a row of tokens may take before it starts scrolling. */
233
+ const TOKEN_MAX_SHARE = 0.6;
234
+
235
+ /*
236
+ * The card is pinned to the edge of the field's own slot and grows away from
237
+ * it, so the field's box is the one thing that never moves.
238
+ */
239
+ const CARD_ABOVE: ViewStyle = { position: 'absolute', bottom: 0, left: 0, right: 0 };
240
+ const CARD_BELOW: ViewStyle = { position: 'absolute', top: 0, left: 0, right: 0 };
241
+
242
+ /*
243
+ * `zIndex` *and* `elevation`, on the field's own box rather than on the card:
244
+ * Android draws siblings in tree order and takes its stacking from elevation,
245
+ * so a card overlapping the content above the field would otherwise be painted
246
+ * under it — and putting it here keeps the field painting over the card, which
247
+ * is what lets the two be one surface.
248
+ *
249
+ * Applied always, and never toggled. React Native implements `zIndex` on iOS
250
+ * by reordering the parent's subviews, which takes the view out of the
251
+ * hierarchy and puts it back — and a `UITextField` removed from the window
252
+ * resigns first responder. Setting this at the moment the panel opens
253
+ * therefore blurred the field that had just been focused, which closed the
254
+ * panel again: the keyboard came up and went straight back down, and the only
255
+ * way to get a search open was to be quicker than the render.
256
+ *
257
+ * A constant stacking order costs nothing while the panel is shut — there is
258
+ * nothing to stack against — and the elevation draws no shadow, because the
259
+ * box has no background for Android to cast one from.
260
+ */
261
+ const RAISED: ViewStyle = { zIndex: 20, elevation: 20 };
262
+
92
263
  const searchBarVariants = tv({
93
264
  slots: {
94
265
  row: 'w-full flex-row items-center',
266
+ /*
267
+ * The box the panel is positioned against — the field alone, so the panel
268
+ * is the field's width rather than the row's and does not run out under a
269
+ * Cancel button that is only sometimes there.
270
+ */
271
+ anchor: 'relative',
95
272
  field: '',
273
+ /*
274
+ * One card around the results *and* the field, with the field drawn over
275
+ * the space kept for it at the bottom.
276
+ *
277
+ * It has to be one box because the outline is one outline. Drawn as two —
278
+ * a bordered panel above a bordered field — the field's edge is the focus
279
+ * ring, since a field with a panel open is a field being typed into, and
280
+ * the card ends up with a brighter box welded to a dimmer one.
281
+ *
282
+ * `bg-popover`, not `bg-card`: this floats over the page rather than
283
+ * sitting in it, and a card is one step from the background — close enough
284
+ * that in dark mode the whole thing dissolves into the screen behind it.
285
+ */
286
+ panel: 'overflow-hidden rounded-2xl border border-border bg-popover shadow-lg',
287
+ panelList: 'p-1.5',
288
+ /** The hairline between the results and the field. */
289
+ panelDivider: 'w-full bg-border',
290
+ sectionLabel: 'px-3 pb-1 pt-2 text-sm text-muted-foreground',
291
+ item: 'flex-row items-center gap-3 rounded-lg px-3 py-2.5',
292
+ itemLabel: 'flex-1 text-base text-foreground',
293
+ status: 'flex-row items-center justify-center gap-2 px-3 py-8',
96
294
  // Clipped, because this is what the Cancel button is revealed out of: the
97
295
  // button keeps its measured width and the container's grows past it.
98
296
  //
@@ -103,12 +301,39 @@ const searchBarVariants = tv({
103
301
  cancelButton: 'absolute bottom-0 end-0 top-0 items-center justify-center ps-2',
104
302
  cancelLabel: 'font-medium text-primary',
105
303
  clear: 'items-center justify-center rounded-full',
304
+ /*
305
+ * The chips sit in the field's start content, which `Input` measures and
306
+ * turns into padding on the text — so the caret starts after them however
307
+ * many there are, and nothing typed ever runs underneath them.
308
+ */
309
+ tokenRow: 'flex-row items-center gap-1.5',
310
+ token: 'flex-row items-center gap-1 rounded-full bg-accent ps-2 pe-1',
311
+ tokenLabel: 'text-accent-foreground',
312
+ tokenRemove: 'items-center justify-center rounded-full',
106
313
  },
107
314
  variants: {
108
315
  size: {
109
- sm: { cancelLabel: 'text-[14px]', clear: 'h-6 w-6' },
110
- md: { cancelLabel: 'text-[16px]', clear: 'h-6 w-6' },
111
- lg: { cancelLabel: 'text-[16px]', clear: 'h-7 w-7' },
316
+ sm: {
317
+ cancelLabel: 'text-[14px]',
318
+ clear: 'h-6 w-6',
319
+ token: 'h-6',
320
+ tokenLabel: 'text-[13px]',
321
+ tokenRemove: 'h-4 w-4',
322
+ },
323
+ md: {
324
+ cancelLabel: 'text-[16px]',
325
+ clear: 'h-6 w-6',
326
+ token: 'h-7',
327
+ tokenLabel: 'text-[14px]',
328
+ tokenRemove: 'h-5 w-5',
329
+ },
330
+ lg: {
331
+ cancelLabel: 'text-[16px]',
332
+ clear: 'h-7 w-7',
333
+ token: 'h-8',
334
+ tokenLabel: 'text-[15px]',
335
+ tokenRemove: 'h-5 w-5',
336
+ },
112
337
  },
113
338
  /**
114
339
  * The field's corner. `pill` is the shape a search field takes when it is
@@ -119,10 +344,24 @@ const searchBarVariants = tv({
119
344
  rounded: { field: '' },
120
345
  pill: { field: 'rounded-full' },
121
346
  },
347
+ /**
348
+ * Which edge of the field the card grows out of. The field's corners on
349
+ * that edge go square and its border comes off entirely — the card around
350
+ * both of them is what draws the edge.
351
+ */
352
+ attached: {
353
+ none: {},
354
+ top: { field: 'rounded-t-none rounded-b-2xl border-0' },
355
+ bottom: { field: 'rounded-b-none rounded-t-2xl border-0' },
356
+ },
357
+ selected: {
358
+ true: { item: 'bg-accent' },
359
+ },
122
360
  },
123
361
  defaultVariants: {
124
362
  size: 'md',
125
363
  shape: 'rounded',
364
+ attached: 'none',
126
365
  },
127
366
  });
128
367
 
@@ -131,21 +370,47 @@ type SearchBarVariantProps = VariantProps<typeof searchBarVariants>;
131
370
  /** Glyph sizes per field size — the icon tracks the text, not the box. */
132
371
  const ICON_SIZE = { sm: 16, md: 18, lg: 20 } as const;
133
372
 
373
+ interface SearchBarContextValue {
374
+ /** The field's size, so a chip drawn in it matches the text beside it. */
375
+ size: NonNullable<SearchBarVariantProps['size']>;
376
+ /**
377
+ * Marks the field as still in use and asks for focus back. Called by
378
+ * anything pressable the panel or the field contains, before the press has
379
+ * had a chance to blur the field and close the search around it.
380
+ */
381
+ retainFocus: () => void;
382
+ }
383
+
384
+ const SearchBarContext = createContext<SearchBarContextValue | null>(null);
385
+
386
+ /** Where the results open. */
387
+ export type SearchBarPanelPlacement = 'top' | 'bottom';
388
+
389
+ /** When the results are shown. */
390
+ export type SearchBarPanelMode = 'never' | 'focus' | 'always';
391
+
134
392
  /**
135
393
  * What SearchBar takes from Input, minus everything it owns itself. The form
136
394
  * furniture is dropped along with it: a label and an error line stack above
137
395
  * and below the field, and Cancel sits beside the whole stack rather than
138
396
  * beside the field it belongs to. Use `Field` for a search that is one answer
139
397
  * in a form.
398
+ *
399
+ * The keyboard props go too. Input's would move the field and leave the Cancel
400
+ * button and the panel where they were; SearchBar lifts all three together.
140
401
  */
141
402
  type InheritedInputProps = Omit<
142
403
  InputProps,
404
+ | 'avoidKeyboard'
143
405
  | 'defaultValue'
144
406
  | 'description'
145
407
  | 'endContent'
146
408
  | 'errorMessage'
147
409
  | 'interactiveContent'
148
410
  | 'isRequired'
411
+ | 'keyboardBottomInset'
412
+ | 'keyboardMode'
413
+ | 'keyboardOffset'
149
414
  | 'label'
150
415
  | 'multiline'
151
416
  | 'onChangeText'
@@ -154,7 +419,9 @@ type InheritedInputProps = Omit<
154
419
  | 'value'
155
420
  >;
156
421
 
157
- export interface SearchBarProps extends InheritedInputProps, SearchBarVariantProps {
422
+ export interface SearchBarProps
423
+ extends InheritedInputProps,
424
+ Omit<SearchBarVariantProps, 'attached' | 'selected'> {
158
425
  /**
159
426
  * The field's background, from `Input`. `outline` draws its own edge, for a
160
427
  * search bar sitting on the page; `filled` drops it, for one inside a card
@@ -202,9 +469,58 @@ export interface SearchBarProps extends InheritedInputProps, SearchBarVariantPro
202
469
  loading?: boolean;
203
470
  /** The leading glyph, for a search over something with a symbol of its own. */
204
471
  icon?: ReactNode;
472
+ /**
473
+ * Lift the whole search — field, Cancel button and panel — until it sits
474
+ * clear of the software keyboard, and put it back on blur. Without it the
475
+ * field stays where the page left it, which on most screens is behind the
476
+ * keyboard it just opened.
477
+ *
478
+ * Install `react-native-keyboard-controller` for this to behave on Android.
479
+ *
480
+ * Do not toggle it at runtime: it changes which component wraps the row, so
481
+ * the field would remount and lose focus.
482
+ */
483
+ avoidKeyboard?: boolean;
484
+ /** Gap kept between the field's bottom edge and the keyboard. */
485
+ keyboardOffset?: number;
486
+ /**
487
+ * When the results panel is shown. `focus` opens it while the field is being
488
+ * typed into, `always` keeps it out for a screen that is nothing but the
489
+ * search, `never` ignores the children entirely.
490
+ */
491
+ panel?: SearchBarPanelMode;
492
+ /**
493
+ * Which side of the field the panel opens out of. `top` is the default,
494
+ * because the space under a focused field belongs to the keyboard.
495
+ */
496
+ panelPlacement?: SearchBarPanelPlacement;
497
+ /**
498
+ * Cap on the panel's height, in points.
499
+ *
500
+ * The panel takes the smaller of this and the room between the field and the
501
+ * edge of the screen, so it never runs off the top of the display. Unset, it
502
+ * is capped at about six rows: the space above a lifted field is most of the
503
+ * screen, and a panel that takes all of it stops reading as something laid
504
+ * over the app. Longer lists scroll.
505
+ */
506
+ panelMaxHeight?: number;
507
+ /**
508
+ * What has been picked so far, drawn inside the field before the caret.
509
+ * `SearchBar.Token` is the chip; anything else that fits on one line works
510
+ * too. Tokens scroll rather than wrap, so the field stays one line tall.
511
+ */
512
+ tokens?: ReactNode;
513
+ /**
514
+ * Fires when backspace is pressed in an empty field. Remove the last token
515
+ * here — it is the gesture every token field answers, and without it the
516
+ * only way back out of a choice is its own ✕.
517
+ */
518
+ onRemoveLastToken?: () => void;
519
+ /** The panel's contents — `SearchBar.Section`, `.Item` and `.Status`. */
520
+ children?: ReactNode;
205
521
  }
206
522
 
207
- export const SearchBar = forwardRef<TextInput, SearchBarProps>(
523
+ const SearchBarRoot = forwardRef<TextInput, SearchBarProps>(
208
524
  (
209
525
  {
210
526
  value: valueProp,
@@ -226,8 +542,17 @@ export const SearchBar = forwardRef<TextInput, SearchBarProps>(
226
542
  className,
227
543
  containerClassName,
228
544
  disabled,
545
+ avoidKeyboard = false,
546
+ keyboardOffset = 12,
547
+ panel = 'focus',
548
+ panelPlacement = 'top',
549
+ panelMaxHeight,
550
+ tokens,
551
+ onRemoveLastToken,
552
+ children,
229
553
  onFocus,
230
554
  onBlur,
555
+ onKeyPress,
231
556
  onSubmitEditing,
232
557
  ...props
233
558
  },
@@ -241,7 +566,27 @@ export const SearchBar = forwardRef<TextInput, SearchBarProps>(
241
566
  const inputRef = useRef<TextInput | null>(null);
242
567
  useImperativeHandle(ref, () => inputRef.current as TextInput, []);
243
568
 
244
- const slots = searchBarVariants({ size, shape });
569
+ /*
570
+ * Counted rather than tested for truthiness: `{results.map(…)}` over an
571
+ * empty array is a child, and a panel that opens on nothing is a card of
572
+ * padding.
573
+ */
574
+ const hasPanel = useMemo(() => {
575
+ let found = false;
576
+ Children.forEach(children, (child) => {
577
+ if (isValidElement(child)) found = true;
578
+ });
579
+ return found;
580
+ }, [children]);
581
+
582
+ const panelOpen =
583
+ !disabled &&
584
+ panel !== 'never' &&
585
+ hasPanel &&
586
+ (panel === 'always' || focused);
587
+
588
+ const attached = panelOpen ? panelPlacement : 'none';
589
+ const slots = searchBarVariants({ size, shape, attached });
245
590
 
246
591
  const setText = useCallback(
247
592
  (next: string) => {
@@ -276,20 +621,96 @@ export const SearchBar = forwardRef<TextInput, SearchBarProps>(
276
621
  };
277
622
  }, [text, debounce]);
278
623
 
624
+ /*
625
+ * Set while a press inside the panel or the field is being served. A blur
626
+ * arriving under it is the press taking focus rather than the search
627
+ * ending, so it is answered by asking for focus back — the panel is drawn
628
+ * out of `focused`, and letting it through would close the panel the
629
+ * pressed control is standing in.
630
+ */
631
+ const guarded = useRef(false);
632
+ const guardTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
633
+
634
+ useEffect(
635
+ () => () => {
636
+ if (guardTimer.current) clearTimeout(guardTimer.current);
637
+ },
638
+ []
639
+ );
640
+
641
+ const retainFocus = useCallback(() => {
642
+ if (disabled) return;
643
+ guarded.current = true;
644
+ if (guardTimer.current) clearTimeout(guardTimer.current);
645
+ guardTimer.current = setTimeout(() => {
646
+ guarded.current = false;
647
+ }, FOCUS_GUARD);
648
+ inputRef.current?.focus();
649
+ }, [disabled]);
650
+
651
+ /*
652
+ * Set while the search is being ended deliberately — Cancel, or a caller
653
+ * calling `blur()`. Without it the recovery below would fight the very
654
+ * thing that is trying to close the search.
655
+ */
656
+ const ending = useRef(false);
657
+ const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
658
+ const clearCloseTimer = useCallback(() => {
659
+ if (closeTimer.current) clearTimeout(closeTimer.current);
660
+ closeTimer.current = null;
661
+ }, []);
662
+
663
+ useEffect(() => clearCloseTimer, [clearCloseTimer]);
664
+
665
+ // Read inside a timer, so it has to be a ref rather than the render value.
666
+ const keyboardUp = useRef(false);
667
+
279
668
  const handleFocus = useCallback<NonNullable<InputProps['onFocus']>>(
280
669
  (event) => {
670
+ clearCloseTimer();
671
+ ending.current = false;
281
672
  setFocused(true);
282
673
  onFocus?.(event);
283
674
  },
284
- [onFocus]
675
+ [clearCloseTimer, onFocus]
285
676
  );
286
677
 
287
678
  const handleBlur = useCallback<NonNullable<InputProps['onBlur']>>(
288
679
  (event) => {
289
- setFocused(false);
680
+ // The caller asked for the event, so it goes out now rather than at
681
+ // the end of the pause — this is about what the *panel* believes.
290
682
  onBlur?.(event);
683
+
684
+ if (guarded.current) {
685
+ inputRef.current?.focus();
686
+ return;
687
+ }
688
+
689
+ clearCloseTimer();
690
+ closeTimer.current = setTimeout(() => {
691
+ closeTimer.current = null;
692
+ if (!ending.current && keyboardUp.current) {
693
+ // The keyboard never went down, so the search is still on screen
694
+ // and something inside it has taken the focus. Give it back.
695
+ inputRef.current?.focus();
696
+ return;
697
+ }
698
+ setFocused(false);
699
+ }, BLUR_GRACE);
700
+ },
701
+ [clearCloseTimer, onBlur]
702
+ );
703
+
704
+ const handleKeyPress = useCallback(
705
+ (event: NativeSyntheticEvent<TextInputKeyPressEventData>) => {
706
+ // Only on an empty field: while there is a query, backspace is editing
707
+ // it, and eating a token instead would delete something nobody aimed at.
708
+ if (event.nativeEvent.key === 'Backspace' && text.length === 0) {
709
+ onRemoveLastToken?.();
710
+ }
711
+ onKeyPress?.(event);
291
712
  },
292
- [onBlur]
713
+ [onKeyPress, onRemoveLastToken, text.length]
293
714
  );
294
715
 
295
716
  const handleSubmit = useCallback(
@@ -315,7 +736,12 @@ export const SearchBar = forwardRef<TextInput, SearchBarProps>(
315
736
 
316
737
  const handleCancel = useCallback(() => {
317
738
  if (disabled) return;
739
+ // Deliberate: the recovery above must not answer this blur by handing
740
+ // the focus straight back.
741
+ ending.current = true;
742
+ guarded.current = false;
318
743
  setText('');
744
+ setFocused(false);
319
745
  inputRef.current?.blur();
320
746
  onCancel?.();
321
747
  }, [disabled, onCancel, setText]);
@@ -349,7 +775,77 @@ export const SearchBar = forwardRef<TextInput, SearchBarProps>(
349
775
  [cancelWidth]
350
776
  );
351
777
 
352
- const startContent = (
778
+ /*
779
+ * How much room the panel has, in the direction it opens. Measured on the
780
+ * JS side because it decides a layout constraint rather than a frame of an
781
+ * animation: a `maxHeight` that changed every frame would re-lay out the
782
+ * list under the finger scrolling it.
783
+ */
784
+ const { height: windowHeight } = useWindowDimensions();
785
+ const { height: keyboardHeight } = useKeyboard();
786
+ useEffect(() => {
787
+ keyboardUp.current = keyboardHeight > 0;
788
+ }, [keyboardHeight]);
789
+ const anchorRef = useRef<View | null>(null);
790
+ const [anchorBox, setAnchorBox] = useState<{
791
+ top: number;
792
+ height: number;
793
+ width: number;
794
+ } | null>(null);
795
+
796
+ const measureAnchor = useCallback(() => {
797
+ anchorRef.current?.measureInWindow((_x, y, width, height) => {
798
+ setAnchorBox((current) =>
799
+ current &&
800
+ current.top === y &&
801
+ current.height === height &&
802
+ current.width === width
803
+ ? current
804
+ : { top: y, height, width }
805
+ );
806
+ });
807
+ }, []);
808
+
809
+ useEffect(() => {
810
+ if (!panelOpen) return;
811
+ measureAnchor();
812
+ }, [panelOpen, keyboardHeight, measureAnchor]);
813
+
814
+ const resolvedMaxHeight = useMemo(() => {
815
+ const fieldHeight = anchorBox?.height ?? 0;
816
+ /*
817
+ * While the field is riding the keyboard, where it has come to rest is
818
+ * computed rather than measured: the lift is a transform applied on the
819
+ * UI thread, so a measurement taken from JavaScript is a frame behind it
820
+ * for the whole of the animation.
821
+ */
822
+ const fieldTop =
823
+ avoidKeyboard && keyboardHeight > 0
824
+ ? windowHeight - keyboardHeight - keyboardOffset - fieldHeight
825
+ : (anchorBox?.top ?? 0);
826
+ const room =
827
+ panelPlacement === 'top'
828
+ ? fieldTop - PANEL_EDGE_GAP
829
+ : windowHeight - keyboardHeight - fieldTop - fieldHeight - PANEL_EDGE_GAP;
830
+ /*
831
+ * The smaller of what it is allowed and what it actually has, floored so
832
+ * a cramped screen still opens something worth reading. A caller's cap
833
+ * is clamped to the room too — a cap taller than the space available is
834
+ * a panel running off the top of the display.
835
+ */
836
+ const cap = panelMaxHeight ?? PANEL_MAX_HEIGHT;
837
+ return Math.max(Math.min(cap, room), PANEL_MIN_HEIGHT);
838
+ }, [
839
+ anchorBox,
840
+ avoidKeyboard,
841
+ keyboardHeight,
842
+ keyboardOffset,
843
+ panelMaxHeight,
844
+ panelPlacement,
845
+ windowHeight,
846
+ ]);
847
+
848
+ const glyph = (
353
849
  /*
354
850
  * Decorative, and said so here rather than through Input's
355
851
  * `interactiveContent` — that flag covers both ends of the field, and
@@ -357,6 +853,7 @@ export const SearchBar = forwardRef<TextInput, SearchBarProps>(
357
853
  * touches with it.
358
854
  */
359
855
  <View
856
+ key="glyph"
360
857
  pointerEvents="none"
361
858
  accessibilityElementsHidden
362
859
  importantForAccessibility="no-hide-descendants"
@@ -365,6 +862,36 @@ export const SearchBar = forwardRef<TextInput, SearchBarProps>(
365
862
  </View>
366
863
  );
367
864
 
865
+ /*
866
+ * Capped and scrolling rather than wrapping. The field is one line tall, so
867
+ * a row of chips allowed to grow would move the caret every time something
868
+ * was picked; and left uncapped it would take the whole field and leave
869
+ * nowhere to type the next query.
870
+ */
871
+ const tokenRow = tokens ? (
872
+ <ScrollView
873
+ key="tokens"
874
+ horizontal
875
+ keyboardShouldPersistTaps="always"
876
+ showsHorizontalScrollIndicator={false}
877
+ style={{
878
+ maxWidth: anchorBox ? anchorBox.width * TOKEN_MAX_SHARE : undefined,
879
+ }}
880
+ contentContainerClassName={slots.tokenRow()}
881
+ >
882
+ {tokens}
883
+ </ScrollView>
884
+ ) : null;
885
+
886
+ const startContent = tokenRow ? (
887
+ <>
888
+ {glyph}
889
+ {tokenRow}
890
+ </>
891
+ ) : (
892
+ glyph
893
+ );
894
+
368
895
  const endContent = loading ? (
369
896
  <Spinner size="sm" />
370
897
  ) : isClearable && text.length > 0 ? (
@@ -386,13 +913,14 @@ export const SearchBar = forwardRef<TextInput, SearchBarProps>(
386
913
  size={size}
387
914
  disabled={disabled}
388
915
  className={slots.field({ className })}
389
- containerClassName={cancel === 'never' ? containerClassName : 'flex-1'}
916
+ containerClassName="w-full"
390
917
  startContent={startContent}
391
918
  endContent={endContent}
392
919
  value={text}
393
920
  onChangeText={setText}
394
921
  onFocus={handleFocus}
395
922
  onBlur={handleBlur}
923
+ onKeyPress={handleKeyPress}
396
924
  onSubmitEditing={handleSubmit}
397
925
  accessibilityRole="search"
398
926
  returnKeyType="search"
@@ -407,41 +935,401 @@ export const SearchBar = forwardRef<TextInput, SearchBarProps>(
407
935
  />
408
936
  );
409
937
 
410
- if (cancel === 'never') return field;
938
+ const context = useMemo<SearchBarContextValue>(
939
+ () => ({ size, retainFocus }),
940
+ [retainFocus, size]
941
+ );
411
942
 
412
- return (
413
- <View className={slots.row({ className: containerClassName })}>
414
- {field}
415
- <Animated.View
416
- style={cancelStyle}
417
- className={slots.cancelClip()}
418
- pointerEvents={cancelOut ? 'auto' : 'none'}
419
- accessibilityElementsHidden={!cancelOut}
420
- importantForAccessibility={cancelOut ? 'auto' : 'no-hide-descendants'}
421
- >
422
- {/*
423
- * Absolute, and pinned to the end edge: it keeps its natural width
424
- * inside a container whose width is animating, so the clip reveals
425
- * it from the edge instead of squeezing the word as it arrives. It
426
- * is also what makes the measurement possible at all — a child laid
427
- * out against a container of width 0 would otherwise report 0.
428
- */}
429
- <AnimatedPressable
430
- onLayout={handleCancelLayout}
431
- onPress={handleCancel}
432
- disabled={disabled}
433
- focusable={!disabled}
434
- hitSlop={8}
435
- accessibilityRole="button"
436
- accessibilityState={{ disabled: !!disabled }}
437
- className={slots.cancelButton()}
943
+ // Nothing beside it and nothing under it: the field is the whole component,
944
+ // and a wrapper around it would only be a box for the caller's layout to
945
+ // fight.
946
+ if (cancel === 'never' && !avoidKeyboard && !hasPanel) {
947
+ return (
948
+ <SearchBarContext.Provider value={context}>
949
+ <View ref={anchorRef} onLayout={measureAnchor} className={containerClassName}>
950
+ {field}
951
+ </View>
952
+ </SearchBarContext.Provider>
953
+ );
954
+ }
955
+
956
+ /*
957
+ * The results, the hairline and the room the field occupies, in the order
958
+ * they are stacked. The field itself is drawn over that last piece rather
959
+ * than inside the card: it has to keep its own place in the layout, and a
960
+ * text field that moved into an absolutely positioned box on focus would
961
+ * remount and lose the keyboard it just opened.
962
+ */
963
+ const list = (
964
+ <ScrollView
965
+ key="list"
966
+ style={{ maxHeight: resolvedMaxHeight }}
967
+ /*
968
+ * `always`, not `handled`: everything in the panel that is not itself
969
+ * a button — the padding, the gaps between rows, a section heading,
970
+ * the whole of `SearchBar.Status` — would otherwise spend the first
971
+ * tap dismissing the keyboard, which ends the search.
972
+ */
973
+ keyboardShouldPersistTaps="always"
974
+ keyboardDismissMode="none"
975
+ showsVerticalScrollIndicator={false}
976
+ bounces={false}
977
+ className={slots.panelList()}
978
+ >
979
+ {children}
980
+ </ScrollView>
981
+ );
982
+ const divider = (
983
+ <View
984
+ key="divider"
985
+ pointerEvents="none"
986
+ style={{ height: StyleSheet.hairlineWidth }}
987
+ className={slots.panelDivider()}
988
+ />
989
+ );
990
+ /*
991
+ * The room the card keeps for the field, and the reason it is inert.
992
+ *
993
+ * It is a spacer drawn at exactly the field's position, inside the card and
994
+ * therefore outside the list's `keyboardShouldPersistTaps`. Any touch it
995
+ * wins is a touch on a plain view while a field is focused, which is the
996
+ * platform's cue to dismiss the keyboard — so the field blurs, and the
997
+ * panel drawn out of that focus closes. From the outside that is "tapping
998
+ * the search box closes the results", which is exactly backwards.
999
+ *
1000
+ * It has nothing to be pressed for. `none` puts the touch through to the
1001
+ * field underneath it, where it was aimed.
1002
+ */
1003
+ const fieldSlot = (
1004
+ <View
1005
+ key="slot"
1006
+ pointerEvents="none"
1007
+ style={{ height: anchorBox?.height ?? FIELD_HEIGHT[size] }}
1008
+ />
1009
+ );
1010
+
1011
+ const anchor = (
1012
+ <View
1013
+ ref={anchorRef}
1014
+ onLayout={measureAnchor}
1015
+ style={RAISED}
1016
+ className={slots.anchor({ className: cancel === 'never' ? 'w-full' : 'flex-1' })}
1017
+ >
1018
+ {panelOpen ? (
1019
+ <Animated.View
1020
+ entering={FadeIn.duration(PANEL_IN)}
1021
+ exiting={FadeOut.duration(PANEL_OUT)}
1022
+ // `box-none`: the card is a surface, not a target. Only the rows
1023
+ // inside it answer a touch, and the space kept for the field lets
1024
+ // one through to the field.
1025
+ pointerEvents="box-none"
1026
+ style={panelPlacement === 'top' ? CARD_ABOVE : CARD_BELOW}
1027
+ className={slots.panel()}
438
1028
  >
439
- <Text className={slots.cancelLabel()}>{cancelLabel}</Text>
440
- </AnimatedPressable>
441
- </Animated.View>
1029
+ {panelPlacement === 'top'
1030
+ ? [list, divider, fieldSlot]
1031
+ : [fieldSlot, divider, list]}
1032
+ </Animated.View>
1033
+ ) : null}
1034
+ {field}
442
1035
  </View>
443
1036
  );
1037
+
1038
+ const body =
1039
+ cancel === 'never' ? (
1040
+ anchor
1041
+ ) : (
1042
+ <>
1043
+ {anchor}
1044
+ <Animated.View
1045
+ style={cancelStyle}
1046
+ className={slots.cancelClip()}
1047
+ pointerEvents={cancelOut ? 'auto' : 'none'}
1048
+ accessibilityElementsHidden={!cancelOut}
1049
+ importantForAccessibility={cancelOut ? 'auto' : 'no-hide-descendants'}
1050
+ >
1051
+ {/*
1052
+ * Absolute, and pinned to the end edge: it keeps its natural width
1053
+ * inside a container whose width is animating, so the clip reveals
1054
+ * it from the edge instead of squeezing the word as it arrives. It
1055
+ * is also what makes the measurement possible at all — a child laid
1056
+ * out against a container of width 0 would otherwise report 0.
1057
+ */}
1058
+ <AnimatedPressable
1059
+ onLayout={handleCancelLayout}
1060
+ onPress={handleCancel}
1061
+ disabled={disabled}
1062
+ focusable={!disabled}
1063
+ hitSlop={8}
1064
+ accessibilityRole="button"
1065
+ accessibilityState={{ disabled: !!disabled }}
1066
+ className={slots.cancelButton()}
1067
+ >
1068
+ <Text className={slots.cancelLabel()}>{cancelLabel}</Text>
1069
+ </AnimatedPressable>
1070
+ </Animated.View>
1071
+ </>
1072
+ );
1073
+
1074
+ /*
1075
+ * The keyboard hook is behind a component boundary rather than a flag.
1076
+ * Calling it at all has global consequences — without the keyboard
1077
+ * controller installed it falls back to Reanimated's useAnimatedKeyboard,
1078
+ * which switches Android out of adjustResize for the whole app. A search
1079
+ * bar that never asked to avoid the keyboard must not do that to every
1080
+ * other screen.
1081
+ */
1082
+ if (avoidKeyboard) {
1083
+ return (
1084
+ <SearchBarContext.Provider value={context}>
1085
+ <KeyboardAvoider
1086
+ // Only while *this* field is the one being typed into. Without it
1087
+ // every avoiding field on the screen lifts the moment any field
1088
+ // anywhere is tapped, and since they all aim at the same gap above
1089
+ // the keyboard, they arrive stacked on top of one another.
1090
+ active={focused}
1091
+ mode="lift"
1092
+ offset={keyboardOffset}
1093
+ className={slots.row({ className: containerClassName })}
1094
+ >
1095
+ {body}
1096
+ </KeyboardAvoider>
1097
+ </SearchBarContext.Provider>
1098
+ );
1099
+ }
1100
+
1101
+ return (
1102
+ <SearchBarContext.Provider value={context}>
1103
+ <View className={slots.row({ className: containerClassName })}>{body}</View>
1104
+ </SearchBarContext.Provider>
1105
+ );
444
1106
  }
445
1107
  );
446
1108
 
447
- SearchBar.displayName = 'SearchBar';
1109
+ SearchBarRoot.displayName = 'SearchBar';
1110
+
1111
+ export interface SearchBarSectionProps extends ViewProps {
1112
+ className?: string;
1113
+ /**
1114
+ * The heading over the run of rows — "Suggested", "Results". Announced as a
1115
+ * header, so a screen reader reaching the group is told what it is before
1116
+ * walking into it.
1117
+ */
1118
+ label?: string;
1119
+ children?: ReactNode;
1120
+ }
1121
+
1122
+ /** A labelled run of rows inside the panel. */
1123
+ function SearchBarSection({ className, label, children, ...props }: SearchBarSectionProps) {
1124
+ const { sectionLabel } = searchBarVariants();
1125
+ return (
1126
+ <View {...props} className={className}>
1127
+ {label ? (
1128
+ <Text accessibilityRole="header" className={sectionLabel()}>
1129
+ {label}
1130
+ </Text>
1131
+ ) : null}
1132
+ {children}
1133
+ </View>
1134
+ );
1135
+ }
1136
+
1137
+ SearchBarSection.displayName = 'SearchBar.Section';
1138
+
1139
+ export interface SearchBarItemProps extends Omit<AnimatedPressableProps, 'children'> {
1140
+ className?: string;
1141
+ /** Anything before the label — an avatar, a logo, a status dot. */
1142
+ leading?: ReactNode;
1143
+ /**
1144
+ * Anything after it. A slot rather than a built-in button, because what a
1145
+ * result row offers differs per search: an add, a pin, a count, nothing.
1146
+ */
1147
+ trailing?: ReactNode;
1148
+ /** A second line under the label, for what the label alone cannot say. */
1149
+ description?: string;
1150
+ /** Draws the row as the one the search has settled on. */
1151
+ selected?: boolean;
1152
+ /** The row's label. */
1153
+ children?: ReactNode;
1154
+ }
1155
+
1156
+ /** One result. */
1157
+ function SearchBarItem({
1158
+ className,
1159
+ leading,
1160
+ trailing,
1161
+ description,
1162
+ selected,
1163
+ children,
1164
+ onPressIn,
1165
+ ...props
1166
+ }: SearchBarItemProps) {
1167
+ const { item, itemLabel } = searchBarVariants({ selected: !!selected });
1168
+ const search = useContext(SearchBarContext);
1169
+ return (
1170
+ <AnimatedPressable
1171
+ accessibilityRole="button"
1172
+ accessibilityState={{ selected: !!selected, disabled: !!props.disabled }}
1173
+ /*
1174
+ * Before the press, not after it: the blur it may cause is what closes
1175
+ * the panel this row is drawn in, and by the time `onPress` runs the row
1176
+ * can already be gone.
1177
+ */
1178
+ onPressIn={(event) => {
1179
+ search?.retainFocus();
1180
+ onPressIn?.(event);
1181
+ }}
1182
+ // A row is a wide target, and a target that shrinks when pressed reads as
1183
+ // a card rather than a line in a list. The dim is the whole feedback.
1184
+ pressScale={1}
1185
+ pressOpacity={0.6}
1186
+ // The rows sit flush against each other, so the points either side of the
1187
+ // gap between two of them would otherwise belong to neither.
1188
+ hitSlop={{ top: 2, bottom: 2 }}
1189
+ {...props}
1190
+ className={item({ className })}
1191
+ >
1192
+ {leading}
1193
+ <View className="flex-1">
1194
+ <Text numberOfLines={1} className={itemLabel()}>
1195
+ {children}
1196
+ </Text>
1197
+ {description ? (
1198
+ <Text size="sm" muted numberOfLines={1}>
1199
+ {description}
1200
+ </Text>
1201
+ ) : null}
1202
+ </View>
1203
+ {trailing}
1204
+ </AnimatedPressable>
1205
+ );
1206
+ }
1207
+
1208
+ SearchBarItem.displayName = 'SearchBar.Item';
1209
+
1210
+ export interface SearchBarStatusProps extends ViewProps {
1211
+ className?: string;
1212
+ /** A spinner beside the line, for a search that is still running. */
1213
+ loading?: boolean;
1214
+ children?: ReactNode;
1215
+ }
1216
+
1217
+ /**
1218
+ * The one line a panel shows instead of rows — nothing typed yet, a search in
1219
+ * flight, or a query that matched nothing. It is a sentence rather than an
1220
+ * empty box because those three states look identical when they are blank, and
1221
+ * which one it is decides what the person does next.
1222
+ */
1223
+ function SearchBarStatus({
1224
+ className,
1225
+ loading = false,
1226
+ children,
1227
+ ...props
1228
+ }: SearchBarStatusProps) {
1229
+ const { status } = searchBarVariants();
1230
+ return (
1231
+ <View accessibilityRole="text" {...props} className={status({ className })}>
1232
+ {loading ? <Spinner size="sm" /> : null}
1233
+ <Text size="sm" muted>
1234
+ {children}
1235
+ </Text>
1236
+ </View>
1237
+ );
1238
+ }
1239
+
1240
+ SearchBarStatus.displayName = 'SearchBar.Status';
1241
+
1242
+ export interface SearchBarActionProps extends AnimatedPressableProps {
1243
+ className?: string;
1244
+ children?: ReactNode;
1245
+ }
1246
+
1247
+ /**
1248
+ * A button inside a row — an add, a pin, a remove — for the `trailing` slot.
1249
+ *
1250
+ * It exists rather than being left to a plain `Pressable` because a control
1251
+ * nested inside a row takes the touch itself, so the row above it never sees
1252
+ * the press and cannot hold the field's focus on its behalf. Pressed, this one
1253
+ * ends up blurring the field, and a blurred field closes the panel the button
1254
+ * was standing in — the press lands and the search disappears under it.
1255
+ */
1256
+ function SearchBarAction({ className, children, onPressIn, ...props }: SearchBarActionProps) {
1257
+ const search = useContext(SearchBarContext);
1258
+ return (
1259
+ <AnimatedPressable
1260
+ accessibilityRole="button"
1261
+ hitSlop={12}
1262
+ pressScale={1}
1263
+ pressOpacity={0.5}
1264
+ onPressIn={(event) => {
1265
+ search?.retainFocus();
1266
+ onPressIn?.(event);
1267
+ }}
1268
+ {...props}
1269
+ className={className}
1270
+ >
1271
+ {children}
1272
+ </AnimatedPressable>
1273
+ );
1274
+ }
1275
+
1276
+ SearchBarAction.displayName = 'SearchBar.Action';
1277
+
1278
+ export interface SearchBarTokenProps extends ViewProps {
1279
+ className?: string;
1280
+ /** Anything before the label — an avatar, a logo, a status dot. */
1281
+ leading?: ReactNode;
1282
+ /** Fires when the chip's ✕ is pressed. Without it no ✕ is drawn. */
1283
+ onRemove?: () => void;
1284
+ /** How the ✕ announces itself. Defaults to `Remove <label>`. */
1285
+ removeLabel?: string;
1286
+ /** The chip's label. */
1287
+ children?: ReactNode;
1288
+ }
1289
+
1290
+ /**
1291
+ * One choice already made, drawn inside the field before the caret.
1292
+ *
1293
+ * It sits in the field rather than in a list above it so that the query and
1294
+ * what the query has produced are one control. A search that files its results
1295
+ * somewhere else asks the reader to look in two places to know where they are.
1296
+ */
1297
+ function SearchBarToken({
1298
+ className,
1299
+ leading,
1300
+ onRemove,
1301
+ removeLabel,
1302
+ children,
1303
+ ...props
1304
+ }: SearchBarTokenProps) {
1305
+ const search = useContext(SearchBarContext);
1306
+ const slots = searchBarVariants({ size: search?.size ?? 'md' });
1307
+ const label = typeof children === 'string' ? children : undefined;
1308
+ return (
1309
+ <View {...props} className={slots.token({ className })}>
1310
+ {leading}
1311
+ <Text numberOfLines={1} className={slots.tokenLabel()}>
1312
+ {children}
1313
+ </Text>
1314
+ {onRemove ? (
1315
+ <SearchBarAction
1316
+ accessibilityLabel={removeLabel ?? (label ? `Remove ${label}` : 'Remove')}
1317
+ onPress={onRemove}
1318
+ className={slots.tokenRemove()}
1319
+ >
1320
+ <XIcon size={12} />
1321
+ </SearchBarAction>
1322
+ ) : null}
1323
+ </View>
1324
+ );
1325
+ }
1326
+
1327
+ SearchBarToken.displayName = 'SearchBar.Token';
1328
+
1329
+ export const SearchBar = Object.assign(SearchBarRoot, {
1330
+ Section: SearchBarSection,
1331
+ Item: SearchBarItem,
1332
+ Action: SearchBarAction,
1333
+ Token: SearchBarToken,
1334
+ Status: SearchBarStatus,
1335
+ });