panelui-native 0.87.0 → 0.88.1
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/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 +557 -41
- package/lib/module/components/bubble-chart/index.js.map +1 -1
- package/lib/module/components/feedback/index.js +474 -0
- package/lib/module/components/feedback/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 +390 -45
- 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 +3 -0
- package/lib/module/index.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 +150 -7
- package/lib/typescript/src/components/bubble-chart/index.d.ts.map +1 -1
- package/lib/typescript/src/components/feedback/index.d.ts +156 -0
- package/lib/typescript/src/components/feedback/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 +142 -7
- 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 +5 -2
- package/lib/typescript/src/index.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 +664 -46
- package/src/components/feedback/index.tsx +557 -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 +471 -41
- package/src/hooks/use-keyboard-avoidance.ts +24 -1
- package/src/hooks/use-reveal-progress.ts +30 -1
- package/src/index.ts +27 -0
|
@@ -34,9 +34,48 @@
|
|
|
34
34
|
*
|
|
35
35
|
* ## Touches inside the panel must not close the keyboard
|
|
36
36
|
*
|
|
37
|
-
* The panel scrolls with `keyboardShouldPersistTaps="
|
|
38
|
-
*
|
|
39
|
-
*
|
|
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.
|
|
40
79
|
*
|
|
41
80
|
* ## The clear button, and why it is not the platform's
|
|
42
81
|
*
|
|
@@ -73,7 +112,7 @@
|
|
|
73
112
|
* pause instead of once per letter. Submitting flushes it immediately, since
|
|
74
113
|
* a return key is somebody saying they are done waiting.
|
|
75
114
|
*/
|
|
76
|
-
import { Children, forwardRef, isValidElement, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
|
|
115
|
+
import { Children, createContext, forwardRef, isValidElement, useContext, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
|
|
77
116
|
import { ScrollView, StyleSheet, View, useWindowDimensions } from 'react-native';
|
|
78
117
|
import Animated, { FadeIn, FadeOut, useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated';
|
|
79
118
|
import { tv } from 'tailwind-variants';
|
|
@@ -87,7 +126,7 @@ import { Spinner } from "../spinner/index.js";
|
|
|
87
126
|
import { cancelSearchBarDebounce, flushSearchBarDebounce, scheduleSearchBarDebounce } from "./search-bar-debounce.js";
|
|
88
127
|
|
|
89
128
|
/** Matches the field's own focus crossfade, so the row settles as one thing. */
|
|
90
|
-
import { jsx as _jsx,
|
|
129
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
91
130
|
const CANCEL_DURATION = 180;
|
|
92
131
|
|
|
93
132
|
/** Space kept between the field and the Cancel button while it is out. */
|
|
@@ -109,6 +148,58 @@ const PANEL_EDGE_GAP = 24;
|
|
|
109
148
|
/** Floor for the derived height — below this a list is not worth opening. */
|
|
110
149
|
const PANEL_MIN_HEIGHT = 160;
|
|
111
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
|
+
|
|
112
203
|
/*
|
|
113
204
|
* The card is pinned to the edge of the field's own slot and grows away from
|
|
114
205
|
* it, so the field's box is the one thing that never moves.
|
|
@@ -132,6 +223,18 @@ const CARD_BELOW = {
|
|
|
132
223
|
* so a card overlapping the content above the field would otherwise be painted
|
|
133
224
|
* under it — and putting it here keeps the field painting over the card, which
|
|
134
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.
|
|
135
238
|
*/
|
|
136
239
|
const RAISED = {
|
|
137
240
|
zIndex: 20,
|
|
@@ -177,21 +280,39 @@ const searchBarVariants = tv({
|
|
|
177
280
|
cancelClip: 'self-stretch overflow-hidden',
|
|
178
281
|
cancelButton: 'absolute bottom-0 end-0 top-0 items-center justify-center ps-2',
|
|
179
282
|
cancelLabel: 'font-medium text-primary',
|
|
180
|
-
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'
|
|
181
293
|
},
|
|
182
294
|
variants: {
|
|
183
295
|
size: {
|
|
184
296
|
sm: {
|
|
185
297
|
cancelLabel: 'text-[14px]',
|
|
186
|
-
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'
|
|
187
302
|
},
|
|
188
303
|
md: {
|
|
189
304
|
cancelLabel: 'text-[16px]',
|
|
190
|
-
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'
|
|
191
309
|
},
|
|
192
310
|
lg: {
|
|
193
311
|
cancelLabel: 'text-[16px]',
|
|
194
|
-
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'
|
|
195
316
|
}
|
|
196
317
|
},
|
|
197
318
|
/**
|
|
@@ -239,6 +360,7 @@ const ICON_SIZE = {
|
|
|
239
360
|
md: 18,
|
|
240
361
|
lg: 20
|
|
241
362
|
};
|
|
363
|
+
const SearchBarContext = /*#__PURE__*/createContext(null);
|
|
242
364
|
|
|
243
365
|
/** Where the results open. */
|
|
244
366
|
|
|
@@ -280,9 +402,12 @@ const SearchBarRoot = /*#__PURE__*/forwardRef(({
|
|
|
280
402
|
panel = 'focus',
|
|
281
403
|
panelPlacement = 'top',
|
|
282
404
|
panelMaxHeight,
|
|
405
|
+
tokens,
|
|
406
|
+
onRemoveLastToken,
|
|
283
407
|
children,
|
|
284
408
|
onFocus,
|
|
285
409
|
onBlur,
|
|
410
|
+
onKeyPress,
|
|
286
411
|
onSubmitEditing,
|
|
287
412
|
...props
|
|
288
413
|
}, ref) => {
|
|
@@ -341,14 +466,78 @@ const SearchBarRoot = /*#__PURE__*/forwardRef(({
|
|
|
341
466
|
cancelSearchBarDebounce(debounceTimerRef);
|
|
342
467
|
};
|
|
343
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);
|
|
344
507
|
const handleFocus = useCallback(event => {
|
|
508
|
+
clearCloseTimer();
|
|
509
|
+
ending.current = false;
|
|
345
510
|
setFocused(true);
|
|
346
511
|
onFocus?.(event);
|
|
347
|
-
}, [onFocus]);
|
|
512
|
+
}, [clearCloseTimer, onFocus]);
|
|
348
513
|
const handleBlur = useCallback(event => {
|
|
349
|
-
|
|
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.
|
|
350
516
|
onBlur?.(event);
|
|
351
|
-
|
|
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]);
|
|
352
541
|
const handleSubmit = useCallback(event => {
|
|
353
542
|
if (disabled) return;
|
|
354
543
|
// A return key is somebody saying they are done waiting, so the
|
|
@@ -367,7 +556,12 @@ const SearchBarRoot = /*#__PURE__*/forwardRef(({
|
|
|
367
556
|
}, [disabled, onClear, setText]);
|
|
368
557
|
const handleCancel = useCallback(() => {
|
|
369
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;
|
|
370
563
|
setText('');
|
|
564
|
+
setFocused(false);
|
|
371
565
|
inputRef.current?.blur();
|
|
372
566
|
onCancel?.();
|
|
373
567
|
}, [disabled, onCancel, setText]);
|
|
@@ -407,13 +601,17 @@ const SearchBarRoot = /*#__PURE__*/forwardRef(({
|
|
|
407
601
|
const {
|
|
408
602
|
height: keyboardHeight
|
|
409
603
|
} = useKeyboard();
|
|
604
|
+
useEffect(() => {
|
|
605
|
+
keyboardUp.current = keyboardHeight > 0;
|
|
606
|
+
}, [keyboardHeight]);
|
|
410
607
|
const anchorRef = useRef(null);
|
|
411
608
|
const [anchorBox, setAnchorBox] = useState(null);
|
|
412
609
|
const measureAnchor = useCallback(() => {
|
|
413
|
-
anchorRef.current?.measureInWindow((_x, y,
|
|
414
|
-
setAnchorBox(current => current && current.top === y && current.height === height ? current : {
|
|
610
|
+
anchorRef.current?.measureInWindow((_x, y, width, height) => {
|
|
611
|
+
setAnchorBox(current => current && current.top === y && current.height === height && current.width === width ? current : {
|
|
415
612
|
top: y,
|
|
416
|
-
height
|
|
613
|
+
height,
|
|
614
|
+
width
|
|
417
615
|
});
|
|
418
616
|
});
|
|
419
617
|
}, []);
|
|
@@ -422,7 +620,6 @@ const SearchBarRoot = /*#__PURE__*/forwardRef(({
|
|
|
422
620
|
measureAnchor();
|
|
423
621
|
}, [panelOpen, keyboardHeight, measureAnchor]);
|
|
424
622
|
const resolvedMaxHeight = useMemo(() => {
|
|
425
|
-
if (panelMaxHeight !== undefined) return panelMaxHeight;
|
|
426
623
|
const fieldHeight = anchorBox?.height ?? 0;
|
|
427
624
|
/*
|
|
428
625
|
* While the field is riding the keyboard, where it has come to rest is
|
|
@@ -432,9 +629,16 @@ const SearchBarRoot = /*#__PURE__*/forwardRef(({
|
|
|
432
629
|
*/
|
|
433
630
|
const fieldTop = avoidKeyboard && keyboardHeight > 0 ? windowHeight - keyboardHeight - keyboardOffset - fieldHeight : anchorBox?.top ?? 0;
|
|
434
631
|
const room = panelPlacement === 'top' ? fieldTop - PANEL_EDGE_GAP : windowHeight - keyboardHeight - fieldTop - fieldHeight - PANEL_EDGE_GAP;
|
|
435
|
-
|
|
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);
|
|
436
640
|
}, [anchorBox, avoidKeyboard, keyboardHeight, keyboardOffset, panelMaxHeight, panelPlacement, windowHeight]);
|
|
437
|
-
const
|
|
641
|
+
const glyph =
|
|
438
642
|
/*#__PURE__*/
|
|
439
643
|
/*
|
|
440
644
|
* Decorative, and said so here rather than through Input's
|
|
@@ -449,7 +653,27 @@ const SearchBarRoot = /*#__PURE__*/forwardRef(({
|
|
|
449
653
|
children: icon ?? /*#__PURE__*/_jsx(SearchIcon, {
|
|
450
654
|
size: ICON_SIZE[size]
|
|
451
655
|
})
|
|
452
|
-
});
|
|
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;
|
|
453
677
|
const endContent = loading ? /*#__PURE__*/_jsx(Spinner, {
|
|
454
678
|
size: "sm"
|
|
455
679
|
}) : isClearable && text.length > 0 ? /*#__PURE__*/_jsx(AnimatedPressable, {
|
|
@@ -477,6 +701,7 @@ const SearchBarRoot = /*#__PURE__*/forwardRef(({
|
|
|
477
701
|
onChangeText: setText,
|
|
478
702
|
onFocus: handleFocus,
|
|
479
703
|
onBlur: handleBlur,
|
|
704
|
+
onKeyPress: handleKeyPress,
|
|
480
705
|
onSubmitEditing: handleSubmit,
|
|
481
706
|
accessibilityRole: "search",
|
|
482
707
|
returnKeyType: "search"
|
|
@@ -490,14 +715,23 @@ const SearchBarRoot = /*#__PURE__*/forwardRef(({
|
|
|
490
715
|
placeholder: "Search",
|
|
491
716
|
...props
|
|
492
717
|
});
|
|
718
|
+
const context = useMemo(() => ({
|
|
719
|
+
size,
|
|
720
|
+
retainFocus
|
|
721
|
+
}), [retainFocus, size]);
|
|
493
722
|
|
|
494
723
|
// Nothing beside it and nothing under it: the field is the whole component,
|
|
495
724
|
// and a wrapper around it would only be a box for the caller's layout to
|
|
496
725
|
// fight.
|
|
497
726
|
if (cancel === 'never' && !avoidKeyboard && !hasPanel) {
|
|
498
|
-
return /*#__PURE__*/_jsx(
|
|
499
|
-
|
|
500
|
-
children:
|
|
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
|
+
})
|
|
501
735
|
});
|
|
502
736
|
}
|
|
503
737
|
|
|
@@ -513,11 +747,12 @@ const SearchBarRoot = /*#__PURE__*/forwardRef(({
|
|
|
513
747
|
maxHeight: resolvedMaxHeight
|
|
514
748
|
}
|
|
515
749
|
/*
|
|
516
|
-
*
|
|
517
|
-
*
|
|
518
|
-
*
|
|
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.
|
|
519
754
|
*/,
|
|
520
|
-
keyboardShouldPersistTaps: "
|
|
755
|
+
keyboardShouldPersistTaps: "always",
|
|
521
756
|
keyboardDismissMode: "none",
|
|
522
757
|
showsVerticalScrollIndicator: false,
|
|
523
758
|
bounces: false,
|
|
@@ -525,26 +760,46 @@ const SearchBarRoot = /*#__PURE__*/forwardRef(({
|
|
|
525
760
|
children: children
|
|
526
761
|
}, "list");
|
|
527
762
|
const divider = /*#__PURE__*/_jsx(View, {
|
|
763
|
+
pointerEvents: "none",
|
|
528
764
|
style: {
|
|
529
765
|
height: StyleSheet.hairlineWidth
|
|
530
766
|
},
|
|
531
767
|
className: slots.panelDivider()
|
|
532
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
|
+
*/
|
|
533
782
|
const fieldSlot = /*#__PURE__*/_jsx(View, {
|
|
783
|
+
pointerEvents: "none",
|
|
534
784
|
style: {
|
|
535
|
-
height: anchorBox?.height ??
|
|
785
|
+
height: anchorBox?.height ?? FIELD_HEIGHT[size]
|
|
536
786
|
}
|
|
537
787
|
}, "slot");
|
|
538
788
|
const anchor = /*#__PURE__*/_jsxs(View, {
|
|
539
789
|
ref: anchorRef,
|
|
540
790
|
onLayout: measureAnchor,
|
|
541
|
-
style:
|
|
791
|
+
style: RAISED,
|
|
542
792
|
className: slots.anchor({
|
|
543
793
|
className: cancel === 'never' ? 'w-full' : 'flex-1'
|
|
544
794
|
}),
|
|
545
795
|
children: [panelOpen ? /*#__PURE__*/_jsx(Animated.View, {
|
|
546
796
|
entering: FadeIn.duration(PANEL_IN),
|
|
547
|
-
exiting: FadeOut.duration(PANEL_OUT)
|
|
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",
|
|
548
803
|
style: panelPlacement === 'top' ? CARD_ABOVE : CARD_BELOW,
|
|
549
804
|
className: slots.panel(),
|
|
550
805
|
children: panelPlacement === 'top' ? [list, divider, fieldSlot] : [fieldSlot, divider, list]
|
|
@@ -585,26 +840,32 @@ const SearchBarRoot = /*#__PURE__*/forwardRef(({
|
|
|
585
840
|
* other screen.
|
|
586
841
|
*/
|
|
587
842
|
if (avoidKeyboard) {
|
|
588
|
-
return /*#__PURE__*/_jsx(
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
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, {
|
|
597
864
|
className: slots.row({
|
|
598
865
|
className: containerClassName
|
|
599
866
|
}),
|
|
600
867
|
children: body
|
|
601
|
-
})
|
|
602
|
-
}
|
|
603
|
-
return /*#__PURE__*/_jsx(View, {
|
|
604
|
-
className: slots.row({
|
|
605
|
-
className: containerClassName
|
|
606
|
-
}),
|
|
607
|
-
children: body
|
|
868
|
+
})
|
|
608
869
|
});
|
|
609
870
|
});
|
|
610
871
|
SearchBarRoot.displayName = 'SearchBar';
|
|
@@ -637,6 +898,7 @@ function SearchBarItem({
|
|
|
637
898
|
description,
|
|
638
899
|
selected,
|
|
639
900
|
children,
|
|
901
|
+
onPressIn,
|
|
640
902
|
...props
|
|
641
903
|
}) {
|
|
642
904
|
const {
|
|
@@ -645,12 +907,22 @@ function SearchBarItem({
|
|
|
645
907
|
} = searchBarVariants({
|
|
646
908
|
selected: !!selected
|
|
647
909
|
});
|
|
910
|
+
const search = useContext(SearchBarContext);
|
|
648
911
|
return /*#__PURE__*/_jsxs(AnimatedPressable, {
|
|
649
912
|
accessibilityRole: "button",
|
|
650
913
|
accessibilityState: {
|
|
651
914
|
selected: !!selected,
|
|
652
915
|
disabled: !!props.disabled
|
|
653
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
|
+
}
|
|
654
926
|
// A row is a wide target, and a target that shrinks when pressed reads as
|
|
655
927
|
// a card rather than a line in a list. The dim is the whole feedback.
|
|
656
928
|
,
|
|
@@ -714,9 +986,82 @@ function SearchBarStatus({
|
|
|
714
986
|
});
|
|
715
987
|
}
|
|
716
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';
|
|
717
1060
|
export const SearchBar = Object.assign(SearchBarRoot, {
|
|
718
1061
|
Section: SearchBarSection,
|
|
719
1062
|
Item: SearchBarItem,
|
|
1063
|
+
Action: SearchBarAction,
|
|
1064
|
+
Token: SearchBarToken,
|
|
720
1065
|
Status: SearchBarStatus
|
|
721
1066
|
});
|
|
722
1067
|
//# sourceMappingURL=index.js.map
|