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
@@ -2,14 +2,81 @@
2
2
 
3
3
  /**
4
4
  * SearchBar — a text field for querying a list, with the two controls a search
5
- * needs and an ordinary field does not.
5
+ * needs and an ordinary field does not, and a panel of results that opens out
6
+ * of the field itself.
6
7
  *
7
8
  * ```tsx
8
9
  * <SearchBar placeholder="Search orders" onSubmit={run} />
9
10
  * <SearchBar variant="filled" shape="pill" cancel="focus" />
10
- * <SearchBar debounce={250} onDebouncedChange={filter} loading={pending} />
11
+ * <SearchBar avoidKeyboard value={query} onChangeText={setQuery}>
12
+ * <SearchBar.Section label="Suggested">
13
+ * <SearchBar.Item trailing={<AddButton />} onPress={add}>Claude</SearchBar.Item>
14
+ * </SearchBar.Section>
15
+ * </SearchBar>
11
16
  * ```
12
17
  *
18
+ * ## The results are above the field, and the field is above the keyboard
19
+ *
20
+ * A search that is being typed into has a keyboard under it, and a list drawn
21
+ * below the field is a list drawn behind the keyboard. So `avoidKeyboard`
22
+ * lifts the field until it sits `keyboardOffset` points clear of the keyboard's
23
+ * top edge, and the panel opens *upward* out of it into the space that is
24
+ * actually free.
25
+ *
26
+ * That puts the first result nearest the field and the last one furthest away,
27
+ * which is the order a reader walking away from the caret expects. Pass
28
+ * `panelPlacement="bottom"` for a search bar in a header, where the space is
29
+ * the other way round.
30
+ *
31
+ * The panel is positioned absolutely rather than laid out in the flow, so
32
+ * opening it never moves the page underneath — a list that pushes the field it
33
+ * belongs to is a field that walks away from the finger typing into it.
34
+ *
35
+ * ## Touches inside the panel must not close the keyboard
36
+ *
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.
79
+ *
13
80
  * ## The clear button, and why it is not the platform's
14
81
  *
15
82
  * A ✕ appears inside the field as soon as there is something to clear, and
@@ -45,19 +112,21 @@
45
112
  * pause instead of once per letter. Submitting flushes it immediately, since
46
113
  * a return key is somebody saying they are done waiting.
47
114
  */
48
- import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';
49
- import { View } from 'react-native';
50
- import Animated, { useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated';
115
+ import { Children, createContext, forwardRef, isValidElement, useContext, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
116
+ import { ScrollView, StyleSheet, View, useWindowDimensions } from 'react-native';
117
+ import Animated, { FadeIn, FadeOut, useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated';
51
118
  import { tv } from 'tailwind-variants';
119
+ import { useKeyboard } from "../../hooks/use-keyboard.js";
52
120
  import { SearchIcon, XIcon } from "../../icons/index.js";
53
121
  import { AnimatedPressable } from "../../primitives/animated-pressable.js";
122
+ import { KeyboardAvoider } from "../../primitives/keyboard-avoider.js";
54
123
  import { Text } from "../../primitives/text.js";
55
124
  import { Input } from "../input/index.js";
56
125
  import { Spinner } from "../spinner/index.js";
57
126
  import { cancelSearchBarDebounce, flushSearchBarDebounce, scheduleSearchBarDebounce } from "./search-bar-debounce.js";
58
127
 
59
128
  /** Matches the field's own focus crossfade, so the row settles as one thing. */
60
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
129
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
61
130
  const CANCEL_DURATION = 180;
62
131
 
63
132
  /** Space kept between the field and the Cancel button while it is out. */
@@ -68,10 +137,140 @@ const CANCEL_GAP = 8;
68
137
  * changing the height of the field it sits in.
69
138
  */
70
139
  const CLEAR_HIT_SLOP = 12;
140
+
141
+ /** The panel's crossfade, matching every other anchored list in the library. */
142
+ const PANEL_IN = 140;
143
+ const PANEL_OUT = 120;
144
+
145
+ /** Gap left between the panel's far edge and the edge of the screen. */
146
+ const PANEL_EDGE_GAP = 24;
147
+
148
+ /** Floor for the derived height — below this a list is not worth opening. */
149
+ const PANEL_MIN_HEIGHT = 160;
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
+
203
+ /*
204
+ * The card is pinned to the edge of the field's own slot and grows away from
205
+ * it, so the field's box is the one thing that never moves.
206
+ */
207
+ const CARD_ABOVE = {
208
+ position: 'absolute',
209
+ bottom: 0,
210
+ left: 0,
211
+ right: 0
212
+ };
213
+ const CARD_BELOW = {
214
+ position: 'absolute',
215
+ top: 0,
216
+ left: 0,
217
+ right: 0
218
+ };
219
+
220
+ /*
221
+ * `zIndex` *and* `elevation`, on the field's own box rather than on the card:
222
+ * Android draws siblings in tree order and takes its stacking from elevation,
223
+ * so a card overlapping the content above the field would otherwise be painted
224
+ * under it — and putting it here keeps the field painting over the card, which
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.
238
+ */
239
+ const RAISED = {
240
+ zIndex: 20,
241
+ elevation: 20
242
+ };
71
243
  const searchBarVariants = tv({
72
244
  slots: {
73
245
  row: 'w-full flex-row items-center',
246
+ /*
247
+ * The box the panel is positioned against — the field alone, so the panel
248
+ * is the field's width rather than the row's and does not run out under a
249
+ * Cancel button that is only sometimes there.
250
+ */
251
+ anchor: 'relative',
74
252
  field: '',
253
+ /*
254
+ * One card around the results *and* the field, with the field drawn over
255
+ * the space kept for it at the bottom.
256
+ *
257
+ * It has to be one box because the outline is one outline. Drawn as two —
258
+ * a bordered panel above a bordered field — the field's edge is the focus
259
+ * ring, since a field with a panel open is a field being typed into, and
260
+ * the card ends up with a brighter box welded to a dimmer one.
261
+ *
262
+ * `bg-popover`, not `bg-card`: this floats over the page rather than
263
+ * sitting in it, and a card is one step from the background — close enough
264
+ * that in dark mode the whole thing dissolves into the screen behind it.
265
+ */
266
+ panel: 'overflow-hidden rounded-2xl border border-border bg-popover shadow-lg',
267
+ panelList: 'p-1.5',
268
+ /** The hairline between the results and the field. */
269
+ panelDivider: 'w-full bg-border',
270
+ sectionLabel: 'px-3 pb-1 pt-2 text-sm text-muted-foreground',
271
+ item: 'flex-row items-center gap-3 rounded-lg px-3 py-2.5',
272
+ itemLabel: 'flex-1 text-base text-foreground',
273
+ status: 'flex-row items-center justify-center gap-2 px-3 py-8',
75
274
  // Clipped, because this is what the Cancel button is revealed out of: the
76
275
  // button keeps its measured width and the container's grows past it.
77
276
  //
@@ -81,21 +280,39 @@ const searchBarVariants = tv({
81
280
  cancelClip: 'self-stretch overflow-hidden',
82
281
  cancelButton: 'absolute bottom-0 end-0 top-0 items-center justify-center ps-2',
83
282
  cancelLabel: 'font-medium text-primary',
84
- 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'
85
293
  },
86
294
  variants: {
87
295
  size: {
88
296
  sm: {
89
297
  cancelLabel: 'text-[14px]',
90
- 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'
91
302
  },
92
303
  md: {
93
304
  cancelLabel: 'text-[16px]',
94
- 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'
95
309
  },
96
310
  lg: {
97
311
  cancelLabel: 'text-[16px]',
98
- 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'
99
316
  }
100
317
  },
101
318
  /**
@@ -110,11 +327,31 @@ const searchBarVariants = tv({
110
327
  pill: {
111
328
  field: 'rounded-full'
112
329
  }
330
+ },
331
+ /**
332
+ * Which edge of the field the card grows out of. The field's corners on
333
+ * that edge go square and its border comes off entirely — the card around
334
+ * both of them is what draws the edge.
335
+ */
336
+ attached: {
337
+ none: {},
338
+ top: {
339
+ field: 'rounded-t-none rounded-b-2xl border-0'
340
+ },
341
+ bottom: {
342
+ field: 'rounded-b-none rounded-t-2xl border-0'
343
+ }
344
+ },
345
+ selected: {
346
+ true: {
347
+ item: 'bg-accent'
348
+ }
113
349
  }
114
350
  },
115
351
  defaultVariants: {
116
352
  size: 'md',
117
- shape: 'rounded'
353
+ shape: 'rounded',
354
+ attached: 'none'
118
355
  }
119
356
  });
120
357
  /** Glyph sizes per field size — the icon tracks the text, not the box. */
@@ -123,6 +360,11 @@ const ICON_SIZE = {
123
360
  md: 18,
124
361
  lg: 20
125
362
  };
363
+ const SearchBarContext = /*#__PURE__*/createContext(null);
364
+
365
+ /** Where the results open. */
366
+
367
+ /** When the results are shown. */
126
368
 
127
369
  /**
128
370
  * What SearchBar takes from Input, minus everything it owns itself. The form
@@ -130,9 +372,12 @@ const ICON_SIZE = {
130
372
  * and below the field, and Cancel sits beside the whole stack rather than
131
373
  * beside the field it belongs to. Use `Field` for a search that is one answer
132
374
  * in a form.
375
+ *
376
+ * The keyboard props go too. Input's would move the field and leave the Cancel
377
+ * button and the panel where they were; SearchBar lifts all three together.
133
378
  */
134
379
 
135
- export const SearchBar = /*#__PURE__*/forwardRef(({
380
+ const SearchBarRoot = /*#__PURE__*/forwardRef(({
136
381
  value: valueProp,
137
382
  defaultValue,
138
383
  onChangeText,
@@ -152,8 +397,17 @@ export const SearchBar = /*#__PURE__*/forwardRef(({
152
397
  className,
153
398
  containerClassName,
154
399
  disabled,
400
+ avoidKeyboard = false,
401
+ keyboardOffset = 12,
402
+ panel = 'focus',
403
+ panelPlacement = 'top',
404
+ panelMaxHeight,
405
+ tokens,
406
+ onRemoveLastToken,
407
+ children,
155
408
  onFocus,
156
409
  onBlur,
410
+ onKeyPress,
157
411
  onSubmitEditing,
158
412
  ...props
159
413
  }, ref) => {
@@ -163,9 +417,25 @@ export const SearchBar = /*#__PURE__*/forwardRef(({
163
417
  const [focused, setFocused] = useState(false);
164
418
  const inputRef = useRef(null);
165
419
  useImperativeHandle(ref, () => inputRef.current, []);
420
+
421
+ /*
422
+ * Counted rather than tested for truthiness: `{results.map(…)}` over an
423
+ * empty array is a child, and a panel that opens on nothing is a card of
424
+ * padding.
425
+ */
426
+ const hasPanel = useMemo(() => {
427
+ let found = false;
428
+ Children.forEach(children, child => {
429
+ if (/*#__PURE__*/isValidElement(child)) found = true;
430
+ });
431
+ return found;
432
+ }, [children]);
433
+ const panelOpen = !disabled && panel !== 'never' && hasPanel && (panel === 'always' || focused);
434
+ const attached = panelOpen ? panelPlacement : 'none';
166
435
  const slots = searchBarVariants({
167
436
  size,
168
- shape
437
+ shape,
438
+ attached
169
439
  });
170
440
  const setText = useCallback(next => {
171
441
  if (!controlled) setInternal(next);
@@ -196,14 +466,78 @@ export const SearchBar = /*#__PURE__*/forwardRef(({
196
466
  cancelSearchBarDebounce(debounceTimerRef);
197
467
  };
198
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);
199
507
  const handleFocus = useCallback(event => {
508
+ clearCloseTimer();
509
+ ending.current = false;
200
510
  setFocused(true);
201
511
  onFocus?.(event);
202
- }, [onFocus]);
512
+ }, [clearCloseTimer, onFocus]);
203
513
  const handleBlur = useCallback(event => {
204
- 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.
205
516
  onBlur?.(event);
206
- }, [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]);
207
541
  const handleSubmit = useCallback(event => {
208
542
  if (disabled) return;
209
543
  // A return key is somebody saying they are done waiting, so the
@@ -222,7 +556,12 @@ export const SearchBar = /*#__PURE__*/forwardRef(({
222
556
  }, [disabled, onClear, setText]);
223
557
  const handleCancel = useCallback(() => {
224
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;
225
563
  setText('');
564
+ setFocused(false);
226
565
  inputRef.current?.blur();
227
566
  onCancel?.();
228
567
  }, [disabled, onCancel, setText]);
@@ -249,7 +588,57 @@ export const SearchBar = /*#__PURE__*/forwardRef(({
249
588
  const handleCancelLayout = useCallback(event => {
250
589
  cancelWidth.value = event.nativeEvent.layout.width;
251
590
  }, [cancelWidth]);
252
- const startContent =
591
+
592
+ /*
593
+ * How much room the panel has, in the direction it opens. Measured on the
594
+ * JS side because it decides a layout constraint rather than a frame of an
595
+ * animation: a `maxHeight` that changed every frame would re-lay out the
596
+ * list under the finger scrolling it.
597
+ */
598
+ const {
599
+ height: windowHeight
600
+ } = useWindowDimensions();
601
+ const {
602
+ height: keyboardHeight
603
+ } = useKeyboard();
604
+ useEffect(() => {
605
+ keyboardUp.current = keyboardHeight > 0;
606
+ }, [keyboardHeight]);
607
+ const anchorRef = useRef(null);
608
+ const [anchorBox, setAnchorBox] = useState(null);
609
+ const measureAnchor = useCallback(() => {
610
+ anchorRef.current?.measureInWindow((_x, y, width, height) => {
611
+ setAnchorBox(current => current && current.top === y && current.height === height && current.width === width ? current : {
612
+ top: y,
613
+ height,
614
+ width
615
+ });
616
+ });
617
+ }, []);
618
+ useEffect(() => {
619
+ if (!panelOpen) return;
620
+ measureAnchor();
621
+ }, [panelOpen, keyboardHeight, measureAnchor]);
622
+ const resolvedMaxHeight = useMemo(() => {
623
+ const fieldHeight = anchorBox?.height ?? 0;
624
+ /*
625
+ * While the field is riding the keyboard, where it has come to rest is
626
+ * computed rather than measured: the lift is a transform applied on the
627
+ * UI thread, so a measurement taken from JavaScript is a frame behind it
628
+ * for the whole of the animation.
629
+ */
630
+ const fieldTop = avoidKeyboard && keyboardHeight > 0 ? windowHeight - keyboardHeight - keyboardOffset - fieldHeight : anchorBox?.top ?? 0;
631
+ const room = panelPlacement === 'top' ? fieldTop - PANEL_EDGE_GAP : windowHeight - keyboardHeight - fieldTop - fieldHeight - PANEL_EDGE_GAP;
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);
640
+ }, [anchorBox, avoidKeyboard, keyboardHeight, keyboardOffset, panelMaxHeight, panelPlacement, windowHeight]);
641
+ const glyph =
253
642
  /*#__PURE__*/
254
643
  /*
255
644
  * Decorative, and said so here rather than through Input's
@@ -264,7 +653,27 @@ export const SearchBar = /*#__PURE__*/forwardRef(({
264
653
  children: icon ?? /*#__PURE__*/_jsx(SearchIcon, {
265
654
  size: ICON_SIZE[size]
266
655
  })
267
- });
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;
268
677
  const endContent = loading ? /*#__PURE__*/_jsx(Spinner, {
269
678
  size: "sm"
270
679
  }) : isClearable && text.length > 0 ? /*#__PURE__*/_jsx(AnimatedPressable, {
@@ -285,13 +694,14 @@ export const SearchBar = /*#__PURE__*/forwardRef(({
285
694
  className: slots.field({
286
695
  className
287
696
  }),
288
- containerClassName: cancel === 'never' ? containerClassName : 'flex-1',
697
+ containerClassName: "w-full",
289
698
  startContent: startContent,
290
699
  endContent: endContent,
291
700
  value: text,
292
701
  onChangeText: setText,
293
702
  onFocus: handleFocus,
294
703
  onBlur: handleBlur,
704
+ onKeyPress: handleKeyPress,
295
705
  onSubmitEditing: handleSubmit,
296
706
  accessibilityRole: "search",
297
707
  returnKeyType: "search"
@@ -305,12 +715,98 @@ export const SearchBar = /*#__PURE__*/forwardRef(({
305
715
  placeholder: "Search",
306
716
  ...props
307
717
  });
308
- if (cancel === 'never') return field;
309
- return /*#__PURE__*/_jsxs(View, {
310
- className: slots.row({
311
- className: containerClassName
718
+ const context = useMemo(() => ({
719
+ size,
720
+ retainFocus
721
+ }), [retainFocus, size]);
722
+
723
+ // Nothing beside it and nothing under it: the field is the whole component,
724
+ // and a wrapper around it would only be a box for the caller's layout to
725
+ // fight.
726
+ if (cancel === 'never' && !avoidKeyboard && !hasPanel) {
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
+ })
735
+ });
736
+ }
737
+
738
+ /*
739
+ * The results, the hairline and the room the field occupies, in the order
740
+ * they are stacked. The field itself is drawn over that last piece rather
741
+ * than inside the card: it has to keep its own place in the layout, and a
742
+ * text field that moved into an absolutely positioned box on focus would
743
+ * remount and lose the keyboard it just opened.
744
+ */
745
+ const list = /*#__PURE__*/_jsx(ScrollView, {
746
+ style: {
747
+ maxHeight: resolvedMaxHeight
748
+ }
749
+ /*
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.
754
+ */,
755
+ keyboardShouldPersistTaps: "always",
756
+ keyboardDismissMode: "none",
757
+ showsVerticalScrollIndicator: false,
758
+ bounces: false,
759
+ className: slots.panelList(),
760
+ children: children
761
+ }, "list");
762
+ const divider = /*#__PURE__*/_jsx(View, {
763
+ pointerEvents: "none",
764
+ style: {
765
+ height: StyleSheet.hairlineWidth
766
+ },
767
+ className: slots.panelDivider()
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
+ */
782
+ const fieldSlot = /*#__PURE__*/_jsx(View, {
783
+ pointerEvents: "none",
784
+ style: {
785
+ height: anchorBox?.height ?? FIELD_HEIGHT[size]
786
+ }
787
+ }, "slot");
788
+ const anchor = /*#__PURE__*/_jsxs(View, {
789
+ ref: anchorRef,
790
+ onLayout: measureAnchor,
791
+ style: RAISED,
792
+ className: slots.anchor({
793
+ className: cancel === 'never' ? 'w-full' : 'flex-1'
312
794
  }),
313
- children: [field, /*#__PURE__*/_jsx(Animated.View, {
795
+ children: [panelOpen ? /*#__PURE__*/_jsx(Animated.View, {
796
+ entering: FadeIn.duration(PANEL_IN),
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",
803
+ style: panelPlacement === 'top' ? CARD_ABOVE : CARD_BELOW,
804
+ className: slots.panel(),
805
+ children: panelPlacement === 'top' ? [list, divider, fieldSlot] : [fieldSlot, divider, list]
806
+ }) : null, field]
807
+ });
808
+ const body = cancel === 'never' ? anchor : /*#__PURE__*/_jsxs(_Fragment, {
809
+ children: [anchor, /*#__PURE__*/_jsx(Animated.View, {
314
810
  style: cancelStyle,
315
811
  className: slots.cancelClip(),
316
812
  pointerEvents: cancelOut ? 'auto' : 'none',
@@ -334,6 +830,238 @@ export const SearchBar = /*#__PURE__*/forwardRef(({
334
830
  })
335
831
  })]
336
832
  });
833
+
834
+ /*
835
+ * The keyboard hook is behind a component boundary rather than a flag.
836
+ * Calling it at all has global consequences — without the keyboard
837
+ * controller installed it falls back to Reanimated's useAnimatedKeyboard,
838
+ * which switches Android out of adjustResize for the whole app. A search
839
+ * bar that never asked to avoid the keyboard must not do that to every
840
+ * other screen.
841
+ */
842
+ if (avoidKeyboard) {
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, {
864
+ className: slots.row({
865
+ className: containerClassName
866
+ }),
867
+ children: body
868
+ })
869
+ });
870
+ });
871
+ SearchBarRoot.displayName = 'SearchBar';
872
+ /** A labelled run of rows inside the panel. */
873
+ function SearchBarSection({
874
+ className,
875
+ label,
876
+ children,
877
+ ...props
878
+ }) {
879
+ const {
880
+ sectionLabel
881
+ } = searchBarVariants();
882
+ return /*#__PURE__*/_jsxs(View, {
883
+ ...props,
884
+ className: className,
885
+ children: [label ? /*#__PURE__*/_jsx(Text, {
886
+ accessibilityRole: "header",
887
+ className: sectionLabel(),
888
+ children: label
889
+ }) : null, children]
890
+ });
891
+ }
892
+ SearchBarSection.displayName = 'SearchBar.Section';
893
+ /** One result. */
894
+ function SearchBarItem({
895
+ className,
896
+ leading,
897
+ trailing,
898
+ description,
899
+ selected,
900
+ children,
901
+ onPressIn,
902
+ ...props
903
+ }) {
904
+ const {
905
+ item,
906
+ itemLabel
907
+ } = searchBarVariants({
908
+ selected: !!selected
909
+ });
910
+ const search = useContext(SearchBarContext);
911
+ return /*#__PURE__*/_jsxs(AnimatedPressable, {
912
+ accessibilityRole: "button",
913
+ accessibilityState: {
914
+ selected: !!selected,
915
+ disabled: !!props.disabled
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
+ }
926
+ // A row is a wide target, and a target that shrinks when pressed reads as
927
+ // a card rather than a line in a list. The dim is the whole feedback.
928
+ ,
929
+ pressScale: 1,
930
+ pressOpacity: 0.6
931
+ // The rows sit flush against each other, so the points either side of the
932
+ // gap between two of them would otherwise belong to neither.
933
+ ,
934
+ hitSlop: {
935
+ top: 2,
936
+ bottom: 2
937
+ },
938
+ ...props,
939
+ className: item({
940
+ className
941
+ }),
942
+ children: [leading, /*#__PURE__*/_jsxs(View, {
943
+ className: "flex-1",
944
+ children: [/*#__PURE__*/_jsx(Text, {
945
+ numberOfLines: 1,
946
+ className: itemLabel(),
947
+ children: children
948
+ }), description ? /*#__PURE__*/_jsx(Text, {
949
+ size: "sm",
950
+ muted: true,
951
+ numberOfLines: 1,
952
+ children: description
953
+ }) : null]
954
+ }), trailing]
955
+ });
956
+ }
957
+ SearchBarItem.displayName = 'SearchBar.Item';
958
+ /**
959
+ * The one line a panel shows instead of rows — nothing typed yet, a search in
960
+ * flight, or a query that matched nothing. It is a sentence rather than an
961
+ * empty box because those three states look identical when they are blank, and
962
+ * which one it is decides what the person does next.
963
+ */
964
+ function SearchBarStatus({
965
+ className,
966
+ loading = false,
967
+ children,
968
+ ...props
969
+ }) {
970
+ const {
971
+ status
972
+ } = searchBarVariants();
973
+ return /*#__PURE__*/_jsxs(View, {
974
+ accessibilityRole: "text",
975
+ ...props,
976
+ className: status({
977
+ className
978
+ }),
979
+ children: [loading ? /*#__PURE__*/_jsx(Spinner, {
980
+ size: "sm"
981
+ }) : null, /*#__PURE__*/_jsx(Text, {
982
+ size: "sm",
983
+ muted: true,
984
+ children: children
985
+ })]
986
+ });
987
+ }
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';
1060
+ export const SearchBar = Object.assign(SearchBarRoot, {
1061
+ Section: SearchBarSection,
1062
+ Item: SearchBarItem,
1063
+ Action: SearchBarAction,
1064
+ Token: SearchBarToken,
1065
+ Status: SearchBarStatus
337
1066
  });
338
- SearchBar.displayName = 'SearchBar';
339
1067
  //# sourceMappingURL=index.js.map