panelui-native 0.79.0 → 0.80.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/accordion/index.js +13 -1
- package/lib/module/components/accordion/index.js.map +1 -1
- package/lib/module/components/bottom-sheet/index.js +9 -2
- package/lib/module/components/bottom-sheet/index.js.map +1 -1
- package/lib/module/components/panelside/index.js +527 -19
- package/lib/module/components/panelside/index.js.map +1 -1
- package/lib/module/components/qr-code/index.js +84 -15
- package/lib/module/components/qr-code/index.js.map +1 -1
- package/lib/module/components/qr-code/qr-shapes.js +202 -0
- package/lib/module/components/qr-code/qr-shapes.js.map +1 -0
- package/lib/module/components/timeline/index.js +145 -18
- package/lib/module/components/timeline/index.js.map +1 -1
- package/lib/module/index.js.map +1 -1
- package/lib/typescript/src/components/accordion/index.d.ts.map +1 -1
- package/lib/typescript/src/components/bottom-sheet/index.d.ts +16 -0
- package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -1
- package/lib/typescript/src/components/panelside/index.d.ts +242 -5
- package/lib/typescript/src/components/panelside/index.d.ts.map +1 -1
- package/lib/typescript/src/components/qr-code/index.d.ts +42 -3
- package/lib/typescript/src/components/qr-code/index.d.ts.map +1 -1
- package/lib/typescript/src/components/qr-code/qr-shapes.d.ts +64 -0
- package/lib/typescript/src/components/qr-code/qr-shapes.d.ts.map +1 -0
- package/lib/typescript/src/components/timeline/index.d.ts +33 -0
- package/lib/typescript/src/components/timeline/index.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +1 -1
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/accordion/index.tsx +13 -0
- package/src/components/bottom-sheet/index.tsx +9 -2
- package/src/components/panelside/index.tsx +750 -28
- package/src/components/qr-code/index.tsx +124 -18
- package/src/components/qr-code/qr-shapes.ts +234 -0
- package/src/components/timeline/index.tsx +165 -14
- package/src/index.ts +3 -0
|
@@ -84,6 +84,7 @@ import {
|
|
|
84
84
|
useContext,
|
|
85
85
|
useEffect,
|
|
86
86
|
useMemo,
|
|
87
|
+
useRef,
|
|
87
88
|
useState,
|
|
88
89
|
type ReactElement,
|
|
89
90
|
type ReactNode,
|
|
@@ -116,9 +117,18 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
|
116
117
|
import { tv } from 'tailwind-variants';
|
|
117
118
|
import { useCSSVariable } from 'uniwind';
|
|
118
119
|
import { LinearGradient } from 'expo-linear-gradient';
|
|
119
|
-
import { EllipsisIcon, IconColorProvider, MenuIcon, SearchIcon } from '../../icons';
|
|
120
|
+
import { EllipsisIcon, IconColorProvider, MenuIcon, SearchIcon, XIcon } from '../../icons';
|
|
121
|
+
import {
|
|
122
|
+
BottomSheet,
|
|
123
|
+
bottomSheetDetentHeight,
|
|
124
|
+
type BottomSheetBodyProps,
|
|
125
|
+
type BottomSheetProps,
|
|
126
|
+
} from '../bottom-sheet';
|
|
120
127
|
import { Button } from '../button';
|
|
128
|
+
import { Tabs } from '../tabs';
|
|
129
|
+
import { getNativeUI } from '../../native';
|
|
121
130
|
import { AnimatedPressable } from '../../primitives/animated-pressable';
|
|
131
|
+
import { KeyboardAvoider } from '../../primitives/keyboard-avoider';
|
|
122
132
|
import { Text, textChildren, type TextProps } from '../../primitives/text';
|
|
123
133
|
import { useBackHandler } from '../../hooks/use-back-handler';
|
|
124
134
|
import { useDirectionSign } from '../../hooks/use-direction';
|
|
@@ -236,6 +246,20 @@ const DOCK_WIDTH_MAX = 320;
|
|
|
236
246
|
/** How far above the floating footer the list starts dissolving into it. */
|
|
237
247
|
const FOOTER_FADE = 28;
|
|
238
248
|
|
|
249
|
+
/**
|
|
250
|
+
* What `BottomSheet.Content` leaves below its last child, maxed against the
|
|
251
|
+
* home indicator. Mirrored here so the search field can subtract the strip it
|
|
252
|
+
* already sits above before it travels with the keyboard.
|
|
253
|
+
*/
|
|
254
|
+
const SHEET_BOTTOM_PADDING = 16;
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* The top padding the search surface asks `BottomSheet.Content` for, and takes
|
|
258
|
+
* back off the column's height. Smaller than the sheet's own default, because
|
|
259
|
+
* this surface leads with a round button rather than with a title.
|
|
260
|
+
*/
|
|
261
|
+
const SHEET_TOP_PADDING = 12;
|
|
262
|
+
|
|
239
263
|
/** Progress past which a layer is treated as fully hidden for accessibility. */
|
|
240
264
|
const HIDDEN_EPSILON = 0.05;
|
|
241
265
|
|
|
@@ -248,6 +272,15 @@ export type PanelsideMode = 'push' | 'overlay';
|
|
|
248
272
|
export type PanelsideSwipeFrom = 'anywhere' | 'edge';
|
|
249
273
|
export type PanelsideItemSize = 'default' | 'sm';
|
|
250
274
|
export type PanelsideCtaSize = 'default' | 'lg';
|
|
275
|
+
/**
|
|
276
|
+
* What a header or a footer paints behind itself.
|
|
277
|
+
*
|
|
278
|
+
* `transparent` paints nothing, and the list runs the full height of the panel
|
|
279
|
+
* underneath it. `fade` dissolves the list into the panel background over the
|
|
280
|
+
* strip above the controls. `solid` is a band with an edge on it, for a footer
|
|
281
|
+
* that is a row of the layout rather than something floating over one.
|
|
282
|
+
*/
|
|
283
|
+
export type PanelsideSurface = 'transparent' | 'fade' | 'solid';
|
|
251
284
|
|
|
252
285
|
const itemVariants = tv({
|
|
253
286
|
// No width: in a group it stretches on its own, and pinning it to full width
|
|
@@ -272,9 +305,14 @@ const itemVariants = tv({
|
|
|
272
305
|
const ctaVariants = tv({
|
|
273
306
|
// Taller and wider than a list row's control. It is the one thing in the
|
|
274
307
|
// panel you are meant to reach for without reading, so it should not be the
|
|
275
|
-
// same size as the eight chat titles above it
|
|
276
|
-
//
|
|
277
|
-
//
|
|
308
|
+
// same size as the eight chat titles above it.
|
|
309
|
+
//
|
|
310
|
+
// It used to be 40pt, chosen to sit level with the account button beside it.
|
|
311
|
+
// That is the wrong thing to size it against: the account button is a target
|
|
312
|
+
// you find once, and the compose pill is the one pressed every session — at
|
|
313
|
+
// matching heights the two read as a pair of equals and the pill stopped
|
|
314
|
+
// being the thing the footer is for. Four points is enough to separate them
|
|
315
|
+
// without making the footer taller than the panel needs.
|
|
278
316
|
base: 'shrink flex-row items-center justify-center rounded-full',
|
|
279
317
|
variants: {
|
|
280
318
|
variant: {
|
|
@@ -282,8 +320,8 @@ const ctaVariants = tv({
|
|
|
282
320
|
secondary: 'bg-secondary',
|
|
283
321
|
},
|
|
284
322
|
size: {
|
|
285
|
-
default: 'h-
|
|
286
|
-
lg: 'h-
|
|
323
|
+
default: 'h-11 gap-2 px-6',
|
|
324
|
+
lg: 'h-13 gap-2.5 px-7',
|
|
287
325
|
},
|
|
288
326
|
},
|
|
289
327
|
defaultVariants: {
|
|
@@ -308,6 +346,16 @@ interface PanelsideContextValue {
|
|
|
308
346
|
scale?: number;
|
|
309
347
|
radius?: number;
|
|
310
348
|
dim?: number;
|
|
349
|
+
/**
|
|
350
|
+
* Whether the search surface is up.
|
|
351
|
+
*
|
|
352
|
+
* It lives on the root rather than on the sheet because the two halves of
|
|
353
|
+
* search are in different subtrees: the button is in the header and the
|
|
354
|
+
* sheet is a sibling of the panel. Anything else means every app wiring one
|
|
355
|
+
* `useState` through both.
|
|
356
|
+
*/
|
|
357
|
+
searchOpen: boolean;
|
|
358
|
+
setSearchOpen: (open: boolean) => void;
|
|
311
359
|
}
|
|
312
360
|
|
|
313
361
|
const PanelsideContext = createContext<PanelsideContextValue | null>(null);
|
|
@@ -332,6 +380,9 @@ export interface UsePanelsideResult {
|
|
|
332
380
|
progress: SharedValue<number>;
|
|
333
381
|
/** True while the panel is docked open beside the scene. */
|
|
334
382
|
docked: boolean;
|
|
383
|
+
/** Whether the search surface is up. `Panelside.SearchTrigger` sets it. */
|
|
384
|
+
searchOpen: boolean;
|
|
385
|
+
setSearchOpen: (open: boolean) => void;
|
|
335
386
|
}
|
|
336
387
|
|
|
337
388
|
/**
|
|
@@ -340,8 +391,9 @@ export interface UsePanelsideResult {
|
|
|
340
391
|
* lives.
|
|
341
392
|
*/
|
|
342
393
|
export function usePanelside(): UsePanelsideResult {
|
|
343
|
-
const { open, setOpen, toggle, progress, docked } =
|
|
344
|
-
|
|
394
|
+
const { open, setOpen, toggle, progress, docked, searchOpen, setSearchOpen } =
|
|
395
|
+
usePanelsideContext('usePanelside');
|
|
396
|
+
return { open, setOpen, toggle, progress, docked, searchOpen, setSearchOpen };
|
|
345
397
|
}
|
|
346
398
|
|
|
347
399
|
/**
|
|
@@ -464,6 +516,7 @@ function PanelsideRoot({
|
|
|
464
516
|
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
|
|
465
517
|
const controlled = controlledOpen !== undefined;
|
|
466
518
|
const open = controlled ? controlledOpen : uncontrolledOpen;
|
|
519
|
+
const [searchOpen, setSearchOpen] = useState(false);
|
|
467
520
|
|
|
468
521
|
/*
|
|
469
522
|
* Measured rather than taken from the window, because Panelside does not
|
|
@@ -640,8 +693,23 @@ function PanelsideRoot({
|
|
|
640
693
|
scale,
|
|
641
694
|
radius,
|
|
642
695
|
dim,
|
|
696
|
+
searchOpen,
|
|
697
|
+
setSearchOpen,
|
|
643
698
|
}),
|
|
644
|
-
[
|
|
699
|
+
[
|
|
700
|
+
dim,
|
|
701
|
+
dismissible,
|
|
702
|
+
docked,
|
|
703
|
+
mode,
|
|
704
|
+
open,
|
|
705
|
+
progress,
|
|
706
|
+
radius,
|
|
707
|
+
scale,
|
|
708
|
+
searchOpen,
|
|
709
|
+
setOpen,
|
|
710
|
+
toggle,
|
|
711
|
+
width,
|
|
712
|
+
]
|
|
645
713
|
);
|
|
646
714
|
|
|
647
715
|
return (
|
|
@@ -726,6 +794,20 @@ export interface PanelsideHeaderProps extends ViewProps {
|
|
|
726
794
|
title?: string;
|
|
727
795
|
/** A single element pinned to the trailing end of the title row. */
|
|
728
796
|
action?: ReactNode;
|
|
797
|
+
/**
|
|
798
|
+
* What the header paints behind itself.
|
|
799
|
+
*
|
|
800
|
+
* `transparent` is the default and paints nothing, so the header is the
|
|
801
|
+
* panel's own surface with a title on it rather than a bar sitting on top of
|
|
802
|
+
* one. In the panel's normal stacking that is the whole story — the header
|
|
803
|
+
* takes a row and the list starts below it.
|
|
804
|
+
*
|
|
805
|
+
* `fade` and `solid` are for a header the caller has lifted out of that
|
|
806
|
+
* stack — `className="absolute start-0 end-0 top-0"` — so the list runs
|
|
807
|
+
* underneath it. They are the two shapes `Panelside.Footer` offers, drawn
|
|
808
|
+
* the other way up.
|
|
809
|
+
*/
|
|
810
|
+
surface?: PanelsideSurface;
|
|
729
811
|
/** Anything below the title row — a search field, a workspace switcher. */
|
|
730
812
|
children?: ReactNode;
|
|
731
813
|
}
|
|
@@ -734,11 +816,14 @@ function PanelsideHeader({
|
|
|
734
816
|
className,
|
|
735
817
|
title,
|
|
736
818
|
action,
|
|
819
|
+
surface = 'transparent',
|
|
737
820
|
children,
|
|
738
821
|
style,
|
|
739
822
|
...props
|
|
740
823
|
}: PanelsideHeaderProps) {
|
|
741
824
|
const insets = useSafeAreaInsets();
|
|
825
|
+
const background = useCSSVariable('--color-background');
|
|
826
|
+
const solid = typeof background === 'string' ? background : '#000000';
|
|
742
827
|
|
|
743
828
|
return (
|
|
744
829
|
<View
|
|
@@ -750,9 +835,32 @@ function PanelsideHeader({
|
|
|
750
835
|
// `px-3` matches the scroller below it, so the search field and the rows
|
|
751
836
|
// share one edge. A header inset further would leave the field floating
|
|
752
837
|
// a few points inside the list it filters.
|
|
753
|
-
className={cn(
|
|
838
|
+
className={cn(
|
|
839
|
+
'gap-3 px-3 pb-3',
|
|
840
|
+
surface === 'solid' && 'border-b border-border bg-background',
|
|
841
|
+
className
|
|
842
|
+
)}
|
|
754
843
|
{...props}
|
|
755
844
|
>
|
|
845
|
+
{/* The footer's fade, upside down: opaque under the title and clearing to
|
|
846
|
+
nothing at the bottom edge, so a row scrolled up into the header
|
|
847
|
+
dissolves rather than sliding out from under a line. */}
|
|
848
|
+
{surface === 'fade' ? (
|
|
849
|
+
<>
|
|
850
|
+
<View
|
|
851
|
+
pointerEvents="none"
|
|
852
|
+
className="absolute end-0 start-0 top-0 bg-background"
|
|
853
|
+
style={{ bottom: FOOTER_FADE }}
|
|
854
|
+
/>
|
|
855
|
+
<LinearGradient
|
|
856
|
+
colors={[solid, `${solid}00`]}
|
|
857
|
+
start={{ x: 0, y: 0 }}
|
|
858
|
+
end={{ x: 0, y: 1 }}
|
|
859
|
+
pointerEvents="none"
|
|
860
|
+
style={[styles.rise, { height: FOOTER_FADE }]}
|
|
861
|
+
/>
|
|
862
|
+
</>
|
|
863
|
+
) : null}
|
|
756
864
|
{(title || action) && (
|
|
757
865
|
<View className="h-9 flex-row items-center justify-between gap-2">
|
|
758
866
|
{title ? (
|
|
@@ -1153,22 +1261,43 @@ export interface PanelsideFooterProps extends ViewProps {
|
|
|
1153
1261
|
* leaves exactly this footer's height of room at the end.
|
|
1154
1262
|
*/
|
|
1155
1263
|
floating?: boolean;
|
|
1264
|
+
/**
|
|
1265
|
+
* What the footer paints behind its controls.
|
|
1266
|
+
*
|
|
1267
|
+
* `transparent` is the default and paints nothing: the list runs under the
|
|
1268
|
+
* controls, which is how the panel reads as one surface with two things
|
|
1269
|
+
* floating on it rather than as a list with a bar bolted to the bottom.
|
|
1270
|
+
*
|
|
1271
|
+
* `fade` dissolves the list into the panel background over the strip above
|
|
1272
|
+
* the controls. It costs a band of the panel, and buys a compose button that
|
|
1273
|
+
* never has a chat title running through its label — worth turning on for a
|
|
1274
|
+
* panel whose history is long enough that something is always underneath.
|
|
1275
|
+
*
|
|
1276
|
+
* `solid` is a band with a hairline over it, for a footer that is a row of
|
|
1277
|
+
* the layout. Implied by `floating={false}`, which has no list to float over.
|
|
1278
|
+
*/
|
|
1279
|
+
surface?: PanelsideSurface;
|
|
1156
1280
|
children?: ReactNode;
|
|
1157
1281
|
}
|
|
1158
1282
|
|
|
1159
1283
|
function PanelsideFooter({
|
|
1160
1284
|
className,
|
|
1161
1285
|
floating = true,
|
|
1286
|
+
surface = 'transparent',
|
|
1162
1287
|
children,
|
|
1163
1288
|
style,
|
|
1164
1289
|
...props
|
|
1165
1290
|
}: PanelsideFooterProps) {
|
|
1166
1291
|
const insets = useSafeAreaInsets();
|
|
1167
|
-
const
|
|
1168
|
-
const setFooterHeight =
|
|
1292
|
+
const panel = useContext(PanelsideSurfaceContext);
|
|
1293
|
+
const setFooterHeight = panel?.setFooterHeight;
|
|
1169
1294
|
const background = useCSSVariable('--color-background');
|
|
1170
1295
|
const solid = typeof background === 'string' ? background : '#000000';
|
|
1171
1296
|
|
|
1297
|
+
// A footer in the flow has nothing to float over, so the only thing it can
|
|
1298
|
+
// be is the band — whatever was asked for.
|
|
1299
|
+
const paint = floating ? surface : 'solid';
|
|
1300
|
+
|
|
1172
1301
|
const onLayout = useCallback(
|
|
1173
1302
|
(event: LayoutChangeEvent) => {
|
|
1174
1303
|
setFooterHeight?.(event.nativeEvent.layout.height);
|
|
@@ -1181,7 +1310,7 @@ function PanelsideFooter({
|
|
|
1181
1310
|
onLayout={floating ? onLayout : undefined}
|
|
1182
1311
|
style={[
|
|
1183
1312
|
{ paddingBottom: Math.max(insets.bottom, 12) },
|
|
1184
|
-
floating ? { paddingTop: FOOTER_FADE } : null,
|
|
1313
|
+
floating ? { paddingTop: paint === 'fade' ? FOOTER_FADE : 12 } : null,
|
|
1185
1314
|
style,
|
|
1186
1315
|
]}
|
|
1187
1316
|
className={cn(
|
|
@@ -1196,22 +1325,16 @@ function PanelsideFooter({
|
|
|
1196
1325
|
{...props}
|
|
1197
1326
|
>
|
|
1198
1327
|
{/*
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
`FOOTER_FADE` points are
|
|
1203
|
-
|
|
1204
|
-
plain background.
|
|
1205
|
-
|
|
1206
|
-
The fade has to finish *above* the first control, not run through it.
|
|
1207
|
-
Two layers rather than one gradient across the whole box, because a
|
|
1208
|
-
gradient sized to the box puts its midpoint wherever the box happens to
|
|
1209
|
-
be tall, which is exactly where the labels are.
|
|
1328
|
+
The fade is two layers rather than one gradient across the whole box.
|
|
1329
|
+
A gradient sized to the box puts its midpoint wherever the box happens
|
|
1330
|
+
to be tall, which is exactly where the labels are — so the top
|
|
1331
|
+
`FOOTER_FADE` points are the gradient and everything below it, the band
|
|
1332
|
+
the controls actually sit in, is plain background.
|
|
1210
1333
|
|
|
1211
1334
|
Both are inside the footer's own bounds, so neither depends on a parent
|
|
1212
1335
|
that does not clip its children.
|
|
1213
1336
|
*/}
|
|
1214
|
-
{
|
|
1337
|
+
{paint === 'fade' ? (
|
|
1215
1338
|
<>
|
|
1216
1339
|
<LinearGradient
|
|
1217
1340
|
colors={[`${solid}00`, solid]}
|
|
@@ -1227,6 +1350,12 @@ function PanelsideFooter({
|
|
|
1227
1350
|
/>
|
|
1228
1351
|
</>
|
|
1229
1352
|
) : null}
|
|
1353
|
+
{paint === 'solid' && floating ? (
|
|
1354
|
+
<View
|
|
1355
|
+
pointerEvents="none"
|
|
1356
|
+
className="absolute bottom-0 end-0 start-0 top-0 border-t border-border bg-background"
|
|
1357
|
+
/>
|
|
1358
|
+
) : null}
|
|
1230
1359
|
{textChildren(children)}
|
|
1231
1360
|
</View>
|
|
1232
1361
|
);
|
|
@@ -1234,6 +1363,7 @@ function PanelsideFooter({
|
|
|
1234
1363
|
|
|
1235
1364
|
const styles = StyleSheet.create({
|
|
1236
1365
|
fade: { position: 'absolute', top: 0, left: 0, right: 0 },
|
|
1366
|
+
rise: { position: 'absolute', bottom: 0, left: 0, right: 0 },
|
|
1237
1367
|
});
|
|
1238
1368
|
|
|
1239
1369
|
export interface PanelsideCtaProps extends Omit<PressableProps, 'children'> {
|
|
@@ -1245,9 +1375,10 @@ export interface PanelsideCtaProps extends Omit<PressableProps, 'children'> {
|
|
|
1245
1375
|
/** `primary` is the filled accent pill; `secondary` is the quiet one. */
|
|
1246
1376
|
variant?: 'primary' | 'secondary';
|
|
1247
1377
|
/**
|
|
1248
|
-
* How tall the pill is. `default` is
|
|
1249
|
-
*
|
|
1250
|
-
* the call to action is the only thing
|
|
1378
|
+
* How tall the pill is. `default` is 44pt — a step above the account button
|
|
1379
|
+
* beside it, so the footer reads as one primary control and one secondary
|
|
1380
|
+
* one. `lg` is 52pt, for a panel where the call to action is the only thing
|
|
1381
|
+
* in the row.
|
|
1251
1382
|
*
|
|
1252
1383
|
* Ignored under `native` — the platform sizes its own button, and asks for a
|
|
1253
1384
|
* control size rather than a height.
|
|
@@ -1582,6 +1713,583 @@ function PanelsideTrigger({
|
|
|
1582
1713
|
);
|
|
1583
1714
|
}
|
|
1584
1715
|
|
|
1716
|
+
/* ------------------------------------------------------------------ *
|
|
1717
|
+
* Search.
|
|
1718
|
+
*
|
|
1719
|
+
* A field in the header is the obvious way to put search in a navigation
|
|
1720
|
+
* panel, and it is the wrong one on a phone. The panel is 80% of the screen
|
|
1721
|
+
* and the field is 40 points of it, so a search that returns anything has to
|
|
1722
|
+
* push the history down the screen it is already filling — and the field is at
|
|
1723
|
+
* the top, which is the far end of the screen from the keyboard that has just
|
|
1724
|
+
* opened under it.
|
|
1725
|
+
*
|
|
1726
|
+
* So search is a surface rather than a row. A round button in the header opens
|
|
1727
|
+
* a sheet that is the whole screen; the tabs across the top narrow what is
|
|
1728
|
+
* being searched; the results fill the middle; and the field is at the bottom,
|
|
1729
|
+
* where the thumb already is, riding the keyboard rather than hiding behind
|
|
1730
|
+
* it.
|
|
1731
|
+
*
|
|
1732
|
+
* `Panelside.Search` — the inline field — is still exported, and is still
|
|
1733
|
+
* right for a docked panel on a tablet, where there is width for a field and
|
|
1734
|
+
* no keyboard covering half the screen.
|
|
1735
|
+
* ------------------------------------------------------------------ */
|
|
1736
|
+
|
|
1737
|
+
export interface PanelsideSearchTriggerProps extends Omit<PressableProps, 'children'> {
|
|
1738
|
+
className?: string;
|
|
1739
|
+
/** What a screen reader announces. */
|
|
1740
|
+
label?: string;
|
|
1741
|
+
/** Replaces the default magnifier. */
|
|
1742
|
+
children?: ReactNode;
|
|
1743
|
+
/**
|
|
1744
|
+
* Render the platform's own button instead of the circle. Requires the
|
|
1745
|
+
* optional `@expo/ui` package; without it this prop does nothing.
|
|
1746
|
+
*/
|
|
1747
|
+
native?: boolean;
|
|
1748
|
+
/**
|
|
1749
|
+
* Draw the native button in the platform's Liquid Glass material. Requires
|
|
1750
|
+
* `native`, and iOS 26 or later; ignored anywhere else.
|
|
1751
|
+
*/
|
|
1752
|
+
glass?: boolean;
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
/**
|
|
1756
|
+
* The button that opens the search surface. Goes in `Panelside.Header`'s
|
|
1757
|
+
* `action` slot.
|
|
1758
|
+
*
|
|
1759
|
+
* It toggles the root's `searchOpen`, which `Panelside.SearchSheet` reads —
|
|
1760
|
+
* so the two need nothing wired between them.
|
|
1761
|
+
*/
|
|
1762
|
+
function PanelsideSearchTrigger({
|
|
1763
|
+
className,
|
|
1764
|
+
label = 'Search',
|
|
1765
|
+
children,
|
|
1766
|
+
native = false,
|
|
1767
|
+
glass = false,
|
|
1768
|
+
onPress,
|
|
1769
|
+
...props
|
|
1770
|
+
}: PanelsideSearchTriggerProps) {
|
|
1771
|
+
const { setSearchOpen } = usePanelsideContext('Panelside.SearchTrigger');
|
|
1772
|
+
const tint = useCSSVariable('--color-foreground');
|
|
1773
|
+
const color = typeof tint === 'string' ? tint : undefined;
|
|
1774
|
+
|
|
1775
|
+
const open = useCallback(
|
|
1776
|
+
(event: Parameters<NonNullable<PressableProps['onPress']>>[0]) => {
|
|
1777
|
+
onPress?.(event);
|
|
1778
|
+
setSearchOpen(true);
|
|
1779
|
+
},
|
|
1780
|
+
[onPress, setSearchOpen]
|
|
1781
|
+
);
|
|
1782
|
+
|
|
1783
|
+
const glyph = children ?? <SearchIcon size={18} color={native ? color : undefined} />;
|
|
1784
|
+
|
|
1785
|
+
if (native) {
|
|
1786
|
+
return (
|
|
1787
|
+
<Button
|
|
1788
|
+
native
|
|
1789
|
+
glass={glass}
|
|
1790
|
+
size="icon"
|
|
1791
|
+
variant="ghost"
|
|
1792
|
+
accessibilityLabel={label}
|
|
1793
|
+
onPress={open}
|
|
1794
|
+
>
|
|
1795
|
+
{glyph}
|
|
1796
|
+
</Button>
|
|
1797
|
+
);
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
return (
|
|
1801
|
+
<AnimatedPressable
|
|
1802
|
+
{...props}
|
|
1803
|
+
onPress={open}
|
|
1804
|
+
className={cn(
|
|
1805
|
+
// Filled rather than outlined. It sits on the panel's own surface with
|
|
1806
|
+
// nothing else on that row, so an outline at this size reads as an
|
|
1807
|
+
// empty circle before it reads as a control.
|
|
1808
|
+
'h-10 w-10 items-center justify-center rounded-full bg-secondary',
|
|
1809
|
+
className
|
|
1810
|
+
)}
|
|
1811
|
+
accessibilityRole="button"
|
|
1812
|
+
accessibilityLabel={label}
|
|
1813
|
+
>
|
|
1814
|
+
<IconColorProvider color={color}>{glyph}</IconColorProvider>
|
|
1815
|
+
</AnimatedPressable>
|
|
1816
|
+
);
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1819
|
+
interface PanelsideSearchSheetContextValue {
|
|
1820
|
+
/**
|
|
1821
|
+
* Whether the sheet is up.
|
|
1822
|
+
*
|
|
1823
|
+
* The field needs it, and cannot read it from being mounted: under `native`
|
|
1824
|
+
* the platform owns presentation, so the content stays mounted for the life
|
|
1825
|
+
* of the screen and only `isPresented` changes. An `autoFocus` on a field
|
|
1826
|
+
* inside it would fire at app start, opening the keyboard over a sheet
|
|
1827
|
+
* nobody has asked for.
|
|
1828
|
+
*/
|
|
1829
|
+
open: boolean;
|
|
1830
|
+
query: string;
|
|
1831
|
+
setQuery: (query: string) => void;
|
|
1832
|
+
tab: string;
|
|
1833
|
+
setTab: (tab: string) => void;
|
|
1834
|
+
close: () => void;
|
|
1835
|
+
/** The inset the field already sits above, so docking does not travel it twice. */
|
|
1836
|
+
bottomInset: number;
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
const PanelsideSearchSheetContext = createContext<PanelsideSearchSheetContextValue | null>(
|
|
1840
|
+
null
|
|
1841
|
+
);
|
|
1842
|
+
|
|
1843
|
+
function usePanelsideSearchSheet(part: string): PanelsideSearchSheetContextValue {
|
|
1844
|
+
const value = useContext(PanelsideSearchSheetContext);
|
|
1845
|
+
if (!value) throw new Error(`${part} must be used inside a <Panelside.SearchSheet>.`);
|
|
1846
|
+
return value;
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
export interface PanelsideSearchSheetProps {
|
|
1850
|
+
className?: string;
|
|
1851
|
+
/** Controlled. Omit it and the sheet follows the root's `searchOpen`. */
|
|
1852
|
+
open?: boolean;
|
|
1853
|
+
onOpenChange?: (open: boolean) => void;
|
|
1854
|
+
/** The query. Controlled; pair it with `onValueChange`. */
|
|
1855
|
+
value?: string;
|
|
1856
|
+
defaultValue?: string;
|
|
1857
|
+
onValueChange?: (value: string) => void;
|
|
1858
|
+
/** Which tab is selected — the `value` of a `Panelside.SearchTab`. */
|
|
1859
|
+
tab?: string;
|
|
1860
|
+
/**
|
|
1861
|
+
* Which tab starts selected, when the sheet is not controlling `tab`. Set it
|
|
1862
|
+
* to the first tab's `value`: the expanding row shows the selected tab open
|
|
1863
|
+
* and the rest as their icons, so with nothing selected every tab is closed
|
|
1864
|
+
* and the row is a line of unlabelled glyphs.
|
|
1865
|
+
*/
|
|
1866
|
+
defaultTab?: string;
|
|
1867
|
+
onTabChange?: (tab: string) => void;
|
|
1868
|
+
/**
|
|
1869
|
+
* Present the platform's own sheet. Default true, because this one is the
|
|
1870
|
+
* whole screen and the system's presentation, detents and dismiss gesture
|
|
1871
|
+
* are the ones people already know. Requires the optional `@expo/ui`
|
|
1872
|
+
* package; without it the styled sheet renders instead.
|
|
1873
|
+
*/
|
|
1874
|
+
native?: boolean;
|
|
1875
|
+
/** Heights the sheet can rest at. Defaults to the tall one. */
|
|
1876
|
+
snapPoints?: BottomSheetProps['snapPoints'];
|
|
1877
|
+
/** Gap between the field and the top of the keyboard. */
|
|
1878
|
+
keyboardGap?: number;
|
|
1879
|
+
/**
|
|
1880
|
+
* Draw the round dismiss button at the leading edge of the top row. On by
|
|
1881
|
+
* default — it is the way out of a surface that covers the screen, and it
|
|
1882
|
+
* belongs where a thumb reaching across arrives rather than in the corner
|
|
1883
|
+
* furthest from one.
|
|
1884
|
+
*/
|
|
1885
|
+
showClose?: boolean;
|
|
1886
|
+
/** What a screen reader announces for that button. */
|
|
1887
|
+
closeLabel?: string;
|
|
1888
|
+
children?: ReactNode;
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1891
|
+
/**
|
|
1892
|
+
* The search surface: tabs, results, and a field at the bottom.
|
|
1893
|
+
*
|
|
1894
|
+
* ```tsx
|
|
1895
|
+
* <Panelside.SearchSheet value={query} onValueChange={setQuery} tab={tab} onTabChange={setTab}>
|
|
1896
|
+
* <Panelside.SearchTabs>
|
|
1897
|
+
* <Panelside.SearchTab value="all" icon={<SparklesIcon size={16} />}>All</Panelside.SearchTab>
|
|
1898
|
+
* <Panelside.SearchTab value="chats" icon={<MessageCircleIcon size={16} />}>Chats</Panelside.SearchTab>
|
|
1899
|
+
* </Panelside.SearchTabs>
|
|
1900
|
+
*
|
|
1901
|
+
* <Panelside.SearchResults>
|
|
1902
|
+
* {hits.map((hit) => (
|
|
1903
|
+
* <Panelside.SearchResult key={hit.id} title={hit.title} description={hit.kind} />
|
|
1904
|
+
* ))}
|
|
1905
|
+
* </Panelside.SearchResults>
|
|
1906
|
+
*
|
|
1907
|
+
* <Panelside.SearchField placeholder="Search chats" />
|
|
1908
|
+
* </Panelside.SearchSheet>
|
|
1909
|
+
* ```
|
|
1910
|
+
*
|
|
1911
|
+
* Put it under `<Panelside>` and outside `Panelside.Panel` — a sheet is
|
|
1912
|
+
* presented over the whole app, and the panel is a layer that slides.
|
|
1913
|
+
*
|
|
1914
|
+
* It reports what was typed and which tab is selected. What counts as a match,
|
|
1915
|
+
* and what a result is, are yours: a search that only read chat titles would
|
|
1916
|
+
* be wrong for the first app that indexes message bodies.
|
|
1917
|
+
*/
|
|
1918
|
+
function PanelsideSearchSheet({
|
|
1919
|
+
className,
|
|
1920
|
+
open: openProp,
|
|
1921
|
+
onOpenChange,
|
|
1922
|
+
value,
|
|
1923
|
+
defaultValue = '',
|
|
1924
|
+
onValueChange,
|
|
1925
|
+
tab,
|
|
1926
|
+
defaultTab = '',
|
|
1927
|
+
onTabChange,
|
|
1928
|
+
native = true,
|
|
1929
|
+
snapPoints,
|
|
1930
|
+
keyboardGap = 10,
|
|
1931
|
+
showClose = true,
|
|
1932
|
+
closeLabel = 'Close search',
|
|
1933
|
+
children,
|
|
1934
|
+
}: PanelsideSearchSheetProps) {
|
|
1935
|
+
const { searchOpen, setSearchOpen } = usePanelsideContext('Panelside.SearchSheet');
|
|
1936
|
+
const insets = useSafeAreaInsets();
|
|
1937
|
+
const { height: screenHeight } = useWindowDimensions();
|
|
1938
|
+
const tint = useCSSVariable('--color-foreground');
|
|
1939
|
+
const glyph = typeof tint === 'string' ? tint : undefined;
|
|
1940
|
+
|
|
1941
|
+
const [uncontrolledQuery, setUncontrolledQuery] = useState(defaultValue);
|
|
1942
|
+
const [uncontrolledTab, setUncontrolledTab] = useState(defaultTab);
|
|
1943
|
+
|
|
1944
|
+
const open = openProp ?? searchOpen;
|
|
1945
|
+
const query = value ?? uncontrolledQuery;
|
|
1946
|
+
const activeTab = tab ?? uncontrolledTab;
|
|
1947
|
+
|
|
1948
|
+
const setOpen = useCallback(
|
|
1949
|
+
(next: boolean) => {
|
|
1950
|
+
if (openProp === undefined) setSearchOpen(next);
|
|
1951
|
+
onOpenChange?.(next);
|
|
1952
|
+
},
|
|
1953
|
+
[onOpenChange, openProp, setSearchOpen]
|
|
1954
|
+
);
|
|
1955
|
+
|
|
1956
|
+
const setQuery = useCallback(
|
|
1957
|
+
(next: string) => {
|
|
1958
|
+
if (value === undefined) setUncontrolledQuery(next);
|
|
1959
|
+
onValueChange?.(next);
|
|
1960
|
+
},
|
|
1961
|
+
[onValueChange, value]
|
|
1962
|
+
);
|
|
1963
|
+
|
|
1964
|
+
const setTab = useCallback(
|
|
1965
|
+
(next: string) => {
|
|
1966
|
+
if (tab === undefined) setUncontrolledTab(next);
|
|
1967
|
+
onTabChange?.(next);
|
|
1968
|
+
},
|
|
1969
|
+
[onTabChange, tab]
|
|
1970
|
+
);
|
|
1971
|
+
|
|
1972
|
+
const close = useCallback(() => setOpen(false), [setOpen]);
|
|
1973
|
+
|
|
1974
|
+
const detents = snapPoints ?? (['full'] as const satisfies BottomSheetProps['snapPoints']);
|
|
1975
|
+
|
|
1976
|
+
/*
|
|
1977
|
+
* What `BottomSheet.Content` pads the bottom of the sheet by. The field
|
|
1978
|
+
* already sits that far above the screen edge, and docking travels by the
|
|
1979
|
+
* keyboard's height less whatever the element has already cleared — so
|
|
1980
|
+
* getting this wrong is a gap under the field, or the field over the
|
|
1981
|
+
* keyboard's top row.
|
|
1982
|
+
*/
|
|
1983
|
+
const bottomInset = Math.max(insets.bottom, SHEET_BOTTOM_PADDING) - keyboardGap;
|
|
1984
|
+
|
|
1985
|
+
/*
|
|
1986
|
+
* A definite height for the column, not `flex-1` against the sheet's own.
|
|
1987
|
+
*
|
|
1988
|
+
* The platform sheet gives its hosted content a *minimum* height, and a
|
|
1989
|
+
* minimum is not something `flex-1` can divide: the results list sizes to
|
|
1990
|
+
* its own rows instead, grows past the sheet, and pushes the field off the
|
|
1991
|
+
* bottom — which is a search surface with no way to type in it. So the
|
|
1992
|
+
* column is told exactly how tall it is, and the list gets the room left
|
|
1993
|
+
* between the tabs and the field.
|
|
1994
|
+
*
|
|
1995
|
+
* Only under the platform sheet. The styled one is laid out by us and has a
|
|
1996
|
+
* real height already, so `flex-1` resolves there and a second opinion about
|
|
1997
|
+
* how tall the sheet is would only be a chance to disagree with it.
|
|
1998
|
+
*/
|
|
1999
|
+
const hosted = native && getNativeUI() !== null;
|
|
2000
|
+
const columnHeight = hosted
|
|
2001
|
+
? (bottomSheetDetentHeight(detents as BottomSheetProps['snapPoints'], screenHeight) ??
|
|
2002
|
+
screenHeight * 0.9) -
|
|
2003
|
+
SHEET_TOP_PADDING -
|
|
2004
|
+
Math.max(insets.bottom, SHEET_BOTTOM_PADDING)
|
|
2005
|
+
: undefined;
|
|
2006
|
+
|
|
2007
|
+
const context = useMemo<PanelsideSearchSheetContextValue>(
|
|
2008
|
+
() => ({ open, query, setQuery, tab: activeTab, setTab, close, bottomInset }),
|
|
2009
|
+
[activeTab, bottomInset, close, open, query, setQuery, setTab]
|
|
2010
|
+
);
|
|
2011
|
+
|
|
2012
|
+
return (
|
|
2013
|
+
<BottomSheet
|
|
2014
|
+
native={native}
|
|
2015
|
+
open={open}
|
|
2016
|
+
onOpenChange={setOpen}
|
|
2017
|
+
snapPoints={detents as BottomSheetProps['snapPoints']}
|
|
2018
|
+
>
|
|
2019
|
+
{/*
|
|
2020
|
+
`showClose` off on the sheet itself: this surface draws its own, at the
|
|
2021
|
+
leading edge of the top row, where the reference for this pattern puts
|
|
2022
|
+
it and where a thumb reaching across the screen arrives.
|
|
2023
|
+
*/}
|
|
2024
|
+
<BottomSheet.Content size="full" showClose={false} className="gap-0 px-0 pt-3">
|
|
2025
|
+
{/*
|
|
2026
|
+
The provider is *inside* Content, not around the sheet.
|
|
2027
|
+
|
|
2028
|
+
The styled sheet mounts its content through a portal, under the
|
|
2029
|
+
portal host and outside this component's subtree — so a provider
|
|
2030
|
+
wrapped around the sheet is a provider the children never see, and
|
|
2031
|
+
every part below throws about not being inside a `SearchSheet`. Only
|
|
2032
|
+
the native path happened to work, because the platform hosts the
|
|
2033
|
+
content in place.
|
|
2034
|
+
*/}
|
|
2035
|
+
<PanelsideSearchSheetContext.Provider value={context}>
|
|
2036
|
+
<View
|
|
2037
|
+
className={cn('gap-2', columnHeight === undefined && 'flex-1', className)}
|
|
2038
|
+
style={columnHeight === undefined ? undefined : { height: columnHeight }}
|
|
2039
|
+
>
|
|
2040
|
+
{showClose ? (
|
|
2041
|
+
<View className="flex-row px-4 pb-1">
|
|
2042
|
+
<AnimatedPressable
|
|
2043
|
+
onPress={close}
|
|
2044
|
+
accessibilityRole="button"
|
|
2045
|
+
accessibilityLabel={closeLabel}
|
|
2046
|
+
className="h-9 w-9 items-center justify-center rounded-full bg-secondary"
|
|
2047
|
+
>
|
|
2048
|
+
<XIcon size={17} color={glyph} />
|
|
2049
|
+
</AnimatedPressable>
|
|
2050
|
+
</View>
|
|
2051
|
+
) : null}
|
|
2052
|
+
{children}
|
|
2053
|
+
</View>
|
|
2054
|
+
</PanelsideSearchSheetContext.Provider>
|
|
2055
|
+
</BottomSheet.Content>
|
|
2056
|
+
</BottomSheet>
|
|
2057
|
+
);
|
|
2058
|
+
}
|
|
2059
|
+
|
|
2060
|
+
export interface PanelsideSearchTabsProps {
|
|
2061
|
+
className?: string;
|
|
2062
|
+
children?: ReactNode;
|
|
2063
|
+
}
|
|
2064
|
+
|
|
2065
|
+
/**
|
|
2066
|
+
* The row across the top of the search sheet, narrowing what is searched.
|
|
2067
|
+
*
|
|
2068
|
+
* The expanding variant: only the selected tab is open, and the rest are their
|
|
2069
|
+
* icons. A row of four full labels takes the whole width to say four words
|
|
2070
|
+
* nobody rereads, and this row has to leave the results the screen.
|
|
2071
|
+
*
|
|
2072
|
+
* Every `Panelside.SearchTab` therefore needs an `icon` — a closed tab has
|
|
2073
|
+
* nothing else to be.
|
|
2074
|
+
*/
|
|
2075
|
+
function PanelsideSearchTabs({ className, children }: PanelsideSearchTabsProps) {
|
|
2076
|
+
const { tab, setTab } = usePanelsideSearchSheet('Panelside.SearchTabs');
|
|
2077
|
+
|
|
2078
|
+
return (
|
|
2079
|
+
<Tabs
|
|
2080
|
+
variant="expanding"
|
|
2081
|
+
value={tab}
|
|
2082
|
+
onValueChange={setTab}
|
|
2083
|
+
defaultValue={tab}
|
|
2084
|
+
className={cn('px-4 pt-1', className)}
|
|
2085
|
+
>
|
|
2086
|
+
<Tabs.List>{children}</Tabs.List>
|
|
2087
|
+
</Tabs>
|
|
2088
|
+
);
|
|
2089
|
+
}
|
|
2090
|
+
|
|
2091
|
+
export interface PanelsideSearchTabProps {
|
|
2092
|
+
className?: string;
|
|
2093
|
+
/** What selecting this tab reports as the sheet's `tab`. */
|
|
2094
|
+
value: string;
|
|
2095
|
+
/** Required: a closed tab is its icon and nothing else. */
|
|
2096
|
+
icon: ReactNode;
|
|
2097
|
+
children?: ReactNode;
|
|
2098
|
+
}
|
|
2099
|
+
|
|
2100
|
+
function PanelsideSearchTab({ className, value, icon, children }: PanelsideSearchTabProps) {
|
|
2101
|
+
return (
|
|
2102
|
+
<Tabs.Trigger value={value} icon={icon} className={className}>
|
|
2103
|
+
{children}
|
|
2104
|
+
</Tabs.Trigger>
|
|
2105
|
+
);
|
|
2106
|
+
}
|
|
2107
|
+
|
|
2108
|
+
export interface PanelsideSearchResultsProps extends BottomSheetBodyProps {
|
|
2109
|
+
className?: string;
|
|
2110
|
+
contentContainerClassName?: string;
|
|
2111
|
+
children?: ReactNode;
|
|
2112
|
+
}
|
|
2113
|
+
|
|
2114
|
+
/**
|
|
2115
|
+
* The scrolling middle of the search sheet.
|
|
2116
|
+
*
|
|
2117
|
+
* `BottomSheet.Body` rather than a `ScrollView`, so the list's scroll and the
|
|
2118
|
+
* sheet's dismiss drag agree on which of them a downward pull belongs to.
|
|
2119
|
+
*/
|
|
2120
|
+
function PanelsideSearchResults({
|
|
2121
|
+
className,
|
|
2122
|
+
contentContainerClassName,
|
|
2123
|
+
children,
|
|
2124
|
+
...props
|
|
2125
|
+
}: PanelsideSearchResultsProps) {
|
|
2126
|
+
usePanelsideSearchSheet('Panelside.SearchResults');
|
|
2127
|
+
|
|
2128
|
+
return (
|
|
2129
|
+
<BottomSheet.Body
|
|
2130
|
+
className={cn('flex-1', className)}
|
|
2131
|
+
contentContainerClassName={cn('gap-1 px-4 pb-4 pt-1', contentContainerClassName)}
|
|
2132
|
+
keyboardShouldPersistTaps="handled"
|
|
2133
|
+
keyboardDismissMode="interactive"
|
|
2134
|
+
{...props}
|
|
2135
|
+
>
|
|
2136
|
+
{textChildren(children)}
|
|
2137
|
+
</BottomSheet.Body>
|
|
2138
|
+
);
|
|
2139
|
+
}
|
|
2140
|
+
|
|
2141
|
+
export interface PanelsideSearchResultProps extends Omit<PressableProps, 'children'> {
|
|
2142
|
+
className?: string;
|
|
2143
|
+
/** Leading element — a thumbnail, an icon, an avatar. */
|
|
2144
|
+
media?: ReactNode;
|
|
2145
|
+
/** The result's name, truncated to one line. */
|
|
2146
|
+
title?: string;
|
|
2147
|
+
/** What kind of thing it is, or where it was found. */
|
|
2148
|
+
description?: string;
|
|
2149
|
+
children?: ReactNode;
|
|
2150
|
+
}
|
|
2151
|
+
|
|
2152
|
+
/**
|
|
2153
|
+
* One hit. A leading thumbnail, a title, and a line saying what it is.
|
|
2154
|
+
*
|
|
2155
|
+
* Taller than a `Panelside.Item`, and deliberately: a navigation row is a
|
|
2156
|
+
* place you already know the name of, and a result is a thing you are deciding
|
|
2157
|
+
* about — the second line is what the decision is made on.
|
|
2158
|
+
*/
|
|
2159
|
+
function PanelsideSearchResult({
|
|
2160
|
+
className,
|
|
2161
|
+
media,
|
|
2162
|
+
title,
|
|
2163
|
+
description,
|
|
2164
|
+
children,
|
|
2165
|
+
...props
|
|
2166
|
+
}: PanelsideSearchResultProps) {
|
|
2167
|
+
const tint = useCSSVariable('--color-muted-foreground');
|
|
2168
|
+
const color = typeof tint === 'string' ? tint : undefined;
|
|
2169
|
+
|
|
2170
|
+
return (
|
|
2171
|
+
<AnimatedPressable
|
|
2172
|
+
className={cn('flex-row items-center gap-3 rounded-xl px-2 py-2', className)}
|
|
2173
|
+
accessibilityRole="button"
|
|
2174
|
+
accessibilityLabel={title}
|
|
2175
|
+
pressScale={0.985}
|
|
2176
|
+
{...props}
|
|
2177
|
+
>
|
|
2178
|
+
{media ? (
|
|
2179
|
+
<View className="h-11 w-11 items-center justify-center overflow-hidden rounded-xl bg-secondary">
|
|
2180
|
+
<IconColorProvider color={color}>{media}</IconColorProvider>
|
|
2181
|
+
</View>
|
|
2182
|
+
) : null}
|
|
2183
|
+
{title !== undefined || description !== undefined ? (
|
|
2184
|
+
<View className="flex-1 gap-0.5">
|
|
2185
|
+
{title !== undefined ? (
|
|
2186
|
+
<Text size="base" weight="medium" numberOfLines={1}>
|
|
2187
|
+
{title}
|
|
2188
|
+
</Text>
|
|
2189
|
+
) : null}
|
|
2190
|
+
{description !== undefined ? (
|
|
2191
|
+
<Text size="sm" muted numberOfLines={1}>
|
|
2192
|
+
{description}
|
|
2193
|
+
</Text>
|
|
2194
|
+
) : null}
|
|
2195
|
+
</View>
|
|
2196
|
+
) : null}
|
|
2197
|
+
{children}
|
|
2198
|
+
</AnimatedPressable>
|
|
2199
|
+
);
|
|
2200
|
+
}
|
|
2201
|
+
|
|
2202
|
+
export interface PanelsideSearchFieldProps extends TextInputProps {
|
|
2203
|
+
className?: string;
|
|
2204
|
+
containerClassName?: string;
|
|
2205
|
+
}
|
|
2206
|
+
|
|
2207
|
+
/**
|
|
2208
|
+
* The field, at the bottom of the sheet, riding the keyboard.
|
|
2209
|
+
*
|
|
2210
|
+
* At the bottom because that is where the thumb is and where the keyboard
|
|
2211
|
+
* comes up: a field at the top of a full-height sheet is at the far end of the
|
|
2212
|
+
* screen from both, and every character typed into it is read at the other
|
|
2213
|
+
* end of a list that is moving.
|
|
2214
|
+
*
|
|
2215
|
+
* The way out of the surface is not here — `Panelside.SearchSheet` draws it at
|
|
2216
|
+
* the leading edge of the top row, so the dismiss control does not move with
|
|
2217
|
+
* the keyboard and is not one mis-tap away from the field.
|
|
2218
|
+
*/
|
|
2219
|
+
function PanelsideSearchField({
|
|
2220
|
+
className,
|
|
2221
|
+
containerClassName,
|
|
2222
|
+
placeholder = 'Search',
|
|
2223
|
+
value,
|
|
2224
|
+
onChangeText,
|
|
2225
|
+
...props
|
|
2226
|
+
}: PanelsideSearchFieldProps) {
|
|
2227
|
+
const { open, query, setQuery, bottomInset } = usePanelsideSearchSheet(
|
|
2228
|
+
'Panelside.SearchField'
|
|
2229
|
+
);
|
|
2230
|
+
const placeholderTint = useCSSVariable('--color-muted-foreground');
|
|
2231
|
+
const textTint = useCSSVariable('--color-foreground');
|
|
2232
|
+
const muted = typeof placeholderTint === 'string' ? placeholderTint : undefined;
|
|
2233
|
+
const field = useRef<TextInput>(null);
|
|
2234
|
+
|
|
2235
|
+
const text = value ?? query;
|
|
2236
|
+
const change = onChangeText ?? setQuery;
|
|
2237
|
+
|
|
2238
|
+
/*
|
|
2239
|
+
* Focus on the transition rather than with `autoFocus`. Under `native` the
|
|
2240
|
+
* sheet's content is mounted for the life of the screen and only
|
|
2241
|
+
* `isPresented` changes, so `autoFocus` fires once — at startup, on a sheet
|
|
2242
|
+
* that is not up — and the keyboard opens over whatever is.
|
|
2243
|
+
*/
|
|
2244
|
+
useEffect(() => {
|
|
2245
|
+
if (open) field.current?.focus();
|
|
2246
|
+
else field.current?.blur();
|
|
2247
|
+
}, [open]);
|
|
2248
|
+
|
|
2249
|
+
return (
|
|
2250
|
+
<KeyboardAvoider
|
|
2251
|
+
mode="dock"
|
|
2252
|
+
active={open}
|
|
2253
|
+
bottomInset={bottomInset}
|
|
2254
|
+
className="w-full px-4 pb-1"
|
|
2255
|
+
>
|
|
2256
|
+
<View
|
|
2257
|
+
className={cn(
|
|
2258
|
+
'h-12 w-full flex-row items-center gap-2 rounded-full bg-secondary px-4',
|
|
2259
|
+
containerClassName
|
|
2260
|
+
)}
|
|
2261
|
+
>
|
|
2262
|
+
<SearchIcon size={17} color={muted} />
|
|
2263
|
+
<TextInput
|
|
2264
|
+
ref={field}
|
|
2265
|
+
value={text}
|
|
2266
|
+
onChangeText={change}
|
|
2267
|
+
placeholder={placeholder}
|
|
2268
|
+
placeholderTextColor={muted}
|
|
2269
|
+
/* `text-[16px]` rather than a `text-*` step — see Panelside.Search. */
|
|
2270
|
+
className={cn('h-full flex-1 font-normal text-[16px] text-foreground', className)}
|
|
2271
|
+
style={typeof textTint === 'string' ? { color: textTint } : undefined}
|
|
2272
|
+
accessibilityRole="search"
|
|
2273
|
+
returnKeyType="search"
|
|
2274
|
+
{...props}
|
|
2275
|
+
/>
|
|
2276
|
+
{text.length > 0 ? (
|
|
2277
|
+
<AnimatedPressable
|
|
2278
|
+
onPress={() => change('')}
|
|
2279
|
+
hitSlop={8}
|
|
2280
|
+
accessibilityRole="button"
|
|
2281
|
+
accessibilityLabel="Clear search"
|
|
2282
|
+
className="h-5 w-5 items-center justify-center rounded-full bg-muted"
|
|
2283
|
+
>
|
|
2284
|
+
<XIcon size={12} color={muted} />
|
|
2285
|
+
</AnimatedPressable>
|
|
2286
|
+
) : null}
|
|
2287
|
+
</View>
|
|
2288
|
+
|
|
2289
|
+
</KeyboardAvoider>
|
|
2290
|
+
);
|
|
2291
|
+
}
|
|
2292
|
+
|
|
1585
2293
|
PanelsidePanel.displayName = 'Panelside.Panel';
|
|
1586
2294
|
PanelsideHeader.displayName = 'Panelside.Header';
|
|
1587
2295
|
PanelsideSearch.displayName = 'Panelside.Search';
|
|
@@ -1597,6 +2305,13 @@ PanelsideFooter.displayName = 'Panelside.Footer';
|
|
|
1597
2305
|
PanelsideCta.displayName = 'Panelside.Cta';
|
|
1598
2306
|
PanelsideScene.displayName = 'Panelside.Scene';
|
|
1599
2307
|
PanelsideTrigger.displayName = 'Panelside.Trigger';
|
|
2308
|
+
PanelsideSearchTrigger.displayName = 'Panelside.SearchTrigger';
|
|
2309
|
+
PanelsideSearchSheet.displayName = 'Panelside.SearchSheet';
|
|
2310
|
+
PanelsideSearchTabs.displayName = 'Panelside.SearchTabs';
|
|
2311
|
+
PanelsideSearchTab.displayName = 'Panelside.SearchTab';
|
|
2312
|
+
PanelsideSearchResults.displayName = 'Panelside.SearchResults';
|
|
2313
|
+
PanelsideSearchResult.displayName = 'Panelside.SearchResult';
|
|
2314
|
+
PanelsideSearchField.displayName = 'Panelside.SearchField';
|
|
1600
2315
|
|
|
1601
2316
|
export const Panelside = Object.assign(PanelsideRoot, {
|
|
1602
2317
|
Panel: PanelsidePanel,
|
|
@@ -1614,4 +2329,11 @@ export const Panelside = Object.assign(PanelsideRoot, {
|
|
|
1614
2329
|
Cta: PanelsideCta,
|
|
1615
2330
|
Scene: PanelsideScene,
|
|
1616
2331
|
Trigger: PanelsideTrigger,
|
|
2332
|
+
SearchTrigger: PanelsideSearchTrigger,
|
|
2333
|
+
SearchSheet: PanelsideSearchSheet,
|
|
2334
|
+
SearchTabs: PanelsideSearchTabs,
|
|
2335
|
+
SearchTab: PanelsideSearchTab,
|
|
2336
|
+
SearchResults: PanelsideSearchResults,
|
|
2337
|
+
SearchResult: PanelsideSearchResult,
|
|
2338
|
+
SearchField: PanelsideSearchField,
|
|
1617
2339
|
});
|