panelui-native 0.86.0 → 0.87.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/bubble-chart/index.js +1266 -0
- package/lib/module/components/bubble-chart/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/search-bar/index.js +399 -16
- package/lib/module/components/search-bar/index.js.map +1 -1
- package/lib/module/index.js +2 -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/bubble-chart/index.d.ts +338 -0
- package/lib/typescript/src/components/bubble-chart/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/search-bar/index.d.ts +215 -6
- package/lib/typescript/src/components/search-bar/index.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +3 -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/bubble-chart/index.tsx +1527 -0
- package/src/components/pyramid-chart/index.tsx +1360 -0
- package/src/components/search-bar/index.tsx +496 -38
- package/src/index.ts +45 -1
- package/src/utils/chart.ts +27 -0
|
@@ -1,13 +1,41 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* SearchBar — a text field for querying a list, with the two controls a search
|
|
3
|
-
* needs and an ordinary field does not
|
|
3
|
+
* needs and an ordinary field does not, and a panel of results that opens out
|
|
4
|
+
* of the field itself.
|
|
4
5
|
*
|
|
5
6
|
* ```tsx
|
|
6
7
|
* <SearchBar placeholder="Search orders" onSubmit={run} />
|
|
7
8
|
* <SearchBar variant="filled" shape="pill" cancel="focus" />
|
|
8
|
-
* <SearchBar
|
|
9
|
+
* <SearchBar avoidKeyboard value={query} onChangeText={setQuery}>
|
|
10
|
+
* <SearchBar.Section label="Suggested">
|
|
11
|
+
* <SearchBar.Item trailing={<AddButton />} onPress={add}>Claude</SearchBar.Item>
|
|
12
|
+
* </SearchBar.Section>
|
|
13
|
+
* </SearchBar>
|
|
9
14
|
* ```
|
|
10
15
|
*
|
|
16
|
+
* ## The results are above the field, and the field is above the keyboard
|
|
17
|
+
*
|
|
18
|
+
* A search that is being typed into has a keyboard under it, and a list drawn
|
|
19
|
+
* below the field is a list drawn behind the keyboard. So `avoidKeyboard`
|
|
20
|
+
* lifts the field until it sits `keyboardOffset` points clear of the keyboard's
|
|
21
|
+
* top edge, and the panel opens *upward* out of it into the space that is
|
|
22
|
+
* actually free.
|
|
23
|
+
*
|
|
24
|
+
* That puts the first result nearest the field and the last one furthest away,
|
|
25
|
+
* which is the order a reader walking away from the caret expects. Pass
|
|
26
|
+
* `panelPlacement="bottom"` for a search bar in a header, where the space is
|
|
27
|
+
* the other way round.
|
|
28
|
+
*
|
|
29
|
+
* The panel is positioned absolutely rather than laid out in the flow, so
|
|
30
|
+
* opening it never moves the page underneath — a list that pushes the field it
|
|
31
|
+
* belongs to is a field that walks away from the finger typing into it.
|
|
32
|
+
*
|
|
33
|
+
* ## Touches inside the panel must not close the keyboard
|
|
34
|
+
*
|
|
35
|
+
* The panel scrolls with `keyboardShouldPersistTaps="handled"`. Without it the
|
|
36
|
+
* first tap on a row is spent dismissing the keyboard and the press never
|
|
37
|
+
* arrives, which reads as a row that ignores every other tap.
|
|
38
|
+
*
|
|
11
39
|
* ## The clear button, and why it is not the platform's
|
|
12
40
|
*
|
|
13
41
|
* A ✕ appears inside the field as soon as there is something to clear, and
|
|
@@ -44,29 +72,44 @@
|
|
|
44
72
|
* a return key is somebody saying they are done waiting.
|
|
45
73
|
*/
|
|
46
74
|
import {
|
|
75
|
+
Children,
|
|
47
76
|
forwardRef,
|
|
77
|
+
isValidElement,
|
|
48
78
|
useCallback,
|
|
49
79
|
useEffect,
|
|
50
80
|
useImperativeHandle,
|
|
81
|
+
useMemo,
|
|
51
82
|
useRef,
|
|
52
83
|
useState,
|
|
53
84
|
type ReactNode,
|
|
54
85
|
} from 'react';
|
|
55
86
|
import {
|
|
87
|
+
ScrollView,
|
|
88
|
+
StyleSheet,
|
|
56
89
|
View,
|
|
90
|
+
useWindowDimensions,
|
|
57
91
|
type LayoutChangeEvent,
|
|
58
92
|
type NativeSyntheticEvent,
|
|
59
93
|
type TextInput,
|
|
60
94
|
type TextInputSubmitEditingEventData,
|
|
95
|
+
type ViewProps,
|
|
96
|
+
type ViewStyle,
|
|
61
97
|
} from 'react-native';
|
|
62
98
|
import Animated, {
|
|
99
|
+
FadeIn,
|
|
100
|
+
FadeOut,
|
|
63
101
|
useAnimatedStyle,
|
|
64
102
|
useSharedValue,
|
|
65
103
|
withTiming,
|
|
66
104
|
} from 'react-native-reanimated';
|
|
67
105
|
import { tv, type VariantProps } from 'tailwind-variants';
|
|
106
|
+
import { useKeyboard } from '../../hooks/use-keyboard';
|
|
68
107
|
import { SearchIcon, XIcon } from '../../icons';
|
|
69
|
-
import {
|
|
108
|
+
import {
|
|
109
|
+
AnimatedPressable,
|
|
110
|
+
type AnimatedPressableProps,
|
|
111
|
+
} from '../../primitives/animated-pressable';
|
|
112
|
+
import { KeyboardAvoider } from '../../primitives/keyboard-avoider';
|
|
70
113
|
import { Text } from '../../primitives/text';
|
|
71
114
|
import { Input, type InputProps } from '../input';
|
|
72
115
|
import { Spinner } from '../spinner';
|
|
@@ -89,10 +132,63 @@ const CANCEL_GAP = 8;
|
|
|
89
132
|
*/
|
|
90
133
|
const CLEAR_HIT_SLOP = 12;
|
|
91
134
|
|
|
135
|
+
/** The panel's crossfade, matching every other anchored list in the library. */
|
|
136
|
+
const PANEL_IN = 140;
|
|
137
|
+
const PANEL_OUT = 120;
|
|
138
|
+
|
|
139
|
+
/** Gap left between the panel's far edge and the edge of the screen. */
|
|
140
|
+
const PANEL_EDGE_GAP = 24;
|
|
141
|
+
|
|
142
|
+
/** Floor for the derived height — below this a list is not worth opening. */
|
|
143
|
+
const PANEL_MIN_HEIGHT = 160;
|
|
144
|
+
|
|
145
|
+
/*
|
|
146
|
+
* The card is pinned to the edge of the field's own slot and grows away from
|
|
147
|
+
* it, so the field's box is the one thing that never moves.
|
|
148
|
+
*/
|
|
149
|
+
const CARD_ABOVE: ViewStyle = { position: 'absolute', bottom: 0, left: 0, right: 0 };
|
|
150
|
+
const CARD_BELOW: ViewStyle = { position: 'absolute', top: 0, left: 0, right: 0 };
|
|
151
|
+
|
|
152
|
+
/*
|
|
153
|
+
* `zIndex` *and* `elevation`, on the field's own box rather than on the card:
|
|
154
|
+
* Android draws siblings in tree order and takes its stacking from elevation,
|
|
155
|
+
* so a card overlapping the content above the field would otherwise be painted
|
|
156
|
+
* under it — and putting it here keeps the field painting over the card, which
|
|
157
|
+
* is what lets the two be one surface.
|
|
158
|
+
*/
|
|
159
|
+
const RAISED: ViewStyle = { zIndex: 20, elevation: 20 };
|
|
160
|
+
|
|
92
161
|
const searchBarVariants = tv({
|
|
93
162
|
slots: {
|
|
94
163
|
row: 'w-full flex-row items-center',
|
|
164
|
+
/*
|
|
165
|
+
* The box the panel is positioned against — the field alone, so the panel
|
|
166
|
+
* is the field's width rather than the row's and does not run out under a
|
|
167
|
+
* Cancel button that is only sometimes there.
|
|
168
|
+
*/
|
|
169
|
+
anchor: 'relative',
|
|
95
170
|
field: '',
|
|
171
|
+
/*
|
|
172
|
+
* One card around the results *and* the field, with the field drawn over
|
|
173
|
+
* the space kept for it at the bottom.
|
|
174
|
+
*
|
|
175
|
+
* It has to be one box because the outline is one outline. Drawn as two —
|
|
176
|
+
* a bordered panel above a bordered field — the field's edge is the focus
|
|
177
|
+
* ring, since a field with a panel open is a field being typed into, and
|
|
178
|
+
* the card ends up with a brighter box welded to a dimmer one.
|
|
179
|
+
*
|
|
180
|
+
* `bg-popover`, not `bg-card`: this floats over the page rather than
|
|
181
|
+
* sitting in it, and a card is one step from the background — close enough
|
|
182
|
+
* that in dark mode the whole thing dissolves into the screen behind it.
|
|
183
|
+
*/
|
|
184
|
+
panel: 'overflow-hidden rounded-2xl border border-border bg-popover shadow-lg',
|
|
185
|
+
panelList: 'p-1.5',
|
|
186
|
+
/** The hairline between the results and the field. */
|
|
187
|
+
panelDivider: 'w-full bg-border',
|
|
188
|
+
sectionLabel: 'px-3 pb-1 pt-2 text-sm text-muted-foreground',
|
|
189
|
+
item: 'flex-row items-center gap-3 rounded-lg px-3 py-2.5',
|
|
190
|
+
itemLabel: 'flex-1 text-base text-foreground',
|
|
191
|
+
status: 'flex-row items-center justify-center gap-2 px-3 py-8',
|
|
96
192
|
// Clipped, because this is what the Cancel button is revealed out of: the
|
|
97
193
|
// button keeps its measured width and the container's grows past it.
|
|
98
194
|
//
|
|
@@ -119,10 +215,24 @@ const searchBarVariants = tv({
|
|
|
119
215
|
rounded: { field: '' },
|
|
120
216
|
pill: { field: 'rounded-full' },
|
|
121
217
|
},
|
|
218
|
+
/**
|
|
219
|
+
* Which edge of the field the card grows out of. The field's corners on
|
|
220
|
+
* that edge go square and its border comes off entirely — the card around
|
|
221
|
+
* both of them is what draws the edge.
|
|
222
|
+
*/
|
|
223
|
+
attached: {
|
|
224
|
+
none: {},
|
|
225
|
+
top: { field: 'rounded-t-none rounded-b-2xl border-0' },
|
|
226
|
+
bottom: { field: 'rounded-b-none rounded-t-2xl border-0' },
|
|
227
|
+
},
|
|
228
|
+
selected: {
|
|
229
|
+
true: { item: 'bg-accent' },
|
|
230
|
+
},
|
|
122
231
|
},
|
|
123
232
|
defaultVariants: {
|
|
124
233
|
size: 'md',
|
|
125
234
|
shape: 'rounded',
|
|
235
|
+
attached: 'none',
|
|
126
236
|
},
|
|
127
237
|
});
|
|
128
238
|
|
|
@@ -131,21 +241,34 @@ type SearchBarVariantProps = VariantProps<typeof searchBarVariants>;
|
|
|
131
241
|
/** Glyph sizes per field size — the icon tracks the text, not the box. */
|
|
132
242
|
const ICON_SIZE = { sm: 16, md: 18, lg: 20 } as const;
|
|
133
243
|
|
|
244
|
+
/** Where the results open. */
|
|
245
|
+
export type SearchBarPanelPlacement = 'top' | 'bottom';
|
|
246
|
+
|
|
247
|
+
/** When the results are shown. */
|
|
248
|
+
export type SearchBarPanelMode = 'never' | 'focus' | 'always';
|
|
249
|
+
|
|
134
250
|
/**
|
|
135
251
|
* What SearchBar takes from Input, minus everything it owns itself. The form
|
|
136
252
|
* furniture is dropped along with it: a label and an error line stack above
|
|
137
253
|
* and below the field, and Cancel sits beside the whole stack rather than
|
|
138
254
|
* beside the field it belongs to. Use `Field` for a search that is one answer
|
|
139
255
|
* in a form.
|
|
256
|
+
*
|
|
257
|
+
* The keyboard props go too. Input's would move the field and leave the Cancel
|
|
258
|
+
* button and the panel where they were; SearchBar lifts all three together.
|
|
140
259
|
*/
|
|
141
260
|
type InheritedInputProps = Omit<
|
|
142
261
|
InputProps,
|
|
262
|
+
| 'avoidKeyboard'
|
|
143
263
|
| 'defaultValue'
|
|
144
264
|
| 'description'
|
|
145
265
|
| 'endContent'
|
|
146
266
|
| 'errorMessage'
|
|
147
267
|
| 'interactiveContent'
|
|
148
268
|
| 'isRequired'
|
|
269
|
+
| 'keyboardBottomInset'
|
|
270
|
+
| 'keyboardMode'
|
|
271
|
+
| 'keyboardOffset'
|
|
149
272
|
| 'label'
|
|
150
273
|
| 'multiline'
|
|
151
274
|
| 'onChangeText'
|
|
@@ -154,7 +277,9 @@ type InheritedInputProps = Omit<
|
|
|
154
277
|
| 'value'
|
|
155
278
|
>;
|
|
156
279
|
|
|
157
|
-
export interface SearchBarProps
|
|
280
|
+
export interface SearchBarProps
|
|
281
|
+
extends InheritedInputProps,
|
|
282
|
+
Omit<SearchBarVariantProps, 'attached' | 'selected'> {
|
|
158
283
|
/**
|
|
159
284
|
* The field's background, from `Input`. `outline` draws its own edge, for a
|
|
160
285
|
* search bar sitting on the page; `filled` drops it, for one inside a card
|
|
@@ -202,9 +327,42 @@ export interface SearchBarProps extends InheritedInputProps, SearchBarVariantPro
|
|
|
202
327
|
loading?: boolean;
|
|
203
328
|
/** The leading glyph, for a search over something with a symbol of its own. */
|
|
204
329
|
icon?: ReactNode;
|
|
330
|
+
/**
|
|
331
|
+
* Lift the whole search — field, Cancel button and panel — until it sits
|
|
332
|
+
* clear of the software keyboard, and put it back on blur. Without it the
|
|
333
|
+
* field stays where the page left it, which on most screens is behind the
|
|
334
|
+
* keyboard it just opened.
|
|
335
|
+
*
|
|
336
|
+
* Install `react-native-keyboard-controller` for this to behave on Android.
|
|
337
|
+
*
|
|
338
|
+
* Do not toggle it at runtime: it changes which component wraps the row, so
|
|
339
|
+
* the field would remount and lose focus.
|
|
340
|
+
*/
|
|
341
|
+
avoidKeyboard?: boolean;
|
|
342
|
+
/** Gap kept between the field's bottom edge and the keyboard. */
|
|
343
|
+
keyboardOffset?: number;
|
|
344
|
+
/**
|
|
345
|
+
* When the results panel is shown. `focus` opens it while the field is being
|
|
346
|
+
* typed into, `always` keeps it out for a screen that is nothing but the
|
|
347
|
+
* search, `never` ignores the children entirely.
|
|
348
|
+
*/
|
|
349
|
+
panel?: SearchBarPanelMode;
|
|
350
|
+
/**
|
|
351
|
+
* Which side of the field the panel opens out of. `top` is the default,
|
|
352
|
+
* because the space under a focused field belongs to the keyboard.
|
|
353
|
+
*/
|
|
354
|
+
panelPlacement?: SearchBarPanelPlacement;
|
|
355
|
+
/**
|
|
356
|
+
* Cap on the panel's height, in points. Derived from the room between the
|
|
357
|
+
* field and the edge of the screen when it is not given, so a panel never
|
|
358
|
+
* runs off the top of the display.
|
|
359
|
+
*/
|
|
360
|
+
panelMaxHeight?: number;
|
|
361
|
+
/** The panel's contents — `SearchBar.Section`, `.Item` and `.Status`. */
|
|
362
|
+
children?: ReactNode;
|
|
205
363
|
}
|
|
206
364
|
|
|
207
|
-
|
|
365
|
+
const SearchBarRoot = forwardRef<TextInput, SearchBarProps>(
|
|
208
366
|
(
|
|
209
367
|
{
|
|
210
368
|
value: valueProp,
|
|
@@ -226,6 +384,12 @@ export const SearchBar = forwardRef<TextInput, SearchBarProps>(
|
|
|
226
384
|
className,
|
|
227
385
|
containerClassName,
|
|
228
386
|
disabled,
|
|
387
|
+
avoidKeyboard = false,
|
|
388
|
+
keyboardOffset = 12,
|
|
389
|
+
panel = 'focus',
|
|
390
|
+
panelPlacement = 'top',
|
|
391
|
+
panelMaxHeight,
|
|
392
|
+
children,
|
|
229
393
|
onFocus,
|
|
230
394
|
onBlur,
|
|
231
395
|
onSubmitEditing,
|
|
@@ -241,7 +405,27 @@ export const SearchBar = forwardRef<TextInput, SearchBarProps>(
|
|
|
241
405
|
const inputRef = useRef<TextInput | null>(null);
|
|
242
406
|
useImperativeHandle(ref, () => inputRef.current as TextInput, []);
|
|
243
407
|
|
|
244
|
-
|
|
408
|
+
/*
|
|
409
|
+
* Counted rather than tested for truthiness: `{results.map(…)}` over an
|
|
410
|
+
* empty array is a child, and a panel that opens on nothing is a card of
|
|
411
|
+
* padding.
|
|
412
|
+
*/
|
|
413
|
+
const hasPanel = useMemo(() => {
|
|
414
|
+
let found = false;
|
|
415
|
+
Children.forEach(children, (child) => {
|
|
416
|
+
if (isValidElement(child)) found = true;
|
|
417
|
+
});
|
|
418
|
+
return found;
|
|
419
|
+
}, [children]);
|
|
420
|
+
|
|
421
|
+
const panelOpen =
|
|
422
|
+
!disabled &&
|
|
423
|
+
panel !== 'never' &&
|
|
424
|
+
hasPanel &&
|
|
425
|
+
(panel === 'always' || focused);
|
|
426
|
+
|
|
427
|
+
const attached = panelOpen ? panelPlacement : 'none';
|
|
428
|
+
const slots = searchBarVariants({ size, shape, attached });
|
|
245
429
|
|
|
246
430
|
const setText = useCallback(
|
|
247
431
|
(next: string) => {
|
|
@@ -349,6 +533,62 @@ export const SearchBar = forwardRef<TextInput, SearchBarProps>(
|
|
|
349
533
|
[cancelWidth]
|
|
350
534
|
);
|
|
351
535
|
|
|
536
|
+
/*
|
|
537
|
+
* How much room the panel has, in the direction it opens. Measured on the
|
|
538
|
+
* JS side because it decides a layout constraint rather than a frame of an
|
|
539
|
+
* animation: a `maxHeight` that changed every frame would re-lay out the
|
|
540
|
+
* list under the finger scrolling it.
|
|
541
|
+
*/
|
|
542
|
+
const { height: windowHeight } = useWindowDimensions();
|
|
543
|
+
const { height: keyboardHeight } = useKeyboard();
|
|
544
|
+
const anchorRef = useRef<View | null>(null);
|
|
545
|
+
const [anchorBox, setAnchorBox] = useState<{ top: number; height: number } | null>(
|
|
546
|
+
null
|
|
547
|
+
);
|
|
548
|
+
|
|
549
|
+
const measureAnchor = useCallback(() => {
|
|
550
|
+
anchorRef.current?.measureInWindow((_x, y, _width, height) => {
|
|
551
|
+
setAnchorBox((current) =>
|
|
552
|
+
current && current.top === y && current.height === height
|
|
553
|
+
? current
|
|
554
|
+
: { top: y, height }
|
|
555
|
+
);
|
|
556
|
+
});
|
|
557
|
+
}, []);
|
|
558
|
+
|
|
559
|
+
useEffect(() => {
|
|
560
|
+
if (!panelOpen) return;
|
|
561
|
+
measureAnchor();
|
|
562
|
+
}, [panelOpen, keyboardHeight, measureAnchor]);
|
|
563
|
+
|
|
564
|
+
const resolvedMaxHeight = useMemo(() => {
|
|
565
|
+
if (panelMaxHeight !== undefined) return panelMaxHeight;
|
|
566
|
+
const fieldHeight = anchorBox?.height ?? 0;
|
|
567
|
+
/*
|
|
568
|
+
* While the field is riding the keyboard, where it has come to rest is
|
|
569
|
+
* computed rather than measured: the lift is a transform applied on the
|
|
570
|
+
* UI thread, so a measurement taken from JavaScript is a frame behind it
|
|
571
|
+
* for the whole of the animation.
|
|
572
|
+
*/
|
|
573
|
+
const fieldTop =
|
|
574
|
+
avoidKeyboard && keyboardHeight > 0
|
|
575
|
+
? windowHeight - keyboardHeight - keyboardOffset - fieldHeight
|
|
576
|
+
: (anchorBox?.top ?? 0);
|
|
577
|
+
const room =
|
|
578
|
+
panelPlacement === 'top'
|
|
579
|
+
? fieldTop - PANEL_EDGE_GAP
|
|
580
|
+
: windowHeight - keyboardHeight - fieldTop - fieldHeight - PANEL_EDGE_GAP;
|
|
581
|
+
return Math.max(room, PANEL_MIN_HEIGHT);
|
|
582
|
+
}, [
|
|
583
|
+
anchorBox,
|
|
584
|
+
avoidKeyboard,
|
|
585
|
+
keyboardHeight,
|
|
586
|
+
keyboardOffset,
|
|
587
|
+
panelMaxHeight,
|
|
588
|
+
panelPlacement,
|
|
589
|
+
windowHeight,
|
|
590
|
+
]);
|
|
591
|
+
|
|
352
592
|
const startContent = (
|
|
353
593
|
/*
|
|
354
594
|
* Decorative, and said so here rather than through Input's
|
|
@@ -386,7 +626,7 @@ export const SearchBar = forwardRef<TextInput, SearchBarProps>(
|
|
|
386
626
|
size={size}
|
|
387
627
|
disabled={disabled}
|
|
388
628
|
className={slots.field({ className })}
|
|
389
|
-
containerClassName=
|
|
629
|
+
containerClassName="w-full"
|
|
390
630
|
startContent={startContent}
|
|
391
631
|
endContent={endContent}
|
|
392
632
|
value={text}
|
|
@@ -407,41 +647,259 @@ export const SearchBar = forwardRef<TextInput, SearchBarProps>(
|
|
|
407
647
|
/>
|
|
408
648
|
);
|
|
409
649
|
|
|
410
|
-
|
|
650
|
+
// Nothing beside it and nothing under it: the field is the whole component,
|
|
651
|
+
// and a wrapper around it would only be a box for the caller's layout to
|
|
652
|
+
// fight.
|
|
653
|
+
if (cancel === 'never' && !avoidKeyboard && !hasPanel) {
|
|
654
|
+
return <View className={containerClassName}>{field}</View>;
|
|
655
|
+
}
|
|
411
656
|
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
657
|
+
/*
|
|
658
|
+
* The results, the hairline and the room the field occupies, in the order
|
|
659
|
+
* they are stacked. The field itself is drawn over that last piece rather
|
|
660
|
+
* than inside the card: it has to keep its own place in the layout, and a
|
|
661
|
+
* text field that moved into an absolutely positioned box on focus would
|
|
662
|
+
* remount and lose the keyboard it just opened.
|
|
663
|
+
*/
|
|
664
|
+
const list = (
|
|
665
|
+
<ScrollView
|
|
666
|
+
key="list"
|
|
667
|
+
style={{ maxHeight: resolvedMaxHeight }}
|
|
668
|
+
/*
|
|
669
|
+
* Without this the first tap on a row is spent dismissing the keyboard
|
|
670
|
+
* and the press never lands, which reads as a list that ignores every
|
|
671
|
+
* other touch.
|
|
672
|
+
*/
|
|
673
|
+
keyboardShouldPersistTaps="handled"
|
|
674
|
+
keyboardDismissMode="none"
|
|
675
|
+
showsVerticalScrollIndicator={false}
|
|
676
|
+
bounces={false}
|
|
677
|
+
className={slots.panelList()}
|
|
678
|
+
>
|
|
679
|
+
{children}
|
|
680
|
+
</ScrollView>
|
|
681
|
+
);
|
|
682
|
+
const divider = (
|
|
683
|
+
<View
|
|
684
|
+
key="divider"
|
|
685
|
+
style={{ height: StyleSheet.hairlineWidth }}
|
|
686
|
+
className={slots.panelDivider()}
|
|
687
|
+
/>
|
|
688
|
+
);
|
|
689
|
+
const fieldSlot = <View key="slot" style={{ height: anchorBox?.height ?? 0 }} />;
|
|
690
|
+
|
|
691
|
+
const anchor = (
|
|
692
|
+
<View
|
|
693
|
+
ref={anchorRef}
|
|
694
|
+
onLayout={measureAnchor}
|
|
695
|
+
style={panelOpen ? RAISED : undefined}
|
|
696
|
+
className={slots.anchor({ className: cancel === 'never' ? 'w-full' : 'flex-1' })}
|
|
697
|
+
>
|
|
698
|
+
{panelOpen ? (
|
|
699
|
+
<Animated.View
|
|
700
|
+
entering={FadeIn.duration(PANEL_IN)}
|
|
701
|
+
exiting={FadeOut.duration(PANEL_OUT)}
|
|
702
|
+
style={panelPlacement === 'top' ? CARD_ABOVE : CARD_BELOW}
|
|
703
|
+
className={slots.panel()}
|
|
438
704
|
>
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
705
|
+
{panelPlacement === 'top'
|
|
706
|
+
? [list, divider, fieldSlot]
|
|
707
|
+
: [fieldSlot, divider, list]}
|
|
708
|
+
</Animated.View>
|
|
709
|
+
) : null}
|
|
710
|
+
{field}
|
|
442
711
|
</View>
|
|
443
712
|
);
|
|
713
|
+
|
|
714
|
+
const body =
|
|
715
|
+
cancel === 'never' ? (
|
|
716
|
+
anchor
|
|
717
|
+
) : (
|
|
718
|
+
<>
|
|
719
|
+
{anchor}
|
|
720
|
+
<Animated.View
|
|
721
|
+
style={cancelStyle}
|
|
722
|
+
className={slots.cancelClip()}
|
|
723
|
+
pointerEvents={cancelOut ? 'auto' : 'none'}
|
|
724
|
+
accessibilityElementsHidden={!cancelOut}
|
|
725
|
+
importantForAccessibility={cancelOut ? 'auto' : 'no-hide-descendants'}
|
|
726
|
+
>
|
|
727
|
+
{/*
|
|
728
|
+
* Absolute, and pinned to the end edge: it keeps its natural width
|
|
729
|
+
* inside a container whose width is animating, so the clip reveals
|
|
730
|
+
* it from the edge instead of squeezing the word as it arrives. It
|
|
731
|
+
* is also what makes the measurement possible at all — a child laid
|
|
732
|
+
* out against a container of width 0 would otherwise report 0.
|
|
733
|
+
*/}
|
|
734
|
+
<AnimatedPressable
|
|
735
|
+
onLayout={handleCancelLayout}
|
|
736
|
+
onPress={handleCancel}
|
|
737
|
+
disabled={disabled}
|
|
738
|
+
focusable={!disabled}
|
|
739
|
+
hitSlop={8}
|
|
740
|
+
accessibilityRole="button"
|
|
741
|
+
accessibilityState={{ disabled: !!disabled }}
|
|
742
|
+
className={slots.cancelButton()}
|
|
743
|
+
>
|
|
744
|
+
<Text className={slots.cancelLabel()}>{cancelLabel}</Text>
|
|
745
|
+
</AnimatedPressable>
|
|
746
|
+
</Animated.View>
|
|
747
|
+
</>
|
|
748
|
+
);
|
|
749
|
+
|
|
750
|
+
/*
|
|
751
|
+
* The keyboard hook is behind a component boundary rather than a flag.
|
|
752
|
+
* Calling it at all has global consequences — without the keyboard
|
|
753
|
+
* controller installed it falls back to Reanimated's useAnimatedKeyboard,
|
|
754
|
+
* which switches Android out of adjustResize for the whole app. A search
|
|
755
|
+
* bar that never asked to avoid the keyboard must not do that to every
|
|
756
|
+
* other screen.
|
|
757
|
+
*/
|
|
758
|
+
if (avoidKeyboard) {
|
|
759
|
+
return (
|
|
760
|
+
<KeyboardAvoider
|
|
761
|
+
// Only while *this* field is the one being typed into. Without it
|
|
762
|
+
// every avoiding field on the screen lifts the moment any field
|
|
763
|
+
// anywhere is tapped, and since they all aim at the same gap above
|
|
764
|
+
// the keyboard, they arrive stacked on top of one another.
|
|
765
|
+
active={focused}
|
|
766
|
+
mode="lift"
|
|
767
|
+
offset={keyboardOffset}
|
|
768
|
+
className={slots.row({ className: containerClassName })}
|
|
769
|
+
>
|
|
770
|
+
{body}
|
|
771
|
+
</KeyboardAvoider>
|
|
772
|
+
);
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
return <View className={slots.row({ className: containerClassName })}>{body}</View>;
|
|
444
776
|
}
|
|
445
777
|
);
|
|
446
778
|
|
|
447
|
-
|
|
779
|
+
SearchBarRoot.displayName = 'SearchBar';
|
|
780
|
+
|
|
781
|
+
export interface SearchBarSectionProps extends ViewProps {
|
|
782
|
+
className?: string;
|
|
783
|
+
/**
|
|
784
|
+
* The heading over the run of rows — "Suggested", "Results". Announced as a
|
|
785
|
+
* header, so a screen reader reaching the group is told what it is before
|
|
786
|
+
* walking into it.
|
|
787
|
+
*/
|
|
788
|
+
label?: string;
|
|
789
|
+
children?: ReactNode;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/** A labelled run of rows inside the panel. */
|
|
793
|
+
function SearchBarSection({ className, label, children, ...props }: SearchBarSectionProps) {
|
|
794
|
+
const { sectionLabel } = searchBarVariants();
|
|
795
|
+
return (
|
|
796
|
+
<View {...props} className={className}>
|
|
797
|
+
{label ? (
|
|
798
|
+
<Text accessibilityRole="header" className={sectionLabel()}>
|
|
799
|
+
{label}
|
|
800
|
+
</Text>
|
|
801
|
+
) : null}
|
|
802
|
+
{children}
|
|
803
|
+
</View>
|
|
804
|
+
);
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
SearchBarSection.displayName = 'SearchBar.Section';
|
|
808
|
+
|
|
809
|
+
export interface SearchBarItemProps extends Omit<AnimatedPressableProps, 'children'> {
|
|
810
|
+
className?: string;
|
|
811
|
+
/** Anything before the label — an avatar, a logo, a status dot. */
|
|
812
|
+
leading?: ReactNode;
|
|
813
|
+
/**
|
|
814
|
+
* Anything after it. A slot rather than a built-in button, because what a
|
|
815
|
+
* result row offers differs per search: an add, a pin, a count, nothing.
|
|
816
|
+
*/
|
|
817
|
+
trailing?: ReactNode;
|
|
818
|
+
/** A second line under the label, for what the label alone cannot say. */
|
|
819
|
+
description?: string;
|
|
820
|
+
/** Draws the row as the one the search has settled on. */
|
|
821
|
+
selected?: boolean;
|
|
822
|
+
/** The row's label. */
|
|
823
|
+
children?: ReactNode;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/** One result. */
|
|
827
|
+
function SearchBarItem({
|
|
828
|
+
className,
|
|
829
|
+
leading,
|
|
830
|
+
trailing,
|
|
831
|
+
description,
|
|
832
|
+
selected,
|
|
833
|
+
children,
|
|
834
|
+
...props
|
|
835
|
+
}: SearchBarItemProps) {
|
|
836
|
+
const { item, itemLabel } = searchBarVariants({ selected: !!selected });
|
|
837
|
+
return (
|
|
838
|
+
<AnimatedPressable
|
|
839
|
+
accessibilityRole="button"
|
|
840
|
+
accessibilityState={{ selected: !!selected, disabled: !!props.disabled }}
|
|
841
|
+
// A row is a wide target, and a target that shrinks when pressed reads as
|
|
842
|
+
// a card rather than a line in a list. The dim is the whole feedback.
|
|
843
|
+
pressScale={1}
|
|
844
|
+
pressOpacity={0.6}
|
|
845
|
+
// The rows sit flush against each other, so the points either side of the
|
|
846
|
+
// gap between two of them would otherwise belong to neither.
|
|
847
|
+
hitSlop={{ top: 2, bottom: 2 }}
|
|
848
|
+
{...props}
|
|
849
|
+
className={item({ className })}
|
|
850
|
+
>
|
|
851
|
+
{leading}
|
|
852
|
+
<View className="flex-1">
|
|
853
|
+
<Text numberOfLines={1} className={itemLabel()}>
|
|
854
|
+
{children}
|
|
855
|
+
</Text>
|
|
856
|
+
{description ? (
|
|
857
|
+
<Text size="sm" muted numberOfLines={1}>
|
|
858
|
+
{description}
|
|
859
|
+
</Text>
|
|
860
|
+
) : null}
|
|
861
|
+
</View>
|
|
862
|
+
{trailing}
|
|
863
|
+
</AnimatedPressable>
|
|
864
|
+
);
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
SearchBarItem.displayName = 'SearchBar.Item';
|
|
868
|
+
|
|
869
|
+
export interface SearchBarStatusProps extends ViewProps {
|
|
870
|
+
className?: string;
|
|
871
|
+
/** A spinner beside the line, for a search that is still running. */
|
|
872
|
+
loading?: boolean;
|
|
873
|
+
children?: ReactNode;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
/**
|
|
877
|
+
* The one line a panel shows instead of rows — nothing typed yet, a search in
|
|
878
|
+
* flight, or a query that matched nothing. It is a sentence rather than an
|
|
879
|
+
* empty box because those three states look identical when they are blank, and
|
|
880
|
+
* which one it is decides what the person does next.
|
|
881
|
+
*/
|
|
882
|
+
function SearchBarStatus({
|
|
883
|
+
className,
|
|
884
|
+
loading = false,
|
|
885
|
+
children,
|
|
886
|
+
...props
|
|
887
|
+
}: SearchBarStatusProps) {
|
|
888
|
+
const { status } = searchBarVariants();
|
|
889
|
+
return (
|
|
890
|
+
<View accessibilityRole="text" {...props} className={status({ className })}>
|
|
891
|
+
{loading ? <Spinner size="sm" /> : null}
|
|
892
|
+
<Text size="sm" muted>
|
|
893
|
+
{children}
|
|
894
|
+
</Text>
|
|
895
|
+
</View>
|
|
896
|
+
);
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
SearchBarStatus.displayName = 'SearchBar.Status';
|
|
900
|
+
|
|
901
|
+
export const SearchBar = Object.assign(SearchBarRoot, {
|
|
902
|
+
Section: SearchBarSection,
|
|
903
|
+
Item: SearchBarItem,
|
|
904
|
+
Status: SearchBarStatus,
|
|
905
|
+
});
|