panelui-native 0.87.0 → 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/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-dialog/index.js +474 -0
- package/lib/module/components/feedback-dialog/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-dialog/index.d.ts +156 -0
- package/lib/typescript/src/components/feedback-dialog/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-dialog/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
|
@@ -32,9 +32,48 @@
|
|
|
32
32
|
*
|
|
33
33
|
* ## Touches inside the panel must not close the keyboard
|
|
34
34
|
*
|
|
35
|
-
* The panel scrolls with `keyboardShouldPersistTaps="
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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.
|
|
38
77
|
*
|
|
39
78
|
* ## The clear button, and why it is not the platform's
|
|
40
79
|
*
|
|
@@ -73,8 +112,10 @@
|
|
|
73
112
|
*/
|
|
74
113
|
import {
|
|
75
114
|
Children,
|
|
115
|
+
createContext,
|
|
76
116
|
forwardRef,
|
|
77
117
|
isValidElement,
|
|
118
|
+
useContext,
|
|
78
119
|
useCallback,
|
|
79
120
|
useEffect,
|
|
80
121
|
useImperativeHandle,
|
|
@@ -91,6 +132,7 @@ import {
|
|
|
91
132
|
type LayoutChangeEvent,
|
|
92
133
|
type NativeSyntheticEvent,
|
|
93
134
|
type TextInput,
|
|
135
|
+
type TextInputKeyPressEventData,
|
|
94
136
|
type TextInputSubmitEditingEventData,
|
|
95
137
|
type ViewProps,
|
|
96
138
|
type ViewStyle,
|
|
@@ -142,6 +184,54 @@ const PANEL_EDGE_GAP = 24;
|
|
|
142
184
|
/** Floor for the derived height — below this a list is not worth opening. */
|
|
143
185
|
const PANEL_MIN_HEIGHT = 160;
|
|
144
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
|
+
|
|
145
235
|
/*
|
|
146
236
|
* The card is pinned to the edge of the field's own slot and grows away from
|
|
147
237
|
* it, so the field's box is the one thing that never moves.
|
|
@@ -155,6 +245,18 @@ const CARD_BELOW: ViewStyle = { position: 'absolute', top: 0, left: 0, right: 0
|
|
|
155
245
|
* so a card overlapping the content above the field would otherwise be painted
|
|
156
246
|
* under it — and putting it here keeps the field painting over the card, which
|
|
157
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.
|
|
158
260
|
*/
|
|
159
261
|
const RAISED: ViewStyle = { zIndex: 20, elevation: 20 };
|
|
160
262
|
|
|
@@ -199,12 +301,39 @@ const searchBarVariants = tv({
|
|
|
199
301
|
cancelButton: 'absolute bottom-0 end-0 top-0 items-center justify-center ps-2',
|
|
200
302
|
cancelLabel: 'font-medium text-primary',
|
|
201
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',
|
|
202
313
|
},
|
|
203
314
|
variants: {
|
|
204
315
|
size: {
|
|
205
|
-
sm: {
|
|
206
|
-
|
|
207
|
-
|
|
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
|
+
},
|
|
208
337
|
},
|
|
209
338
|
/**
|
|
210
339
|
* The field's corner. `pill` is the shape a search field takes when it is
|
|
@@ -241,6 +370,19 @@ type SearchBarVariantProps = VariantProps<typeof searchBarVariants>;
|
|
|
241
370
|
/** Glyph sizes per field size — the icon tracks the text, not the box. */
|
|
242
371
|
const ICON_SIZE = { sm: 16, md: 18, lg: 20 } as const;
|
|
243
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
|
+
|
|
244
386
|
/** Where the results open. */
|
|
245
387
|
export type SearchBarPanelPlacement = 'top' | 'bottom';
|
|
246
388
|
|
|
@@ -353,11 +495,27 @@ export interface SearchBarProps
|
|
|
353
495
|
*/
|
|
354
496
|
panelPlacement?: SearchBarPanelPlacement;
|
|
355
497
|
/**
|
|
356
|
-
* Cap on the panel's height, in points.
|
|
357
|
-
*
|
|
358
|
-
*
|
|
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.
|
|
359
505
|
*/
|
|
360
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;
|
|
361
519
|
/** The panel's contents — `SearchBar.Section`, `.Item` and `.Status`. */
|
|
362
520
|
children?: ReactNode;
|
|
363
521
|
}
|
|
@@ -389,9 +547,12 @@ const SearchBarRoot = forwardRef<TextInput, SearchBarProps>(
|
|
|
389
547
|
panel = 'focus',
|
|
390
548
|
panelPlacement = 'top',
|
|
391
549
|
panelMaxHeight,
|
|
550
|
+
tokens,
|
|
551
|
+
onRemoveLastToken,
|
|
392
552
|
children,
|
|
393
553
|
onFocus,
|
|
394
554
|
onBlur,
|
|
555
|
+
onKeyPress,
|
|
395
556
|
onSubmitEditing,
|
|
396
557
|
...props
|
|
397
558
|
},
|
|
@@ -460,20 +621,96 @@ const SearchBarRoot = forwardRef<TextInput, SearchBarProps>(
|
|
|
460
621
|
};
|
|
461
622
|
}, [text, debounce]);
|
|
462
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
|
+
|
|
463
668
|
const handleFocus = useCallback<NonNullable<InputProps['onFocus']>>(
|
|
464
669
|
(event) => {
|
|
670
|
+
clearCloseTimer();
|
|
671
|
+
ending.current = false;
|
|
465
672
|
setFocused(true);
|
|
466
673
|
onFocus?.(event);
|
|
467
674
|
},
|
|
468
|
-
[onFocus]
|
|
675
|
+
[clearCloseTimer, onFocus]
|
|
469
676
|
);
|
|
470
677
|
|
|
471
678
|
const handleBlur = useCallback<NonNullable<InputProps['onBlur']>>(
|
|
472
679
|
(event) => {
|
|
473
|
-
|
|
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.
|
|
474
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);
|
|
475
700
|
},
|
|
476
|
-
[onBlur]
|
|
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);
|
|
712
|
+
},
|
|
713
|
+
[onKeyPress, onRemoveLastToken, text.length]
|
|
477
714
|
);
|
|
478
715
|
|
|
479
716
|
const handleSubmit = useCallback(
|
|
@@ -499,7 +736,12 @@ const SearchBarRoot = forwardRef<TextInput, SearchBarProps>(
|
|
|
499
736
|
|
|
500
737
|
const handleCancel = useCallback(() => {
|
|
501
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;
|
|
502
743
|
setText('');
|
|
744
|
+
setFocused(false);
|
|
503
745
|
inputRef.current?.blur();
|
|
504
746
|
onCancel?.();
|
|
505
747
|
}, [disabled, onCancel, setText]);
|
|
@@ -541,17 +783,25 @@ const SearchBarRoot = forwardRef<TextInput, SearchBarProps>(
|
|
|
541
783
|
*/
|
|
542
784
|
const { height: windowHeight } = useWindowDimensions();
|
|
543
785
|
const { height: keyboardHeight } = useKeyboard();
|
|
786
|
+
useEffect(() => {
|
|
787
|
+
keyboardUp.current = keyboardHeight > 0;
|
|
788
|
+
}, [keyboardHeight]);
|
|
544
789
|
const anchorRef = useRef<View | null>(null);
|
|
545
|
-
const [anchorBox, setAnchorBox] = useState<{
|
|
546
|
-
|
|
547
|
-
|
|
790
|
+
const [anchorBox, setAnchorBox] = useState<{
|
|
791
|
+
top: number;
|
|
792
|
+
height: number;
|
|
793
|
+
width: number;
|
|
794
|
+
} | null>(null);
|
|
548
795
|
|
|
549
796
|
const measureAnchor = useCallback(() => {
|
|
550
|
-
anchorRef.current?.measureInWindow((_x, y,
|
|
797
|
+
anchorRef.current?.measureInWindow((_x, y, width, height) => {
|
|
551
798
|
setAnchorBox((current) =>
|
|
552
|
-
current &&
|
|
799
|
+
current &&
|
|
800
|
+
current.top === y &&
|
|
801
|
+
current.height === height &&
|
|
802
|
+
current.width === width
|
|
553
803
|
? current
|
|
554
|
-
: { top: y, height }
|
|
804
|
+
: { top: y, height, width }
|
|
555
805
|
);
|
|
556
806
|
});
|
|
557
807
|
}, []);
|
|
@@ -562,7 +812,6 @@ const SearchBarRoot = forwardRef<TextInput, SearchBarProps>(
|
|
|
562
812
|
}, [panelOpen, keyboardHeight, measureAnchor]);
|
|
563
813
|
|
|
564
814
|
const resolvedMaxHeight = useMemo(() => {
|
|
565
|
-
if (panelMaxHeight !== undefined) return panelMaxHeight;
|
|
566
815
|
const fieldHeight = anchorBox?.height ?? 0;
|
|
567
816
|
/*
|
|
568
817
|
* While the field is riding the keyboard, where it has come to rest is
|
|
@@ -578,7 +827,14 @@ const SearchBarRoot = forwardRef<TextInput, SearchBarProps>(
|
|
|
578
827
|
panelPlacement === 'top'
|
|
579
828
|
? fieldTop - PANEL_EDGE_GAP
|
|
580
829
|
: windowHeight - keyboardHeight - fieldTop - fieldHeight - PANEL_EDGE_GAP;
|
|
581
|
-
|
|
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);
|
|
582
838
|
}, [
|
|
583
839
|
anchorBox,
|
|
584
840
|
avoidKeyboard,
|
|
@@ -589,7 +845,7 @@ const SearchBarRoot = forwardRef<TextInput, SearchBarProps>(
|
|
|
589
845
|
windowHeight,
|
|
590
846
|
]);
|
|
591
847
|
|
|
592
|
-
const
|
|
848
|
+
const glyph = (
|
|
593
849
|
/*
|
|
594
850
|
* Decorative, and said so here rather than through Input's
|
|
595
851
|
* `interactiveContent` — that flag covers both ends of the field, and
|
|
@@ -597,6 +853,7 @@ const SearchBarRoot = forwardRef<TextInput, SearchBarProps>(
|
|
|
597
853
|
* touches with it.
|
|
598
854
|
*/
|
|
599
855
|
<View
|
|
856
|
+
key="glyph"
|
|
600
857
|
pointerEvents="none"
|
|
601
858
|
accessibilityElementsHidden
|
|
602
859
|
importantForAccessibility="no-hide-descendants"
|
|
@@ -605,6 +862,36 @@ const SearchBarRoot = forwardRef<TextInput, SearchBarProps>(
|
|
|
605
862
|
</View>
|
|
606
863
|
);
|
|
607
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
|
+
|
|
608
895
|
const endContent = loading ? (
|
|
609
896
|
<Spinner size="sm" />
|
|
610
897
|
) : isClearable && text.length > 0 ? (
|
|
@@ -633,6 +920,7 @@ const SearchBarRoot = forwardRef<TextInput, SearchBarProps>(
|
|
|
633
920
|
onChangeText={setText}
|
|
634
921
|
onFocus={handleFocus}
|
|
635
922
|
onBlur={handleBlur}
|
|
923
|
+
onKeyPress={handleKeyPress}
|
|
636
924
|
onSubmitEditing={handleSubmit}
|
|
637
925
|
accessibilityRole="search"
|
|
638
926
|
returnKeyType="search"
|
|
@@ -647,11 +935,22 @@ const SearchBarRoot = forwardRef<TextInput, SearchBarProps>(
|
|
|
647
935
|
/>
|
|
648
936
|
);
|
|
649
937
|
|
|
938
|
+
const context = useMemo<SearchBarContextValue>(
|
|
939
|
+
() => ({ size, retainFocus }),
|
|
940
|
+
[retainFocus, size]
|
|
941
|
+
);
|
|
942
|
+
|
|
650
943
|
// Nothing beside it and nothing under it: the field is the whole component,
|
|
651
944
|
// and a wrapper around it would only be a box for the caller's layout to
|
|
652
945
|
// fight.
|
|
653
946
|
if (cancel === 'never' && !avoidKeyboard && !hasPanel) {
|
|
654
|
-
return
|
|
947
|
+
return (
|
|
948
|
+
<SearchBarContext.Provider value={context}>
|
|
949
|
+
<View ref={anchorRef} onLayout={measureAnchor} className={containerClassName}>
|
|
950
|
+
{field}
|
|
951
|
+
</View>
|
|
952
|
+
</SearchBarContext.Provider>
|
|
953
|
+
);
|
|
655
954
|
}
|
|
656
955
|
|
|
657
956
|
/*
|
|
@@ -666,11 +965,12 @@ const SearchBarRoot = forwardRef<TextInput, SearchBarProps>(
|
|
|
666
965
|
key="list"
|
|
667
966
|
style={{ maxHeight: resolvedMaxHeight }}
|
|
668
967
|
/*
|
|
669
|
-
*
|
|
670
|
-
*
|
|
671
|
-
*
|
|
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.
|
|
672
972
|
*/
|
|
673
|
-
keyboardShouldPersistTaps="
|
|
973
|
+
keyboardShouldPersistTaps="always"
|
|
674
974
|
keyboardDismissMode="none"
|
|
675
975
|
showsVerticalScrollIndicator={false}
|
|
676
976
|
bounces={false}
|
|
@@ -682,23 +982,47 @@ const SearchBarRoot = forwardRef<TextInput, SearchBarProps>(
|
|
|
682
982
|
const divider = (
|
|
683
983
|
<View
|
|
684
984
|
key="divider"
|
|
985
|
+
pointerEvents="none"
|
|
685
986
|
style={{ height: StyleSheet.hairlineWidth }}
|
|
686
987
|
className={slots.panelDivider()}
|
|
687
988
|
/>
|
|
688
989
|
);
|
|
689
|
-
|
|
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
|
+
);
|
|
690
1010
|
|
|
691
1011
|
const anchor = (
|
|
692
1012
|
<View
|
|
693
1013
|
ref={anchorRef}
|
|
694
1014
|
onLayout={measureAnchor}
|
|
695
|
-
style={
|
|
1015
|
+
style={RAISED}
|
|
696
1016
|
className={slots.anchor({ className: cancel === 'never' ? 'w-full' : 'flex-1' })}
|
|
697
1017
|
>
|
|
698
1018
|
{panelOpen ? (
|
|
699
1019
|
<Animated.View
|
|
700
1020
|
entering={FadeIn.duration(PANEL_IN)}
|
|
701
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"
|
|
702
1026
|
style={panelPlacement === 'top' ? CARD_ABOVE : CARD_BELOW}
|
|
703
1027
|
className={slots.panel()}
|
|
704
1028
|
>
|
|
@@ -757,22 +1081,28 @@ const SearchBarRoot = forwardRef<TextInput, SearchBarProps>(
|
|
|
757
1081
|
*/
|
|
758
1082
|
if (avoidKeyboard) {
|
|
759
1083
|
return (
|
|
760
|
-
<
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
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>
|
|
772
1098
|
);
|
|
773
1099
|
}
|
|
774
1100
|
|
|
775
|
-
return
|
|
1101
|
+
return (
|
|
1102
|
+
<SearchBarContext.Provider value={context}>
|
|
1103
|
+
<View className={slots.row({ className: containerClassName })}>{body}</View>
|
|
1104
|
+
</SearchBarContext.Provider>
|
|
1105
|
+
);
|
|
776
1106
|
}
|
|
777
1107
|
);
|
|
778
1108
|
|
|
@@ -831,13 +1161,24 @@ function SearchBarItem({
|
|
|
831
1161
|
description,
|
|
832
1162
|
selected,
|
|
833
1163
|
children,
|
|
1164
|
+
onPressIn,
|
|
834
1165
|
...props
|
|
835
1166
|
}: SearchBarItemProps) {
|
|
836
1167
|
const { item, itemLabel } = searchBarVariants({ selected: !!selected });
|
|
1168
|
+
const search = useContext(SearchBarContext);
|
|
837
1169
|
return (
|
|
838
1170
|
<AnimatedPressable
|
|
839
1171
|
accessibilityRole="button"
|
|
840
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
|
+
}}
|
|
841
1182
|
// A row is a wide target, and a target that shrinks when pressed reads as
|
|
842
1183
|
// a card rather than a line in a list. The dim is the whole feedback.
|
|
843
1184
|
pressScale={1}
|
|
@@ -898,8 +1239,97 @@ function SearchBarStatus({
|
|
|
898
1239
|
|
|
899
1240
|
SearchBarStatus.displayName = 'SearchBar.Status';
|
|
900
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
|
+
|
|
901
1329
|
export const SearchBar = Object.assign(SearchBarRoot, {
|
|
902
1330
|
Section: SearchBarSection,
|
|
903
1331
|
Item: SearchBarItem,
|
|
1332
|
+
Action: SearchBarAction,
|
|
1333
|
+
Token: SearchBarToken,
|
|
904
1334
|
Status: SearchBarStatus,
|
|
905
1335
|
});
|