/**
* SearchBar — a text field for querying a list, with the two controls a search
* needs and an ordinary field does not, and a panel of results that opens out
* of the field itself.
*
* ```tsx
*
*
*
*
* } onPress={add}>Claude
*
*
* ```
*
* ## The results are above the field, and the field is above the keyboard
*
* A search that is being typed into has a keyboard under it, and a list drawn
* below the field is a list drawn behind the keyboard. So `avoidKeyboard`
* lifts the field until it sits `keyboardOffset` points clear of the keyboard's
* top edge, and the panel opens *upward* out of it into the space that is
* actually free.
*
* That puts the first result nearest the field and the last one furthest away,
* which is the order a reader walking away from the caret expects. Pass
* `panelPlacement="bottom"` for a search bar in a header, where the space is
* the other way round.
*
* The panel is positioned absolutely rather than laid out in the flow, so
* opening it never moves the page underneath — a list that pushes the field it
* belongs to is a field that walks away from the finger typing into it.
*
* ## Touches inside the panel must not close the keyboard
*
* The panel scrolls with `keyboardShouldPersistTaps="always"`, and every press
* inside it holds the field's focus open for a moment afterwards. Both are
* needed, because a search closes the instant the field blurs and there are
* two separate ways for a touch in the panel to blur it.
*
* `"handled"` only spares presses a child takes responsibility for, which
* leaves the panel's own padding, the gaps between rows, a section heading and
* the whole of `SearchBar.Status` as live dismiss surfaces — tapping the word
* "Searching …" would end the search. `"always"` gives the panel back.
*
* The focus guard covers the other way: a control inside a row — an add
* button, a remove ✕ — takes focus with the press on Android, and returning it
* a frame later is not enough on its own, because the blur has already closed
* the panel the control was drawn in. So a press in the panel marks the field
* as still being used, and a blur arriving under that mark is answered by
* asking for focus back rather than by ending the search.
*
* That guard only knows about presses that go through this component's own
* parts, and a caller's `Pressable` in a row's `trailing` slot takes the touch
* itself. So the panel also waits before believing any blur, and asks the
* keyboard: it is still up, because nothing in the panel dismisses it, and a
* search whose keyboard is still up has not ended. Focus goes back instead.
*
* ## The space kept for the field is not a target
*
* The card is one box around the results *and* the field, so it carries a
* spacer where the field sits. That spacer is a plain view drawn over a
* focused field, and a touch on a plain view is the platform's cue to dismiss
* the keyboard — so winning one blurred the field and closed the panel drawn
* out of that focus. Tapping the search box shut the results, which is exactly
* backwards. The card and its spacer take no touches at all now.
*
* ## What has already been picked goes in the field
*
* `tokens` puts the choices made so far inside the field, before the caret, so
* the query and what it has produced are one control rather than a control and
* a list somewhere above it. `SearchBar.Token` is the chip; backspace on an
* empty field fires `onRemoveLastToken`, which is what a token field does
* everywhere else.
*
* They scroll rather than wrap: the field is one line tall, and a row of chips
* that grew it would move the caret every time something was picked.
*
* ## The clear button, and why it is not the platform's
*
* A ✕ appears inside the field as soon as there is something to clear, and
* takes it back to empty without dismissing the keyboard — clearing a query is
* the start of the next one, not the end of the search. It is drawn here
* rather than left to `clearButtonMode`, which exists on iOS only, cannot be
* labelled for a screen reader and cannot be swapped for a spinner while
* results are in flight.
*
* The glyph is 24 points and its touch box is 48, made up with slop rather
* than with size. A 48-point circle inside a 40-point field either overflows
* it or forces every search bar in an app to be as tall as the largest one.
*
* ## Cancel is a row, not a decoration
*
* `cancel="focus"` puts a Cancel button beside the field and slides it in
* while the field is being edited, which is the platform's own answer to
* "how do I get out of this search". It is a sibling of the field rather than
* something inside it, because it acts on the search as a whole: it empties
* the query, drops focus and calls `onCancel`, and a control that ends the
* thing it sits inside reads as part of the query it is about to discard.
*
* Its width is measured once and animated on the UI thread. The button is
* always mounted when `cancel` is not `never`, so the measurement is already
* there the first time the field is touched and the first slide is as smooth
* as the tenth.
*
* ## Debouncing belongs to the caller's search, not to the field
*
* `onChangeText` always fires on every keystroke — a controlled field that
* lags its own input is unusable. `debounce` is about the *query*: it holds
* `onDebouncedChange` until typing pauses, so a network search runs once per
* pause instead of once per letter. Submitting flushes it immediately, since
* a return key is somebody saying they are done waiting.
*/
import {
Children,
createContext,
forwardRef,
isValidElement,
useContext,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
type ReactNode,
} from 'react';
import {
ScrollView,
StyleSheet,
TextInput,
View,
useWindowDimensions,
type LayoutChangeEvent,
type NativeSyntheticEvent,
type TextInputKeyPressEventData,
type TextInputSubmitEditingEventData,
type ViewProps,
type ViewStyle,
} from 'react-native';
import Animated, {
FadeIn,
FadeOut,
useAnimatedStyle,
useSharedValue,
withTiming,
} from 'react-native-reanimated';
import { tv, type VariantProps } from 'tailwind-variants';
import { useKeyboard } from '../../hooks/use-keyboard';
import { SearchIcon, XIcon } from '../../icons';
import {
AnimatedPressable,
type AnimatedPressableProps,
} from '../../primitives/animated-pressable';
import { KeyboardAvoider } from '../../primitives/keyboard-avoider';
import { Text } from '../../primitives/text';
import { Input, type InputProps } from '../input';
import { Spinner } from '../spinner';
import {
cancelSearchBarDebounce,
flushSearchBarDebounce,
scheduleSearchBarDebounce,
type SearchBarDebounceTimer,
} from './search-bar-debounce';
/** Matches the field's own focus crossfade, so the row settles as one thing. */
const CANCEL_DURATION = 180;
/** Space kept between the field and the Cancel button while it is out. */
const CANCEL_GAP = 8;
/**
* Slop around the 24-point clear glyph, taking its touch box to 48 without
* changing the height of the field it sits in.
*/
const CLEAR_HIT_SLOP = 12;
/** The panel's crossfade, matching every other anchored list in the library. */
const PANEL_IN = 140;
const PANEL_OUT = 120;
/** Gap left between the panel's far edge and the edge of the screen. */
const PANEL_EDGE_GAP = 24;
/** Floor for the derived height — below this a list is not worth opening. */
const PANEL_MIN_HEIGHT = 160;
/**
* Ceiling for the derived height, in points. About six rows and a heading.
*
* The room above a lifted field is most of the screen, and a panel that takes
* all of it is a full-screen list that happens to have a search box under it —
* the page it was opened over stops being visible, so the search stops reading
* as something laid over the app. Past this the results scroll, which is what
* a long list should do anyway.
*
* `panelMaxHeight` overrides it for a screen that really is nothing but the
* search.
*/
const PANEL_MAX_HEIGHT = 320;
/**
* The field's height per size, matching `Input`'s own `h-10 / h-12 / h-14`.
*
* It is the fallback for the slot the panel keeps for the field, which is
* otherwise the measured height and therefore zero on the frame the panel
* first opens. A zero slot puts the card's bottom edge at the field's, so the
* last row is drawn underneath the field — which is painted after the card and
* takes the touch. The press then reads as a tap on the input.
*/
const FIELD_HEIGHT = { sm: 40, md: 48, lg: 56 } as const;
/**
* How long a press inside the panel keeps the field's focus. Long enough to
* cover the blur Android sends with the press and the re-render that follows
* it, short enough that a real dismissal is never held open.
*/
const FOCUS_GUARD = 400;
/**
* How long the panel waits after a blur before believing the search is over.
*
* The guard above only covers presses that go through this component's own
* parts. A caller's own `Pressable` in a row's `trailing` slot takes the touch
* itself and this component never hears about it — so the catch-all is the
* keyboard: it is still up, because nothing in the panel dismisses it, and a
* search whose keyboard is still up has not ended. The pause is long enough
* for focus to come back on its own and short enough that a real dismissal
* does not leave the panel hanging.
*/
const BLUR_GRACE = 220;
/** Share of the field a row of tokens may take before it starts scrolling. */
const TOKEN_MAX_SHARE = 0.6;
/*
* The card is pinned to the edge of the field's own slot and grows away from
* it, so the field's box is the one thing that never moves.
*/
const CARD_ABOVE: ViewStyle = { position: 'absolute', bottom: 0, left: 0, right: 0 };
const CARD_BELOW: ViewStyle = { position: 'absolute', top: 0, left: 0, right: 0 };
/*
* `zIndex` *and* `elevation`, on the field's own box rather than on the card:
* Android draws siblings in tree order and takes its stacking from elevation,
* so a card overlapping the content above the field would otherwise be painted
* under it — and putting it here keeps the field painting over the card, which
* is what lets the two be one surface.
*
* Applied always, and never toggled. React Native implements `zIndex` on iOS
* by reordering the parent's subviews, which takes the view out of the
* hierarchy and puts it back — and a `UITextField` removed from the window
* resigns first responder. Setting this at the moment the panel opens
* therefore blurred the field that had just been focused, which closed the
* panel again: the keyboard came up and went straight back down, and the only
* way to get a search open was to be quicker than the render.
*
* A constant stacking order costs nothing while the panel is shut — there is
* nothing to stack against — and the elevation draws no shadow, because the
* box has no background for Android to cast one from.
*/
const RAISED: ViewStyle = { zIndex: 20, elevation: 20 };
const searchBarVariants = tv({
slots: {
row: 'w-full flex-row items-center',
/*
* The box the panel is positioned against — the field alone, so the panel
* is the field's width rather than the row's and does not run out under a
* Cancel button that is only sometimes there.
*/
anchor: 'relative',
field: '',
/*
* One card around the results *and* the field, with the field drawn over
* the space kept for it at the bottom.
*
* It has to be one box because the outline is one outline. Drawn as two —
* a bordered panel above a bordered field — the field's edge is the focus
* ring, since a field with a panel open is a field being typed into, and
* the card ends up with a brighter box welded to a dimmer one.
*
* `bg-popover`, not `bg-card`: this floats over the page rather than
* sitting in it, and a card is one step from the background — close enough
* that in dark mode the whole thing dissolves into the screen behind it.
*/
panel: 'overflow-hidden rounded-2xl border border-border bg-popover shadow-lg',
panelList: 'p-1.5',
/** The hairline between the results and the field. */
panelDivider: 'w-full bg-border',
/*
* A gap between the rows, because a selected one is drawn as a filled
* shape. Stacked flush, its fill runs into the row underneath and the two
* read as one block with a coloured top half — a row's rounded corners are
* only visible when there is background either side of them to round
* against. Two points separates the fills and still reads as a list rather
* than a set of cards.
*/
section: 'gap-0.5',
sectionLabel: 'px-3 pb-1 pt-2 text-sm text-muted-foreground',
item: 'flex-row items-center gap-3 rounded-lg px-3 py-2.5',
itemLabel: 'flex-1 text-base text-foreground',
status: 'flex-row items-center justify-center gap-2 px-3 py-8',
// Clipped, because this is what the Cancel button is revealed out of: the
// button keeps its measured width and the container's grows past it.
//
// `self-stretch` gives it the row's height, which it has no other way of
// getting — its only child is positioned absolutely, so it has no content
// to be as tall as.
cancelClip: 'self-stretch overflow-hidden',
cancelButton: 'absolute bottom-0 end-0 top-0 items-center justify-center ps-2',
cancelLabel: 'font-medium text-primary',
clear: 'items-center justify-center rounded-full',
/*
* The chips sit in the field's start content, which `Input` measures and
* turns into padding on the text — so the caret starts after them however
* many there are, and nothing typed ever runs underneath them.
*/
tokenRow: 'flex-row items-center gap-1.5',
token: 'flex-row items-center gap-1 rounded-full bg-accent ps-2 pe-1',
tokenLabel: 'text-accent-foreground',
tokenRemove: 'items-center justify-center rounded-full',
},
variants: {
size: {
sm: {
cancelLabel: 'text-[14px]',
clear: 'h-6 w-6',
token: 'h-6',
tokenLabel: 'text-[13px]',
tokenRemove: 'h-4 w-4',
},
md: {
cancelLabel: 'text-[16px]',
clear: 'h-6 w-6',
token: 'h-7',
tokenLabel: 'text-[14px]',
tokenRemove: 'h-5 w-5',
},
lg: {
cancelLabel: 'text-[16px]',
clear: 'h-7 w-7',
token: 'h-8',
tokenLabel: 'text-[15px]',
tokenRemove: 'h-5 w-5',
},
},
/**
* The field's corner. `pill` is the shape a search field takes when it is
* chrome — sitting above a list, in a header — and `rounded` the one it
* takes inside a form beside other fields.
*/
shape: {
rounded: { field: '' },
pill: { field: 'rounded-full' },
},
/**
* Which edge of the field the card grows out of. The field's corners on
* that edge go square and its border comes off entirely — the card around
* both of them is what draws the edge.
*/
attached: {
none: {},
top: { field: 'rounded-t-none rounded-b-2xl border-0' },
bottom: { field: 'rounded-b-none rounded-t-2xl border-0' },
},
selected: {
true: { item: 'bg-accent' },
},
},
defaultVariants: {
size: 'md',
shape: 'rounded',
attached: 'none',
},
});
type SearchBarVariantProps = VariantProps;
/** Glyph sizes per field size — the icon tracks the text, not the box. */
const ICON_SIZE = { sm: 16, md: 18, lg: 20 } as const;
interface SearchBarContextValue {
/** The field's size, so a chip drawn in it matches the text beside it. */
size: NonNullable;
/**
* Marks the field as still in use and asks for focus back. Called by
* anything pressable the panel or the field contains, before the press has
* had a chance to blur the field and close the search around it.
*/
retainFocus: () => void;
}
const SearchBarContext = createContext(null);
/** Where the results open. */
export type SearchBarPanelPlacement = 'top' | 'bottom';
/** When the results are shown. */
export type SearchBarPanelMode = 'never' | 'focus' | 'always';
/**
* What SearchBar takes from Input, minus everything it owns itself. The form
* furniture is dropped along with it: a label and an error line stack above
* and below the field, and Cancel sits beside the whole stack rather than
* beside the field it belongs to. Use `Field` for a search that is one answer
* in a form.
*
* The keyboard props go too. Input's would move the field and leave the Cancel
* button and the panel where they were; SearchBar lifts all three together.
*/
type InheritedInputProps = Omit<
InputProps,
| 'avoidKeyboard'
| 'defaultValue'
| 'description'
| 'endContent'
| 'errorMessage'
| 'interactiveContent'
| 'isRequired'
| 'keyboardBottomInset'
| 'keyboardMode'
| 'keyboardOffset'
| 'label'
| 'multiline'
| 'onChangeText'
| 'size'
| 'startContent'
| 'value'
>;
export interface SearchBarProps
extends InheritedInputProps,
Omit {
/**
* The field's background, from `Input`. `outline` draws its own edge, for a
* search bar sitting on the page; `filled` drops it, for one inside a card
* or a header where a second border reads as a seam. Defaults to `outline`.
*/
variant?: InputProps['variant'];
/** The query, when the caller holds it. Leave unset to let the field keep it. */
value?: string;
/** Starting query for an uncontrolled field. Ignored once `value` is passed. */
defaultValue?: string;
/** Fires on every keystroke. For a search that costs something, see `debounce`. */
onChangeText?: (value: string) => void;
/** The return key, which is labelled Search. Flushes `onDebouncedChange` first. */
onSubmit?: (value: string) => void;
/**
* How long typing has to pause before `onDebouncedChange` runs, in
* milliseconds. `0` runs it on every keystroke, which is only right for a
* filter over a list already in memory.
*/
debounce?: number;
/** The query, once typing has paused for `debounce` milliseconds. */
onDebouncedChange?: (value: string) => void;
/** Fires after the ✕ empties the field. The field keeps focus. */
onClear?: () => void;
/** Fires after Cancel empties the field and drops focus. */
onCancel?: () => void;
/** Whether the ✕ appears once there is a query. */
isClearable?: boolean;
/**
* When the Cancel button is beside the field. `focus` slides it in while the
* field is being edited and away again when it is not, which is what a
* search bar above a list wants. `always` keeps it out, for a screen that is
* nothing but the search.
*/
cancel?: 'never' | 'focus' | 'always';
/** The Cancel button's word. */
cancelLabel?: string;
/** How the ✕ announces itself. */
clearLabel?: string;
/**
* Results are on their way. A spinner takes the ✕'s place, because the two
* would otherwise sit on top of one another at exactly the moment a query is
* both non-empty and running.
*/
loading?: boolean;
/** The leading glyph, for a search over something with a symbol of its own. */
icon?: ReactNode;
/**
* Lift the whole search — field, Cancel button and panel — until it sits
* clear of the software keyboard, and put it back on blur. Without it the
* field stays where the page left it, which on most screens is behind the
* keyboard it just opened.
*
* Install `react-native-keyboard-controller` for this to behave on Android.
*
* Do not toggle it at runtime: it changes which component wraps the row, so
* the field would remount and lose focus.
*/
avoidKeyboard?: boolean;
/** Gap kept between the field's bottom edge and the keyboard. */
keyboardOffset?: number;
/**
* When the results panel is shown. `focus` opens it while the field is being
* typed into, `always` keeps it out for a screen that is nothing but the
* search, `never` ignores the children entirely.
*/
panel?: SearchBarPanelMode;
/**
* Which side of the field the panel opens out of. `top` is the default,
* because the space under a focused field belongs to the keyboard.
*/
panelPlacement?: SearchBarPanelPlacement;
/**
* Cap on the panel's height, in points.
*
* The panel takes the smaller of this and the room between the field and the
* edge of the screen, so it never runs off the top of the display. Unset, it
* is capped at about six rows: the space above a lifted field is most of the
* screen, and a panel that takes all of it stops reading as something laid
* over the app. Longer lists scroll.
*/
panelMaxHeight?: number;
/**
* What has been picked so far, drawn inside the field before the caret.
* `SearchBar.Token` is the chip; anything else that fits on one line works
* too. Tokens scroll rather than wrap, so the field stays one line tall.
*/
tokens?: ReactNode;
/**
* Fires when backspace is pressed in an empty field. Remove the last token
* here — it is the gesture every token field answers, and without it the
* only way back out of a choice is its own ✕.
*/
onRemoveLastToken?: () => void;
/** The panel's contents — `SearchBar.Section`, `.Item` and `.Status`. */
children?: ReactNode;
}
const SearchBarRoot = forwardRef(
(
{
value: valueProp,
defaultValue,
onChangeText,
onSubmit,
debounce = 0,
onDebouncedChange,
onClear,
onCancel,
isClearable = true,
cancel = 'never',
cancelLabel = 'Cancel',
clearLabel = 'Clear search',
loading = false,
icon,
shape,
size = 'md',
className,
containerClassName,
disabled,
avoidKeyboard = false,
keyboardOffset = 12,
panel = 'focus',
panelPlacement = 'top',
panelMaxHeight,
tokens,
onRemoveLastToken,
children,
onFocus,
onBlur,
onKeyPress,
onSubmitEditing,
...props
},
ref
) => {
const controlled = valueProp !== undefined;
const [internal, setInternal] = useState(defaultValue ?? '');
const text = controlled ? valueProp : internal;
const [focused, setFocused] = useState(false);
const inputRef = useRef(null);
useImperativeHandle(ref, () => inputRef.current as TextInput, []);
/*
* Counted rather than tested for truthiness: `{results.map(…)}` over an
* empty array is a child, and a panel that opens on nothing is a card of
* padding.
*/
const hasPanel = useMemo(() => {
let found = false;
Children.forEach(children, (child) => {
if (isValidElement(child)) found = true;
});
return found;
}, [children]);
const panelOpen =
!disabled &&
panel !== 'never' &&
hasPanel &&
(panel === 'always' || focused);
const attached = panelOpen ? panelPlacement : 'none';
const slots = searchBarVariants({ size, shape, attached });
const setText = useCallback(
(next: string) => {
if (!controlled) setInternal(next);
onChangeText?.(next);
},
[controlled, onChangeText]
);
/*
* The debounced callback is read through a ref so an inline arrow function
* — which is what a caller writes — does not restart the timer on every
* render and push the pause out forever.
*/
const debouncedRef = useRef(onDebouncedChange);
const debounceTimerRef = useRef(null);
useEffect(() => {
debouncedRef.current = onDebouncedChange;
});
// Skipped on mount: nothing was typed, so there is no pause to be at the
// end of, and firing here would run a search for the initial value.
const settled = useRef(false);
useEffect(() => {
if (!settled.current) {
settled.current = true;
return;
}
scheduleSearchBarDebounce(debounceTimerRef, debouncedRef.current, text, debounce);
return () => {
cancelSearchBarDebounce(debounceTimerRef);
};
}, [text, debounce]);
/*
* Set while a press inside the panel or the field is being served. A blur
* arriving under it is the press taking focus rather than the search
* ending, so it is answered by asking for focus back — the panel is drawn
* out of `focused`, and letting it through would close the panel the
* pressed control is standing in.
*/
const guarded = useRef(false);
const guardTimer = useRef | null>(null);
useEffect(
() => () => {
if (guardTimer.current) clearTimeout(guardTimer.current);
},
[]
);
const retainFocus = useCallback(() => {
if (disabled) return;
guarded.current = true;
if (guardTimer.current) clearTimeout(guardTimer.current);
guardTimer.current = setTimeout(() => {
guarded.current = false;
}, FOCUS_GUARD);
inputRef.current?.focus();
}, [disabled]);
/*
* Set while the search is being ended deliberately — Cancel, or a caller
* calling `blur()`. Without it the recovery below would fight the very
* thing that is trying to close the search.
*/
const ending = useRef(false);
const closeTimer = useRef | null>(null);
const clearCloseTimer = useCallback(() => {
if (closeTimer.current) clearTimeout(closeTimer.current);
closeTimer.current = null;
}, []);
useEffect(() => clearCloseTimer, [clearCloseTimer]);
// Read inside a timer, so it has to be a ref rather than the render value.
const keyboardUp = useRef(false);
const handleFocus = useCallback>(
(event) => {
clearCloseTimer();
ending.current = false;
setFocused(true);
onFocus?.(event);
},
[clearCloseTimer, onFocus]
);
const handleBlur = useCallback>(
(event) => {
// The caller asked for the event, so it goes out now rather than at
// the end of the pause — this is about what the *panel* believes.
onBlur?.(event);
if (guarded.current) {
inputRef.current?.focus();
return;
}
clearCloseTimer();
closeTimer.current = setTimeout(() => {
closeTimer.current = null;
/*
* A keyboard that never went down is not on its own proof that the
* focus is still inside this search.
*
* Put two search bars on one screen and they hand the keyboard
* between them: the first blurs, the keyboard stays up because the
* second now has it, and the first takes it straight back — then the
* second does the same, and both draw themselves as the field being
* typed into. A screen showing sizes or variants is four of them in a
* column, so this is the arrangement, not a corner case.
*
* So ask who actually holds it. Another field means somebody moved
* on deliberately; a focus that has landed nowhere is one of this
* search's own buttons taking it, which is what the recovery is for.
*/
const holder = TextInput.State.currentlyFocusedInput();
const elsewhere = holder != null && holder !== inputRef.current;
if (!ending.current && keyboardUp.current && !elsewhere) {
inputRef.current?.focus();
return;
}
setFocused(false);
}, BLUR_GRACE);
},
[clearCloseTimer, onBlur]
);
const handleKeyPress = useCallback(
(event: NativeSyntheticEvent) => {
// Only on an empty field: while there is a query, backspace is editing
// it, and eating a token instead would delete something nobody aimed at.
if (event.nativeEvent.key === 'Backspace' && text.length === 0) {
onRemoveLastToken?.();
}
onKeyPress?.(event);
},
[onKeyPress, onRemoveLastToken, text.length]
);
const handleSubmit = useCallback(
(event: NativeSyntheticEvent) => {
if (disabled) return;
// A return key is somebody saying they are done waiting, so the
// pending pause is spent rather than waited out.
flushSearchBarDebounce(debounceTimerRef, debouncedRef.current, text);
onSubmit?.(text);
onSubmitEditing?.(event);
},
[disabled, onSubmit, onSubmitEditing, text]
);
const handleClear = useCallback(() => {
if (disabled) return;
setText('');
onClear?.();
// The keyboard stays: clearing a query is the start of the next one.
// Android takes focus away with the press, so it is asked back.
inputRef.current?.focus();
}, [disabled, onClear, setText]);
const handleCancel = useCallback(() => {
if (disabled) return;
// Deliberate: the recovery above must not answer this blur by handing
// the focus straight back.
ending.current = true;
guarded.current = false;
setText('');
setFocused(false);
inputRef.current?.blur();
onCancel?.();
}, [disabled, onCancel, setText]);
/*
* Cancel's width is measured once and driven from a shared value, so the
* slide costs no re-render. `cancelWidth` is a shared value rather than
* state for the same reason — a measurement that arrives as state would
* re-render the field it is beside.
*/
const cancelWidth = useSharedValue(0);
const cancelOut = cancel === 'always' || (cancel === 'focus' && focused);
const cancelProgress = useSharedValue(cancel === 'always' ? 1 : 0);
useEffect(() => {
if (cancel === 'never') return;
cancelProgress.value = withTiming(cancelOut ? 1 : 0, {
duration: CANCEL_DURATION,
});
}, [cancel, cancelOut, cancelProgress]);
const cancelStyle = useAnimatedStyle(() => ({
width: (cancelWidth.value + CANCEL_GAP) * cancelProgress.value,
opacity: cancelProgress.value,
}));
const handleCancelLayout = useCallback(
(event: LayoutChangeEvent) => {
cancelWidth.value = event.nativeEvent.layout.width;
},
[cancelWidth]
);
/*
* How much room the panel has, in the direction it opens. Measured on the
* JS side because it decides a layout constraint rather than a frame of an
* animation: a `maxHeight` that changed every frame would re-lay out the
* list under the finger scrolling it.
*/
const { height: windowHeight } = useWindowDimensions();
const { height: keyboardHeight } = useKeyboard();
useEffect(() => {
keyboardUp.current = keyboardHeight > 0;
}, [keyboardHeight]);
const anchorRef = useRef(null);
const [anchorBox, setAnchorBox] = useState<{
top: number;
height: number;
width: number;
} | null>(null);
const measureAnchor = useCallback(() => {
anchorRef.current?.measureInWindow((_x, y, width, height) => {
setAnchorBox((current) =>
current &&
current.top === y &&
current.height === height &&
current.width === width
? current
: { top: y, height, width }
);
});
}, []);
useEffect(() => {
if (!panelOpen) return;
measureAnchor();
}, [panelOpen, keyboardHeight, measureAnchor]);
const resolvedMaxHeight = useMemo(() => {
const fieldHeight = anchorBox?.height ?? 0;
/*
* While the field is riding the keyboard, where it has come to rest is
* computed rather than measured: the lift is a transform applied on the
* UI thread, so a measurement taken from JavaScript is a frame behind it
* for the whole of the animation.
*/
const fieldTop =
avoidKeyboard && keyboardHeight > 0
? windowHeight - keyboardHeight - keyboardOffset - fieldHeight
: (anchorBox?.top ?? 0);
const room =
panelPlacement === 'top'
? fieldTop - PANEL_EDGE_GAP
: windowHeight - keyboardHeight - fieldTop - fieldHeight - PANEL_EDGE_GAP;
/*
* The smaller of what it is allowed and what it actually has, floored so
* a cramped screen still opens something worth reading. A caller's cap
* is clamped to the room too — a cap taller than the space available is
* a panel running off the top of the display.
*/
const cap = panelMaxHeight ?? PANEL_MAX_HEIGHT;
return Math.max(Math.min(cap, room), PANEL_MIN_HEIGHT);
}, [
anchorBox,
avoidKeyboard,
keyboardHeight,
keyboardOffset,
panelMaxHeight,
panelPlacement,
windowHeight,
]);
const glyph = (
/*
* Decorative, and said so here rather than through Input's
* `interactiveContent` — that flag covers both ends of the field, and
* turning it off to quieten the magnifier would take the clear button's
* touches with it.
*/
{icon ?? }
);
/*
* Capped and scrolling rather than wrapping. The field is one line tall, so
* a row of chips allowed to grow would move the caret every time something
* was picked; and left uncapped it would take the whole field and leave
* nowhere to type the next query.
*/
const tokenRow = tokens ? (
{tokens}
) : null;
const startContent = tokenRow ? (
<>
{glyph}
{tokenRow}
>
) : (
glyph
);
const endContent = loading ? (
) : isClearable && text.length > 0 ? (
) : null;
const field = (
);
const context = useMemo(
() => ({ size, retainFocus }),
[retainFocus, size]
);
// Nothing beside it and nothing under it: the field is the whole component,
// and a wrapper around it would only be a box for the caller's layout to
// fight.
if (cancel === 'never' && !avoidKeyboard && !hasPanel) {
return (
{field}
);
}
/*
* The results, the hairline and the room the field occupies, in the order
* they are stacked. The field itself is drawn over that last piece rather
* than inside the card: it has to keep its own place in the layout, and a
* text field that moved into an absolutely positioned box on focus would
* remount and lose the keyboard it just opened.
*/
const list = (
{children}
);
const divider = (
);
/*
* The room the card keeps for the field, and the reason it is inert.
*
* It is a spacer drawn at exactly the field's position, inside the card and
* therefore outside the list's `keyboardShouldPersistTaps`. Any touch it
* wins is a touch on a plain view while a field is focused, which is the
* platform's cue to dismiss the keyboard — so the field blurs, and the
* panel drawn out of that focus closes. From the outside that is "tapping
* the search box closes the results", which is exactly backwards.
*
* It has nothing to be pressed for. `none` puts the touch through to the
* field underneath it, where it was aimed.
*/
const fieldSlot = (
);
const anchor = (
{panelOpen ? (
{panelPlacement === 'top'
? [list, divider, fieldSlot]
: [fieldSlot, divider, list]}
) : null}
{field}
);
const body =
cancel === 'never' ? (
anchor
) : (
<>
{anchor}
{/*
* Absolute, and pinned to the end edge: it keeps its natural width
* inside a container whose width is animating, so the clip reveals
* it from the edge instead of squeezing the word as it arrives. It
* is also what makes the measurement possible at all — a child laid
* out against a container of width 0 would otherwise report 0.
*/}
{cancelLabel}
>
);
/*
* The keyboard hook is behind a component boundary rather than a flag.
* Calling it at all has global consequences — without the keyboard
* controller installed it falls back to Reanimated's useAnimatedKeyboard,
* which switches Android out of adjustResize for the whole app. A search
* bar that never asked to avoid the keyboard must not do that to every
* other screen.
*/
if (avoidKeyboard) {
return (
{body}
);
}
return (
{body}
);
}
);
SearchBarRoot.displayName = 'SearchBar';
export interface SearchBarSectionProps extends ViewProps {
className?: string;
/**
* The heading over the run of rows — "Suggested", "Results". Announced as a
* header, so a screen reader reaching the group is told what it is before
* walking into it.
*/
label?: string;
children?: ReactNode;
}
/** A labelled run of rows inside the panel. */
function SearchBarSection({ className, label, children, ...props }: SearchBarSectionProps) {
const { section, sectionLabel } = searchBarVariants();
return (
{label ? (
{label}
) : null}
{children}
);
}
SearchBarSection.displayName = 'SearchBar.Section';
export interface SearchBarItemProps extends Omit {
className?: string;
/** Anything before the label — an avatar, a logo, a status dot. */
leading?: ReactNode;
/**
* Anything after it. A slot rather than a built-in button, because what a
* result row offers differs per search: an add, a pin, a count, nothing.
*/
trailing?: ReactNode;
/** A second line under the label, for what the label alone cannot say. */
description?: string;
/** Draws the row as the one the search has settled on. */
selected?: boolean;
/** The row's label. */
children?: ReactNode;
}
/** One result. */
function SearchBarItem({
className,
leading,
trailing,
description,
selected,
children,
onPressIn,
...props
}: SearchBarItemProps) {
const { item, itemLabel } = searchBarVariants({ selected: !!selected });
const search = useContext(SearchBarContext);
return (
{
search?.retainFocus();
onPressIn?.(event);
}}
// A row is a wide target, and a target that shrinks when pressed reads as
// a card rather than a line in a list. The dim is the whole feedback.
pressScale={1}
pressOpacity={0.6}
// The two points of gap the section leaves between rows belong to
// neither of them otherwise, and a dead line across a list of targets is
// a tap that does nothing.
hitSlop={{ top: 2, bottom: 2 }}
{...props}
className={item({ className })}
>
{leading}
{children}
{description ? (
{description}
) : null}
{trailing}
);
}
SearchBarItem.displayName = 'SearchBar.Item';
export interface SearchBarStatusProps extends ViewProps {
className?: string;
/** A spinner beside the line, for a search that is still running. */
loading?: boolean;
children?: ReactNode;
}
/**
* The one line a panel shows instead of rows — nothing typed yet, a search in
* flight, or a query that matched nothing. It is a sentence rather than an
* empty box because those three states look identical when they are blank, and
* which one it is decides what the person does next.
*/
function SearchBarStatus({
className,
loading = false,
children,
...props
}: SearchBarStatusProps) {
const { status } = searchBarVariants();
return (
{loading ? : null}
{children}
);
}
SearchBarStatus.displayName = 'SearchBar.Status';
export interface SearchBarActionProps extends AnimatedPressableProps {
className?: string;
children?: ReactNode;
}
/**
* A button inside a row — an add, a pin, a remove — for the `trailing` slot.
*
* It exists rather than being left to a plain `Pressable` because a control
* nested inside a row takes the touch itself, so the row above it never sees
* the press and cannot hold the field's focus on its behalf. Pressed, this one
* ends up blurring the field, and a blurred field closes the panel the button
* was standing in — the press lands and the search disappears under it.
*/
function SearchBarAction({ className, children, onPressIn, ...props }: SearchBarActionProps) {
const search = useContext(SearchBarContext);
return (
{
search?.retainFocus();
onPressIn?.(event);
}}
{...props}
className={className}
>
{children}
);
}
SearchBarAction.displayName = 'SearchBar.Action';
export interface SearchBarTokenProps extends ViewProps {
className?: string;
/** Anything before the label — an avatar, a logo, a status dot. */
leading?: ReactNode;
/** Fires when the chip's ✕ is pressed. Without it no ✕ is drawn. */
onRemove?: () => void;
/** How the ✕ announces itself. Defaults to `Remove