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
|
@@ -2,14 +2,81 @@
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* SearchBar — a text field for querying a list, with the two controls a search
|
|
5
|
-
* needs and an ordinary field does not
|
|
5
|
+
* needs and an ordinary field does not, and a panel of results that opens out
|
|
6
|
+
* of the field itself.
|
|
6
7
|
*
|
|
7
8
|
* ```tsx
|
|
8
9
|
* <SearchBar placeholder="Search orders" onSubmit={run} />
|
|
9
10
|
* <SearchBar variant="filled" shape="pill" cancel="focus" />
|
|
10
|
-
* <SearchBar
|
|
11
|
+
* <SearchBar avoidKeyboard value={query} onChangeText={setQuery}>
|
|
12
|
+
* <SearchBar.Section label="Suggested">
|
|
13
|
+
* <SearchBar.Item trailing={<AddButton />} onPress={add}>Claude</SearchBar.Item>
|
|
14
|
+
* </SearchBar.Section>
|
|
15
|
+
* </SearchBar>
|
|
11
16
|
* ```
|
|
12
17
|
*
|
|
18
|
+
* ## The results are above the field, and the field is above the keyboard
|
|
19
|
+
*
|
|
20
|
+
* A search that is being typed into has a keyboard under it, and a list drawn
|
|
21
|
+
* below the field is a list drawn behind the keyboard. So `avoidKeyboard`
|
|
22
|
+
* lifts the field until it sits `keyboardOffset` points clear of the keyboard's
|
|
23
|
+
* top edge, and the panel opens *upward* out of it into the space that is
|
|
24
|
+
* actually free.
|
|
25
|
+
*
|
|
26
|
+
* That puts the first result nearest the field and the last one furthest away,
|
|
27
|
+
* which is the order a reader walking away from the caret expects. Pass
|
|
28
|
+
* `panelPlacement="bottom"` for a search bar in a header, where the space is
|
|
29
|
+
* the other way round.
|
|
30
|
+
*
|
|
31
|
+
* The panel is positioned absolutely rather than laid out in the flow, so
|
|
32
|
+
* opening it never moves the page underneath — a list that pushes the field it
|
|
33
|
+
* belongs to is a field that walks away from the finger typing into it.
|
|
34
|
+
*
|
|
35
|
+
* ## Touches inside the panel must not close the keyboard
|
|
36
|
+
*
|
|
37
|
+
* The panel scrolls with `keyboardShouldPersistTaps="always"`, and every press
|
|
38
|
+
* inside it holds the field's focus open for a moment afterwards. Both are
|
|
39
|
+
* needed, because a search closes the instant the field blurs and there are
|
|
40
|
+
* two separate ways for a touch in the panel to blur it.
|
|
41
|
+
*
|
|
42
|
+
* `"handled"` only spares presses a child takes responsibility for, which
|
|
43
|
+
* leaves the panel's own padding, the gaps between rows, a section heading and
|
|
44
|
+
* the whole of `SearchBar.Status` as live dismiss surfaces — tapping the word
|
|
45
|
+
* "Searching …" would end the search. `"always"` gives the panel back.
|
|
46
|
+
*
|
|
47
|
+
* The focus guard covers the other way: a control inside a row — an add
|
|
48
|
+
* button, a remove ✕ — takes focus with the press on Android, and returning it
|
|
49
|
+
* a frame later is not enough on its own, because the blur has already closed
|
|
50
|
+
* the panel the control was drawn in. So a press in the panel marks the field
|
|
51
|
+
* as still being used, and a blur arriving under that mark is answered by
|
|
52
|
+
* asking for focus back rather than by ending the search.
|
|
53
|
+
*
|
|
54
|
+
* That guard only knows about presses that go through this component's own
|
|
55
|
+
* parts, and a caller's `Pressable` in a row's `trailing` slot takes the touch
|
|
56
|
+
* itself. So the panel also waits before believing any blur, and asks the
|
|
57
|
+
* keyboard: it is still up, because nothing in the panel dismisses it, and a
|
|
58
|
+
* search whose keyboard is still up has not ended. Focus goes back instead.
|
|
59
|
+
*
|
|
60
|
+
* ## The space kept for the field is not a target
|
|
61
|
+
*
|
|
62
|
+
* The card is one box around the results *and* the field, so it carries a
|
|
63
|
+
* spacer where the field sits. That spacer is a plain view drawn over a
|
|
64
|
+
* focused field, and a touch on a plain view is the platform's cue to dismiss
|
|
65
|
+
* the keyboard — so winning one blurred the field and closed the panel drawn
|
|
66
|
+
* out of that focus. Tapping the search box shut the results, which is exactly
|
|
67
|
+
* backwards. The card and its spacer take no touches at all now.
|
|
68
|
+
*
|
|
69
|
+
* ## What has already been picked goes in the field
|
|
70
|
+
*
|
|
71
|
+
* `tokens` puts the choices made so far inside the field, before the caret, so
|
|
72
|
+
* the query and what it has produced are one control rather than a control and
|
|
73
|
+
* a list somewhere above it. `SearchBar.Token` is the chip; backspace on an
|
|
74
|
+
* empty field fires `onRemoveLastToken`, which is what a token field does
|
|
75
|
+
* everywhere else.
|
|
76
|
+
*
|
|
77
|
+
* They scroll rather than wrap: the field is one line tall, and a row of chips
|
|
78
|
+
* that grew it would move the caret every time something was picked.
|
|
79
|
+
*
|
|
13
80
|
* ## The clear button, and why it is not the platform's
|
|
14
81
|
*
|
|
15
82
|
* A ✕ appears inside the field as soon as there is something to clear, and
|
|
@@ -45,19 +112,21 @@
|
|
|
45
112
|
* pause instead of once per letter. Submitting flushes it immediately, since
|
|
46
113
|
* a return key is somebody saying they are done waiting.
|
|
47
114
|
*/
|
|
48
|
-
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';
|
|
49
|
-
import { View } from 'react-native';
|
|
50
|
-
import Animated, { useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated';
|
|
115
|
+
import { Children, createContext, forwardRef, isValidElement, useContext, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
|
|
116
|
+
import { ScrollView, StyleSheet, View, useWindowDimensions } from 'react-native';
|
|
117
|
+
import Animated, { FadeIn, FadeOut, useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated';
|
|
51
118
|
import { tv } from 'tailwind-variants';
|
|
119
|
+
import { useKeyboard } from "../../hooks/use-keyboard.js";
|
|
52
120
|
import { SearchIcon, XIcon } from "../../icons/index.js";
|
|
53
121
|
import { AnimatedPressable } from "../../primitives/animated-pressable.js";
|
|
122
|
+
import { KeyboardAvoider } from "../../primitives/keyboard-avoider.js";
|
|
54
123
|
import { Text } from "../../primitives/text.js";
|
|
55
124
|
import { Input } from "../input/index.js";
|
|
56
125
|
import { Spinner } from "../spinner/index.js";
|
|
57
126
|
import { cancelSearchBarDebounce, flushSearchBarDebounce, scheduleSearchBarDebounce } from "./search-bar-debounce.js";
|
|
58
127
|
|
|
59
128
|
/** Matches the field's own focus crossfade, so the row settles as one thing. */
|
|
60
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
129
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
61
130
|
const CANCEL_DURATION = 180;
|
|
62
131
|
|
|
63
132
|
/** Space kept between the field and the Cancel button while it is out. */
|
|
@@ -68,10 +137,140 @@ const CANCEL_GAP = 8;
|
|
|
68
137
|
* changing the height of the field it sits in.
|
|
69
138
|
*/
|
|
70
139
|
const CLEAR_HIT_SLOP = 12;
|
|
140
|
+
|
|
141
|
+
/** The panel's crossfade, matching every other anchored list in the library. */
|
|
142
|
+
const PANEL_IN = 140;
|
|
143
|
+
const PANEL_OUT = 120;
|
|
144
|
+
|
|
145
|
+
/** Gap left between the panel's far edge and the edge of the screen. */
|
|
146
|
+
const PANEL_EDGE_GAP = 24;
|
|
147
|
+
|
|
148
|
+
/** Floor for the derived height — below this a list is not worth opening. */
|
|
149
|
+
const PANEL_MIN_HEIGHT = 160;
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Ceiling for the derived height, in points. About six rows and a heading.
|
|
153
|
+
*
|
|
154
|
+
* The room above a lifted field is most of the screen, and a panel that takes
|
|
155
|
+
* all of it is a full-screen list that happens to have a search box under it —
|
|
156
|
+
* the page it was opened over stops being visible, so the search stops reading
|
|
157
|
+
* as something laid over the app. Past this the results scroll, which is what
|
|
158
|
+
* a long list should do anyway.
|
|
159
|
+
*
|
|
160
|
+
* `panelMaxHeight` overrides it for a screen that really is nothing but the
|
|
161
|
+
* search.
|
|
162
|
+
*/
|
|
163
|
+
const PANEL_MAX_HEIGHT = 320;
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The field's height per size, matching `Input`'s own `h-10 / h-12 / h-14`.
|
|
167
|
+
*
|
|
168
|
+
* It is the fallback for the slot the panel keeps for the field, which is
|
|
169
|
+
* otherwise the measured height and therefore zero on the frame the panel
|
|
170
|
+
* first opens. A zero slot puts the card's bottom edge at the field's, so the
|
|
171
|
+
* last row is drawn underneath the field — which is painted after the card and
|
|
172
|
+
* takes the touch. The press then reads as a tap on the input.
|
|
173
|
+
*/
|
|
174
|
+
const FIELD_HEIGHT = {
|
|
175
|
+
sm: 40,
|
|
176
|
+
md: 48,
|
|
177
|
+
lg: 56
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* How long a press inside the panel keeps the field's focus. Long enough to
|
|
182
|
+
* cover the blur Android sends with the press and the re-render that follows
|
|
183
|
+
* it, short enough that a real dismissal is never held open.
|
|
184
|
+
*/
|
|
185
|
+
const FOCUS_GUARD = 400;
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* How long the panel waits after a blur before believing the search is over.
|
|
189
|
+
*
|
|
190
|
+
* The guard above only covers presses that go through this component's own
|
|
191
|
+
* parts. A caller's own `Pressable` in a row's `trailing` slot takes the touch
|
|
192
|
+
* itself and this component never hears about it — so the catch-all is the
|
|
193
|
+
* keyboard: it is still up, because nothing in the panel dismisses it, and a
|
|
194
|
+
* search whose keyboard is still up has not ended. The pause is long enough
|
|
195
|
+
* for focus to come back on its own and short enough that a real dismissal
|
|
196
|
+
* does not leave the panel hanging.
|
|
197
|
+
*/
|
|
198
|
+
const BLUR_GRACE = 220;
|
|
199
|
+
|
|
200
|
+
/** Share of the field a row of tokens may take before it starts scrolling. */
|
|
201
|
+
const TOKEN_MAX_SHARE = 0.6;
|
|
202
|
+
|
|
203
|
+
/*
|
|
204
|
+
* The card is pinned to the edge of the field's own slot and grows away from
|
|
205
|
+
* it, so the field's box is the one thing that never moves.
|
|
206
|
+
*/
|
|
207
|
+
const CARD_ABOVE = {
|
|
208
|
+
position: 'absolute',
|
|
209
|
+
bottom: 0,
|
|
210
|
+
left: 0,
|
|
211
|
+
right: 0
|
|
212
|
+
};
|
|
213
|
+
const CARD_BELOW = {
|
|
214
|
+
position: 'absolute',
|
|
215
|
+
top: 0,
|
|
216
|
+
left: 0,
|
|
217
|
+
right: 0
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
/*
|
|
221
|
+
* `zIndex` *and* `elevation`, on the field's own box rather than on the card:
|
|
222
|
+
* Android draws siblings in tree order and takes its stacking from elevation,
|
|
223
|
+
* so a card overlapping the content above the field would otherwise be painted
|
|
224
|
+
* under it — and putting it here keeps the field painting over the card, which
|
|
225
|
+
* is what lets the two be one surface.
|
|
226
|
+
*
|
|
227
|
+
* Applied always, and never toggled. React Native implements `zIndex` on iOS
|
|
228
|
+
* by reordering the parent's subviews, which takes the view out of the
|
|
229
|
+
* hierarchy and puts it back — and a `UITextField` removed from the window
|
|
230
|
+
* resigns first responder. Setting this at the moment the panel opens
|
|
231
|
+
* therefore blurred the field that had just been focused, which closed the
|
|
232
|
+
* panel again: the keyboard came up and went straight back down, and the only
|
|
233
|
+
* way to get a search open was to be quicker than the render.
|
|
234
|
+
*
|
|
235
|
+
* A constant stacking order costs nothing while the panel is shut — there is
|
|
236
|
+
* nothing to stack against — and the elevation draws no shadow, because the
|
|
237
|
+
* box has no background for Android to cast one from.
|
|
238
|
+
*/
|
|
239
|
+
const RAISED = {
|
|
240
|
+
zIndex: 20,
|
|
241
|
+
elevation: 20
|
|
242
|
+
};
|
|
71
243
|
const searchBarVariants = tv({
|
|
72
244
|
slots: {
|
|
73
245
|
row: 'w-full flex-row items-center',
|
|
246
|
+
/*
|
|
247
|
+
* The box the panel is positioned against — the field alone, so the panel
|
|
248
|
+
* is the field's width rather than the row's and does not run out under a
|
|
249
|
+
* Cancel button that is only sometimes there.
|
|
250
|
+
*/
|
|
251
|
+
anchor: 'relative',
|
|
74
252
|
field: '',
|
|
253
|
+
/*
|
|
254
|
+
* One card around the results *and* the field, with the field drawn over
|
|
255
|
+
* the space kept for it at the bottom.
|
|
256
|
+
*
|
|
257
|
+
* It has to be one box because the outline is one outline. Drawn as two —
|
|
258
|
+
* a bordered panel above a bordered field — the field's edge is the focus
|
|
259
|
+
* ring, since a field with a panel open is a field being typed into, and
|
|
260
|
+
* the card ends up with a brighter box welded to a dimmer one.
|
|
261
|
+
*
|
|
262
|
+
* `bg-popover`, not `bg-card`: this floats over the page rather than
|
|
263
|
+
* sitting in it, and a card is one step from the background — close enough
|
|
264
|
+
* that in dark mode the whole thing dissolves into the screen behind it.
|
|
265
|
+
*/
|
|
266
|
+
panel: 'overflow-hidden rounded-2xl border border-border bg-popover shadow-lg',
|
|
267
|
+
panelList: 'p-1.5',
|
|
268
|
+
/** The hairline between the results and the field. */
|
|
269
|
+
panelDivider: 'w-full bg-border',
|
|
270
|
+
sectionLabel: 'px-3 pb-1 pt-2 text-sm text-muted-foreground',
|
|
271
|
+
item: 'flex-row items-center gap-3 rounded-lg px-3 py-2.5',
|
|
272
|
+
itemLabel: 'flex-1 text-base text-foreground',
|
|
273
|
+
status: 'flex-row items-center justify-center gap-2 px-3 py-8',
|
|
75
274
|
// Clipped, because this is what the Cancel button is revealed out of: the
|
|
76
275
|
// button keeps its measured width and the container's grows past it.
|
|
77
276
|
//
|
|
@@ -81,21 +280,39 @@ const searchBarVariants = tv({
|
|
|
81
280
|
cancelClip: 'self-stretch overflow-hidden',
|
|
82
281
|
cancelButton: 'absolute bottom-0 end-0 top-0 items-center justify-center ps-2',
|
|
83
282
|
cancelLabel: 'font-medium text-primary',
|
|
84
|
-
clear: 'items-center justify-center rounded-full'
|
|
283
|
+
clear: 'items-center justify-center rounded-full',
|
|
284
|
+
/*
|
|
285
|
+
* The chips sit in the field's start content, which `Input` measures and
|
|
286
|
+
* turns into padding on the text — so the caret starts after them however
|
|
287
|
+
* many there are, and nothing typed ever runs underneath them.
|
|
288
|
+
*/
|
|
289
|
+
tokenRow: 'flex-row items-center gap-1.5',
|
|
290
|
+
token: 'flex-row items-center gap-1 rounded-full bg-accent ps-2 pe-1',
|
|
291
|
+
tokenLabel: 'text-accent-foreground',
|
|
292
|
+
tokenRemove: 'items-center justify-center rounded-full'
|
|
85
293
|
},
|
|
86
294
|
variants: {
|
|
87
295
|
size: {
|
|
88
296
|
sm: {
|
|
89
297
|
cancelLabel: 'text-[14px]',
|
|
90
|
-
clear: 'h-6 w-6'
|
|
298
|
+
clear: 'h-6 w-6',
|
|
299
|
+
token: 'h-6',
|
|
300
|
+
tokenLabel: 'text-[13px]',
|
|
301
|
+
tokenRemove: 'h-4 w-4'
|
|
91
302
|
},
|
|
92
303
|
md: {
|
|
93
304
|
cancelLabel: 'text-[16px]',
|
|
94
|
-
clear: 'h-6 w-6'
|
|
305
|
+
clear: 'h-6 w-6',
|
|
306
|
+
token: 'h-7',
|
|
307
|
+
tokenLabel: 'text-[14px]',
|
|
308
|
+
tokenRemove: 'h-5 w-5'
|
|
95
309
|
},
|
|
96
310
|
lg: {
|
|
97
311
|
cancelLabel: 'text-[16px]',
|
|
98
|
-
clear: 'h-7 w-7'
|
|
312
|
+
clear: 'h-7 w-7',
|
|
313
|
+
token: 'h-8',
|
|
314
|
+
tokenLabel: 'text-[15px]',
|
|
315
|
+
tokenRemove: 'h-5 w-5'
|
|
99
316
|
}
|
|
100
317
|
},
|
|
101
318
|
/**
|
|
@@ -110,11 +327,31 @@ const searchBarVariants = tv({
|
|
|
110
327
|
pill: {
|
|
111
328
|
field: 'rounded-full'
|
|
112
329
|
}
|
|
330
|
+
},
|
|
331
|
+
/**
|
|
332
|
+
* Which edge of the field the card grows out of. The field's corners on
|
|
333
|
+
* that edge go square and its border comes off entirely — the card around
|
|
334
|
+
* both of them is what draws the edge.
|
|
335
|
+
*/
|
|
336
|
+
attached: {
|
|
337
|
+
none: {},
|
|
338
|
+
top: {
|
|
339
|
+
field: 'rounded-t-none rounded-b-2xl border-0'
|
|
340
|
+
},
|
|
341
|
+
bottom: {
|
|
342
|
+
field: 'rounded-b-none rounded-t-2xl border-0'
|
|
343
|
+
}
|
|
344
|
+
},
|
|
345
|
+
selected: {
|
|
346
|
+
true: {
|
|
347
|
+
item: 'bg-accent'
|
|
348
|
+
}
|
|
113
349
|
}
|
|
114
350
|
},
|
|
115
351
|
defaultVariants: {
|
|
116
352
|
size: 'md',
|
|
117
|
-
shape: 'rounded'
|
|
353
|
+
shape: 'rounded',
|
|
354
|
+
attached: 'none'
|
|
118
355
|
}
|
|
119
356
|
});
|
|
120
357
|
/** Glyph sizes per field size — the icon tracks the text, not the box. */
|
|
@@ -123,6 +360,11 @@ const ICON_SIZE = {
|
|
|
123
360
|
md: 18,
|
|
124
361
|
lg: 20
|
|
125
362
|
};
|
|
363
|
+
const SearchBarContext = /*#__PURE__*/createContext(null);
|
|
364
|
+
|
|
365
|
+
/** Where the results open. */
|
|
366
|
+
|
|
367
|
+
/** When the results are shown. */
|
|
126
368
|
|
|
127
369
|
/**
|
|
128
370
|
* What SearchBar takes from Input, minus everything it owns itself. The form
|
|
@@ -130,9 +372,12 @@ const ICON_SIZE = {
|
|
|
130
372
|
* and below the field, and Cancel sits beside the whole stack rather than
|
|
131
373
|
* beside the field it belongs to. Use `Field` for a search that is one answer
|
|
132
374
|
* in a form.
|
|
375
|
+
*
|
|
376
|
+
* The keyboard props go too. Input's would move the field and leave the Cancel
|
|
377
|
+
* button and the panel where they were; SearchBar lifts all three together.
|
|
133
378
|
*/
|
|
134
379
|
|
|
135
|
-
|
|
380
|
+
const SearchBarRoot = /*#__PURE__*/forwardRef(({
|
|
136
381
|
value: valueProp,
|
|
137
382
|
defaultValue,
|
|
138
383
|
onChangeText,
|
|
@@ -152,8 +397,17 @@ export const SearchBar = /*#__PURE__*/forwardRef(({
|
|
|
152
397
|
className,
|
|
153
398
|
containerClassName,
|
|
154
399
|
disabled,
|
|
400
|
+
avoidKeyboard = false,
|
|
401
|
+
keyboardOffset = 12,
|
|
402
|
+
panel = 'focus',
|
|
403
|
+
panelPlacement = 'top',
|
|
404
|
+
panelMaxHeight,
|
|
405
|
+
tokens,
|
|
406
|
+
onRemoveLastToken,
|
|
407
|
+
children,
|
|
155
408
|
onFocus,
|
|
156
409
|
onBlur,
|
|
410
|
+
onKeyPress,
|
|
157
411
|
onSubmitEditing,
|
|
158
412
|
...props
|
|
159
413
|
}, ref) => {
|
|
@@ -163,9 +417,25 @@ export const SearchBar = /*#__PURE__*/forwardRef(({
|
|
|
163
417
|
const [focused, setFocused] = useState(false);
|
|
164
418
|
const inputRef = useRef(null);
|
|
165
419
|
useImperativeHandle(ref, () => inputRef.current, []);
|
|
420
|
+
|
|
421
|
+
/*
|
|
422
|
+
* Counted rather than tested for truthiness: `{results.map(…)}` over an
|
|
423
|
+
* empty array is a child, and a panel that opens on nothing is a card of
|
|
424
|
+
* padding.
|
|
425
|
+
*/
|
|
426
|
+
const hasPanel = useMemo(() => {
|
|
427
|
+
let found = false;
|
|
428
|
+
Children.forEach(children, child => {
|
|
429
|
+
if (/*#__PURE__*/isValidElement(child)) found = true;
|
|
430
|
+
});
|
|
431
|
+
return found;
|
|
432
|
+
}, [children]);
|
|
433
|
+
const panelOpen = !disabled && panel !== 'never' && hasPanel && (panel === 'always' || focused);
|
|
434
|
+
const attached = panelOpen ? panelPlacement : 'none';
|
|
166
435
|
const slots = searchBarVariants({
|
|
167
436
|
size,
|
|
168
|
-
shape
|
|
437
|
+
shape,
|
|
438
|
+
attached
|
|
169
439
|
});
|
|
170
440
|
const setText = useCallback(next => {
|
|
171
441
|
if (!controlled) setInternal(next);
|
|
@@ -196,14 +466,78 @@ export const SearchBar = /*#__PURE__*/forwardRef(({
|
|
|
196
466
|
cancelSearchBarDebounce(debounceTimerRef);
|
|
197
467
|
};
|
|
198
468
|
}, [text, debounce]);
|
|
469
|
+
|
|
470
|
+
/*
|
|
471
|
+
* Set while a press inside the panel or the field is being served. A blur
|
|
472
|
+
* arriving under it is the press taking focus rather than the search
|
|
473
|
+
* ending, so it is answered by asking for focus back — the panel is drawn
|
|
474
|
+
* out of `focused`, and letting it through would close the panel the
|
|
475
|
+
* pressed control is standing in.
|
|
476
|
+
*/
|
|
477
|
+
const guarded = useRef(false);
|
|
478
|
+
const guardTimer = useRef(null);
|
|
479
|
+
useEffect(() => () => {
|
|
480
|
+
if (guardTimer.current) clearTimeout(guardTimer.current);
|
|
481
|
+
}, []);
|
|
482
|
+
const retainFocus = useCallback(() => {
|
|
483
|
+
if (disabled) return;
|
|
484
|
+
guarded.current = true;
|
|
485
|
+
if (guardTimer.current) clearTimeout(guardTimer.current);
|
|
486
|
+
guardTimer.current = setTimeout(() => {
|
|
487
|
+
guarded.current = false;
|
|
488
|
+
}, FOCUS_GUARD);
|
|
489
|
+
inputRef.current?.focus();
|
|
490
|
+
}, [disabled]);
|
|
491
|
+
|
|
492
|
+
/*
|
|
493
|
+
* Set while the search is being ended deliberately — Cancel, or a caller
|
|
494
|
+
* calling `blur()`. Without it the recovery below would fight the very
|
|
495
|
+
* thing that is trying to close the search.
|
|
496
|
+
*/
|
|
497
|
+
const ending = useRef(false);
|
|
498
|
+
const closeTimer = useRef(null);
|
|
499
|
+
const clearCloseTimer = useCallback(() => {
|
|
500
|
+
if (closeTimer.current) clearTimeout(closeTimer.current);
|
|
501
|
+
closeTimer.current = null;
|
|
502
|
+
}, []);
|
|
503
|
+
useEffect(() => clearCloseTimer, [clearCloseTimer]);
|
|
504
|
+
|
|
505
|
+
// Read inside a timer, so it has to be a ref rather than the render value.
|
|
506
|
+
const keyboardUp = useRef(false);
|
|
199
507
|
const handleFocus = useCallback(event => {
|
|
508
|
+
clearCloseTimer();
|
|
509
|
+
ending.current = false;
|
|
200
510
|
setFocused(true);
|
|
201
511
|
onFocus?.(event);
|
|
202
|
-
}, [onFocus]);
|
|
512
|
+
}, [clearCloseTimer, onFocus]);
|
|
203
513
|
const handleBlur = useCallback(event => {
|
|
204
|
-
|
|
514
|
+
// The caller asked for the event, so it goes out now rather than at
|
|
515
|
+
// the end of the pause — this is about what the *panel* believes.
|
|
205
516
|
onBlur?.(event);
|
|
206
|
-
|
|
517
|
+
if (guarded.current) {
|
|
518
|
+
inputRef.current?.focus();
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
clearCloseTimer();
|
|
522
|
+
closeTimer.current = setTimeout(() => {
|
|
523
|
+
closeTimer.current = null;
|
|
524
|
+
if (!ending.current && keyboardUp.current) {
|
|
525
|
+
// The keyboard never went down, so the search is still on screen
|
|
526
|
+
// and something inside it has taken the focus. Give it back.
|
|
527
|
+
inputRef.current?.focus();
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
setFocused(false);
|
|
531
|
+
}, BLUR_GRACE);
|
|
532
|
+
}, [clearCloseTimer, onBlur]);
|
|
533
|
+
const handleKeyPress = useCallback(event => {
|
|
534
|
+
// Only on an empty field: while there is a query, backspace is editing
|
|
535
|
+
// it, and eating a token instead would delete something nobody aimed at.
|
|
536
|
+
if (event.nativeEvent.key === 'Backspace' && text.length === 0) {
|
|
537
|
+
onRemoveLastToken?.();
|
|
538
|
+
}
|
|
539
|
+
onKeyPress?.(event);
|
|
540
|
+
}, [onKeyPress, onRemoveLastToken, text.length]);
|
|
207
541
|
const handleSubmit = useCallback(event => {
|
|
208
542
|
if (disabled) return;
|
|
209
543
|
// A return key is somebody saying they are done waiting, so the
|
|
@@ -222,7 +556,12 @@ export const SearchBar = /*#__PURE__*/forwardRef(({
|
|
|
222
556
|
}, [disabled, onClear, setText]);
|
|
223
557
|
const handleCancel = useCallback(() => {
|
|
224
558
|
if (disabled) return;
|
|
559
|
+
// Deliberate: the recovery above must not answer this blur by handing
|
|
560
|
+
// the focus straight back.
|
|
561
|
+
ending.current = true;
|
|
562
|
+
guarded.current = false;
|
|
225
563
|
setText('');
|
|
564
|
+
setFocused(false);
|
|
226
565
|
inputRef.current?.blur();
|
|
227
566
|
onCancel?.();
|
|
228
567
|
}, [disabled, onCancel, setText]);
|
|
@@ -249,7 +588,57 @@ export const SearchBar = /*#__PURE__*/forwardRef(({
|
|
|
249
588
|
const handleCancelLayout = useCallback(event => {
|
|
250
589
|
cancelWidth.value = event.nativeEvent.layout.width;
|
|
251
590
|
}, [cancelWidth]);
|
|
252
|
-
|
|
591
|
+
|
|
592
|
+
/*
|
|
593
|
+
* How much room the panel has, in the direction it opens. Measured on the
|
|
594
|
+
* JS side because it decides a layout constraint rather than a frame of an
|
|
595
|
+
* animation: a `maxHeight` that changed every frame would re-lay out the
|
|
596
|
+
* list under the finger scrolling it.
|
|
597
|
+
*/
|
|
598
|
+
const {
|
|
599
|
+
height: windowHeight
|
|
600
|
+
} = useWindowDimensions();
|
|
601
|
+
const {
|
|
602
|
+
height: keyboardHeight
|
|
603
|
+
} = useKeyboard();
|
|
604
|
+
useEffect(() => {
|
|
605
|
+
keyboardUp.current = keyboardHeight > 0;
|
|
606
|
+
}, [keyboardHeight]);
|
|
607
|
+
const anchorRef = useRef(null);
|
|
608
|
+
const [anchorBox, setAnchorBox] = useState(null);
|
|
609
|
+
const measureAnchor = useCallback(() => {
|
|
610
|
+
anchorRef.current?.measureInWindow((_x, y, width, height) => {
|
|
611
|
+
setAnchorBox(current => current && current.top === y && current.height === height && current.width === width ? current : {
|
|
612
|
+
top: y,
|
|
613
|
+
height,
|
|
614
|
+
width
|
|
615
|
+
});
|
|
616
|
+
});
|
|
617
|
+
}, []);
|
|
618
|
+
useEffect(() => {
|
|
619
|
+
if (!panelOpen) return;
|
|
620
|
+
measureAnchor();
|
|
621
|
+
}, [panelOpen, keyboardHeight, measureAnchor]);
|
|
622
|
+
const resolvedMaxHeight = useMemo(() => {
|
|
623
|
+
const fieldHeight = anchorBox?.height ?? 0;
|
|
624
|
+
/*
|
|
625
|
+
* While the field is riding the keyboard, where it has come to rest is
|
|
626
|
+
* computed rather than measured: the lift is a transform applied on the
|
|
627
|
+
* UI thread, so a measurement taken from JavaScript is a frame behind it
|
|
628
|
+
* for the whole of the animation.
|
|
629
|
+
*/
|
|
630
|
+
const fieldTop = avoidKeyboard && keyboardHeight > 0 ? windowHeight - keyboardHeight - keyboardOffset - fieldHeight : anchorBox?.top ?? 0;
|
|
631
|
+
const room = panelPlacement === 'top' ? fieldTop - PANEL_EDGE_GAP : windowHeight - keyboardHeight - fieldTop - fieldHeight - PANEL_EDGE_GAP;
|
|
632
|
+
/*
|
|
633
|
+
* The smaller of what it is allowed and what it actually has, floored so
|
|
634
|
+
* a cramped screen still opens something worth reading. A caller's cap
|
|
635
|
+
* is clamped to the room too — a cap taller than the space available is
|
|
636
|
+
* a panel running off the top of the display.
|
|
637
|
+
*/
|
|
638
|
+
const cap = panelMaxHeight ?? PANEL_MAX_HEIGHT;
|
|
639
|
+
return Math.max(Math.min(cap, room), PANEL_MIN_HEIGHT);
|
|
640
|
+
}, [anchorBox, avoidKeyboard, keyboardHeight, keyboardOffset, panelMaxHeight, panelPlacement, windowHeight]);
|
|
641
|
+
const glyph =
|
|
253
642
|
/*#__PURE__*/
|
|
254
643
|
/*
|
|
255
644
|
* Decorative, and said so here rather than through Input's
|
|
@@ -264,7 +653,27 @@ export const SearchBar = /*#__PURE__*/forwardRef(({
|
|
|
264
653
|
children: icon ?? /*#__PURE__*/_jsx(SearchIcon, {
|
|
265
654
|
size: ICON_SIZE[size]
|
|
266
655
|
})
|
|
267
|
-
});
|
|
656
|
+
}, "glyph");
|
|
657
|
+
|
|
658
|
+
/*
|
|
659
|
+
* Capped and scrolling rather than wrapping. The field is one line tall, so
|
|
660
|
+
* a row of chips allowed to grow would move the caret every time something
|
|
661
|
+
* was picked; and left uncapped it would take the whole field and leave
|
|
662
|
+
* nowhere to type the next query.
|
|
663
|
+
*/
|
|
664
|
+
const tokenRow = tokens ? /*#__PURE__*/_jsx(ScrollView, {
|
|
665
|
+
horizontal: true,
|
|
666
|
+
keyboardShouldPersistTaps: "always",
|
|
667
|
+
showsHorizontalScrollIndicator: false,
|
|
668
|
+
style: {
|
|
669
|
+
maxWidth: anchorBox ? anchorBox.width * TOKEN_MAX_SHARE : undefined
|
|
670
|
+
},
|
|
671
|
+
contentContainerClassName: slots.tokenRow(),
|
|
672
|
+
children: tokens
|
|
673
|
+
}, "tokens") : null;
|
|
674
|
+
const startContent = tokenRow ? /*#__PURE__*/_jsxs(_Fragment, {
|
|
675
|
+
children: [glyph, tokenRow]
|
|
676
|
+
}) : glyph;
|
|
268
677
|
const endContent = loading ? /*#__PURE__*/_jsx(Spinner, {
|
|
269
678
|
size: "sm"
|
|
270
679
|
}) : isClearable && text.length > 0 ? /*#__PURE__*/_jsx(AnimatedPressable, {
|
|
@@ -285,13 +694,14 @@ export const SearchBar = /*#__PURE__*/forwardRef(({
|
|
|
285
694
|
className: slots.field({
|
|
286
695
|
className
|
|
287
696
|
}),
|
|
288
|
-
containerClassName:
|
|
697
|
+
containerClassName: "w-full",
|
|
289
698
|
startContent: startContent,
|
|
290
699
|
endContent: endContent,
|
|
291
700
|
value: text,
|
|
292
701
|
onChangeText: setText,
|
|
293
702
|
onFocus: handleFocus,
|
|
294
703
|
onBlur: handleBlur,
|
|
704
|
+
onKeyPress: handleKeyPress,
|
|
295
705
|
onSubmitEditing: handleSubmit,
|
|
296
706
|
accessibilityRole: "search",
|
|
297
707
|
returnKeyType: "search"
|
|
@@ -305,12 +715,98 @@ export const SearchBar = /*#__PURE__*/forwardRef(({
|
|
|
305
715
|
placeholder: "Search",
|
|
306
716
|
...props
|
|
307
717
|
});
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
718
|
+
const context = useMemo(() => ({
|
|
719
|
+
size,
|
|
720
|
+
retainFocus
|
|
721
|
+
}), [retainFocus, size]);
|
|
722
|
+
|
|
723
|
+
// Nothing beside it and nothing under it: the field is the whole component,
|
|
724
|
+
// and a wrapper around it would only be a box for the caller's layout to
|
|
725
|
+
// fight.
|
|
726
|
+
if (cancel === 'never' && !avoidKeyboard && !hasPanel) {
|
|
727
|
+
return /*#__PURE__*/_jsx(SearchBarContext.Provider, {
|
|
728
|
+
value: context,
|
|
729
|
+
children: /*#__PURE__*/_jsx(View, {
|
|
730
|
+
ref: anchorRef,
|
|
731
|
+
onLayout: measureAnchor,
|
|
732
|
+
className: containerClassName,
|
|
733
|
+
children: field
|
|
734
|
+
})
|
|
735
|
+
});
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/*
|
|
739
|
+
* The results, the hairline and the room the field occupies, in the order
|
|
740
|
+
* they are stacked. The field itself is drawn over that last piece rather
|
|
741
|
+
* than inside the card: it has to keep its own place in the layout, and a
|
|
742
|
+
* text field that moved into an absolutely positioned box on focus would
|
|
743
|
+
* remount and lose the keyboard it just opened.
|
|
744
|
+
*/
|
|
745
|
+
const list = /*#__PURE__*/_jsx(ScrollView, {
|
|
746
|
+
style: {
|
|
747
|
+
maxHeight: resolvedMaxHeight
|
|
748
|
+
}
|
|
749
|
+
/*
|
|
750
|
+
* `always`, not `handled`: everything in the panel that is not itself
|
|
751
|
+
* a button — the padding, the gaps between rows, a section heading,
|
|
752
|
+
* the whole of `SearchBar.Status` — would otherwise spend the first
|
|
753
|
+
* tap dismissing the keyboard, which ends the search.
|
|
754
|
+
*/,
|
|
755
|
+
keyboardShouldPersistTaps: "always",
|
|
756
|
+
keyboardDismissMode: "none",
|
|
757
|
+
showsVerticalScrollIndicator: false,
|
|
758
|
+
bounces: false,
|
|
759
|
+
className: slots.panelList(),
|
|
760
|
+
children: children
|
|
761
|
+
}, "list");
|
|
762
|
+
const divider = /*#__PURE__*/_jsx(View, {
|
|
763
|
+
pointerEvents: "none",
|
|
764
|
+
style: {
|
|
765
|
+
height: StyleSheet.hairlineWidth
|
|
766
|
+
},
|
|
767
|
+
className: slots.panelDivider()
|
|
768
|
+
}, "divider");
|
|
769
|
+
/*
|
|
770
|
+
* The room the card keeps for the field, and the reason it is inert.
|
|
771
|
+
*
|
|
772
|
+
* It is a spacer drawn at exactly the field's position, inside the card and
|
|
773
|
+
* therefore outside the list's `keyboardShouldPersistTaps`. Any touch it
|
|
774
|
+
* wins is a touch on a plain view while a field is focused, which is the
|
|
775
|
+
* platform's cue to dismiss the keyboard — so the field blurs, and the
|
|
776
|
+
* panel drawn out of that focus closes. From the outside that is "tapping
|
|
777
|
+
* the search box closes the results", which is exactly backwards.
|
|
778
|
+
*
|
|
779
|
+
* It has nothing to be pressed for. `none` puts the touch through to the
|
|
780
|
+
* field underneath it, where it was aimed.
|
|
781
|
+
*/
|
|
782
|
+
const fieldSlot = /*#__PURE__*/_jsx(View, {
|
|
783
|
+
pointerEvents: "none",
|
|
784
|
+
style: {
|
|
785
|
+
height: anchorBox?.height ?? FIELD_HEIGHT[size]
|
|
786
|
+
}
|
|
787
|
+
}, "slot");
|
|
788
|
+
const anchor = /*#__PURE__*/_jsxs(View, {
|
|
789
|
+
ref: anchorRef,
|
|
790
|
+
onLayout: measureAnchor,
|
|
791
|
+
style: RAISED,
|
|
792
|
+
className: slots.anchor({
|
|
793
|
+
className: cancel === 'never' ? 'w-full' : 'flex-1'
|
|
312
794
|
}),
|
|
313
|
-
children: [
|
|
795
|
+
children: [panelOpen ? /*#__PURE__*/_jsx(Animated.View, {
|
|
796
|
+
entering: FadeIn.duration(PANEL_IN),
|
|
797
|
+
exiting: FadeOut.duration(PANEL_OUT)
|
|
798
|
+
// `box-none`: the card is a surface, not a target. Only the rows
|
|
799
|
+
// inside it answer a touch, and the space kept for the field lets
|
|
800
|
+
// one through to the field.
|
|
801
|
+
,
|
|
802
|
+
pointerEvents: "box-none",
|
|
803
|
+
style: panelPlacement === 'top' ? CARD_ABOVE : CARD_BELOW,
|
|
804
|
+
className: slots.panel(),
|
|
805
|
+
children: panelPlacement === 'top' ? [list, divider, fieldSlot] : [fieldSlot, divider, list]
|
|
806
|
+
}) : null, field]
|
|
807
|
+
});
|
|
808
|
+
const body = cancel === 'never' ? anchor : /*#__PURE__*/_jsxs(_Fragment, {
|
|
809
|
+
children: [anchor, /*#__PURE__*/_jsx(Animated.View, {
|
|
314
810
|
style: cancelStyle,
|
|
315
811
|
className: slots.cancelClip(),
|
|
316
812
|
pointerEvents: cancelOut ? 'auto' : 'none',
|
|
@@ -334,6 +830,238 @@ export const SearchBar = /*#__PURE__*/forwardRef(({
|
|
|
334
830
|
})
|
|
335
831
|
})]
|
|
336
832
|
});
|
|
833
|
+
|
|
834
|
+
/*
|
|
835
|
+
* The keyboard hook is behind a component boundary rather than a flag.
|
|
836
|
+
* Calling it at all has global consequences — without the keyboard
|
|
837
|
+
* controller installed it falls back to Reanimated's useAnimatedKeyboard,
|
|
838
|
+
* which switches Android out of adjustResize for the whole app. A search
|
|
839
|
+
* bar that never asked to avoid the keyboard must not do that to every
|
|
840
|
+
* other screen.
|
|
841
|
+
*/
|
|
842
|
+
if (avoidKeyboard) {
|
|
843
|
+
return /*#__PURE__*/_jsx(SearchBarContext.Provider, {
|
|
844
|
+
value: context,
|
|
845
|
+
children: /*#__PURE__*/_jsx(KeyboardAvoider
|
|
846
|
+
// Only while *this* field is the one being typed into. Without it
|
|
847
|
+
// every avoiding field on the screen lifts the moment any field
|
|
848
|
+
// anywhere is tapped, and since they all aim at the same gap above
|
|
849
|
+
// the keyboard, they arrive stacked on top of one another.
|
|
850
|
+
, {
|
|
851
|
+
active: focused,
|
|
852
|
+
mode: "lift",
|
|
853
|
+
offset: keyboardOffset,
|
|
854
|
+
className: slots.row({
|
|
855
|
+
className: containerClassName
|
|
856
|
+
}),
|
|
857
|
+
children: body
|
|
858
|
+
})
|
|
859
|
+
});
|
|
860
|
+
}
|
|
861
|
+
return /*#__PURE__*/_jsx(SearchBarContext.Provider, {
|
|
862
|
+
value: context,
|
|
863
|
+
children: /*#__PURE__*/_jsx(View, {
|
|
864
|
+
className: slots.row({
|
|
865
|
+
className: containerClassName
|
|
866
|
+
}),
|
|
867
|
+
children: body
|
|
868
|
+
})
|
|
869
|
+
});
|
|
870
|
+
});
|
|
871
|
+
SearchBarRoot.displayName = 'SearchBar';
|
|
872
|
+
/** A labelled run of rows inside the panel. */
|
|
873
|
+
function SearchBarSection({
|
|
874
|
+
className,
|
|
875
|
+
label,
|
|
876
|
+
children,
|
|
877
|
+
...props
|
|
878
|
+
}) {
|
|
879
|
+
const {
|
|
880
|
+
sectionLabel
|
|
881
|
+
} = searchBarVariants();
|
|
882
|
+
return /*#__PURE__*/_jsxs(View, {
|
|
883
|
+
...props,
|
|
884
|
+
className: className,
|
|
885
|
+
children: [label ? /*#__PURE__*/_jsx(Text, {
|
|
886
|
+
accessibilityRole: "header",
|
|
887
|
+
className: sectionLabel(),
|
|
888
|
+
children: label
|
|
889
|
+
}) : null, children]
|
|
890
|
+
});
|
|
891
|
+
}
|
|
892
|
+
SearchBarSection.displayName = 'SearchBar.Section';
|
|
893
|
+
/** One result. */
|
|
894
|
+
function SearchBarItem({
|
|
895
|
+
className,
|
|
896
|
+
leading,
|
|
897
|
+
trailing,
|
|
898
|
+
description,
|
|
899
|
+
selected,
|
|
900
|
+
children,
|
|
901
|
+
onPressIn,
|
|
902
|
+
...props
|
|
903
|
+
}) {
|
|
904
|
+
const {
|
|
905
|
+
item,
|
|
906
|
+
itemLabel
|
|
907
|
+
} = searchBarVariants({
|
|
908
|
+
selected: !!selected
|
|
909
|
+
});
|
|
910
|
+
const search = useContext(SearchBarContext);
|
|
911
|
+
return /*#__PURE__*/_jsxs(AnimatedPressable, {
|
|
912
|
+
accessibilityRole: "button",
|
|
913
|
+
accessibilityState: {
|
|
914
|
+
selected: !!selected,
|
|
915
|
+
disabled: !!props.disabled
|
|
916
|
+
}
|
|
917
|
+
/*
|
|
918
|
+
* Before the press, not after it: the blur it may cause is what closes
|
|
919
|
+
* the panel this row is drawn in, and by the time `onPress` runs the row
|
|
920
|
+
* can already be gone.
|
|
921
|
+
*/,
|
|
922
|
+
onPressIn: event => {
|
|
923
|
+
search?.retainFocus();
|
|
924
|
+
onPressIn?.(event);
|
|
925
|
+
}
|
|
926
|
+
// A row is a wide target, and a target that shrinks when pressed reads as
|
|
927
|
+
// a card rather than a line in a list. The dim is the whole feedback.
|
|
928
|
+
,
|
|
929
|
+
pressScale: 1,
|
|
930
|
+
pressOpacity: 0.6
|
|
931
|
+
// The rows sit flush against each other, so the points either side of the
|
|
932
|
+
// gap between two of them would otherwise belong to neither.
|
|
933
|
+
,
|
|
934
|
+
hitSlop: {
|
|
935
|
+
top: 2,
|
|
936
|
+
bottom: 2
|
|
937
|
+
},
|
|
938
|
+
...props,
|
|
939
|
+
className: item({
|
|
940
|
+
className
|
|
941
|
+
}),
|
|
942
|
+
children: [leading, /*#__PURE__*/_jsxs(View, {
|
|
943
|
+
className: "flex-1",
|
|
944
|
+
children: [/*#__PURE__*/_jsx(Text, {
|
|
945
|
+
numberOfLines: 1,
|
|
946
|
+
className: itemLabel(),
|
|
947
|
+
children: children
|
|
948
|
+
}), description ? /*#__PURE__*/_jsx(Text, {
|
|
949
|
+
size: "sm",
|
|
950
|
+
muted: true,
|
|
951
|
+
numberOfLines: 1,
|
|
952
|
+
children: description
|
|
953
|
+
}) : null]
|
|
954
|
+
}), trailing]
|
|
955
|
+
});
|
|
956
|
+
}
|
|
957
|
+
SearchBarItem.displayName = 'SearchBar.Item';
|
|
958
|
+
/**
|
|
959
|
+
* The one line a panel shows instead of rows — nothing typed yet, a search in
|
|
960
|
+
* flight, or a query that matched nothing. It is a sentence rather than an
|
|
961
|
+
* empty box because those three states look identical when they are blank, and
|
|
962
|
+
* which one it is decides what the person does next.
|
|
963
|
+
*/
|
|
964
|
+
function SearchBarStatus({
|
|
965
|
+
className,
|
|
966
|
+
loading = false,
|
|
967
|
+
children,
|
|
968
|
+
...props
|
|
969
|
+
}) {
|
|
970
|
+
const {
|
|
971
|
+
status
|
|
972
|
+
} = searchBarVariants();
|
|
973
|
+
return /*#__PURE__*/_jsxs(View, {
|
|
974
|
+
accessibilityRole: "text",
|
|
975
|
+
...props,
|
|
976
|
+
className: status({
|
|
977
|
+
className
|
|
978
|
+
}),
|
|
979
|
+
children: [loading ? /*#__PURE__*/_jsx(Spinner, {
|
|
980
|
+
size: "sm"
|
|
981
|
+
}) : null, /*#__PURE__*/_jsx(Text, {
|
|
982
|
+
size: "sm",
|
|
983
|
+
muted: true,
|
|
984
|
+
children: children
|
|
985
|
+
})]
|
|
986
|
+
});
|
|
987
|
+
}
|
|
988
|
+
SearchBarStatus.displayName = 'SearchBar.Status';
|
|
989
|
+
/**
|
|
990
|
+
* A button inside a row — an add, a pin, a remove — for the `trailing` slot.
|
|
991
|
+
*
|
|
992
|
+
* It exists rather than being left to a plain `Pressable` because a control
|
|
993
|
+
* nested inside a row takes the touch itself, so the row above it never sees
|
|
994
|
+
* the press and cannot hold the field's focus on its behalf. Pressed, this one
|
|
995
|
+
* ends up blurring the field, and a blurred field closes the panel the button
|
|
996
|
+
* was standing in — the press lands and the search disappears under it.
|
|
997
|
+
*/
|
|
998
|
+
function SearchBarAction({
|
|
999
|
+
className,
|
|
1000
|
+
children,
|
|
1001
|
+
onPressIn,
|
|
1002
|
+
...props
|
|
1003
|
+
}) {
|
|
1004
|
+
const search = useContext(SearchBarContext);
|
|
1005
|
+
return /*#__PURE__*/_jsx(AnimatedPressable, {
|
|
1006
|
+
accessibilityRole: "button",
|
|
1007
|
+
hitSlop: 12,
|
|
1008
|
+
pressScale: 1,
|
|
1009
|
+
pressOpacity: 0.5,
|
|
1010
|
+
onPressIn: event => {
|
|
1011
|
+
search?.retainFocus();
|
|
1012
|
+
onPressIn?.(event);
|
|
1013
|
+
},
|
|
1014
|
+
...props,
|
|
1015
|
+
className: className,
|
|
1016
|
+
children: children
|
|
1017
|
+
});
|
|
1018
|
+
}
|
|
1019
|
+
SearchBarAction.displayName = 'SearchBar.Action';
|
|
1020
|
+
/**
|
|
1021
|
+
* One choice already made, drawn inside the field before the caret.
|
|
1022
|
+
*
|
|
1023
|
+
* It sits in the field rather than in a list above it so that the query and
|
|
1024
|
+
* what the query has produced are one control. A search that files its results
|
|
1025
|
+
* somewhere else asks the reader to look in two places to know where they are.
|
|
1026
|
+
*/
|
|
1027
|
+
function SearchBarToken({
|
|
1028
|
+
className,
|
|
1029
|
+
leading,
|
|
1030
|
+
onRemove,
|
|
1031
|
+
removeLabel,
|
|
1032
|
+
children,
|
|
1033
|
+
...props
|
|
1034
|
+
}) {
|
|
1035
|
+
const search = useContext(SearchBarContext);
|
|
1036
|
+
const slots = searchBarVariants({
|
|
1037
|
+
size: search?.size ?? 'md'
|
|
1038
|
+
});
|
|
1039
|
+
const label = typeof children === 'string' ? children : undefined;
|
|
1040
|
+
return /*#__PURE__*/_jsxs(View, {
|
|
1041
|
+
...props,
|
|
1042
|
+
className: slots.token({
|
|
1043
|
+
className
|
|
1044
|
+
}),
|
|
1045
|
+
children: [leading, /*#__PURE__*/_jsx(Text, {
|
|
1046
|
+
numberOfLines: 1,
|
|
1047
|
+
className: slots.tokenLabel(),
|
|
1048
|
+
children: children
|
|
1049
|
+
}), onRemove ? /*#__PURE__*/_jsx(SearchBarAction, {
|
|
1050
|
+
accessibilityLabel: removeLabel ?? (label ? `Remove ${label}` : 'Remove'),
|
|
1051
|
+
onPress: onRemove,
|
|
1052
|
+
className: slots.tokenRemove(),
|
|
1053
|
+
children: /*#__PURE__*/_jsx(XIcon, {
|
|
1054
|
+
size: 12
|
|
1055
|
+
})
|
|
1056
|
+
}) : null]
|
|
1057
|
+
});
|
|
1058
|
+
}
|
|
1059
|
+
SearchBarToken.displayName = 'SearchBar.Token';
|
|
1060
|
+
export const SearchBar = Object.assign(SearchBarRoot, {
|
|
1061
|
+
Section: SearchBarSection,
|
|
1062
|
+
Item: SearchBarItem,
|
|
1063
|
+
Action: SearchBarAction,
|
|
1064
|
+
Token: SearchBarToken,
|
|
1065
|
+
Status: SearchBarStatus
|
|
337
1066
|
});
|
|
338
|
-
SearchBar.displayName = 'SearchBar';
|
|
339
1067
|
//# sourceMappingURL=index.js.map
|