panelui-native 0.86.0 → 0.87.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.
@@ -1,13 +1,41 @@
1
1
  /**
2
2
  * SearchBar — a text field for querying a list, with the two controls a search
3
- * needs and an ordinary field does not.
3
+ * needs and an ordinary field does not, and a panel of results that opens out
4
+ * of the field itself.
4
5
  *
5
6
  * ```tsx
6
7
  * <SearchBar placeholder="Search orders" onSubmit={run} />
7
8
  * <SearchBar variant="filled" shape="pill" cancel="focus" />
8
- * <SearchBar debounce={250} onDebouncedChange={filter} loading={pending} />
9
+ * <SearchBar avoidKeyboard value={query} onChangeText={setQuery}>
10
+ * <SearchBar.Section label="Suggested">
11
+ * <SearchBar.Item trailing={<AddButton />} onPress={add}>Claude</SearchBar.Item>
12
+ * </SearchBar.Section>
13
+ * </SearchBar>
9
14
  * ```
10
15
  *
16
+ * ## The results are above the field, and the field is above the keyboard
17
+ *
18
+ * A search that is being typed into has a keyboard under it, and a list drawn
19
+ * below the field is a list drawn behind the keyboard. So `avoidKeyboard`
20
+ * lifts the field until it sits `keyboardOffset` points clear of the keyboard's
21
+ * top edge, and the panel opens *upward* out of it into the space that is
22
+ * actually free.
23
+ *
24
+ * That puts the first result nearest the field and the last one furthest away,
25
+ * which is the order a reader walking away from the caret expects. Pass
26
+ * `panelPlacement="bottom"` for a search bar in a header, where the space is
27
+ * the other way round.
28
+ *
29
+ * The panel is positioned absolutely rather than laid out in the flow, so
30
+ * opening it never moves the page underneath — a list that pushes the field it
31
+ * belongs to is a field that walks away from the finger typing into it.
32
+ *
33
+ * ## Touches inside the panel must not close the keyboard
34
+ *
35
+ * The panel scrolls with `keyboardShouldPersistTaps="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.
38
+ *
11
39
  * ## The clear button, and why it is not the platform's
12
40
  *
13
41
  * A ✕ appears inside the field as soon as there is something to clear, and
@@ -44,29 +72,44 @@
44
72
  * a return key is somebody saying they are done waiting.
45
73
  */
46
74
  import {
75
+ Children,
47
76
  forwardRef,
77
+ isValidElement,
48
78
  useCallback,
49
79
  useEffect,
50
80
  useImperativeHandle,
81
+ useMemo,
51
82
  useRef,
52
83
  useState,
53
84
  type ReactNode,
54
85
  } from 'react';
55
86
  import {
87
+ ScrollView,
88
+ StyleSheet,
56
89
  View,
90
+ useWindowDimensions,
57
91
  type LayoutChangeEvent,
58
92
  type NativeSyntheticEvent,
59
93
  type TextInput,
60
94
  type TextInputSubmitEditingEventData,
95
+ type ViewProps,
96
+ type ViewStyle,
61
97
  } from 'react-native';
62
98
  import Animated, {
99
+ FadeIn,
100
+ FadeOut,
63
101
  useAnimatedStyle,
64
102
  useSharedValue,
65
103
  withTiming,
66
104
  } from 'react-native-reanimated';
67
105
  import { tv, type VariantProps } from 'tailwind-variants';
106
+ import { useKeyboard } from '../../hooks/use-keyboard';
68
107
  import { SearchIcon, XIcon } from '../../icons';
69
- import { AnimatedPressable } from '../../primitives/animated-pressable';
108
+ import {
109
+ AnimatedPressable,
110
+ type AnimatedPressableProps,
111
+ } from '../../primitives/animated-pressable';
112
+ import { KeyboardAvoider } from '../../primitives/keyboard-avoider';
70
113
  import { Text } from '../../primitives/text';
71
114
  import { Input, type InputProps } from '../input';
72
115
  import { Spinner } from '../spinner';
@@ -89,10 +132,63 @@ const CANCEL_GAP = 8;
89
132
  */
90
133
  const CLEAR_HIT_SLOP = 12;
91
134
 
135
+ /** The panel's crossfade, matching every other anchored list in the library. */
136
+ const PANEL_IN = 140;
137
+ const PANEL_OUT = 120;
138
+
139
+ /** Gap left between the panel's far edge and the edge of the screen. */
140
+ const PANEL_EDGE_GAP = 24;
141
+
142
+ /** Floor for the derived height — below this a list is not worth opening. */
143
+ const PANEL_MIN_HEIGHT = 160;
144
+
145
+ /*
146
+ * The card is pinned to the edge of the field's own slot and grows away from
147
+ * it, so the field's box is the one thing that never moves.
148
+ */
149
+ const CARD_ABOVE: ViewStyle = { position: 'absolute', bottom: 0, left: 0, right: 0 };
150
+ const CARD_BELOW: ViewStyle = { position: 'absolute', top: 0, left: 0, right: 0 };
151
+
152
+ /*
153
+ * `zIndex` *and* `elevation`, on the field's own box rather than on the card:
154
+ * Android draws siblings in tree order and takes its stacking from elevation,
155
+ * so a card overlapping the content above the field would otherwise be painted
156
+ * under it — and putting it here keeps the field painting over the card, which
157
+ * is what lets the two be one surface.
158
+ */
159
+ const RAISED: ViewStyle = { zIndex: 20, elevation: 20 };
160
+
92
161
  const searchBarVariants = tv({
93
162
  slots: {
94
163
  row: 'w-full flex-row items-center',
164
+ /*
165
+ * The box the panel is positioned against — the field alone, so the panel
166
+ * is the field's width rather than the row's and does not run out under a
167
+ * Cancel button that is only sometimes there.
168
+ */
169
+ anchor: 'relative',
95
170
  field: '',
171
+ /*
172
+ * One card around the results *and* the field, with the field drawn over
173
+ * the space kept for it at the bottom.
174
+ *
175
+ * It has to be one box because the outline is one outline. Drawn as two —
176
+ * a bordered panel above a bordered field — the field's edge is the focus
177
+ * ring, since a field with a panel open is a field being typed into, and
178
+ * the card ends up with a brighter box welded to a dimmer one.
179
+ *
180
+ * `bg-popover`, not `bg-card`: this floats over the page rather than
181
+ * sitting in it, and a card is one step from the background — close enough
182
+ * that in dark mode the whole thing dissolves into the screen behind it.
183
+ */
184
+ panel: 'overflow-hidden rounded-2xl border border-border bg-popover shadow-lg',
185
+ panelList: 'p-1.5',
186
+ /** The hairline between the results and the field. */
187
+ panelDivider: 'w-full bg-border',
188
+ sectionLabel: 'px-3 pb-1 pt-2 text-sm text-muted-foreground',
189
+ item: 'flex-row items-center gap-3 rounded-lg px-3 py-2.5',
190
+ itemLabel: 'flex-1 text-base text-foreground',
191
+ status: 'flex-row items-center justify-center gap-2 px-3 py-8',
96
192
  // Clipped, because this is what the Cancel button is revealed out of: the
97
193
  // button keeps its measured width and the container's grows past it.
98
194
  //
@@ -119,10 +215,24 @@ const searchBarVariants = tv({
119
215
  rounded: { field: '' },
120
216
  pill: { field: 'rounded-full' },
121
217
  },
218
+ /**
219
+ * Which edge of the field the card grows out of. The field's corners on
220
+ * that edge go square and its border comes off entirely — the card around
221
+ * both of them is what draws the edge.
222
+ */
223
+ attached: {
224
+ none: {},
225
+ top: { field: 'rounded-t-none rounded-b-2xl border-0' },
226
+ bottom: { field: 'rounded-b-none rounded-t-2xl border-0' },
227
+ },
228
+ selected: {
229
+ true: { item: 'bg-accent' },
230
+ },
122
231
  },
123
232
  defaultVariants: {
124
233
  size: 'md',
125
234
  shape: 'rounded',
235
+ attached: 'none',
126
236
  },
127
237
  });
128
238
 
@@ -131,21 +241,34 @@ type SearchBarVariantProps = VariantProps<typeof searchBarVariants>;
131
241
  /** Glyph sizes per field size — the icon tracks the text, not the box. */
132
242
  const ICON_SIZE = { sm: 16, md: 18, lg: 20 } as const;
133
243
 
244
+ /** Where the results open. */
245
+ export type SearchBarPanelPlacement = 'top' | 'bottom';
246
+
247
+ /** When the results are shown. */
248
+ export type SearchBarPanelMode = 'never' | 'focus' | 'always';
249
+
134
250
  /**
135
251
  * What SearchBar takes from Input, minus everything it owns itself. The form
136
252
  * furniture is dropped along with it: a label and an error line stack above
137
253
  * and below the field, and Cancel sits beside the whole stack rather than
138
254
  * beside the field it belongs to. Use `Field` for a search that is one answer
139
255
  * in a form.
256
+ *
257
+ * The keyboard props go too. Input's would move the field and leave the Cancel
258
+ * button and the panel where they were; SearchBar lifts all three together.
140
259
  */
141
260
  type InheritedInputProps = Omit<
142
261
  InputProps,
262
+ | 'avoidKeyboard'
143
263
  | 'defaultValue'
144
264
  | 'description'
145
265
  | 'endContent'
146
266
  | 'errorMessage'
147
267
  | 'interactiveContent'
148
268
  | 'isRequired'
269
+ | 'keyboardBottomInset'
270
+ | 'keyboardMode'
271
+ | 'keyboardOffset'
149
272
  | 'label'
150
273
  | 'multiline'
151
274
  | 'onChangeText'
@@ -154,7 +277,9 @@ type InheritedInputProps = Omit<
154
277
  | 'value'
155
278
  >;
156
279
 
157
- export interface SearchBarProps extends InheritedInputProps, SearchBarVariantProps {
280
+ export interface SearchBarProps
281
+ extends InheritedInputProps,
282
+ Omit<SearchBarVariantProps, 'attached' | 'selected'> {
158
283
  /**
159
284
  * The field's background, from `Input`. `outline` draws its own edge, for a
160
285
  * search bar sitting on the page; `filled` drops it, for one inside a card
@@ -202,9 +327,42 @@ export interface SearchBarProps extends InheritedInputProps, SearchBarVariantPro
202
327
  loading?: boolean;
203
328
  /** The leading glyph, for a search over something with a symbol of its own. */
204
329
  icon?: ReactNode;
330
+ /**
331
+ * Lift the whole search — field, Cancel button and panel — until it sits
332
+ * clear of the software keyboard, and put it back on blur. Without it the
333
+ * field stays where the page left it, which on most screens is behind the
334
+ * keyboard it just opened.
335
+ *
336
+ * Install `react-native-keyboard-controller` for this to behave on Android.
337
+ *
338
+ * Do not toggle it at runtime: it changes which component wraps the row, so
339
+ * the field would remount and lose focus.
340
+ */
341
+ avoidKeyboard?: boolean;
342
+ /** Gap kept between the field's bottom edge and the keyboard. */
343
+ keyboardOffset?: number;
344
+ /**
345
+ * When the results panel is shown. `focus` opens it while the field is being
346
+ * typed into, `always` keeps it out for a screen that is nothing but the
347
+ * search, `never` ignores the children entirely.
348
+ */
349
+ panel?: SearchBarPanelMode;
350
+ /**
351
+ * Which side of the field the panel opens out of. `top` is the default,
352
+ * because the space under a focused field belongs to the keyboard.
353
+ */
354
+ panelPlacement?: SearchBarPanelPlacement;
355
+ /**
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.
359
+ */
360
+ panelMaxHeight?: number;
361
+ /** The panel's contents — `SearchBar.Section`, `.Item` and `.Status`. */
362
+ children?: ReactNode;
205
363
  }
206
364
 
207
- export const SearchBar = forwardRef<TextInput, SearchBarProps>(
365
+ const SearchBarRoot = forwardRef<TextInput, SearchBarProps>(
208
366
  (
209
367
  {
210
368
  value: valueProp,
@@ -226,6 +384,12 @@ export const SearchBar = forwardRef<TextInput, SearchBarProps>(
226
384
  className,
227
385
  containerClassName,
228
386
  disabled,
387
+ avoidKeyboard = false,
388
+ keyboardOffset = 12,
389
+ panel = 'focus',
390
+ panelPlacement = 'top',
391
+ panelMaxHeight,
392
+ children,
229
393
  onFocus,
230
394
  onBlur,
231
395
  onSubmitEditing,
@@ -241,7 +405,27 @@ export const SearchBar = forwardRef<TextInput, SearchBarProps>(
241
405
  const inputRef = useRef<TextInput | null>(null);
242
406
  useImperativeHandle(ref, () => inputRef.current as TextInput, []);
243
407
 
244
- const slots = searchBarVariants({ size, shape });
408
+ /*
409
+ * Counted rather than tested for truthiness: `{results.map(…)}` over an
410
+ * empty array is a child, and a panel that opens on nothing is a card of
411
+ * padding.
412
+ */
413
+ const hasPanel = useMemo(() => {
414
+ let found = false;
415
+ Children.forEach(children, (child) => {
416
+ if (isValidElement(child)) found = true;
417
+ });
418
+ return found;
419
+ }, [children]);
420
+
421
+ const panelOpen =
422
+ !disabled &&
423
+ panel !== 'never' &&
424
+ hasPanel &&
425
+ (panel === 'always' || focused);
426
+
427
+ const attached = panelOpen ? panelPlacement : 'none';
428
+ const slots = searchBarVariants({ size, shape, attached });
245
429
 
246
430
  const setText = useCallback(
247
431
  (next: string) => {
@@ -349,6 +533,62 @@ export const SearchBar = forwardRef<TextInput, SearchBarProps>(
349
533
  [cancelWidth]
350
534
  );
351
535
 
536
+ /*
537
+ * How much room the panel has, in the direction it opens. Measured on the
538
+ * JS side because it decides a layout constraint rather than a frame of an
539
+ * animation: a `maxHeight` that changed every frame would re-lay out the
540
+ * list under the finger scrolling it.
541
+ */
542
+ const { height: windowHeight } = useWindowDimensions();
543
+ const { height: keyboardHeight } = useKeyboard();
544
+ const anchorRef = useRef<View | null>(null);
545
+ const [anchorBox, setAnchorBox] = useState<{ top: number; height: number } | null>(
546
+ null
547
+ );
548
+
549
+ const measureAnchor = useCallback(() => {
550
+ anchorRef.current?.measureInWindow((_x, y, _width, height) => {
551
+ setAnchorBox((current) =>
552
+ current && current.top === y && current.height === height
553
+ ? current
554
+ : { top: y, height }
555
+ );
556
+ });
557
+ }, []);
558
+
559
+ useEffect(() => {
560
+ if (!panelOpen) return;
561
+ measureAnchor();
562
+ }, [panelOpen, keyboardHeight, measureAnchor]);
563
+
564
+ const resolvedMaxHeight = useMemo(() => {
565
+ if (panelMaxHeight !== undefined) return panelMaxHeight;
566
+ const fieldHeight = anchorBox?.height ?? 0;
567
+ /*
568
+ * While the field is riding the keyboard, where it has come to rest is
569
+ * computed rather than measured: the lift is a transform applied on the
570
+ * UI thread, so a measurement taken from JavaScript is a frame behind it
571
+ * for the whole of the animation.
572
+ */
573
+ const fieldTop =
574
+ avoidKeyboard && keyboardHeight > 0
575
+ ? windowHeight - keyboardHeight - keyboardOffset - fieldHeight
576
+ : (anchorBox?.top ?? 0);
577
+ const room =
578
+ panelPlacement === 'top'
579
+ ? fieldTop - PANEL_EDGE_GAP
580
+ : windowHeight - keyboardHeight - fieldTop - fieldHeight - PANEL_EDGE_GAP;
581
+ return Math.max(room, PANEL_MIN_HEIGHT);
582
+ }, [
583
+ anchorBox,
584
+ avoidKeyboard,
585
+ keyboardHeight,
586
+ keyboardOffset,
587
+ panelMaxHeight,
588
+ panelPlacement,
589
+ windowHeight,
590
+ ]);
591
+
352
592
  const startContent = (
353
593
  /*
354
594
  * Decorative, and said so here rather than through Input's
@@ -386,7 +626,7 @@ export const SearchBar = forwardRef<TextInput, SearchBarProps>(
386
626
  size={size}
387
627
  disabled={disabled}
388
628
  className={slots.field({ className })}
389
- containerClassName={cancel === 'never' ? containerClassName : 'flex-1'}
629
+ containerClassName="w-full"
390
630
  startContent={startContent}
391
631
  endContent={endContent}
392
632
  value={text}
@@ -407,41 +647,259 @@ export const SearchBar = forwardRef<TextInput, SearchBarProps>(
407
647
  />
408
648
  );
409
649
 
410
- if (cancel === 'never') return field;
650
+ // Nothing beside it and nothing under it: the field is the whole component,
651
+ // and a wrapper around it would only be a box for the caller's layout to
652
+ // fight.
653
+ if (cancel === 'never' && !avoidKeyboard && !hasPanel) {
654
+ return <View className={containerClassName}>{field}</View>;
655
+ }
411
656
 
412
- return (
413
- <View className={slots.row({ className: containerClassName })}>
414
- {field}
415
- <Animated.View
416
- style={cancelStyle}
417
- className={slots.cancelClip()}
418
- pointerEvents={cancelOut ? 'auto' : 'none'}
419
- accessibilityElementsHidden={!cancelOut}
420
- importantForAccessibility={cancelOut ? 'auto' : 'no-hide-descendants'}
421
- >
422
- {/*
423
- * Absolute, and pinned to the end edge: it keeps its natural width
424
- * inside a container whose width is animating, so the clip reveals
425
- * it from the edge instead of squeezing the word as it arrives. It
426
- * is also what makes the measurement possible at all — a child laid
427
- * out against a container of width 0 would otherwise report 0.
428
- */}
429
- <AnimatedPressable
430
- onLayout={handleCancelLayout}
431
- onPress={handleCancel}
432
- disabled={disabled}
433
- focusable={!disabled}
434
- hitSlop={8}
435
- accessibilityRole="button"
436
- accessibilityState={{ disabled: !!disabled }}
437
- className={slots.cancelButton()}
657
+ /*
658
+ * The results, the hairline and the room the field occupies, in the order
659
+ * they are stacked. The field itself is drawn over that last piece rather
660
+ * than inside the card: it has to keep its own place in the layout, and a
661
+ * text field that moved into an absolutely positioned box on focus would
662
+ * remount and lose the keyboard it just opened.
663
+ */
664
+ const list = (
665
+ <ScrollView
666
+ key="list"
667
+ style={{ maxHeight: resolvedMaxHeight }}
668
+ /*
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.
672
+ */
673
+ keyboardShouldPersistTaps="handled"
674
+ keyboardDismissMode="none"
675
+ showsVerticalScrollIndicator={false}
676
+ bounces={false}
677
+ className={slots.panelList()}
678
+ >
679
+ {children}
680
+ </ScrollView>
681
+ );
682
+ const divider = (
683
+ <View
684
+ key="divider"
685
+ style={{ height: StyleSheet.hairlineWidth }}
686
+ className={slots.panelDivider()}
687
+ />
688
+ );
689
+ const fieldSlot = <View key="slot" style={{ height: anchorBox?.height ?? 0 }} />;
690
+
691
+ const anchor = (
692
+ <View
693
+ ref={anchorRef}
694
+ onLayout={measureAnchor}
695
+ style={panelOpen ? RAISED : undefined}
696
+ className={slots.anchor({ className: cancel === 'never' ? 'w-full' : 'flex-1' })}
697
+ >
698
+ {panelOpen ? (
699
+ <Animated.View
700
+ entering={FadeIn.duration(PANEL_IN)}
701
+ exiting={FadeOut.duration(PANEL_OUT)}
702
+ style={panelPlacement === 'top' ? CARD_ABOVE : CARD_BELOW}
703
+ className={slots.panel()}
438
704
  >
439
- <Text className={slots.cancelLabel()}>{cancelLabel}</Text>
440
- </AnimatedPressable>
441
- </Animated.View>
705
+ {panelPlacement === 'top'
706
+ ? [list, divider, fieldSlot]
707
+ : [fieldSlot, divider, list]}
708
+ </Animated.View>
709
+ ) : null}
710
+ {field}
442
711
  </View>
443
712
  );
713
+
714
+ const body =
715
+ cancel === 'never' ? (
716
+ anchor
717
+ ) : (
718
+ <>
719
+ {anchor}
720
+ <Animated.View
721
+ style={cancelStyle}
722
+ className={slots.cancelClip()}
723
+ pointerEvents={cancelOut ? 'auto' : 'none'}
724
+ accessibilityElementsHidden={!cancelOut}
725
+ importantForAccessibility={cancelOut ? 'auto' : 'no-hide-descendants'}
726
+ >
727
+ {/*
728
+ * Absolute, and pinned to the end edge: it keeps its natural width
729
+ * inside a container whose width is animating, so the clip reveals
730
+ * it from the edge instead of squeezing the word as it arrives. It
731
+ * is also what makes the measurement possible at all — a child laid
732
+ * out against a container of width 0 would otherwise report 0.
733
+ */}
734
+ <AnimatedPressable
735
+ onLayout={handleCancelLayout}
736
+ onPress={handleCancel}
737
+ disabled={disabled}
738
+ focusable={!disabled}
739
+ hitSlop={8}
740
+ accessibilityRole="button"
741
+ accessibilityState={{ disabled: !!disabled }}
742
+ className={slots.cancelButton()}
743
+ >
744
+ <Text className={slots.cancelLabel()}>{cancelLabel}</Text>
745
+ </AnimatedPressable>
746
+ </Animated.View>
747
+ </>
748
+ );
749
+
750
+ /*
751
+ * The keyboard hook is behind a component boundary rather than a flag.
752
+ * Calling it at all has global consequences — without the keyboard
753
+ * controller installed it falls back to Reanimated's useAnimatedKeyboard,
754
+ * which switches Android out of adjustResize for the whole app. A search
755
+ * bar that never asked to avoid the keyboard must not do that to every
756
+ * other screen.
757
+ */
758
+ if (avoidKeyboard) {
759
+ 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>
772
+ );
773
+ }
774
+
775
+ return <View className={slots.row({ className: containerClassName })}>{body}</View>;
444
776
  }
445
777
  );
446
778
 
447
- SearchBar.displayName = 'SearchBar';
779
+ SearchBarRoot.displayName = 'SearchBar';
780
+
781
+ export interface SearchBarSectionProps extends ViewProps {
782
+ className?: string;
783
+ /**
784
+ * The heading over the run of rows — "Suggested", "Results". Announced as a
785
+ * header, so a screen reader reaching the group is told what it is before
786
+ * walking into it.
787
+ */
788
+ label?: string;
789
+ children?: ReactNode;
790
+ }
791
+
792
+ /** A labelled run of rows inside the panel. */
793
+ function SearchBarSection({ className, label, children, ...props }: SearchBarSectionProps) {
794
+ const { sectionLabel } = searchBarVariants();
795
+ return (
796
+ <View {...props} className={className}>
797
+ {label ? (
798
+ <Text accessibilityRole="header" className={sectionLabel()}>
799
+ {label}
800
+ </Text>
801
+ ) : null}
802
+ {children}
803
+ </View>
804
+ );
805
+ }
806
+
807
+ SearchBarSection.displayName = 'SearchBar.Section';
808
+
809
+ export interface SearchBarItemProps extends Omit<AnimatedPressableProps, 'children'> {
810
+ className?: string;
811
+ /** Anything before the label — an avatar, a logo, a status dot. */
812
+ leading?: ReactNode;
813
+ /**
814
+ * Anything after it. A slot rather than a built-in button, because what a
815
+ * result row offers differs per search: an add, a pin, a count, nothing.
816
+ */
817
+ trailing?: ReactNode;
818
+ /** A second line under the label, for what the label alone cannot say. */
819
+ description?: string;
820
+ /** Draws the row as the one the search has settled on. */
821
+ selected?: boolean;
822
+ /** The row's label. */
823
+ children?: ReactNode;
824
+ }
825
+
826
+ /** One result. */
827
+ function SearchBarItem({
828
+ className,
829
+ leading,
830
+ trailing,
831
+ description,
832
+ selected,
833
+ children,
834
+ ...props
835
+ }: SearchBarItemProps) {
836
+ const { item, itemLabel } = searchBarVariants({ selected: !!selected });
837
+ return (
838
+ <AnimatedPressable
839
+ accessibilityRole="button"
840
+ accessibilityState={{ selected: !!selected, disabled: !!props.disabled }}
841
+ // A row is a wide target, and a target that shrinks when pressed reads as
842
+ // a card rather than a line in a list. The dim is the whole feedback.
843
+ pressScale={1}
844
+ pressOpacity={0.6}
845
+ // The rows sit flush against each other, so the points either side of the
846
+ // gap between two of them would otherwise belong to neither.
847
+ hitSlop={{ top: 2, bottom: 2 }}
848
+ {...props}
849
+ className={item({ className })}
850
+ >
851
+ {leading}
852
+ <View className="flex-1">
853
+ <Text numberOfLines={1} className={itemLabel()}>
854
+ {children}
855
+ </Text>
856
+ {description ? (
857
+ <Text size="sm" muted numberOfLines={1}>
858
+ {description}
859
+ </Text>
860
+ ) : null}
861
+ </View>
862
+ {trailing}
863
+ </AnimatedPressable>
864
+ );
865
+ }
866
+
867
+ SearchBarItem.displayName = 'SearchBar.Item';
868
+
869
+ export interface SearchBarStatusProps extends ViewProps {
870
+ className?: string;
871
+ /** A spinner beside the line, for a search that is still running. */
872
+ loading?: boolean;
873
+ children?: ReactNode;
874
+ }
875
+
876
+ /**
877
+ * The one line a panel shows instead of rows — nothing typed yet, a search in
878
+ * flight, or a query that matched nothing. It is a sentence rather than an
879
+ * empty box because those three states look identical when they are blank, and
880
+ * which one it is decides what the person does next.
881
+ */
882
+ function SearchBarStatus({
883
+ className,
884
+ loading = false,
885
+ children,
886
+ ...props
887
+ }: SearchBarStatusProps) {
888
+ const { status } = searchBarVariants();
889
+ return (
890
+ <View accessibilityRole="text" {...props} className={status({ className })}>
891
+ {loading ? <Spinner size="sm" /> : null}
892
+ <Text size="sm" muted>
893
+ {children}
894
+ </Text>
895
+ </View>
896
+ );
897
+ }
898
+
899
+ SearchBarStatus.displayName = 'SearchBar.Status';
900
+
901
+ export const SearchBar = Object.assign(SearchBarRoot, {
902
+ Section: SearchBarSection,
903
+ Item: SearchBarItem,
904
+ Status: SearchBarStatus,
905
+ });