panelui-native 0.87.0 → 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 (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-dialog/index.js +474 -0
  8. package/lib/module/components/feedback-dialog/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-dialog/index.d.ts +156 -0
  30. package/lib/typescript/src/components/feedback-dialog/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-dialog/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
@@ -34,9 +34,48 @@
34
34
  *
35
35
  * ## Touches inside the panel must not close the keyboard
36
36
  *
37
- * The panel scrolls with `keyboardShouldPersistTaps="handled"`. Without it the
38
- * first tap on a row is spent dismissing the keyboard and the press never
39
- * arrives, which reads as a row that ignores every other tap.
37
+ * The panel scrolls with `keyboardShouldPersistTaps="always"`, and every press
38
+ * inside it holds the field's focus open for a moment afterwards. Both are
39
+ * needed, because a search closes the instant the field blurs and there are
40
+ * two separate ways for a touch in the panel to blur it.
41
+ *
42
+ * `"handled"` only spares presses a child takes responsibility for, which
43
+ * leaves the panel's own padding, the gaps between rows, a section heading and
44
+ * the whole of `SearchBar.Status` as live dismiss surfaces — tapping the word
45
+ * "Searching …" would end the search. `"always"` gives the panel back.
46
+ *
47
+ * The focus guard covers the other way: a control inside a row — an add
48
+ * button, a remove ✕ — takes focus with the press on Android, and returning it
49
+ * a frame later is not enough on its own, because the blur has already closed
50
+ * the panel the control was drawn in. So a press in the panel marks the field
51
+ * as still being used, and a blur arriving under that mark is answered by
52
+ * asking for focus back rather than by ending the search.
53
+ *
54
+ * That guard only knows about presses that go through this component's own
55
+ * parts, and a caller's `Pressable` in a row's `trailing` slot takes the touch
56
+ * itself. So the panel also waits before believing any blur, and asks the
57
+ * keyboard: it is still up, because nothing in the panel dismisses it, and a
58
+ * search whose keyboard is still up has not ended. Focus goes back instead.
59
+ *
60
+ * ## The space kept for the field is not a target
61
+ *
62
+ * The card is one box around the results *and* the field, so it carries a
63
+ * spacer where the field sits. That spacer is a plain view drawn over a
64
+ * focused field, and a touch on a plain view is the platform's cue to dismiss
65
+ * the keyboard — so winning one blurred the field and closed the panel drawn
66
+ * out of that focus. Tapping the search box shut the results, which is exactly
67
+ * backwards. The card and its spacer take no touches at all now.
68
+ *
69
+ * ## What has already been picked goes in the field
70
+ *
71
+ * `tokens` puts the choices made so far inside the field, before the caret, so
72
+ * the query and what it has produced are one control rather than a control and
73
+ * a list somewhere above it. `SearchBar.Token` is the chip; backspace on an
74
+ * empty field fires `onRemoveLastToken`, which is what a token field does
75
+ * everywhere else.
76
+ *
77
+ * They scroll rather than wrap: the field is one line tall, and a row of chips
78
+ * that grew it would move the caret every time something was picked.
40
79
  *
41
80
  * ## The clear button, and why it is not the platform's
42
81
  *
@@ -73,7 +112,7 @@
73
112
  * pause instead of once per letter. Submitting flushes it immediately, since
74
113
  * a return key is somebody saying they are done waiting.
75
114
  */
76
- import { Children, forwardRef, isValidElement, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
115
+ import { Children, createContext, forwardRef, isValidElement, useContext, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
77
116
  import { ScrollView, StyleSheet, View, useWindowDimensions } from 'react-native';
78
117
  import Animated, { FadeIn, FadeOut, useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated';
79
118
  import { tv } from 'tailwind-variants';
@@ -87,7 +126,7 @@ import { Spinner } from "../spinner/index.js";
87
126
  import { cancelSearchBarDebounce, flushSearchBarDebounce, scheduleSearchBarDebounce } from "./search-bar-debounce.js";
88
127
 
89
128
  /** Matches the field's own focus crossfade, so the row settles as one thing. */
90
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
129
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
91
130
  const CANCEL_DURATION = 180;
92
131
 
93
132
  /** Space kept between the field and the Cancel button while it is out. */
@@ -109,6 +148,58 @@ const PANEL_EDGE_GAP = 24;
109
148
  /** Floor for the derived height — below this a list is not worth opening. */
110
149
  const PANEL_MIN_HEIGHT = 160;
111
150
 
151
+ /**
152
+ * Ceiling for the derived height, in points. About six rows and a heading.
153
+ *
154
+ * The room above a lifted field is most of the screen, and a panel that takes
155
+ * all of it is a full-screen list that happens to have a search box under it —
156
+ * the page it was opened over stops being visible, so the search stops reading
157
+ * as something laid over the app. Past this the results scroll, which is what
158
+ * a long list should do anyway.
159
+ *
160
+ * `panelMaxHeight` overrides it for a screen that really is nothing but the
161
+ * search.
162
+ */
163
+ const PANEL_MAX_HEIGHT = 320;
164
+
165
+ /**
166
+ * The field's height per size, matching `Input`'s own `h-10 / h-12 / h-14`.
167
+ *
168
+ * It is the fallback for the slot the panel keeps for the field, which is
169
+ * otherwise the measured height and therefore zero on the frame the panel
170
+ * first opens. A zero slot puts the card's bottom edge at the field's, so the
171
+ * last row is drawn underneath the field — which is painted after the card and
172
+ * takes the touch. The press then reads as a tap on the input.
173
+ */
174
+ const FIELD_HEIGHT = {
175
+ sm: 40,
176
+ md: 48,
177
+ lg: 56
178
+ };
179
+
180
+ /**
181
+ * How long a press inside the panel keeps the field's focus. Long enough to
182
+ * cover the blur Android sends with the press and the re-render that follows
183
+ * it, short enough that a real dismissal is never held open.
184
+ */
185
+ const FOCUS_GUARD = 400;
186
+
187
+ /**
188
+ * How long the panel waits after a blur before believing the search is over.
189
+ *
190
+ * The guard above only covers presses that go through this component's own
191
+ * parts. A caller's own `Pressable` in a row's `trailing` slot takes the touch
192
+ * itself and this component never hears about it — so the catch-all is the
193
+ * keyboard: it is still up, because nothing in the panel dismisses it, and a
194
+ * search whose keyboard is still up has not ended. The pause is long enough
195
+ * for focus to come back on its own and short enough that a real dismissal
196
+ * does not leave the panel hanging.
197
+ */
198
+ const BLUR_GRACE = 220;
199
+
200
+ /** Share of the field a row of tokens may take before it starts scrolling. */
201
+ const TOKEN_MAX_SHARE = 0.6;
202
+
112
203
  /*
113
204
  * The card is pinned to the edge of the field's own slot and grows away from
114
205
  * it, so the field's box is the one thing that never moves.
@@ -132,6 +223,18 @@ const CARD_BELOW = {
132
223
  * so a card overlapping the content above the field would otherwise be painted
133
224
  * under it — and putting it here keeps the field painting over the card, which
134
225
  * is what lets the two be one surface.
226
+ *
227
+ * Applied always, and never toggled. React Native implements `zIndex` on iOS
228
+ * by reordering the parent's subviews, which takes the view out of the
229
+ * hierarchy and puts it back — and a `UITextField` removed from the window
230
+ * resigns first responder. Setting this at the moment the panel opens
231
+ * therefore blurred the field that had just been focused, which closed the
232
+ * panel again: the keyboard came up and went straight back down, and the only
233
+ * way to get a search open was to be quicker than the render.
234
+ *
235
+ * A constant stacking order costs nothing while the panel is shut — there is
236
+ * nothing to stack against — and the elevation draws no shadow, because the
237
+ * box has no background for Android to cast one from.
135
238
  */
136
239
  const RAISED = {
137
240
  zIndex: 20,
@@ -177,21 +280,39 @@ const searchBarVariants = tv({
177
280
  cancelClip: 'self-stretch overflow-hidden',
178
281
  cancelButton: 'absolute bottom-0 end-0 top-0 items-center justify-center ps-2',
179
282
  cancelLabel: 'font-medium text-primary',
180
- clear: 'items-center justify-center rounded-full'
283
+ clear: 'items-center justify-center rounded-full',
284
+ /*
285
+ * The chips sit in the field's start content, which `Input` measures and
286
+ * turns into padding on the text — so the caret starts after them however
287
+ * many there are, and nothing typed ever runs underneath them.
288
+ */
289
+ tokenRow: 'flex-row items-center gap-1.5',
290
+ token: 'flex-row items-center gap-1 rounded-full bg-accent ps-2 pe-1',
291
+ tokenLabel: 'text-accent-foreground',
292
+ tokenRemove: 'items-center justify-center rounded-full'
181
293
  },
182
294
  variants: {
183
295
  size: {
184
296
  sm: {
185
297
  cancelLabel: 'text-[14px]',
186
- clear: 'h-6 w-6'
298
+ clear: 'h-6 w-6',
299
+ token: 'h-6',
300
+ tokenLabel: 'text-[13px]',
301
+ tokenRemove: 'h-4 w-4'
187
302
  },
188
303
  md: {
189
304
  cancelLabel: 'text-[16px]',
190
- clear: 'h-6 w-6'
305
+ clear: 'h-6 w-6',
306
+ token: 'h-7',
307
+ tokenLabel: 'text-[14px]',
308
+ tokenRemove: 'h-5 w-5'
191
309
  },
192
310
  lg: {
193
311
  cancelLabel: 'text-[16px]',
194
- clear: 'h-7 w-7'
312
+ clear: 'h-7 w-7',
313
+ token: 'h-8',
314
+ tokenLabel: 'text-[15px]',
315
+ tokenRemove: 'h-5 w-5'
195
316
  }
196
317
  },
197
318
  /**
@@ -239,6 +360,7 @@ const ICON_SIZE = {
239
360
  md: 18,
240
361
  lg: 20
241
362
  };
363
+ const SearchBarContext = /*#__PURE__*/createContext(null);
242
364
 
243
365
  /** Where the results open. */
244
366
 
@@ -280,9 +402,12 @@ const SearchBarRoot = /*#__PURE__*/forwardRef(({
280
402
  panel = 'focus',
281
403
  panelPlacement = 'top',
282
404
  panelMaxHeight,
405
+ tokens,
406
+ onRemoveLastToken,
283
407
  children,
284
408
  onFocus,
285
409
  onBlur,
410
+ onKeyPress,
286
411
  onSubmitEditing,
287
412
  ...props
288
413
  }, ref) => {
@@ -341,14 +466,78 @@ const SearchBarRoot = /*#__PURE__*/forwardRef(({
341
466
  cancelSearchBarDebounce(debounceTimerRef);
342
467
  };
343
468
  }, [text, debounce]);
469
+
470
+ /*
471
+ * Set while a press inside the panel or the field is being served. A blur
472
+ * arriving under it is the press taking focus rather than the search
473
+ * ending, so it is answered by asking for focus back — the panel is drawn
474
+ * out of `focused`, and letting it through would close the panel the
475
+ * pressed control is standing in.
476
+ */
477
+ const guarded = useRef(false);
478
+ const guardTimer = useRef(null);
479
+ useEffect(() => () => {
480
+ if (guardTimer.current) clearTimeout(guardTimer.current);
481
+ }, []);
482
+ const retainFocus = useCallback(() => {
483
+ if (disabled) return;
484
+ guarded.current = true;
485
+ if (guardTimer.current) clearTimeout(guardTimer.current);
486
+ guardTimer.current = setTimeout(() => {
487
+ guarded.current = false;
488
+ }, FOCUS_GUARD);
489
+ inputRef.current?.focus();
490
+ }, [disabled]);
491
+
492
+ /*
493
+ * Set while the search is being ended deliberately — Cancel, or a caller
494
+ * calling `blur()`. Without it the recovery below would fight the very
495
+ * thing that is trying to close the search.
496
+ */
497
+ const ending = useRef(false);
498
+ const closeTimer = useRef(null);
499
+ const clearCloseTimer = useCallback(() => {
500
+ if (closeTimer.current) clearTimeout(closeTimer.current);
501
+ closeTimer.current = null;
502
+ }, []);
503
+ useEffect(() => clearCloseTimer, [clearCloseTimer]);
504
+
505
+ // Read inside a timer, so it has to be a ref rather than the render value.
506
+ const keyboardUp = useRef(false);
344
507
  const handleFocus = useCallback(event => {
508
+ clearCloseTimer();
509
+ ending.current = false;
345
510
  setFocused(true);
346
511
  onFocus?.(event);
347
- }, [onFocus]);
512
+ }, [clearCloseTimer, onFocus]);
348
513
  const handleBlur = useCallback(event => {
349
- setFocused(false);
514
+ // The caller asked for the event, so it goes out now rather than at
515
+ // the end of the pause — this is about what the *panel* believes.
350
516
  onBlur?.(event);
351
- }, [onBlur]);
517
+ if (guarded.current) {
518
+ inputRef.current?.focus();
519
+ return;
520
+ }
521
+ clearCloseTimer();
522
+ closeTimer.current = setTimeout(() => {
523
+ closeTimer.current = null;
524
+ if (!ending.current && keyboardUp.current) {
525
+ // The keyboard never went down, so the search is still on screen
526
+ // and something inside it has taken the focus. Give it back.
527
+ inputRef.current?.focus();
528
+ return;
529
+ }
530
+ setFocused(false);
531
+ }, BLUR_GRACE);
532
+ }, [clearCloseTimer, onBlur]);
533
+ const handleKeyPress = useCallback(event => {
534
+ // Only on an empty field: while there is a query, backspace is editing
535
+ // it, and eating a token instead would delete something nobody aimed at.
536
+ if (event.nativeEvent.key === 'Backspace' && text.length === 0) {
537
+ onRemoveLastToken?.();
538
+ }
539
+ onKeyPress?.(event);
540
+ }, [onKeyPress, onRemoveLastToken, text.length]);
352
541
  const handleSubmit = useCallback(event => {
353
542
  if (disabled) return;
354
543
  // A return key is somebody saying they are done waiting, so the
@@ -367,7 +556,12 @@ const SearchBarRoot = /*#__PURE__*/forwardRef(({
367
556
  }, [disabled, onClear, setText]);
368
557
  const handleCancel = useCallback(() => {
369
558
  if (disabled) return;
559
+ // Deliberate: the recovery above must not answer this blur by handing
560
+ // the focus straight back.
561
+ ending.current = true;
562
+ guarded.current = false;
370
563
  setText('');
564
+ setFocused(false);
371
565
  inputRef.current?.blur();
372
566
  onCancel?.();
373
567
  }, [disabled, onCancel, setText]);
@@ -407,13 +601,17 @@ const SearchBarRoot = /*#__PURE__*/forwardRef(({
407
601
  const {
408
602
  height: keyboardHeight
409
603
  } = useKeyboard();
604
+ useEffect(() => {
605
+ keyboardUp.current = keyboardHeight > 0;
606
+ }, [keyboardHeight]);
410
607
  const anchorRef = useRef(null);
411
608
  const [anchorBox, setAnchorBox] = useState(null);
412
609
  const measureAnchor = useCallback(() => {
413
- anchorRef.current?.measureInWindow((_x, y, _width, height) => {
414
- setAnchorBox(current => current && current.top === y && current.height === height ? current : {
610
+ anchorRef.current?.measureInWindow((_x, y, width, height) => {
611
+ setAnchorBox(current => current && current.top === y && current.height === height && current.width === width ? current : {
415
612
  top: y,
416
- height
613
+ height,
614
+ width
417
615
  });
418
616
  });
419
617
  }, []);
@@ -422,7 +620,6 @@ const SearchBarRoot = /*#__PURE__*/forwardRef(({
422
620
  measureAnchor();
423
621
  }, [panelOpen, keyboardHeight, measureAnchor]);
424
622
  const resolvedMaxHeight = useMemo(() => {
425
- if (panelMaxHeight !== undefined) return panelMaxHeight;
426
623
  const fieldHeight = anchorBox?.height ?? 0;
427
624
  /*
428
625
  * While the field is riding the keyboard, where it has come to rest is
@@ -432,9 +629,16 @@ const SearchBarRoot = /*#__PURE__*/forwardRef(({
432
629
  */
433
630
  const fieldTop = avoidKeyboard && keyboardHeight > 0 ? windowHeight - keyboardHeight - keyboardOffset - fieldHeight : anchorBox?.top ?? 0;
434
631
  const room = panelPlacement === 'top' ? fieldTop - PANEL_EDGE_GAP : windowHeight - keyboardHeight - fieldTop - fieldHeight - PANEL_EDGE_GAP;
435
- return Math.max(room, PANEL_MIN_HEIGHT);
632
+ /*
633
+ * The smaller of what it is allowed and what it actually has, floored so
634
+ * a cramped screen still opens something worth reading. A caller's cap
635
+ * is clamped to the room too — a cap taller than the space available is
636
+ * a panel running off the top of the display.
637
+ */
638
+ const cap = panelMaxHeight ?? PANEL_MAX_HEIGHT;
639
+ return Math.max(Math.min(cap, room), PANEL_MIN_HEIGHT);
436
640
  }, [anchorBox, avoidKeyboard, keyboardHeight, keyboardOffset, panelMaxHeight, panelPlacement, windowHeight]);
437
- const startContent =
641
+ const glyph =
438
642
  /*#__PURE__*/
439
643
  /*
440
644
  * Decorative, and said so here rather than through Input's
@@ -449,7 +653,27 @@ const SearchBarRoot = /*#__PURE__*/forwardRef(({
449
653
  children: icon ?? /*#__PURE__*/_jsx(SearchIcon, {
450
654
  size: ICON_SIZE[size]
451
655
  })
452
- });
656
+ }, "glyph");
657
+
658
+ /*
659
+ * Capped and scrolling rather than wrapping. The field is one line tall, so
660
+ * a row of chips allowed to grow would move the caret every time something
661
+ * was picked; and left uncapped it would take the whole field and leave
662
+ * nowhere to type the next query.
663
+ */
664
+ const tokenRow = tokens ? /*#__PURE__*/_jsx(ScrollView, {
665
+ horizontal: true,
666
+ keyboardShouldPersistTaps: "always",
667
+ showsHorizontalScrollIndicator: false,
668
+ style: {
669
+ maxWidth: anchorBox ? anchorBox.width * TOKEN_MAX_SHARE : undefined
670
+ },
671
+ contentContainerClassName: slots.tokenRow(),
672
+ children: tokens
673
+ }, "tokens") : null;
674
+ const startContent = tokenRow ? /*#__PURE__*/_jsxs(_Fragment, {
675
+ children: [glyph, tokenRow]
676
+ }) : glyph;
453
677
  const endContent = loading ? /*#__PURE__*/_jsx(Spinner, {
454
678
  size: "sm"
455
679
  }) : isClearable && text.length > 0 ? /*#__PURE__*/_jsx(AnimatedPressable, {
@@ -477,6 +701,7 @@ const SearchBarRoot = /*#__PURE__*/forwardRef(({
477
701
  onChangeText: setText,
478
702
  onFocus: handleFocus,
479
703
  onBlur: handleBlur,
704
+ onKeyPress: handleKeyPress,
480
705
  onSubmitEditing: handleSubmit,
481
706
  accessibilityRole: "search",
482
707
  returnKeyType: "search"
@@ -490,14 +715,23 @@ const SearchBarRoot = /*#__PURE__*/forwardRef(({
490
715
  placeholder: "Search",
491
716
  ...props
492
717
  });
718
+ const context = useMemo(() => ({
719
+ size,
720
+ retainFocus
721
+ }), [retainFocus, size]);
493
722
 
494
723
  // Nothing beside it and nothing under it: the field is the whole component,
495
724
  // and a wrapper around it would only be a box for the caller's layout to
496
725
  // fight.
497
726
  if (cancel === 'never' && !avoidKeyboard && !hasPanel) {
498
- return /*#__PURE__*/_jsx(View, {
499
- className: containerClassName,
500
- children: field
727
+ return /*#__PURE__*/_jsx(SearchBarContext.Provider, {
728
+ value: context,
729
+ children: /*#__PURE__*/_jsx(View, {
730
+ ref: anchorRef,
731
+ onLayout: measureAnchor,
732
+ className: containerClassName,
733
+ children: field
734
+ })
501
735
  });
502
736
  }
503
737
 
@@ -513,11 +747,12 @@ const SearchBarRoot = /*#__PURE__*/forwardRef(({
513
747
  maxHeight: resolvedMaxHeight
514
748
  }
515
749
  /*
516
- * Without this the first tap on a row is spent dismissing the keyboard
517
- * and the press never lands, which reads as a list that ignores every
518
- * other touch.
750
+ * `always`, not `handled`: everything in the panel that is not itself
751
+ * a button — the padding, the gaps between rows, a section heading,
752
+ * the whole of `SearchBar.Status` — would otherwise spend the first
753
+ * tap dismissing the keyboard, which ends the search.
519
754
  */,
520
- keyboardShouldPersistTaps: "handled",
755
+ keyboardShouldPersistTaps: "always",
521
756
  keyboardDismissMode: "none",
522
757
  showsVerticalScrollIndicator: false,
523
758
  bounces: false,
@@ -525,26 +760,46 @@ const SearchBarRoot = /*#__PURE__*/forwardRef(({
525
760
  children: children
526
761
  }, "list");
527
762
  const divider = /*#__PURE__*/_jsx(View, {
763
+ pointerEvents: "none",
528
764
  style: {
529
765
  height: StyleSheet.hairlineWidth
530
766
  },
531
767
  className: slots.panelDivider()
532
768
  }, "divider");
769
+ /*
770
+ * The room the card keeps for the field, and the reason it is inert.
771
+ *
772
+ * It is a spacer drawn at exactly the field's position, inside the card and
773
+ * therefore outside the list's `keyboardShouldPersistTaps`. Any touch it
774
+ * wins is a touch on a plain view while a field is focused, which is the
775
+ * platform's cue to dismiss the keyboard — so the field blurs, and the
776
+ * panel drawn out of that focus closes. From the outside that is "tapping
777
+ * the search box closes the results", which is exactly backwards.
778
+ *
779
+ * It has nothing to be pressed for. `none` puts the touch through to the
780
+ * field underneath it, where it was aimed.
781
+ */
533
782
  const fieldSlot = /*#__PURE__*/_jsx(View, {
783
+ pointerEvents: "none",
534
784
  style: {
535
- height: anchorBox?.height ?? 0
785
+ height: anchorBox?.height ?? FIELD_HEIGHT[size]
536
786
  }
537
787
  }, "slot");
538
788
  const anchor = /*#__PURE__*/_jsxs(View, {
539
789
  ref: anchorRef,
540
790
  onLayout: measureAnchor,
541
- style: panelOpen ? RAISED : undefined,
791
+ style: RAISED,
542
792
  className: slots.anchor({
543
793
  className: cancel === 'never' ? 'w-full' : 'flex-1'
544
794
  }),
545
795
  children: [panelOpen ? /*#__PURE__*/_jsx(Animated.View, {
546
796
  entering: FadeIn.duration(PANEL_IN),
547
- exiting: FadeOut.duration(PANEL_OUT),
797
+ exiting: FadeOut.duration(PANEL_OUT)
798
+ // `box-none`: the card is a surface, not a target. Only the rows
799
+ // inside it answer a touch, and the space kept for the field lets
800
+ // one through to the field.
801
+ ,
802
+ pointerEvents: "box-none",
548
803
  style: panelPlacement === 'top' ? CARD_ABOVE : CARD_BELOW,
549
804
  className: slots.panel(),
550
805
  children: panelPlacement === 'top' ? [list, divider, fieldSlot] : [fieldSlot, divider, list]
@@ -585,26 +840,32 @@ const SearchBarRoot = /*#__PURE__*/forwardRef(({
585
840
  * other screen.
586
841
  */
587
842
  if (avoidKeyboard) {
588
- return /*#__PURE__*/_jsx(KeyboardAvoider
589
- // Only while *this* field is the one being typed into. Without it
590
- // every avoiding field on the screen lifts the moment any field
591
- // anywhere is tapped, and since they all aim at the same gap above
592
- // the keyboard, they arrive stacked on top of one another.
593
- , {
594
- active: focused,
595
- mode: "lift",
596
- offset: keyboardOffset,
843
+ return /*#__PURE__*/_jsx(SearchBarContext.Provider, {
844
+ value: context,
845
+ children: /*#__PURE__*/_jsx(KeyboardAvoider
846
+ // Only while *this* field is the one being typed into. Without it
847
+ // every avoiding field on the screen lifts the moment any field
848
+ // anywhere is tapped, and since they all aim at the same gap above
849
+ // the keyboard, they arrive stacked on top of one another.
850
+ , {
851
+ active: focused,
852
+ mode: "lift",
853
+ offset: keyboardOffset,
854
+ className: slots.row({
855
+ className: containerClassName
856
+ }),
857
+ children: body
858
+ })
859
+ });
860
+ }
861
+ return /*#__PURE__*/_jsx(SearchBarContext.Provider, {
862
+ value: context,
863
+ children: /*#__PURE__*/_jsx(View, {
597
864
  className: slots.row({
598
865
  className: containerClassName
599
866
  }),
600
867
  children: body
601
- });
602
- }
603
- return /*#__PURE__*/_jsx(View, {
604
- className: slots.row({
605
- className: containerClassName
606
- }),
607
- children: body
868
+ })
608
869
  });
609
870
  });
610
871
  SearchBarRoot.displayName = 'SearchBar';
@@ -637,6 +898,7 @@ function SearchBarItem({
637
898
  description,
638
899
  selected,
639
900
  children,
901
+ onPressIn,
640
902
  ...props
641
903
  }) {
642
904
  const {
@@ -645,12 +907,22 @@ function SearchBarItem({
645
907
  } = searchBarVariants({
646
908
  selected: !!selected
647
909
  });
910
+ const search = useContext(SearchBarContext);
648
911
  return /*#__PURE__*/_jsxs(AnimatedPressable, {
649
912
  accessibilityRole: "button",
650
913
  accessibilityState: {
651
914
  selected: !!selected,
652
915
  disabled: !!props.disabled
653
916
  }
917
+ /*
918
+ * Before the press, not after it: the blur it may cause is what closes
919
+ * the panel this row is drawn in, and by the time `onPress` runs the row
920
+ * can already be gone.
921
+ */,
922
+ onPressIn: event => {
923
+ search?.retainFocus();
924
+ onPressIn?.(event);
925
+ }
654
926
  // A row is a wide target, and a target that shrinks when pressed reads as
655
927
  // a card rather than a line in a list. The dim is the whole feedback.
656
928
  ,
@@ -714,9 +986,82 @@ function SearchBarStatus({
714
986
  });
715
987
  }
716
988
  SearchBarStatus.displayName = 'SearchBar.Status';
989
+ /**
990
+ * A button inside a row — an add, a pin, a remove — for the `trailing` slot.
991
+ *
992
+ * It exists rather than being left to a plain `Pressable` because a control
993
+ * nested inside a row takes the touch itself, so the row above it never sees
994
+ * the press and cannot hold the field's focus on its behalf. Pressed, this one
995
+ * ends up blurring the field, and a blurred field closes the panel the button
996
+ * was standing in — the press lands and the search disappears under it.
997
+ */
998
+ function SearchBarAction({
999
+ className,
1000
+ children,
1001
+ onPressIn,
1002
+ ...props
1003
+ }) {
1004
+ const search = useContext(SearchBarContext);
1005
+ return /*#__PURE__*/_jsx(AnimatedPressable, {
1006
+ accessibilityRole: "button",
1007
+ hitSlop: 12,
1008
+ pressScale: 1,
1009
+ pressOpacity: 0.5,
1010
+ onPressIn: event => {
1011
+ search?.retainFocus();
1012
+ onPressIn?.(event);
1013
+ },
1014
+ ...props,
1015
+ className: className,
1016
+ children: children
1017
+ });
1018
+ }
1019
+ SearchBarAction.displayName = 'SearchBar.Action';
1020
+ /**
1021
+ * One choice already made, drawn inside the field before the caret.
1022
+ *
1023
+ * It sits in the field rather than in a list above it so that the query and
1024
+ * what the query has produced are one control. A search that files its results
1025
+ * somewhere else asks the reader to look in two places to know where they are.
1026
+ */
1027
+ function SearchBarToken({
1028
+ className,
1029
+ leading,
1030
+ onRemove,
1031
+ removeLabel,
1032
+ children,
1033
+ ...props
1034
+ }) {
1035
+ const search = useContext(SearchBarContext);
1036
+ const slots = searchBarVariants({
1037
+ size: search?.size ?? 'md'
1038
+ });
1039
+ const label = typeof children === 'string' ? children : undefined;
1040
+ return /*#__PURE__*/_jsxs(View, {
1041
+ ...props,
1042
+ className: slots.token({
1043
+ className
1044
+ }),
1045
+ children: [leading, /*#__PURE__*/_jsx(Text, {
1046
+ numberOfLines: 1,
1047
+ className: slots.tokenLabel(),
1048
+ children: children
1049
+ }), onRemove ? /*#__PURE__*/_jsx(SearchBarAction, {
1050
+ accessibilityLabel: removeLabel ?? (label ? `Remove ${label}` : 'Remove'),
1051
+ onPress: onRemove,
1052
+ className: slots.tokenRemove(),
1053
+ children: /*#__PURE__*/_jsx(XIcon, {
1054
+ size: 12
1055
+ })
1056
+ }) : null]
1057
+ });
1058
+ }
1059
+ SearchBarToken.displayName = 'SearchBar.Token';
717
1060
  export const SearchBar = Object.assign(SearchBarRoot, {
718
1061
  Section: SearchBarSection,
719
1062
  Item: SearchBarItem,
1063
+ Action: SearchBarAction,
1064
+ Token: SearchBarToken,
720
1065
  Status: SearchBarStatus
721
1066
  });
722
1067
  //# sourceMappingURL=index.js.map