panelui-native 0.87.0 → 0.88.1

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 (52) hide show
  1. package/lib/module/components/animated-badge/index.js +451 -0
  2. package/lib/module/components/animated-badge/index.js.map +1 -0
  3. package/lib/module/components/bottom-sheet/index.js +17 -6
  4. package/lib/module/components/bottom-sheet/index.js.map +1 -1
  5. package/lib/module/components/bubble-chart/index.js +557 -41
  6. package/lib/module/components/bubble-chart/index.js.map +1 -1
  7. package/lib/module/components/feedback/index.js +474 -0
  8. package/lib/module/components/feedback/index.js.map +1 -0
  9. package/lib/module/components/scroll-blur/index.js +363 -0
  10. package/lib/module/components/scroll-blur/index.js.map +1 -0
  11. package/lib/module/components/scroll-canvas/index.js +2 -0
  12. package/lib/module/components/scroll-canvas/index.js.map +1 -1
  13. package/lib/module/components/scroll-text/index.js +3 -0
  14. package/lib/module/components/scroll-text/index.js.map +1 -1
  15. package/lib/module/components/search-bar/index.js +390 -45
  16. package/lib/module/components/search-bar/index.js.map +1 -1
  17. package/lib/module/hooks/use-keyboard-avoidance.js +21 -1
  18. package/lib/module/hooks/use-keyboard-avoidance.js.map +1 -1
  19. package/lib/module/hooks/use-reveal-progress.js +21 -1
  20. package/lib/module/hooks/use-reveal-progress.js.map +1 -1
  21. package/lib/module/index.js +3 -0
  22. package/lib/module/index.js.map +1 -1
  23. package/lib/typescript/src/components/animated-badge/index.d.ts +224 -0
  24. package/lib/typescript/src/components/animated-badge/index.d.ts.map +1 -0
  25. package/lib/typescript/src/components/bottom-sheet/index.d.ts +1 -15
  26. package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -1
  27. package/lib/typescript/src/components/bubble-chart/index.d.ts +150 -7
  28. package/lib/typescript/src/components/bubble-chart/index.d.ts.map +1 -1
  29. package/lib/typescript/src/components/feedback/index.d.ts +156 -0
  30. package/lib/typescript/src/components/feedback/index.d.ts.map +1 -0
  31. package/lib/typescript/src/components/scroll-blur/index.d.ts +113 -0
  32. package/lib/typescript/src/components/scroll-blur/index.d.ts.map +1 -0
  33. package/lib/typescript/src/components/scroll-canvas/index.d.ts.map +1 -1
  34. package/lib/typescript/src/components/search-bar/index.d.ts +142 -7
  35. package/lib/typescript/src/components/search-bar/index.d.ts.map +1 -1
  36. package/lib/typescript/src/hooks/use-keyboard-avoidance.d.ts.map +1 -1
  37. package/lib/typescript/src/hooks/use-reveal-progress.d.ts +6 -27
  38. package/lib/typescript/src/hooks/use-reveal-progress.d.ts.map +1 -1
  39. package/lib/typescript/src/index.d.ts +5 -2
  40. package/lib/typescript/src/index.d.ts.map +1 -1
  41. package/package.json +1 -1
  42. package/src/components/animated-badge/index.tsx +513 -0
  43. package/src/components/bottom-sheet/index.tsx +23 -8
  44. package/src/components/bubble-chart/index.tsx +664 -46
  45. package/src/components/feedback/index.tsx +557 -0
  46. package/src/components/scroll-blur/index.tsx +466 -0
  47. package/src/components/scroll-canvas/index.tsx +2 -1
  48. package/src/components/scroll-text/index.tsx +3 -3
  49. package/src/components/search-bar/index.tsx +471 -41
  50. package/src/hooks/use-keyboard-avoidance.ts +24 -1
  51. package/src/hooks/use-reveal-progress.ts +30 -1
  52. package/src/index.ts +27 -0
@@ -32,9 +32,48 @@
32
32
  *
33
33
  * ## Touches inside the panel must not close the keyboard
34
34
  *
35
- * The panel scrolls with `keyboardShouldPersistTaps="handled"`. Without it the
36
- * first tap on a row is spent dismissing the keyboard and the press never
37
- * arrives, which reads as a row that ignores every other tap.
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.
38
77
  *
39
78
  * ## The clear button, and why it is not the platform's
40
79
  *
@@ -73,8 +112,10 @@
73
112
  */
74
113
  import {
75
114
  Children,
115
+ createContext,
76
116
  forwardRef,
77
117
  isValidElement,
118
+ useContext,
78
119
  useCallback,
79
120
  useEffect,
80
121
  useImperativeHandle,
@@ -91,6 +132,7 @@ import {
91
132
  type LayoutChangeEvent,
92
133
  type NativeSyntheticEvent,
93
134
  type TextInput,
135
+ type TextInputKeyPressEventData,
94
136
  type TextInputSubmitEditingEventData,
95
137
  type ViewProps,
96
138
  type ViewStyle,
@@ -142,6 +184,54 @@ const PANEL_EDGE_GAP = 24;
142
184
  /** Floor for the derived height — below this a list is not worth opening. */
143
185
  const PANEL_MIN_HEIGHT = 160;
144
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
+
145
235
  /*
146
236
  * The card is pinned to the edge of the field's own slot and grows away from
147
237
  * it, so the field's box is the one thing that never moves.
@@ -155,6 +245,18 @@ const CARD_BELOW: ViewStyle = { position: 'absolute', top: 0, left: 0, right: 0
155
245
  * so a card overlapping the content above the field would otherwise be painted
156
246
  * under it — and putting it here keeps the field painting over the card, which
157
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.
158
260
  */
159
261
  const RAISED: ViewStyle = { zIndex: 20, elevation: 20 };
160
262
 
@@ -199,12 +301,39 @@ const searchBarVariants = tv({
199
301
  cancelButton: 'absolute bottom-0 end-0 top-0 items-center justify-center ps-2',
200
302
  cancelLabel: 'font-medium text-primary',
201
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',
202
313
  },
203
314
  variants: {
204
315
  size: {
205
- sm: { cancelLabel: 'text-[14px]', clear: 'h-6 w-6' },
206
- md: { cancelLabel: 'text-[16px]', clear: 'h-6 w-6' },
207
- 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
+ },
208
337
  },
209
338
  /**
210
339
  * The field's corner. `pill` is the shape a search field takes when it is
@@ -241,6 +370,19 @@ type SearchBarVariantProps = VariantProps<typeof searchBarVariants>;
241
370
  /** Glyph sizes per field size — the icon tracks the text, not the box. */
242
371
  const ICON_SIZE = { sm: 16, md: 18, lg: 20 } as const;
243
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
+
244
386
  /** Where the results open. */
245
387
  export type SearchBarPanelPlacement = 'top' | 'bottom';
246
388
 
@@ -353,11 +495,27 @@ export interface SearchBarProps
353
495
  */
354
496
  panelPlacement?: SearchBarPanelPlacement;
355
497
  /**
356
- * Cap on the panel's height, in points. Derived from the room between the
357
- * field and the edge of the screen when it is not given, so a panel never
358
- * runs off the top of the display.
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.
359
505
  */
360
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;
361
519
  /** The panel's contents — `SearchBar.Section`, `.Item` and `.Status`. */
362
520
  children?: ReactNode;
363
521
  }
@@ -389,9 +547,12 @@ const SearchBarRoot = forwardRef<TextInput, SearchBarProps>(
389
547
  panel = 'focus',
390
548
  panelPlacement = 'top',
391
549
  panelMaxHeight,
550
+ tokens,
551
+ onRemoveLastToken,
392
552
  children,
393
553
  onFocus,
394
554
  onBlur,
555
+ onKeyPress,
395
556
  onSubmitEditing,
396
557
  ...props
397
558
  },
@@ -460,20 +621,96 @@ const SearchBarRoot = forwardRef<TextInput, SearchBarProps>(
460
621
  };
461
622
  }, [text, debounce]);
462
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
+
463
668
  const handleFocus = useCallback<NonNullable<InputProps['onFocus']>>(
464
669
  (event) => {
670
+ clearCloseTimer();
671
+ ending.current = false;
465
672
  setFocused(true);
466
673
  onFocus?.(event);
467
674
  },
468
- [onFocus]
675
+ [clearCloseTimer, onFocus]
469
676
  );
470
677
 
471
678
  const handleBlur = useCallback<NonNullable<InputProps['onBlur']>>(
472
679
  (event) => {
473
- 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.
474
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);
475
700
  },
476
- [onBlur]
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);
712
+ },
713
+ [onKeyPress, onRemoveLastToken, text.length]
477
714
  );
478
715
 
479
716
  const handleSubmit = useCallback(
@@ -499,7 +736,12 @@ const SearchBarRoot = forwardRef<TextInput, SearchBarProps>(
499
736
 
500
737
  const handleCancel = useCallback(() => {
501
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;
502
743
  setText('');
744
+ setFocused(false);
503
745
  inputRef.current?.blur();
504
746
  onCancel?.();
505
747
  }, [disabled, onCancel, setText]);
@@ -541,17 +783,25 @@ const SearchBarRoot = forwardRef<TextInput, SearchBarProps>(
541
783
  */
542
784
  const { height: windowHeight } = useWindowDimensions();
543
785
  const { height: keyboardHeight } = useKeyboard();
786
+ useEffect(() => {
787
+ keyboardUp.current = keyboardHeight > 0;
788
+ }, [keyboardHeight]);
544
789
  const anchorRef = useRef<View | null>(null);
545
- const [anchorBox, setAnchorBox] = useState<{ top: number; height: number } | null>(
546
- null
547
- );
790
+ const [anchorBox, setAnchorBox] = useState<{
791
+ top: number;
792
+ height: number;
793
+ width: number;
794
+ } | null>(null);
548
795
 
549
796
  const measureAnchor = useCallback(() => {
550
- anchorRef.current?.measureInWindow((_x, y, _width, height) => {
797
+ anchorRef.current?.measureInWindow((_x, y, width, height) => {
551
798
  setAnchorBox((current) =>
552
- current && current.top === y && current.height === height
799
+ current &&
800
+ current.top === y &&
801
+ current.height === height &&
802
+ current.width === width
553
803
  ? current
554
- : { top: y, height }
804
+ : { top: y, height, width }
555
805
  );
556
806
  });
557
807
  }, []);
@@ -562,7 +812,6 @@ const SearchBarRoot = forwardRef<TextInput, SearchBarProps>(
562
812
  }, [panelOpen, keyboardHeight, measureAnchor]);
563
813
 
564
814
  const resolvedMaxHeight = useMemo(() => {
565
- if (panelMaxHeight !== undefined) return panelMaxHeight;
566
815
  const fieldHeight = anchorBox?.height ?? 0;
567
816
  /*
568
817
  * While the field is riding the keyboard, where it has come to rest is
@@ -578,7 +827,14 @@ const SearchBarRoot = forwardRef<TextInput, SearchBarProps>(
578
827
  panelPlacement === 'top'
579
828
  ? fieldTop - PANEL_EDGE_GAP
580
829
  : windowHeight - keyboardHeight - fieldTop - fieldHeight - PANEL_EDGE_GAP;
581
- return Math.max(room, PANEL_MIN_HEIGHT);
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);
582
838
  }, [
583
839
  anchorBox,
584
840
  avoidKeyboard,
@@ -589,7 +845,7 @@ const SearchBarRoot = forwardRef<TextInput, SearchBarProps>(
589
845
  windowHeight,
590
846
  ]);
591
847
 
592
- const startContent = (
848
+ const glyph = (
593
849
  /*
594
850
  * Decorative, and said so here rather than through Input's
595
851
  * `interactiveContent` — that flag covers both ends of the field, and
@@ -597,6 +853,7 @@ const SearchBarRoot = forwardRef<TextInput, SearchBarProps>(
597
853
  * touches with it.
598
854
  */
599
855
  <View
856
+ key="glyph"
600
857
  pointerEvents="none"
601
858
  accessibilityElementsHidden
602
859
  importantForAccessibility="no-hide-descendants"
@@ -605,6 +862,36 @@ const SearchBarRoot = forwardRef<TextInput, SearchBarProps>(
605
862
  </View>
606
863
  );
607
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
+
608
895
  const endContent = loading ? (
609
896
  <Spinner size="sm" />
610
897
  ) : isClearable && text.length > 0 ? (
@@ -633,6 +920,7 @@ const SearchBarRoot = forwardRef<TextInput, SearchBarProps>(
633
920
  onChangeText={setText}
634
921
  onFocus={handleFocus}
635
922
  onBlur={handleBlur}
923
+ onKeyPress={handleKeyPress}
636
924
  onSubmitEditing={handleSubmit}
637
925
  accessibilityRole="search"
638
926
  returnKeyType="search"
@@ -647,11 +935,22 @@ const SearchBarRoot = forwardRef<TextInput, SearchBarProps>(
647
935
  />
648
936
  );
649
937
 
938
+ const context = useMemo<SearchBarContextValue>(
939
+ () => ({ size, retainFocus }),
940
+ [retainFocus, size]
941
+ );
942
+
650
943
  // Nothing beside it and nothing under it: the field is the whole component,
651
944
  // and a wrapper around it would only be a box for the caller's layout to
652
945
  // fight.
653
946
  if (cancel === 'never' && !avoidKeyboard && !hasPanel) {
654
- return <View className={containerClassName}>{field}</View>;
947
+ return (
948
+ <SearchBarContext.Provider value={context}>
949
+ <View ref={anchorRef} onLayout={measureAnchor} className={containerClassName}>
950
+ {field}
951
+ </View>
952
+ </SearchBarContext.Provider>
953
+ );
655
954
  }
656
955
 
657
956
  /*
@@ -666,11 +965,12 @@ const SearchBarRoot = forwardRef<TextInput, SearchBarProps>(
666
965
  key="list"
667
966
  style={{ maxHeight: resolvedMaxHeight }}
668
967
  /*
669
- * Without this the first tap on a row is spent dismissing the keyboard
670
- * and the press never lands, which reads as a list that ignores every
671
- * other touch.
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.
672
972
  */
673
- keyboardShouldPersistTaps="handled"
973
+ keyboardShouldPersistTaps="always"
674
974
  keyboardDismissMode="none"
675
975
  showsVerticalScrollIndicator={false}
676
976
  bounces={false}
@@ -682,23 +982,47 @@ const SearchBarRoot = forwardRef<TextInput, SearchBarProps>(
682
982
  const divider = (
683
983
  <View
684
984
  key="divider"
985
+ pointerEvents="none"
685
986
  style={{ height: StyleSheet.hairlineWidth }}
686
987
  className={slots.panelDivider()}
687
988
  />
688
989
  );
689
- const fieldSlot = <View key="slot" style={{ height: anchorBox?.height ?? 0 }} />;
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
+ );
690
1010
 
691
1011
  const anchor = (
692
1012
  <View
693
1013
  ref={anchorRef}
694
1014
  onLayout={measureAnchor}
695
- style={panelOpen ? RAISED : undefined}
1015
+ style={RAISED}
696
1016
  className={slots.anchor({ className: cancel === 'never' ? 'w-full' : 'flex-1' })}
697
1017
  >
698
1018
  {panelOpen ? (
699
1019
  <Animated.View
700
1020
  entering={FadeIn.duration(PANEL_IN)}
701
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"
702
1026
  style={panelPlacement === 'top' ? CARD_ABOVE : CARD_BELOW}
703
1027
  className={slots.panel()}
704
1028
  >
@@ -757,22 +1081,28 @@ const SearchBarRoot = forwardRef<TextInput, SearchBarProps>(
757
1081
  */
758
1082
  if (avoidKeyboard) {
759
1083
  return (
760
- <KeyboardAvoider
761
- // Only while *this* field is the one being typed into. Without it
762
- // every avoiding field on the screen lifts the moment any field
763
- // anywhere is tapped, and since they all aim at the same gap above
764
- // the keyboard, they arrive stacked on top of one another.
765
- active={focused}
766
- mode="lift"
767
- offset={keyboardOffset}
768
- className={slots.row({ className: containerClassName })}
769
- >
770
- {body}
771
- </KeyboardAvoider>
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>
772
1098
  );
773
1099
  }
774
1100
 
775
- return <View className={slots.row({ className: containerClassName })}>{body}</View>;
1101
+ return (
1102
+ <SearchBarContext.Provider value={context}>
1103
+ <View className={slots.row({ className: containerClassName })}>{body}</View>
1104
+ </SearchBarContext.Provider>
1105
+ );
776
1106
  }
777
1107
  );
778
1108
 
@@ -831,13 +1161,24 @@ function SearchBarItem({
831
1161
  description,
832
1162
  selected,
833
1163
  children,
1164
+ onPressIn,
834
1165
  ...props
835
1166
  }: SearchBarItemProps) {
836
1167
  const { item, itemLabel } = searchBarVariants({ selected: !!selected });
1168
+ const search = useContext(SearchBarContext);
837
1169
  return (
838
1170
  <AnimatedPressable
839
1171
  accessibilityRole="button"
840
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
+ }}
841
1182
  // A row is a wide target, and a target that shrinks when pressed reads as
842
1183
  // a card rather than a line in a list. The dim is the whole feedback.
843
1184
  pressScale={1}
@@ -898,8 +1239,97 @@ function SearchBarStatus({
898
1239
 
899
1240
  SearchBarStatus.displayName = 'SearchBar.Status';
900
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
+
901
1329
  export const SearchBar = Object.assign(SearchBarRoot, {
902
1330
  Section: SearchBarSection,
903
1331
  Item: SearchBarItem,
1332
+ Action: SearchBarAction,
1333
+ Token: SearchBarToken,
904
1334
  Status: SearchBarStatus,
905
1335
  });