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.
- package/README.md +4 -2
- package/lib/module/components/animated-badge/index.js +451 -0
- package/lib/module/components/animated-badge/index.js.map +1 -0
- package/lib/module/components/bottom-sheet/index.js +17 -6
- package/lib/module/components/bottom-sheet/index.js.map +1 -1
- package/lib/module/components/bubble-chart/index.js +1782 -0
- package/lib/module/components/bubble-chart/index.js.map +1 -0
- package/lib/module/components/feedback-dialog/index.js +474 -0
- package/lib/module/components/feedback-dialog/index.js.map +1 -0
- package/lib/module/components/pyramid-chart/index.js +1143 -0
- package/lib/module/components/pyramid-chart/index.js.map +1 -0
- package/lib/module/components/scroll-blur/index.js +363 -0
- package/lib/module/components/scroll-blur/index.js.map +1 -0
- package/lib/module/components/scroll-canvas/index.js +2 -0
- package/lib/module/components/scroll-canvas/index.js.map +1 -1
- package/lib/module/components/scroll-text/index.js +3 -0
- package/lib/module/components/scroll-text/index.js.map +1 -1
- package/lib/module/components/search-bar/index.js +753 -25
- package/lib/module/components/search-bar/index.js.map +1 -1
- package/lib/module/hooks/use-keyboard-avoidance.js +21 -1
- package/lib/module/hooks/use-keyboard-avoidance.js.map +1 -1
- package/lib/module/hooks/use-reveal-progress.js +21 -1
- package/lib/module/hooks/use-reveal-progress.js.map +1 -1
- package/lib/module/index.js +5 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/utils/chart.js +24 -0
- package/lib/module/utils/chart.js.map +1 -1
- package/lib/typescript/src/components/animated-badge/index.d.ts +224 -0
- package/lib/typescript/src/components/animated-badge/index.d.ts.map +1 -0
- package/lib/typescript/src/components/bottom-sheet/index.d.ts +1 -15
- package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -1
- package/lib/typescript/src/components/bubble-chart/index.d.ts +481 -0
- package/lib/typescript/src/components/bubble-chart/index.d.ts.map +1 -0
- package/lib/typescript/src/components/feedback-dialog/index.d.ts +156 -0
- package/lib/typescript/src/components/feedback-dialog/index.d.ts.map +1 -0
- package/lib/typescript/src/components/pyramid-chart/index.d.ts +328 -0
- package/lib/typescript/src/components/pyramid-chart/index.d.ts.map +1 -0
- package/lib/typescript/src/components/scroll-blur/index.d.ts +113 -0
- package/lib/typescript/src/components/scroll-blur/index.d.ts.map +1 -0
- package/lib/typescript/src/components/scroll-canvas/index.d.ts.map +1 -1
- package/lib/typescript/src/components/search-bar/index.d.ts +350 -6
- package/lib/typescript/src/components/search-bar/index.d.ts.map +1 -1
- package/lib/typescript/src/hooks/use-keyboard-avoidance.d.ts.map +1 -1
- package/lib/typescript/src/hooks/use-reveal-progress.d.ts +6 -27
- package/lib/typescript/src/hooks/use-reveal-progress.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +6 -1
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/utils/chart.d.ts +14 -0
- package/lib/typescript/src/utils/chart.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/animated-badge/index.tsx +513 -0
- package/src/components/bottom-sheet/index.tsx +23 -8
- package/src/components/bubble-chart/index.tsx +2145 -0
- package/src/components/feedback-dialog/index.tsx +557 -0
- package/src/components/pyramid-chart/index.tsx +1360 -0
- package/src/components/scroll-blur/index.tsx +466 -0
- package/src/components/scroll-canvas/index.tsx +2 -1
- package/src/components/scroll-text/index.tsx +3 -3
- package/src/components/search-bar/index.tsx +933 -45
- package/src/hooks/use-keyboard-avoidance.ts +24 -1
- package/src/hooks/use-reveal-progress.ts +30 -1
- package/src/index.ts +72 -1
- package/src/utils/chart.ts +27 -0
|
@@ -1,13 +1,80 @@
|
|
|
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
|
|
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="always"`, and every press
|
|
36
|
+
* inside it holds the field's focus open for a moment afterwards. Both are
|
|
37
|
+
* needed, because a search closes the instant the field blurs and there are
|
|
38
|
+
* two separate ways for a touch in the panel to blur it.
|
|
39
|
+
*
|
|
40
|
+
* `"handled"` only spares presses a child takes responsibility for, which
|
|
41
|
+
* leaves the panel's own padding, the gaps between rows, a section heading and
|
|
42
|
+
* the whole of `SearchBar.Status` as live dismiss surfaces — tapping the word
|
|
43
|
+
* "Searching …" would end the search. `"always"` gives the panel back.
|
|
44
|
+
*
|
|
45
|
+
* The focus guard covers the other way: a control inside a row — an add
|
|
46
|
+
* button, a remove ✕ — takes focus with the press on Android, and returning it
|
|
47
|
+
* a frame later is not enough on its own, because the blur has already closed
|
|
48
|
+
* the panel the control was drawn in. So a press in the panel marks the field
|
|
49
|
+
* as still being used, and a blur arriving under that mark is answered by
|
|
50
|
+
* asking for focus back rather than by ending the search.
|
|
51
|
+
*
|
|
52
|
+
* That guard only knows about presses that go through this component's own
|
|
53
|
+
* parts, and a caller's `Pressable` in a row's `trailing` slot takes the touch
|
|
54
|
+
* itself. So the panel also waits before believing any blur, and asks the
|
|
55
|
+
* keyboard: it is still up, because nothing in the panel dismisses it, and a
|
|
56
|
+
* search whose keyboard is still up has not ended. Focus goes back instead.
|
|
57
|
+
*
|
|
58
|
+
* ## The space kept for the field is not a target
|
|
59
|
+
*
|
|
60
|
+
* The card is one box around the results *and* the field, so it carries a
|
|
61
|
+
* spacer where the field sits. That spacer is a plain view drawn over a
|
|
62
|
+
* focused field, and a touch on a plain view is the platform's cue to dismiss
|
|
63
|
+
* the keyboard — so winning one blurred the field and closed the panel drawn
|
|
64
|
+
* out of that focus. Tapping the search box shut the results, which is exactly
|
|
65
|
+
* backwards. The card and its spacer take no touches at all now.
|
|
66
|
+
*
|
|
67
|
+
* ## What has already been picked goes in the field
|
|
68
|
+
*
|
|
69
|
+
* `tokens` puts the choices made so far inside the field, before the caret, so
|
|
70
|
+
* the query and what it has produced are one control rather than a control and
|
|
71
|
+
* a list somewhere above it. `SearchBar.Token` is the chip; backspace on an
|
|
72
|
+
* empty field fires `onRemoveLastToken`, which is what a token field does
|
|
73
|
+
* everywhere else.
|
|
74
|
+
*
|
|
75
|
+
* They scroll rather than wrap: the field is one line tall, and a row of chips
|
|
76
|
+
* that grew it would move the caret every time something was picked.
|
|
77
|
+
*
|
|
11
78
|
* ## The clear button, and why it is not the platform's
|
|
12
79
|
*
|
|
13
80
|
* A ✕ appears inside the field as soon as there is something to clear, and
|
|
@@ -44,29 +111,47 @@
|
|
|
44
111
|
* a return key is somebody saying they are done waiting.
|
|
45
112
|
*/
|
|
46
113
|
import {
|
|
114
|
+
Children,
|
|
115
|
+
createContext,
|
|
47
116
|
forwardRef,
|
|
117
|
+
isValidElement,
|
|
118
|
+
useContext,
|
|
48
119
|
useCallback,
|
|
49
120
|
useEffect,
|
|
50
121
|
useImperativeHandle,
|
|
122
|
+
useMemo,
|
|
51
123
|
useRef,
|
|
52
124
|
useState,
|
|
53
125
|
type ReactNode,
|
|
54
126
|
} from 'react';
|
|
55
127
|
import {
|
|
128
|
+
ScrollView,
|
|
129
|
+
StyleSheet,
|
|
56
130
|
View,
|
|
131
|
+
useWindowDimensions,
|
|
57
132
|
type LayoutChangeEvent,
|
|
58
133
|
type NativeSyntheticEvent,
|
|
59
134
|
type TextInput,
|
|
135
|
+
type TextInputKeyPressEventData,
|
|
60
136
|
type TextInputSubmitEditingEventData,
|
|
137
|
+
type ViewProps,
|
|
138
|
+
type ViewStyle,
|
|
61
139
|
} from 'react-native';
|
|
62
140
|
import Animated, {
|
|
141
|
+
FadeIn,
|
|
142
|
+
FadeOut,
|
|
63
143
|
useAnimatedStyle,
|
|
64
144
|
useSharedValue,
|
|
65
145
|
withTiming,
|
|
66
146
|
} from 'react-native-reanimated';
|
|
67
147
|
import { tv, type VariantProps } from 'tailwind-variants';
|
|
148
|
+
import { useKeyboard } from '../../hooks/use-keyboard';
|
|
68
149
|
import { SearchIcon, XIcon } from '../../icons';
|
|
69
|
-
import {
|
|
150
|
+
import {
|
|
151
|
+
AnimatedPressable,
|
|
152
|
+
type AnimatedPressableProps,
|
|
153
|
+
} from '../../primitives/animated-pressable';
|
|
154
|
+
import { KeyboardAvoider } from '../../primitives/keyboard-avoider';
|
|
70
155
|
import { Text } from '../../primitives/text';
|
|
71
156
|
import { Input, type InputProps } from '../input';
|
|
72
157
|
import { Spinner } from '../spinner';
|
|
@@ -89,10 +174,123 @@ const CANCEL_GAP = 8;
|
|
|
89
174
|
*/
|
|
90
175
|
const CLEAR_HIT_SLOP = 12;
|
|
91
176
|
|
|
177
|
+
/** The panel's crossfade, matching every other anchored list in the library. */
|
|
178
|
+
const PANEL_IN = 140;
|
|
179
|
+
const PANEL_OUT = 120;
|
|
180
|
+
|
|
181
|
+
/** Gap left between the panel's far edge and the edge of the screen. */
|
|
182
|
+
const PANEL_EDGE_GAP = 24;
|
|
183
|
+
|
|
184
|
+
/** Floor for the derived height — below this a list is not worth opening. */
|
|
185
|
+
const PANEL_MIN_HEIGHT = 160;
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Ceiling for the derived height, in points. About six rows and a heading.
|
|
189
|
+
*
|
|
190
|
+
* The room above a lifted field is most of the screen, and a panel that takes
|
|
191
|
+
* all of it is a full-screen list that happens to have a search box under it —
|
|
192
|
+
* the page it was opened over stops being visible, so the search stops reading
|
|
193
|
+
* as something laid over the app. Past this the results scroll, which is what
|
|
194
|
+
* a long list should do anyway.
|
|
195
|
+
*
|
|
196
|
+
* `panelMaxHeight` overrides it for a screen that really is nothing but the
|
|
197
|
+
* search.
|
|
198
|
+
*/
|
|
199
|
+
const PANEL_MAX_HEIGHT = 320;
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* The field's height per size, matching `Input`'s own `h-10 / h-12 / h-14`.
|
|
203
|
+
*
|
|
204
|
+
* It is the fallback for the slot the panel keeps for the field, which is
|
|
205
|
+
* otherwise the measured height and therefore zero on the frame the panel
|
|
206
|
+
* first opens. A zero slot puts the card's bottom edge at the field's, so the
|
|
207
|
+
* last row is drawn underneath the field — which is painted after the card and
|
|
208
|
+
* takes the touch. The press then reads as a tap on the input.
|
|
209
|
+
*/
|
|
210
|
+
const FIELD_HEIGHT = { sm: 40, md: 48, lg: 56 } as const;
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* How long a press inside the panel keeps the field's focus. Long enough to
|
|
214
|
+
* cover the blur Android sends with the press and the re-render that follows
|
|
215
|
+
* it, short enough that a real dismissal is never held open.
|
|
216
|
+
*/
|
|
217
|
+
const FOCUS_GUARD = 400;
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* How long the panel waits after a blur before believing the search is over.
|
|
221
|
+
*
|
|
222
|
+
* The guard above only covers presses that go through this component's own
|
|
223
|
+
* parts. A caller's own `Pressable` in a row's `trailing` slot takes the touch
|
|
224
|
+
* itself and this component never hears about it — so the catch-all is the
|
|
225
|
+
* keyboard: it is still up, because nothing in the panel dismisses it, and a
|
|
226
|
+
* search whose keyboard is still up has not ended. The pause is long enough
|
|
227
|
+
* for focus to come back on its own and short enough that a real dismissal
|
|
228
|
+
* does not leave the panel hanging.
|
|
229
|
+
*/
|
|
230
|
+
const BLUR_GRACE = 220;
|
|
231
|
+
|
|
232
|
+
/** Share of the field a row of tokens may take before it starts scrolling. */
|
|
233
|
+
const TOKEN_MAX_SHARE = 0.6;
|
|
234
|
+
|
|
235
|
+
/*
|
|
236
|
+
* The card is pinned to the edge of the field's own slot and grows away from
|
|
237
|
+
* it, so the field's box is the one thing that never moves.
|
|
238
|
+
*/
|
|
239
|
+
const CARD_ABOVE: ViewStyle = { position: 'absolute', bottom: 0, left: 0, right: 0 };
|
|
240
|
+
const CARD_BELOW: ViewStyle = { position: 'absolute', top: 0, left: 0, right: 0 };
|
|
241
|
+
|
|
242
|
+
/*
|
|
243
|
+
* `zIndex` *and* `elevation`, on the field's own box rather than on the card:
|
|
244
|
+
* Android draws siblings in tree order and takes its stacking from elevation,
|
|
245
|
+
* so a card overlapping the content above the field would otherwise be painted
|
|
246
|
+
* under it — and putting it here keeps the field painting over the card, which
|
|
247
|
+
* is what lets the two be one surface.
|
|
248
|
+
*
|
|
249
|
+
* Applied always, and never toggled. React Native implements `zIndex` on iOS
|
|
250
|
+
* by reordering the parent's subviews, which takes the view out of the
|
|
251
|
+
* hierarchy and puts it back — and a `UITextField` removed from the window
|
|
252
|
+
* resigns first responder. Setting this at the moment the panel opens
|
|
253
|
+
* therefore blurred the field that had just been focused, which closed the
|
|
254
|
+
* panel again: the keyboard came up and went straight back down, and the only
|
|
255
|
+
* way to get a search open was to be quicker than the render.
|
|
256
|
+
*
|
|
257
|
+
* A constant stacking order costs nothing while the panel is shut — there is
|
|
258
|
+
* nothing to stack against — and the elevation draws no shadow, because the
|
|
259
|
+
* box has no background for Android to cast one from.
|
|
260
|
+
*/
|
|
261
|
+
const RAISED: ViewStyle = { zIndex: 20, elevation: 20 };
|
|
262
|
+
|
|
92
263
|
const searchBarVariants = tv({
|
|
93
264
|
slots: {
|
|
94
265
|
row: 'w-full flex-row items-center',
|
|
266
|
+
/*
|
|
267
|
+
* The box the panel is positioned against — the field alone, so the panel
|
|
268
|
+
* is the field's width rather than the row's and does not run out under a
|
|
269
|
+
* Cancel button that is only sometimes there.
|
|
270
|
+
*/
|
|
271
|
+
anchor: 'relative',
|
|
95
272
|
field: '',
|
|
273
|
+
/*
|
|
274
|
+
* One card around the results *and* the field, with the field drawn over
|
|
275
|
+
* the space kept for it at the bottom.
|
|
276
|
+
*
|
|
277
|
+
* It has to be one box because the outline is one outline. Drawn as two —
|
|
278
|
+
* a bordered panel above a bordered field — the field's edge is the focus
|
|
279
|
+
* ring, since a field with a panel open is a field being typed into, and
|
|
280
|
+
* the card ends up with a brighter box welded to a dimmer one.
|
|
281
|
+
*
|
|
282
|
+
* `bg-popover`, not `bg-card`: this floats over the page rather than
|
|
283
|
+
* sitting in it, and a card is one step from the background — close enough
|
|
284
|
+
* that in dark mode the whole thing dissolves into the screen behind it.
|
|
285
|
+
*/
|
|
286
|
+
panel: 'overflow-hidden rounded-2xl border border-border bg-popover shadow-lg',
|
|
287
|
+
panelList: 'p-1.5',
|
|
288
|
+
/** The hairline between the results and the field. */
|
|
289
|
+
panelDivider: 'w-full bg-border',
|
|
290
|
+
sectionLabel: 'px-3 pb-1 pt-2 text-sm text-muted-foreground',
|
|
291
|
+
item: 'flex-row items-center gap-3 rounded-lg px-3 py-2.5',
|
|
292
|
+
itemLabel: 'flex-1 text-base text-foreground',
|
|
293
|
+
status: 'flex-row items-center justify-center gap-2 px-3 py-8',
|
|
96
294
|
// Clipped, because this is what the Cancel button is revealed out of: the
|
|
97
295
|
// button keeps its measured width and the container's grows past it.
|
|
98
296
|
//
|
|
@@ -103,12 +301,39 @@ const searchBarVariants = tv({
|
|
|
103
301
|
cancelButton: 'absolute bottom-0 end-0 top-0 items-center justify-center ps-2',
|
|
104
302
|
cancelLabel: 'font-medium text-primary',
|
|
105
303
|
clear: 'items-center justify-center rounded-full',
|
|
304
|
+
/*
|
|
305
|
+
* The chips sit in the field's start content, which `Input` measures and
|
|
306
|
+
* turns into padding on the text — so the caret starts after them however
|
|
307
|
+
* many there are, and nothing typed ever runs underneath them.
|
|
308
|
+
*/
|
|
309
|
+
tokenRow: 'flex-row items-center gap-1.5',
|
|
310
|
+
token: 'flex-row items-center gap-1 rounded-full bg-accent ps-2 pe-1',
|
|
311
|
+
tokenLabel: 'text-accent-foreground',
|
|
312
|
+
tokenRemove: 'items-center justify-center rounded-full',
|
|
106
313
|
},
|
|
107
314
|
variants: {
|
|
108
315
|
size: {
|
|
109
|
-
sm: {
|
|
110
|
-
|
|
111
|
-
|
|
316
|
+
sm: {
|
|
317
|
+
cancelLabel: 'text-[14px]',
|
|
318
|
+
clear: 'h-6 w-6',
|
|
319
|
+
token: 'h-6',
|
|
320
|
+
tokenLabel: 'text-[13px]',
|
|
321
|
+
tokenRemove: 'h-4 w-4',
|
|
322
|
+
},
|
|
323
|
+
md: {
|
|
324
|
+
cancelLabel: 'text-[16px]',
|
|
325
|
+
clear: 'h-6 w-6',
|
|
326
|
+
token: 'h-7',
|
|
327
|
+
tokenLabel: 'text-[14px]',
|
|
328
|
+
tokenRemove: 'h-5 w-5',
|
|
329
|
+
},
|
|
330
|
+
lg: {
|
|
331
|
+
cancelLabel: 'text-[16px]',
|
|
332
|
+
clear: 'h-7 w-7',
|
|
333
|
+
token: 'h-8',
|
|
334
|
+
tokenLabel: 'text-[15px]',
|
|
335
|
+
tokenRemove: 'h-5 w-5',
|
|
336
|
+
},
|
|
112
337
|
},
|
|
113
338
|
/**
|
|
114
339
|
* The field's corner. `pill` is the shape a search field takes when it is
|
|
@@ -119,10 +344,24 @@ const searchBarVariants = tv({
|
|
|
119
344
|
rounded: { field: '' },
|
|
120
345
|
pill: { field: 'rounded-full' },
|
|
121
346
|
},
|
|
347
|
+
/**
|
|
348
|
+
* Which edge of the field the card grows out of. The field's corners on
|
|
349
|
+
* that edge go square and its border comes off entirely — the card around
|
|
350
|
+
* both of them is what draws the edge.
|
|
351
|
+
*/
|
|
352
|
+
attached: {
|
|
353
|
+
none: {},
|
|
354
|
+
top: { field: 'rounded-t-none rounded-b-2xl border-0' },
|
|
355
|
+
bottom: { field: 'rounded-b-none rounded-t-2xl border-0' },
|
|
356
|
+
},
|
|
357
|
+
selected: {
|
|
358
|
+
true: { item: 'bg-accent' },
|
|
359
|
+
},
|
|
122
360
|
},
|
|
123
361
|
defaultVariants: {
|
|
124
362
|
size: 'md',
|
|
125
363
|
shape: 'rounded',
|
|
364
|
+
attached: 'none',
|
|
126
365
|
},
|
|
127
366
|
});
|
|
128
367
|
|
|
@@ -131,21 +370,47 @@ type SearchBarVariantProps = VariantProps<typeof searchBarVariants>;
|
|
|
131
370
|
/** Glyph sizes per field size — the icon tracks the text, not the box. */
|
|
132
371
|
const ICON_SIZE = { sm: 16, md: 18, lg: 20 } as const;
|
|
133
372
|
|
|
373
|
+
interface SearchBarContextValue {
|
|
374
|
+
/** The field's size, so a chip drawn in it matches the text beside it. */
|
|
375
|
+
size: NonNullable<SearchBarVariantProps['size']>;
|
|
376
|
+
/**
|
|
377
|
+
* Marks the field as still in use and asks for focus back. Called by
|
|
378
|
+
* anything pressable the panel or the field contains, before the press has
|
|
379
|
+
* had a chance to blur the field and close the search around it.
|
|
380
|
+
*/
|
|
381
|
+
retainFocus: () => void;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const SearchBarContext = createContext<SearchBarContextValue | null>(null);
|
|
385
|
+
|
|
386
|
+
/** Where the results open. */
|
|
387
|
+
export type SearchBarPanelPlacement = 'top' | 'bottom';
|
|
388
|
+
|
|
389
|
+
/** When the results are shown. */
|
|
390
|
+
export type SearchBarPanelMode = 'never' | 'focus' | 'always';
|
|
391
|
+
|
|
134
392
|
/**
|
|
135
393
|
* What SearchBar takes from Input, minus everything it owns itself. The form
|
|
136
394
|
* furniture is dropped along with it: a label and an error line stack above
|
|
137
395
|
* and below the field, and Cancel sits beside the whole stack rather than
|
|
138
396
|
* beside the field it belongs to. Use `Field` for a search that is one answer
|
|
139
397
|
* in a form.
|
|
398
|
+
*
|
|
399
|
+
* The keyboard props go too. Input's would move the field and leave the Cancel
|
|
400
|
+
* button and the panel where they were; SearchBar lifts all three together.
|
|
140
401
|
*/
|
|
141
402
|
type InheritedInputProps = Omit<
|
|
142
403
|
InputProps,
|
|
404
|
+
| 'avoidKeyboard'
|
|
143
405
|
| 'defaultValue'
|
|
144
406
|
| 'description'
|
|
145
407
|
| 'endContent'
|
|
146
408
|
| 'errorMessage'
|
|
147
409
|
| 'interactiveContent'
|
|
148
410
|
| 'isRequired'
|
|
411
|
+
| 'keyboardBottomInset'
|
|
412
|
+
| 'keyboardMode'
|
|
413
|
+
| 'keyboardOffset'
|
|
149
414
|
| 'label'
|
|
150
415
|
| 'multiline'
|
|
151
416
|
| 'onChangeText'
|
|
@@ -154,7 +419,9 @@ type InheritedInputProps = Omit<
|
|
|
154
419
|
| 'value'
|
|
155
420
|
>;
|
|
156
421
|
|
|
157
|
-
export interface SearchBarProps
|
|
422
|
+
export interface SearchBarProps
|
|
423
|
+
extends InheritedInputProps,
|
|
424
|
+
Omit<SearchBarVariantProps, 'attached' | 'selected'> {
|
|
158
425
|
/**
|
|
159
426
|
* The field's background, from `Input`. `outline` draws its own edge, for a
|
|
160
427
|
* search bar sitting on the page; `filled` drops it, for one inside a card
|
|
@@ -202,9 +469,58 @@ export interface SearchBarProps extends InheritedInputProps, SearchBarVariantPro
|
|
|
202
469
|
loading?: boolean;
|
|
203
470
|
/** The leading glyph, for a search over something with a symbol of its own. */
|
|
204
471
|
icon?: ReactNode;
|
|
472
|
+
/**
|
|
473
|
+
* Lift the whole search — field, Cancel button and panel — until it sits
|
|
474
|
+
* clear of the software keyboard, and put it back on blur. Without it the
|
|
475
|
+
* field stays where the page left it, which on most screens is behind the
|
|
476
|
+
* keyboard it just opened.
|
|
477
|
+
*
|
|
478
|
+
* Install `react-native-keyboard-controller` for this to behave on Android.
|
|
479
|
+
*
|
|
480
|
+
* Do not toggle it at runtime: it changes which component wraps the row, so
|
|
481
|
+
* the field would remount and lose focus.
|
|
482
|
+
*/
|
|
483
|
+
avoidKeyboard?: boolean;
|
|
484
|
+
/** Gap kept between the field's bottom edge and the keyboard. */
|
|
485
|
+
keyboardOffset?: number;
|
|
486
|
+
/**
|
|
487
|
+
* When the results panel is shown. `focus` opens it while the field is being
|
|
488
|
+
* typed into, `always` keeps it out for a screen that is nothing but the
|
|
489
|
+
* search, `never` ignores the children entirely.
|
|
490
|
+
*/
|
|
491
|
+
panel?: SearchBarPanelMode;
|
|
492
|
+
/**
|
|
493
|
+
* Which side of the field the panel opens out of. `top` is the default,
|
|
494
|
+
* because the space under a focused field belongs to the keyboard.
|
|
495
|
+
*/
|
|
496
|
+
panelPlacement?: SearchBarPanelPlacement;
|
|
497
|
+
/**
|
|
498
|
+
* Cap on the panel's height, in points.
|
|
499
|
+
*
|
|
500
|
+
* The panel takes the smaller of this and the room between the field and the
|
|
501
|
+
* edge of the screen, so it never runs off the top of the display. Unset, it
|
|
502
|
+
* is capped at about six rows: the space above a lifted field is most of the
|
|
503
|
+
* screen, and a panel that takes all of it stops reading as something laid
|
|
504
|
+
* over the app. Longer lists scroll.
|
|
505
|
+
*/
|
|
506
|
+
panelMaxHeight?: number;
|
|
507
|
+
/**
|
|
508
|
+
* What has been picked so far, drawn inside the field before the caret.
|
|
509
|
+
* `SearchBar.Token` is the chip; anything else that fits on one line works
|
|
510
|
+
* too. Tokens scroll rather than wrap, so the field stays one line tall.
|
|
511
|
+
*/
|
|
512
|
+
tokens?: ReactNode;
|
|
513
|
+
/**
|
|
514
|
+
* Fires when backspace is pressed in an empty field. Remove the last token
|
|
515
|
+
* here — it is the gesture every token field answers, and without it the
|
|
516
|
+
* only way back out of a choice is its own ✕.
|
|
517
|
+
*/
|
|
518
|
+
onRemoveLastToken?: () => void;
|
|
519
|
+
/** The panel's contents — `SearchBar.Section`, `.Item` and `.Status`. */
|
|
520
|
+
children?: ReactNode;
|
|
205
521
|
}
|
|
206
522
|
|
|
207
|
-
|
|
523
|
+
const SearchBarRoot = forwardRef<TextInput, SearchBarProps>(
|
|
208
524
|
(
|
|
209
525
|
{
|
|
210
526
|
value: valueProp,
|
|
@@ -226,8 +542,17 @@ export const SearchBar = forwardRef<TextInput, SearchBarProps>(
|
|
|
226
542
|
className,
|
|
227
543
|
containerClassName,
|
|
228
544
|
disabled,
|
|
545
|
+
avoidKeyboard = false,
|
|
546
|
+
keyboardOffset = 12,
|
|
547
|
+
panel = 'focus',
|
|
548
|
+
panelPlacement = 'top',
|
|
549
|
+
panelMaxHeight,
|
|
550
|
+
tokens,
|
|
551
|
+
onRemoveLastToken,
|
|
552
|
+
children,
|
|
229
553
|
onFocus,
|
|
230
554
|
onBlur,
|
|
555
|
+
onKeyPress,
|
|
231
556
|
onSubmitEditing,
|
|
232
557
|
...props
|
|
233
558
|
},
|
|
@@ -241,7 +566,27 @@ export const SearchBar = forwardRef<TextInput, SearchBarProps>(
|
|
|
241
566
|
const inputRef = useRef<TextInput | null>(null);
|
|
242
567
|
useImperativeHandle(ref, () => inputRef.current as TextInput, []);
|
|
243
568
|
|
|
244
|
-
|
|
569
|
+
/*
|
|
570
|
+
* Counted rather than tested for truthiness: `{results.map(…)}` over an
|
|
571
|
+
* empty array is a child, and a panel that opens on nothing is a card of
|
|
572
|
+
* padding.
|
|
573
|
+
*/
|
|
574
|
+
const hasPanel = useMemo(() => {
|
|
575
|
+
let found = false;
|
|
576
|
+
Children.forEach(children, (child) => {
|
|
577
|
+
if (isValidElement(child)) found = true;
|
|
578
|
+
});
|
|
579
|
+
return found;
|
|
580
|
+
}, [children]);
|
|
581
|
+
|
|
582
|
+
const panelOpen =
|
|
583
|
+
!disabled &&
|
|
584
|
+
panel !== 'never' &&
|
|
585
|
+
hasPanel &&
|
|
586
|
+
(panel === 'always' || focused);
|
|
587
|
+
|
|
588
|
+
const attached = panelOpen ? panelPlacement : 'none';
|
|
589
|
+
const slots = searchBarVariants({ size, shape, attached });
|
|
245
590
|
|
|
246
591
|
const setText = useCallback(
|
|
247
592
|
(next: string) => {
|
|
@@ -276,20 +621,96 @@ export const SearchBar = forwardRef<TextInput, SearchBarProps>(
|
|
|
276
621
|
};
|
|
277
622
|
}, [text, debounce]);
|
|
278
623
|
|
|
624
|
+
/*
|
|
625
|
+
* Set while a press inside the panel or the field is being served. A blur
|
|
626
|
+
* arriving under it is the press taking focus rather than the search
|
|
627
|
+
* ending, so it is answered by asking for focus back — the panel is drawn
|
|
628
|
+
* out of `focused`, and letting it through would close the panel the
|
|
629
|
+
* pressed control is standing in.
|
|
630
|
+
*/
|
|
631
|
+
const guarded = useRef(false);
|
|
632
|
+
const guardTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
633
|
+
|
|
634
|
+
useEffect(
|
|
635
|
+
() => () => {
|
|
636
|
+
if (guardTimer.current) clearTimeout(guardTimer.current);
|
|
637
|
+
},
|
|
638
|
+
[]
|
|
639
|
+
);
|
|
640
|
+
|
|
641
|
+
const retainFocus = useCallback(() => {
|
|
642
|
+
if (disabled) return;
|
|
643
|
+
guarded.current = true;
|
|
644
|
+
if (guardTimer.current) clearTimeout(guardTimer.current);
|
|
645
|
+
guardTimer.current = setTimeout(() => {
|
|
646
|
+
guarded.current = false;
|
|
647
|
+
}, FOCUS_GUARD);
|
|
648
|
+
inputRef.current?.focus();
|
|
649
|
+
}, [disabled]);
|
|
650
|
+
|
|
651
|
+
/*
|
|
652
|
+
* Set while the search is being ended deliberately — Cancel, or a caller
|
|
653
|
+
* calling `blur()`. Without it the recovery below would fight the very
|
|
654
|
+
* thing that is trying to close the search.
|
|
655
|
+
*/
|
|
656
|
+
const ending = useRef(false);
|
|
657
|
+
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
658
|
+
const clearCloseTimer = useCallback(() => {
|
|
659
|
+
if (closeTimer.current) clearTimeout(closeTimer.current);
|
|
660
|
+
closeTimer.current = null;
|
|
661
|
+
}, []);
|
|
662
|
+
|
|
663
|
+
useEffect(() => clearCloseTimer, [clearCloseTimer]);
|
|
664
|
+
|
|
665
|
+
// Read inside a timer, so it has to be a ref rather than the render value.
|
|
666
|
+
const keyboardUp = useRef(false);
|
|
667
|
+
|
|
279
668
|
const handleFocus = useCallback<NonNullable<InputProps['onFocus']>>(
|
|
280
669
|
(event) => {
|
|
670
|
+
clearCloseTimer();
|
|
671
|
+
ending.current = false;
|
|
281
672
|
setFocused(true);
|
|
282
673
|
onFocus?.(event);
|
|
283
674
|
},
|
|
284
|
-
[onFocus]
|
|
675
|
+
[clearCloseTimer, onFocus]
|
|
285
676
|
);
|
|
286
677
|
|
|
287
678
|
const handleBlur = useCallback<NonNullable<InputProps['onBlur']>>(
|
|
288
679
|
(event) => {
|
|
289
|
-
|
|
680
|
+
// The caller asked for the event, so it goes out now rather than at
|
|
681
|
+
// the end of the pause — this is about what the *panel* believes.
|
|
290
682
|
onBlur?.(event);
|
|
683
|
+
|
|
684
|
+
if (guarded.current) {
|
|
685
|
+
inputRef.current?.focus();
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
clearCloseTimer();
|
|
690
|
+
closeTimer.current = setTimeout(() => {
|
|
691
|
+
closeTimer.current = null;
|
|
692
|
+
if (!ending.current && keyboardUp.current) {
|
|
693
|
+
// The keyboard never went down, so the search is still on screen
|
|
694
|
+
// and something inside it has taken the focus. Give it back.
|
|
695
|
+
inputRef.current?.focus();
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
setFocused(false);
|
|
699
|
+
}, BLUR_GRACE);
|
|
700
|
+
},
|
|
701
|
+
[clearCloseTimer, onBlur]
|
|
702
|
+
);
|
|
703
|
+
|
|
704
|
+
const handleKeyPress = useCallback(
|
|
705
|
+
(event: NativeSyntheticEvent<TextInputKeyPressEventData>) => {
|
|
706
|
+
// Only on an empty field: while there is a query, backspace is editing
|
|
707
|
+
// it, and eating a token instead would delete something nobody aimed at.
|
|
708
|
+
if (event.nativeEvent.key === 'Backspace' && text.length === 0) {
|
|
709
|
+
onRemoveLastToken?.();
|
|
710
|
+
}
|
|
711
|
+
onKeyPress?.(event);
|
|
291
712
|
},
|
|
292
|
-
[
|
|
713
|
+
[onKeyPress, onRemoveLastToken, text.length]
|
|
293
714
|
);
|
|
294
715
|
|
|
295
716
|
const handleSubmit = useCallback(
|
|
@@ -315,7 +736,12 @@ export const SearchBar = forwardRef<TextInput, SearchBarProps>(
|
|
|
315
736
|
|
|
316
737
|
const handleCancel = useCallback(() => {
|
|
317
738
|
if (disabled) return;
|
|
739
|
+
// Deliberate: the recovery above must not answer this blur by handing
|
|
740
|
+
// the focus straight back.
|
|
741
|
+
ending.current = true;
|
|
742
|
+
guarded.current = false;
|
|
318
743
|
setText('');
|
|
744
|
+
setFocused(false);
|
|
319
745
|
inputRef.current?.blur();
|
|
320
746
|
onCancel?.();
|
|
321
747
|
}, [disabled, onCancel, setText]);
|
|
@@ -349,7 +775,77 @@ export const SearchBar = forwardRef<TextInput, SearchBarProps>(
|
|
|
349
775
|
[cancelWidth]
|
|
350
776
|
);
|
|
351
777
|
|
|
352
|
-
|
|
778
|
+
/*
|
|
779
|
+
* How much room the panel has, in the direction it opens. Measured on the
|
|
780
|
+
* JS side because it decides a layout constraint rather than a frame of an
|
|
781
|
+
* animation: a `maxHeight` that changed every frame would re-lay out the
|
|
782
|
+
* list under the finger scrolling it.
|
|
783
|
+
*/
|
|
784
|
+
const { height: windowHeight } = useWindowDimensions();
|
|
785
|
+
const { height: keyboardHeight } = useKeyboard();
|
|
786
|
+
useEffect(() => {
|
|
787
|
+
keyboardUp.current = keyboardHeight > 0;
|
|
788
|
+
}, [keyboardHeight]);
|
|
789
|
+
const anchorRef = useRef<View | null>(null);
|
|
790
|
+
const [anchorBox, setAnchorBox] = useState<{
|
|
791
|
+
top: number;
|
|
792
|
+
height: number;
|
|
793
|
+
width: number;
|
|
794
|
+
} | null>(null);
|
|
795
|
+
|
|
796
|
+
const measureAnchor = useCallback(() => {
|
|
797
|
+
anchorRef.current?.measureInWindow((_x, y, width, height) => {
|
|
798
|
+
setAnchorBox((current) =>
|
|
799
|
+
current &&
|
|
800
|
+
current.top === y &&
|
|
801
|
+
current.height === height &&
|
|
802
|
+
current.width === width
|
|
803
|
+
? current
|
|
804
|
+
: { top: y, height, width }
|
|
805
|
+
);
|
|
806
|
+
});
|
|
807
|
+
}, []);
|
|
808
|
+
|
|
809
|
+
useEffect(() => {
|
|
810
|
+
if (!panelOpen) return;
|
|
811
|
+
measureAnchor();
|
|
812
|
+
}, [panelOpen, keyboardHeight, measureAnchor]);
|
|
813
|
+
|
|
814
|
+
const resolvedMaxHeight = useMemo(() => {
|
|
815
|
+
const fieldHeight = anchorBox?.height ?? 0;
|
|
816
|
+
/*
|
|
817
|
+
* While the field is riding the keyboard, where it has come to rest is
|
|
818
|
+
* computed rather than measured: the lift is a transform applied on the
|
|
819
|
+
* UI thread, so a measurement taken from JavaScript is a frame behind it
|
|
820
|
+
* for the whole of the animation.
|
|
821
|
+
*/
|
|
822
|
+
const fieldTop =
|
|
823
|
+
avoidKeyboard && keyboardHeight > 0
|
|
824
|
+
? windowHeight - keyboardHeight - keyboardOffset - fieldHeight
|
|
825
|
+
: (anchorBox?.top ?? 0);
|
|
826
|
+
const room =
|
|
827
|
+
panelPlacement === 'top'
|
|
828
|
+
? fieldTop - PANEL_EDGE_GAP
|
|
829
|
+
: windowHeight - keyboardHeight - fieldTop - fieldHeight - PANEL_EDGE_GAP;
|
|
830
|
+
/*
|
|
831
|
+
* The smaller of what it is allowed and what it actually has, floored so
|
|
832
|
+
* a cramped screen still opens something worth reading. A caller's cap
|
|
833
|
+
* is clamped to the room too — a cap taller than the space available is
|
|
834
|
+
* a panel running off the top of the display.
|
|
835
|
+
*/
|
|
836
|
+
const cap = panelMaxHeight ?? PANEL_MAX_HEIGHT;
|
|
837
|
+
return Math.max(Math.min(cap, room), PANEL_MIN_HEIGHT);
|
|
838
|
+
}, [
|
|
839
|
+
anchorBox,
|
|
840
|
+
avoidKeyboard,
|
|
841
|
+
keyboardHeight,
|
|
842
|
+
keyboardOffset,
|
|
843
|
+
panelMaxHeight,
|
|
844
|
+
panelPlacement,
|
|
845
|
+
windowHeight,
|
|
846
|
+
]);
|
|
847
|
+
|
|
848
|
+
const glyph = (
|
|
353
849
|
/*
|
|
354
850
|
* Decorative, and said so here rather than through Input's
|
|
355
851
|
* `interactiveContent` — that flag covers both ends of the field, and
|
|
@@ -357,6 +853,7 @@ export const SearchBar = forwardRef<TextInput, SearchBarProps>(
|
|
|
357
853
|
* touches with it.
|
|
358
854
|
*/
|
|
359
855
|
<View
|
|
856
|
+
key="glyph"
|
|
360
857
|
pointerEvents="none"
|
|
361
858
|
accessibilityElementsHidden
|
|
362
859
|
importantForAccessibility="no-hide-descendants"
|
|
@@ -365,6 +862,36 @@ export const SearchBar = forwardRef<TextInput, SearchBarProps>(
|
|
|
365
862
|
</View>
|
|
366
863
|
);
|
|
367
864
|
|
|
865
|
+
/*
|
|
866
|
+
* Capped and scrolling rather than wrapping. The field is one line tall, so
|
|
867
|
+
* a row of chips allowed to grow would move the caret every time something
|
|
868
|
+
* was picked; and left uncapped it would take the whole field and leave
|
|
869
|
+
* nowhere to type the next query.
|
|
870
|
+
*/
|
|
871
|
+
const tokenRow = tokens ? (
|
|
872
|
+
<ScrollView
|
|
873
|
+
key="tokens"
|
|
874
|
+
horizontal
|
|
875
|
+
keyboardShouldPersistTaps="always"
|
|
876
|
+
showsHorizontalScrollIndicator={false}
|
|
877
|
+
style={{
|
|
878
|
+
maxWidth: anchorBox ? anchorBox.width * TOKEN_MAX_SHARE : undefined,
|
|
879
|
+
}}
|
|
880
|
+
contentContainerClassName={slots.tokenRow()}
|
|
881
|
+
>
|
|
882
|
+
{tokens}
|
|
883
|
+
</ScrollView>
|
|
884
|
+
) : null;
|
|
885
|
+
|
|
886
|
+
const startContent = tokenRow ? (
|
|
887
|
+
<>
|
|
888
|
+
{glyph}
|
|
889
|
+
{tokenRow}
|
|
890
|
+
</>
|
|
891
|
+
) : (
|
|
892
|
+
glyph
|
|
893
|
+
);
|
|
894
|
+
|
|
368
895
|
const endContent = loading ? (
|
|
369
896
|
<Spinner size="sm" />
|
|
370
897
|
) : isClearable && text.length > 0 ? (
|
|
@@ -386,13 +913,14 @@ export const SearchBar = forwardRef<TextInput, SearchBarProps>(
|
|
|
386
913
|
size={size}
|
|
387
914
|
disabled={disabled}
|
|
388
915
|
className={slots.field({ className })}
|
|
389
|
-
containerClassName=
|
|
916
|
+
containerClassName="w-full"
|
|
390
917
|
startContent={startContent}
|
|
391
918
|
endContent={endContent}
|
|
392
919
|
value={text}
|
|
393
920
|
onChangeText={setText}
|
|
394
921
|
onFocus={handleFocus}
|
|
395
922
|
onBlur={handleBlur}
|
|
923
|
+
onKeyPress={handleKeyPress}
|
|
396
924
|
onSubmitEditing={handleSubmit}
|
|
397
925
|
accessibilityRole="search"
|
|
398
926
|
returnKeyType="search"
|
|
@@ -407,41 +935,401 @@ export const SearchBar = forwardRef<TextInput, SearchBarProps>(
|
|
|
407
935
|
/>
|
|
408
936
|
);
|
|
409
937
|
|
|
410
|
-
|
|
938
|
+
const context = useMemo<SearchBarContextValue>(
|
|
939
|
+
() => ({ size, retainFocus }),
|
|
940
|
+
[retainFocus, size]
|
|
941
|
+
);
|
|
411
942
|
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
>
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
943
|
+
// Nothing beside it and nothing under it: the field is the whole component,
|
|
944
|
+
// and a wrapper around it would only be a box for the caller's layout to
|
|
945
|
+
// fight.
|
|
946
|
+
if (cancel === 'never' && !avoidKeyboard && !hasPanel) {
|
|
947
|
+
return (
|
|
948
|
+
<SearchBarContext.Provider value={context}>
|
|
949
|
+
<View ref={anchorRef} onLayout={measureAnchor} className={containerClassName}>
|
|
950
|
+
{field}
|
|
951
|
+
</View>
|
|
952
|
+
</SearchBarContext.Provider>
|
|
953
|
+
);
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
/*
|
|
957
|
+
* The results, the hairline and the room the field occupies, in the order
|
|
958
|
+
* they are stacked. The field itself is drawn over that last piece rather
|
|
959
|
+
* than inside the card: it has to keep its own place in the layout, and a
|
|
960
|
+
* text field that moved into an absolutely positioned box on focus would
|
|
961
|
+
* remount and lose the keyboard it just opened.
|
|
962
|
+
*/
|
|
963
|
+
const list = (
|
|
964
|
+
<ScrollView
|
|
965
|
+
key="list"
|
|
966
|
+
style={{ maxHeight: resolvedMaxHeight }}
|
|
967
|
+
/*
|
|
968
|
+
* `always`, not `handled`: everything in the panel that is not itself
|
|
969
|
+
* a button — the padding, the gaps between rows, a section heading,
|
|
970
|
+
* the whole of `SearchBar.Status` — would otherwise spend the first
|
|
971
|
+
* tap dismissing the keyboard, which ends the search.
|
|
972
|
+
*/
|
|
973
|
+
keyboardShouldPersistTaps="always"
|
|
974
|
+
keyboardDismissMode="none"
|
|
975
|
+
showsVerticalScrollIndicator={false}
|
|
976
|
+
bounces={false}
|
|
977
|
+
className={slots.panelList()}
|
|
978
|
+
>
|
|
979
|
+
{children}
|
|
980
|
+
</ScrollView>
|
|
981
|
+
);
|
|
982
|
+
const divider = (
|
|
983
|
+
<View
|
|
984
|
+
key="divider"
|
|
985
|
+
pointerEvents="none"
|
|
986
|
+
style={{ height: StyleSheet.hairlineWidth }}
|
|
987
|
+
className={slots.panelDivider()}
|
|
988
|
+
/>
|
|
989
|
+
);
|
|
990
|
+
/*
|
|
991
|
+
* The room the card keeps for the field, and the reason it is inert.
|
|
992
|
+
*
|
|
993
|
+
* It is a spacer drawn at exactly the field's position, inside the card and
|
|
994
|
+
* therefore outside the list's `keyboardShouldPersistTaps`. Any touch it
|
|
995
|
+
* wins is a touch on a plain view while a field is focused, which is the
|
|
996
|
+
* platform's cue to dismiss the keyboard — so the field blurs, and the
|
|
997
|
+
* panel drawn out of that focus closes. From the outside that is "tapping
|
|
998
|
+
* the search box closes the results", which is exactly backwards.
|
|
999
|
+
*
|
|
1000
|
+
* It has nothing to be pressed for. `none` puts the touch through to the
|
|
1001
|
+
* field underneath it, where it was aimed.
|
|
1002
|
+
*/
|
|
1003
|
+
const fieldSlot = (
|
|
1004
|
+
<View
|
|
1005
|
+
key="slot"
|
|
1006
|
+
pointerEvents="none"
|
|
1007
|
+
style={{ height: anchorBox?.height ?? FIELD_HEIGHT[size] }}
|
|
1008
|
+
/>
|
|
1009
|
+
);
|
|
1010
|
+
|
|
1011
|
+
const anchor = (
|
|
1012
|
+
<View
|
|
1013
|
+
ref={anchorRef}
|
|
1014
|
+
onLayout={measureAnchor}
|
|
1015
|
+
style={RAISED}
|
|
1016
|
+
className={slots.anchor({ className: cancel === 'never' ? 'w-full' : 'flex-1' })}
|
|
1017
|
+
>
|
|
1018
|
+
{panelOpen ? (
|
|
1019
|
+
<Animated.View
|
|
1020
|
+
entering={FadeIn.duration(PANEL_IN)}
|
|
1021
|
+
exiting={FadeOut.duration(PANEL_OUT)}
|
|
1022
|
+
// `box-none`: the card is a surface, not a target. Only the rows
|
|
1023
|
+
// inside it answer a touch, and the space kept for the field lets
|
|
1024
|
+
// one through to the field.
|
|
1025
|
+
pointerEvents="box-none"
|
|
1026
|
+
style={panelPlacement === 'top' ? CARD_ABOVE : CARD_BELOW}
|
|
1027
|
+
className={slots.panel()}
|
|
438
1028
|
>
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
1029
|
+
{panelPlacement === 'top'
|
|
1030
|
+
? [list, divider, fieldSlot]
|
|
1031
|
+
: [fieldSlot, divider, list]}
|
|
1032
|
+
</Animated.View>
|
|
1033
|
+
) : null}
|
|
1034
|
+
{field}
|
|
442
1035
|
</View>
|
|
443
1036
|
);
|
|
1037
|
+
|
|
1038
|
+
const body =
|
|
1039
|
+
cancel === 'never' ? (
|
|
1040
|
+
anchor
|
|
1041
|
+
) : (
|
|
1042
|
+
<>
|
|
1043
|
+
{anchor}
|
|
1044
|
+
<Animated.View
|
|
1045
|
+
style={cancelStyle}
|
|
1046
|
+
className={slots.cancelClip()}
|
|
1047
|
+
pointerEvents={cancelOut ? 'auto' : 'none'}
|
|
1048
|
+
accessibilityElementsHidden={!cancelOut}
|
|
1049
|
+
importantForAccessibility={cancelOut ? 'auto' : 'no-hide-descendants'}
|
|
1050
|
+
>
|
|
1051
|
+
{/*
|
|
1052
|
+
* Absolute, and pinned to the end edge: it keeps its natural width
|
|
1053
|
+
* inside a container whose width is animating, so the clip reveals
|
|
1054
|
+
* it from the edge instead of squeezing the word as it arrives. It
|
|
1055
|
+
* is also what makes the measurement possible at all — a child laid
|
|
1056
|
+
* out against a container of width 0 would otherwise report 0.
|
|
1057
|
+
*/}
|
|
1058
|
+
<AnimatedPressable
|
|
1059
|
+
onLayout={handleCancelLayout}
|
|
1060
|
+
onPress={handleCancel}
|
|
1061
|
+
disabled={disabled}
|
|
1062
|
+
focusable={!disabled}
|
|
1063
|
+
hitSlop={8}
|
|
1064
|
+
accessibilityRole="button"
|
|
1065
|
+
accessibilityState={{ disabled: !!disabled }}
|
|
1066
|
+
className={slots.cancelButton()}
|
|
1067
|
+
>
|
|
1068
|
+
<Text className={slots.cancelLabel()}>{cancelLabel}</Text>
|
|
1069
|
+
</AnimatedPressable>
|
|
1070
|
+
</Animated.View>
|
|
1071
|
+
</>
|
|
1072
|
+
);
|
|
1073
|
+
|
|
1074
|
+
/*
|
|
1075
|
+
* The keyboard hook is behind a component boundary rather than a flag.
|
|
1076
|
+
* Calling it at all has global consequences — without the keyboard
|
|
1077
|
+
* controller installed it falls back to Reanimated's useAnimatedKeyboard,
|
|
1078
|
+
* which switches Android out of adjustResize for the whole app. A search
|
|
1079
|
+
* bar that never asked to avoid the keyboard must not do that to every
|
|
1080
|
+
* other screen.
|
|
1081
|
+
*/
|
|
1082
|
+
if (avoidKeyboard) {
|
|
1083
|
+
return (
|
|
1084
|
+
<SearchBarContext.Provider value={context}>
|
|
1085
|
+
<KeyboardAvoider
|
|
1086
|
+
// Only while *this* field is the one being typed into. Without it
|
|
1087
|
+
// every avoiding field on the screen lifts the moment any field
|
|
1088
|
+
// anywhere is tapped, and since they all aim at the same gap above
|
|
1089
|
+
// the keyboard, they arrive stacked on top of one another.
|
|
1090
|
+
active={focused}
|
|
1091
|
+
mode="lift"
|
|
1092
|
+
offset={keyboardOffset}
|
|
1093
|
+
className={slots.row({ className: containerClassName })}
|
|
1094
|
+
>
|
|
1095
|
+
{body}
|
|
1096
|
+
</KeyboardAvoider>
|
|
1097
|
+
</SearchBarContext.Provider>
|
|
1098
|
+
);
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
return (
|
|
1102
|
+
<SearchBarContext.Provider value={context}>
|
|
1103
|
+
<View className={slots.row({ className: containerClassName })}>{body}</View>
|
|
1104
|
+
</SearchBarContext.Provider>
|
|
1105
|
+
);
|
|
444
1106
|
}
|
|
445
1107
|
);
|
|
446
1108
|
|
|
447
|
-
|
|
1109
|
+
SearchBarRoot.displayName = 'SearchBar';
|
|
1110
|
+
|
|
1111
|
+
export interface SearchBarSectionProps extends ViewProps {
|
|
1112
|
+
className?: string;
|
|
1113
|
+
/**
|
|
1114
|
+
* The heading over the run of rows — "Suggested", "Results". Announced as a
|
|
1115
|
+
* header, so a screen reader reaching the group is told what it is before
|
|
1116
|
+
* walking into it.
|
|
1117
|
+
*/
|
|
1118
|
+
label?: string;
|
|
1119
|
+
children?: ReactNode;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
/** A labelled run of rows inside the panel. */
|
|
1123
|
+
function SearchBarSection({ className, label, children, ...props }: SearchBarSectionProps) {
|
|
1124
|
+
const { sectionLabel } = searchBarVariants();
|
|
1125
|
+
return (
|
|
1126
|
+
<View {...props} className={className}>
|
|
1127
|
+
{label ? (
|
|
1128
|
+
<Text accessibilityRole="header" className={sectionLabel()}>
|
|
1129
|
+
{label}
|
|
1130
|
+
</Text>
|
|
1131
|
+
) : null}
|
|
1132
|
+
{children}
|
|
1133
|
+
</View>
|
|
1134
|
+
);
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
SearchBarSection.displayName = 'SearchBar.Section';
|
|
1138
|
+
|
|
1139
|
+
export interface SearchBarItemProps extends Omit<AnimatedPressableProps, 'children'> {
|
|
1140
|
+
className?: string;
|
|
1141
|
+
/** Anything before the label — an avatar, a logo, a status dot. */
|
|
1142
|
+
leading?: ReactNode;
|
|
1143
|
+
/**
|
|
1144
|
+
* Anything after it. A slot rather than a built-in button, because what a
|
|
1145
|
+
* result row offers differs per search: an add, a pin, a count, nothing.
|
|
1146
|
+
*/
|
|
1147
|
+
trailing?: ReactNode;
|
|
1148
|
+
/** A second line under the label, for what the label alone cannot say. */
|
|
1149
|
+
description?: string;
|
|
1150
|
+
/** Draws the row as the one the search has settled on. */
|
|
1151
|
+
selected?: boolean;
|
|
1152
|
+
/** The row's label. */
|
|
1153
|
+
children?: ReactNode;
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
/** One result. */
|
|
1157
|
+
function SearchBarItem({
|
|
1158
|
+
className,
|
|
1159
|
+
leading,
|
|
1160
|
+
trailing,
|
|
1161
|
+
description,
|
|
1162
|
+
selected,
|
|
1163
|
+
children,
|
|
1164
|
+
onPressIn,
|
|
1165
|
+
...props
|
|
1166
|
+
}: SearchBarItemProps) {
|
|
1167
|
+
const { item, itemLabel } = searchBarVariants({ selected: !!selected });
|
|
1168
|
+
const search = useContext(SearchBarContext);
|
|
1169
|
+
return (
|
|
1170
|
+
<AnimatedPressable
|
|
1171
|
+
accessibilityRole="button"
|
|
1172
|
+
accessibilityState={{ selected: !!selected, disabled: !!props.disabled }}
|
|
1173
|
+
/*
|
|
1174
|
+
* Before the press, not after it: the blur it may cause is what closes
|
|
1175
|
+
* the panel this row is drawn in, and by the time `onPress` runs the row
|
|
1176
|
+
* can already be gone.
|
|
1177
|
+
*/
|
|
1178
|
+
onPressIn={(event) => {
|
|
1179
|
+
search?.retainFocus();
|
|
1180
|
+
onPressIn?.(event);
|
|
1181
|
+
}}
|
|
1182
|
+
// A row is a wide target, and a target that shrinks when pressed reads as
|
|
1183
|
+
// a card rather than a line in a list. The dim is the whole feedback.
|
|
1184
|
+
pressScale={1}
|
|
1185
|
+
pressOpacity={0.6}
|
|
1186
|
+
// The rows sit flush against each other, so the points either side of the
|
|
1187
|
+
// gap between two of them would otherwise belong to neither.
|
|
1188
|
+
hitSlop={{ top: 2, bottom: 2 }}
|
|
1189
|
+
{...props}
|
|
1190
|
+
className={item({ className })}
|
|
1191
|
+
>
|
|
1192
|
+
{leading}
|
|
1193
|
+
<View className="flex-1">
|
|
1194
|
+
<Text numberOfLines={1} className={itemLabel()}>
|
|
1195
|
+
{children}
|
|
1196
|
+
</Text>
|
|
1197
|
+
{description ? (
|
|
1198
|
+
<Text size="sm" muted numberOfLines={1}>
|
|
1199
|
+
{description}
|
|
1200
|
+
</Text>
|
|
1201
|
+
) : null}
|
|
1202
|
+
</View>
|
|
1203
|
+
{trailing}
|
|
1204
|
+
</AnimatedPressable>
|
|
1205
|
+
);
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
SearchBarItem.displayName = 'SearchBar.Item';
|
|
1209
|
+
|
|
1210
|
+
export interface SearchBarStatusProps extends ViewProps {
|
|
1211
|
+
className?: string;
|
|
1212
|
+
/** A spinner beside the line, for a search that is still running. */
|
|
1213
|
+
loading?: boolean;
|
|
1214
|
+
children?: ReactNode;
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
/**
|
|
1218
|
+
* The one line a panel shows instead of rows — nothing typed yet, a search in
|
|
1219
|
+
* flight, or a query that matched nothing. It is a sentence rather than an
|
|
1220
|
+
* empty box because those three states look identical when they are blank, and
|
|
1221
|
+
* which one it is decides what the person does next.
|
|
1222
|
+
*/
|
|
1223
|
+
function SearchBarStatus({
|
|
1224
|
+
className,
|
|
1225
|
+
loading = false,
|
|
1226
|
+
children,
|
|
1227
|
+
...props
|
|
1228
|
+
}: SearchBarStatusProps) {
|
|
1229
|
+
const { status } = searchBarVariants();
|
|
1230
|
+
return (
|
|
1231
|
+
<View accessibilityRole="text" {...props} className={status({ className })}>
|
|
1232
|
+
{loading ? <Spinner size="sm" /> : null}
|
|
1233
|
+
<Text size="sm" muted>
|
|
1234
|
+
{children}
|
|
1235
|
+
</Text>
|
|
1236
|
+
</View>
|
|
1237
|
+
);
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
SearchBarStatus.displayName = 'SearchBar.Status';
|
|
1241
|
+
|
|
1242
|
+
export interface SearchBarActionProps extends AnimatedPressableProps {
|
|
1243
|
+
className?: string;
|
|
1244
|
+
children?: ReactNode;
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
/**
|
|
1248
|
+
* A button inside a row — an add, a pin, a remove — for the `trailing` slot.
|
|
1249
|
+
*
|
|
1250
|
+
* It exists rather than being left to a plain `Pressable` because a control
|
|
1251
|
+
* nested inside a row takes the touch itself, so the row above it never sees
|
|
1252
|
+
* the press and cannot hold the field's focus on its behalf. Pressed, this one
|
|
1253
|
+
* ends up blurring the field, and a blurred field closes the panel the button
|
|
1254
|
+
* was standing in — the press lands and the search disappears under it.
|
|
1255
|
+
*/
|
|
1256
|
+
function SearchBarAction({ className, children, onPressIn, ...props }: SearchBarActionProps) {
|
|
1257
|
+
const search = useContext(SearchBarContext);
|
|
1258
|
+
return (
|
|
1259
|
+
<AnimatedPressable
|
|
1260
|
+
accessibilityRole="button"
|
|
1261
|
+
hitSlop={12}
|
|
1262
|
+
pressScale={1}
|
|
1263
|
+
pressOpacity={0.5}
|
|
1264
|
+
onPressIn={(event) => {
|
|
1265
|
+
search?.retainFocus();
|
|
1266
|
+
onPressIn?.(event);
|
|
1267
|
+
}}
|
|
1268
|
+
{...props}
|
|
1269
|
+
className={className}
|
|
1270
|
+
>
|
|
1271
|
+
{children}
|
|
1272
|
+
</AnimatedPressable>
|
|
1273
|
+
);
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
SearchBarAction.displayName = 'SearchBar.Action';
|
|
1277
|
+
|
|
1278
|
+
export interface SearchBarTokenProps extends ViewProps {
|
|
1279
|
+
className?: string;
|
|
1280
|
+
/** Anything before the label — an avatar, a logo, a status dot. */
|
|
1281
|
+
leading?: ReactNode;
|
|
1282
|
+
/** Fires when the chip's ✕ is pressed. Without it no ✕ is drawn. */
|
|
1283
|
+
onRemove?: () => void;
|
|
1284
|
+
/** How the ✕ announces itself. Defaults to `Remove <label>`. */
|
|
1285
|
+
removeLabel?: string;
|
|
1286
|
+
/** The chip's label. */
|
|
1287
|
+
children?: ReactNode;
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
/**
|
|
1291
|
+
* One choice already made, drawn inside the field before the caret.
|
|
1292
|
+
*
|
|
1293
|
+
* It sits in the field rather than in a list above it so that the query and
|
|
1294
|
+
* what the query has produced are one control. A search that files its results
|
|
1295
|
+
* somewhere else asks the reader to look in two places to know where they are.
|
|
1296
|
+
*/
|
|
1297
|
+
function SearchBarToken({
|
|
1298
|
+
className,
|
|
1299
|
+
leading,
|
|
1300
|
+
onRemove,
|
|
1301
|
+
removeLabel,
|
|
1302
|
+
children,
|
|
1303
|
+
...props
|
|
1304
|
+
}: SearchBarTokenProps) {
|
|
1305
|
+
const search = useContext(SearchBarContext);
|
|
1306
|
+
const slots = searchBarVariants({ size: search?.size ?? 'md' });
|
|
1307
|
+
const label = typeof children === 'string' ? children : undefined;
|
|
1308
|
+
return (
|
|
1309
|
+
<View {...props} className={slots.token({ className })}>
|
|
1310
|
+
{leading}
|
|
1311
|
+
<Text numberOfLines={1} className={slots.tokenLabel()}>
|
|
1312
|
+
{children}
|
|
1313
|
+
</Text>
|
|
1314
|
+
{onRemove ? (
|
|
1315
|
+
<SearchBarAction
|
|
1316
|
+
accessibilityLabel={removeLabel ?? (label ? `Remove ${label}` : 'Remove')}
|
|
1317
|
+
onPress={onRemove}
|
|
1318
|
+
className={slots.tokenRemove()}
|
|
1319
|
+
>
|
|
1320
|
+
<XIcon size={12} />
|
|
1321
|
+
</SearchBarAction>
|
|
1322
|
+
) : null}
|
|
1323
|
+
</View>
|
|
1324
|
+
);
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
SearchBarToken.displayName = 'SearchBar.Token';
|
|
1328
|
+
|
|
1329
|
+
export const SearchBar = Object.assign(SearchBarRoot, {
|
|
1330
|
+
Section: SearchBarSection,
|
|
1331
|
+
Item: SearchBarItem,
|
|
1332
|
+
Action: SearchBarAction,
|
|
1333
|
+
Token: SearchBarToken,
|
|
1334
|
+
Status: SearchBarStatus,
|
|
1335
|
+
});
|