panelui-native 0.32.0 → 0.34.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.
Files changed (35) hide show
  1. package/lib/module/components/button/index.js +106 -11
  2. package/lib/module/components/button/index.js.map +1 -1
  3. package/lib/module/components/drawer/index.js +477 -0
  4. package/lib/module/components/drawer/index.js.map +1 -0
  5. package/lib/module/components/panelside/index.js +1024 -0
  6. package/lib/module/components/panelside/index.js.map +1 -0
  7. package/lib/module/components/select/index.js +14 -3
  8. package/lib/module/components/select/index.js.map +1 -1
  9. package/lib/module/icons/index.js +20 -0
  10. package/lib/module/icons/index.js.map +1 -1
  11. package/lib/module/index.js +3 -1
  12. package/lib/module/index.js.map +1 -1
  13. package/lib/module/native/index.js +30 -0
  14. package/lib/module/native/index.js.map +1 -1
  15. package/lib/typescript/src/components/button/index.d.ts +22 -1
  16. package/lib/typescript/src/components/button/index.d.ts.map +1 -1
  17. package/lib/typescript/src/components/drawer/index.d.ts +146 -0
  18. package/lib/typescript/src/components/drawer/index.d.ts.map +1 -0
  19. package/lib/typescript/src/components/panelside/index.d.ts +354 -0
  20. package/lib/typescript/src/components/panelside/index.d.ts.map +1 -0
  21. package/lib/typescript/src/components/select/index.d.ts.map +1 -1
  22. package/lib/typescript/src/icons/index.d.ts +1 -0
  23. package/lib/typescript/src/icons/index.d.ts.map +1 -1
  24. package/lib/typescript/src/index.d.ts +3 -1
  25. package/lib/typescript/src/index.d.ts.map +1 -1
  26. package/lib/typescript/src/native/index.d.ts +20 -0
  27. package/lib/typescript/src/native/index.d.ts.map +1 -1
  28. package/package.json +1 -1
  29. package/src/components/button/index.tsx +140 -8
  30. package/src/components/drawer/index.tsx +662 -0
  31. package/src/components/panelside/index.tsx +1358 -0
  32. package/src/components/select/index.tsx +23 -3
  33. package/src/icons/index.tsx +14 -0
  34. package/src/index.ts +33 -0
  35. package/src/native/index.ts +51 -0
@@ -0,0 +1,662 @@
1
+ /**
2
+ * Drawer — a panel that comes in from an edge of the screen and covers the app
3
+ * until it is dismissed.
4
+ *
5
+ * ```tsx
6
+ * <Drawer>
7
+ * <Drawer.Trigger>
8
+ * <Button variant="outline">Menu</Button>
9
+ * </Drawer.Trigger>
10
+ * <Drawer.Content>
11
+ * <Drawer.Header title="Workspace" description="Switch or manage" />
12
+ * <Drawer.Body>
13
+ * <Item title="Projects" />
14
+ * <Item title="Members" />
15
+ * </Drawer.Body>
16
+ * <Drawer.Footer>
17
+ * <Button className="flex-1">Settings</Button>
18
+ * </Drawer.Footer>
19
+ * </Drawer.Content>
20
+ * </Drawer>
21
+ * ```
22
+ *
23
+ * ## Why the sides are `start` and `end`
24
+ *
25
+ * A drawer is the one overlay whose whole identity is the edge it belongs to,
26
+ * and in a right-to-left app "the navigation edge" is the right one. Naming the
27
+ * sides `left` and `right` would bake a reading direction into the API and
28
+ * force every caller in an RTL app to invert it themselves, so the sides are
29
+ * logical: `start` is the edge text begins at, `end` the edge it runs toward.
30
+ * Both follow the enclosing `<Direction>`, and `top` and `bottom` mean what
31
+ * they say because the vertical axis does not mirror.
32
+ *
33
+ * Yoga mirrors the panel's own position, because that is laid out from `start-0`
34
+ * / `end-0`. It does not mirror the drag, which is measured in raw pixels — so
35
+ * the gesture consults the direction and negates itself, which is what keeps a
36
+ * swipe *outward* dismissing in both directions rather than only one.
37
+ *
38
+ * ## Why it is not a BottomSheet with a different edge
39
+ *
40
+ * A sheet is sized by its content and dragged along the axis its scroller runs
41
+ * on, which is why so much of it is about sharing one drag between the two. A
42
+ * side drawer is sized by the screen and dragged across its scroller's axis, so
43
+ * the two gestures never compete: the cross-axis drag simply fails and the list
44
+ * keeps it. That difference is the reason this is a separate component and not
45
+ * a prop on the sheet.
46
+ */
47
+ import {
48
+ cloneElement,
49
+ createContext,
50
+ isValidElement,
51
+ useCallback,
52
+ useContext,
53
+ useEffect,
54
+ useMemo,
55
+ useState,
56
+ type ReactElement,
57
+ type ReactNode,
58
+ } from 'react';
59
+ import {
60
+ Pressable,
61
+ ScrollView,
62
+ useWindowDimensions,
63
+ View,
64
+ type ScrollViewProps,
65
+ type ViewProps,
66
+ } from 'react-native';
67
+ import { Gesture, GestureDetector } from 'react-native-gesture-handler';
68
+ import Animated, {
69
+ SlideInDown,
70
+ SlideInLeft,
71
+ SlideInRight,
72
+ SlideInUp,
73
+ SlideOutDown,
74
+ SlideOutLeft,
75
+ SlideOutRight,
76
+ SlideOutUp,
77
+ runOnJS,
78
+ useAnimatedStyle,
79
+ useSharedValue,
80
+ withSpring,
81
+ withTiming,
82
+ } from 'react-native-reanimated';
83
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
84
+ import { tv } from 'tailwind-variants';
85
+ import { useCSSVariable } from 'uniwind';
86
+ import { XIcon } from '../../icons';
87
+ import { Portal } from '../../primitives/portal';
88
+ import { Scrim } from '../../primitives/scrim';
89
+ import { Text, textChildren } from '../../primitives/text';
90
+ import { useBackHandler } from '../../hooks/use-back-handler';
91
+ import {
92
+ DirectionContext,
93
+ useDirection,
94
+ useDirectionSign,
95
+ } from '../../hooks/use-direction';
96
+ import { cn } from '../../utils/cn';
97
+
98
+ const SPRING = { damping: 24, stiffness: 300, mass: 0.7 } as const;
99
+ /** How far a drag has to travel outward before releasing it dismisses. */
100
+ const DISMISS_DISTANCE = 80;
101
+ /** A flick this fast dismisses regardless of how far it got. */
102
+ const DISMISS_VELOCITY = 650;
103
+
104
+ export type DrawerSide = 'start' | 'end' | 'top' | 'bottom';
105
+ export type DrawerSize = 'sm' | 'md' | 'lg' | 'full';
106
+
107
+ /**
108
+ * How much of the screen each size asks for, and the cap it is never allowed
109
+ * past on a wide screen.
110
+ *
111
+ * The cap is the whole point. A fraction alone reads correctly on a phone and
112
+ * absurdly on a tablet, where 78% of the width is a navigation list with a
113
+ * column of whitespace beside it. Capping turns the fraction into "no wider
114
+ * than it needs to be", which is what a drawer is.
115
+ */
116
+ const WIDTH = {
117
+ sm: { fraction: 0.62, max: 280 },
118
+ md: { fraction: 0.78, max: 320 },
119
+ lg: { fraction: 0.88, max: 400 },
120
+ // Not 1: an edge of the app left showing is what says it is still there
121
+ // behind the drawer, and it is also the only thing left to tap to dismiss.
122
+ full: { fraction: 0.94, max: Infinity },
123
+ } as const;
124
+
125
+ /** The same idea on the vertical axis, where there is no sensible cap. */
126
+ const HEIGHT = {
127
+ sm: 0.3,
128
+ md: 0.45,
129
+ lg: 0.62,
130
+ full: 0.94,
131
+ } as const;
132
+
133
+ const panelVariants = tv({
134
+ base: 'absolute border-border bg-popover shadow-lg',
135
+ variants: {
136
+ side: {
137
+ // Three real edges each. The fourth is the screen edge the drawer is
138
+ // docked to, and drawing a border or a radius along it would be a line
139
+ // through the middle of nothing.
140
+ start: 'bottom-0 start-0 top-0 rounded-e-3xl border-e',
141
+ end: 'bottom-0 end-0 top-0 rounded-s-3xl border-s',
142
+ top: 'end-0 start-0 top-0 rounded-b-3xl border-b',
143
+ bottom: 'bottom-0 end-0 start-0 rounded-t-3xl border-t',
144
+ },
145
+ },
146
+ defaultVariants: {
147
+ side: 'start',
148
+ },
149
+ });
150
+
151
+ interface DrawerContextValue {
152
+ open: boolean;
153
+ setOpen: (open: boolean) => void;
154
+ }
155
+
156
+ const DrawerContext = createContext<DrawerContextValue | null>(null);
157
+
158
+ function useDrawer(component: string): DrawerContextValue {
159
+ const context = useContext(DrawerContext);
160
+ if (!context) {
161
+ throw new Error(`${component} must be used within a <Drawer>`);
162
+ }
163
+ return context;
164
+ }
165
+
166
+ /**
167
+ * What the panel knows and its parts need: which corner the close button took,
168
+ * so a header can leave it clear rather than wrap underneath it.
169
+ */
170
+ interface DrawerSurfaceValue {
171
+ side: DrawerSide;
172
+ showClose: boolean;
173
+ }
174
+
175
+ const DrawerSurfaceContext = createContext<DrawerSurfaceValue | null>(null);
176
+
177
+ export interface DrawerProps {
178
+ children: ReactNode;
179
+ /** Open state, when you want to own it. Pair with `onOpenChange`. */
180
+ open?: boolean;
181
+ /** Called with the next open state, whether the drawer or you caused it. */
182
+ onOpenChange?: (open: boolean) => void;
183
+ /** Open state to start at when you are not controlling it. */
184
+ defaultOpen?: boolean;
185
+ }
186
+
187
+ function DrawerRoot({
188
+ children,
189
+ open: controlledOpen,
190
+ onOpenChange,
191
+ defaultOpen = false,
192
+ }: DrawerProps) {
193
+ const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
194
+ const controlled = controlledOpen !== undefined;
195
+ const open = controlled ? controlledOpen : uncontrolledOpen;
196
+
197
+ const setOpen = useCallback(
198
+ (next: boolean) => {
199
+ if (!controlled) setUncontrolledOpen(next);
200
+ onOpenChange?.(next);
201
+ },
202
+ [controlled, onOpenChange]
203
+ );
204
+
205
+ const context = useMemo<DrawerContextValue>(
206
+ () => ({ open, setOpen }),
207
+ [open, setOpen]
208
+ );
209
+
210
+ return (
211
+ <DrawerContext.Provider value={context}>{children}</DrawerContext.Provider>
212
+ );
213
+ }
214
+
215
+ export interface DrawerTriggerProps {
216
+ /** A single pressable element. Its own `onPress` still runs. */
217
+ children: ReactElement<{ onPress?: (...args: unknown[]) => void }>;
218
+ }
219
+
220
+ function DrawerTrigger({ children }: DrawerTriggerProps) {
221
+ const { setOpen } = useDrawer('Drawer.Trigger');
222
+ if (!isValidElement(children)) return children;
223
+
224
+ return cloneElement(children, {
225
+ onPress: (...args: unknown[]) => {
226
+ children.props.onPress?.(...args);
227
+ setOpen(true);
228
+ },
229
+ });
230
+ }
231
+
232
+ export interface DrawerCloseProps {
233
+ /** A single pressable element. Its own `onPress` still runs. */
234
+ children: ReactElement<{ onPress?: (...args: unknown[]) => void }>;
235
+ }
236
+
237
+ function DrawerClose({ children }: DrawerCloseProps) {
238
+ const { setOpen } = useDrawer('Drawer.Close');
239
+ if (!isValidElement(children)) return children;
240
+
241
+ return cloneElement(children, {
242
+ onPress: (...args: unknown[]) => {
243
+ children.props.onPress?.(...args);
244
+ setOpen(false);
245
+ },
246
+ });
247
+ }
248
+
249
+ export interface DrawerContentProps extends ViewProps {
250
+ className?: string;
251
+ /**
252
+ * Which edge the drawer is docked to. `start` and `end` are the edges text
253
+ * begins and ends at, so both follow the enclosing `<Direction>` rather than
254
+ * pinning the drawer to a physical side.
255
+ */
256
+ side?: DrawerSide;
257
+ /**
258
+ * How much of the screen the drawer takes — its width on `start` / `end`, its
259
+ * height on `top` / `bottom`. A horizontal drawer is also capped in points,
260
+ * so `md` is a 320-point navigation panel on a tablet rather than 78% of it.
261
+ */
262
+ size?: DrawerSize;
263
+ /** Tap on the backdrop closes the drawer. Default true. */
264
+ dismissible?: boolean;
265
+ /**
266
+ * Drag the drawer back toward its edge to dismiss it. Default true. Turn it
267
+ * off for a drawer whose content wants the same axis — a horizontal
268
+ * scroller in a side drawer.
269
+ */
270
+ swipeToDismiss?: boolean;
271
+ /** Show a close button in the drawer's inner top corner. Default true. */
272
+ showClose?: boolean;
273
+ /**
274
+ * Frost the screen behind the drawer instead of dimming it. Needs the
275
+ * optional `expo-blur`; without it this dims, rather than failing.
276
+ */
277
+ blur?: boolean;
278
+ children?: ReactNode;
279
+ }
280
+
281
+ function DrawerContent({
282
+ className,
283
+ side = 'start',
284
+ size = 'md',
285
+ dismissible = true,
286
+ swipeToDismiss = true,
287
+ showClose = true,
288
+ blur = false,
289
+ children,
290
+ ...props
291
+ }: DrawerContentProps) {
292
+ const { open, setOpen } = useDrawer('Drawer.Content');
293
+ const { width: screenWidth, height: screenHeight } = useWindowDimensions();
294
+ const insets = useSafeAreaInsets();
295
+ const dir = useDirection();
296
+ const sign = useDirectionSign();
297
+ const closeTint = useCSSVariable('--color-muted-foreground');
298
+
299
+ // Memoised for the reason `Direction` memoises its own: this is the object
300
+ // that invalidates layout for the whole panel, and a fresh one every render
301
+ // would re-run Yoga over it for a value that almost never changes.
302
+ const directionStyle = useMemo(() => ({ direction: dir }) as const, [dir]);
303
+
304
+ const close = useCallback(() => setOpen(false), [setOpen]);
305
+
306
+ // An open drawer catches the Android back button, closing itself instead of
307
+ // popping the screen behind it.
308
+ useBackHandler(open, close);
309
+
310
+ const horizontal = side === 'start' || side === 'end';
311
+
312
+ /**
313
+ * How far the panel has to travel to be fully off-screen — the distance a
314
+ * dismissing drag animates out to, and the axis the drag is measured on.
315
+ */
316
+ const extent = horizontal
317
+ ? Math.min(screenWidth * WIDTH[size].fraction, WIDTH[size].max)
318
+ : screenHeight * HEIGHT[size];
319
+
320
+ /**
321
+ * Which way "outward" points in raw pixels.
322
+ *
323
+ * Yoga has already mirrored where the panel sits, but a transform is not
324
+ * laid out — it is applied to whatever Yoga decided — so this is the one
325
+ * place that has to know the reading direction and negate itself. A `start`
326
+ * drawer leaves toward negative X in a left-to-right app and positive X in a
327
+ * right-to-left one; `end` is the mirror of that.
328
+ */
329
+ const outward = horizontal
330
+ ? (side === 'start' ? -1 : 1) * sign
331
+ : side === 'top'
332
+ ? -1
333
+ : 1;
334
+
335
+ /** Outward drag distance in points. Negative means dragged further in. */
336
+ const travel = useSharedValue(0);
337
+
338
+ /*
339
+ * Parked at zero on every open. A swipe-dismiss leaves the panel a full
340
+ * extent outward, and without this the next open would draw a lit backdrop
341
+ * over an app with the drawer still off-screen behind it — dimmed, blocking,
342
+ * and with nothing on it to close.
343
+ */
344
+ useEffect(() => {
345
+ if (open) travel.value = 0;
346
+ }, [open, travel]);
347
+
348
+ const pan = useMemo(() => {
349
+ const gesture = Gesture.Pan()
350
+ .enabled(swipeToDismiss)
351
+ .onChange((event) => {
352
+ const delta = (horizontal ? event.changeX : event.changeY) * outward;
353
+ const next = travel.value + delta;
354
+ // Follow the finger outward; rubber-band the pull further in, which
355
+ // has nowhere to go.
356
+ travel.value = next > 0 ? next : next / 3;
357
+ })
358
+ .onEnd((event) => {
359
+ const velocity =
360
+ (horizontal ? event.velocityX : event.velocityY) * outward;
361
+ if (travel.value > DISMISS_DISTANCE || velocity > DISMISS_VELOCITY) {
362
+ travel.value = withTiming(extent, { duration: 180 }, (finished) => {
363
+ if (finished) runOnJS(close)();
364
+ });
365
+ } else {
366
+ travel.value = withSpring(0, SPRING);
367
+ }
368
+ });
369
+
370
+ /*
371
+ * The drag has to give way to whatever the content is doing on the other
372
+ * axis. A side drawer holds a vertical list, so a mostly-vertical drag
373
+ * fails here and the list keeps it — which is why a side drawer needs none
374
+ * of the shared-gesture machinery a bottom sheet does.
375
+ */
376
+ return horizontal
377
+ ? gesture.activeOffsetX([-12, 12]).failOffsetY([-18, 18])
378
+ : gesture.activeOffsetY([-12, 12]).failOffsetX([-18, 18]);
379
+ }, [close, extent, horizontal, outward, swipeToDismiss, travel]);
380
+
381
+ const panelStyle = useAnimatedStyle(() =>
382
+ horizontal
383
+ ? { transform: [{ translateX: travel.value * outward }] }
384
+ : { transform: [{ translateY: travel.value * outward }] }
385
+ );
386
+
387
+ const surface = useMemo<DrawerSurfaceValue>(
388
+ () => ({ side, showClose }),
389
+ [side, showClose]
390
+ );
391
+
392
+ if (!open) return null;
393
+
394
+ /*
395
+ * The slide has to be given a physical direction, since the animation
396
+ * presets are physical. This is the same mirroring Yoga did for the panel's
397
+ * position, applied by hand to the one thing Yoga does not own.
398
+ */
399
+ const physical: 'left' | 'right' | 'top' | 'bottom' = horizontal
400
+ ? (side === 'start') === (dir === 'ltr')
401
+ ? 'left'
402
+ : 'right'
403
+ : side;
404
+
405
+ const entering = {
406
+ left: SlideInLeft,
407
+ right: SlideInRight,
408
+ top: SlideInUp,
409
+ bottom: SlideInDown,
410
+ }[physical];
411
+
412
+ const exiting = {
413
+ left: SlideOutLeft,
414
+ right: SlideOutRight,
415
+ top: SlideOutUp,
416
+ bottom: SlideOutDown,
417
+ }[physical];
418
+
419
+ /*
420
+ * The inset and the panel's own padding stack rather than compete.
421
+ *
422
+ * A drawer that reaches the top of the screen is drawn behind the status bar
423
+ * on purpose — a surface the app disappears under should not stop short of
424
+ * the edge — but the clock and the battery are drawn *in* that band, so the
425
+ * content clears the whole of it and then takes its padding on top. `Math.max`
426
+ * gave the two the same slot: on a 59-point inset it resolved to 59, leaving
427
+ * the header and the close button flush against the clock with nothing
428
+ * between them.
429
+ *
430
+ * `bottom` is the one side whose top edge is nowhere near the status bar, and
431
+ * `top` the one side ending in open screen rather than at the home indicator.
432
+ * Each takes plain padding on the edge it does not meet.
433
+ */
434
+ const padding = {
435
+ paddingTop: side === 'bottom' ? 16 : insets.top + 16,
436
+ paddingBottom: side === 'top' ? 16 : Math.max(insets.bottom, 16),
437
+ };
438
+
439
+ return (
440
+ <Portal>
441
+ {/* Portal content mounts under PortalHost, outside this provider's
442
+ subtree — re-provide the context so Drawer.Close keeps working. */}
443
+ <DrawerContext.Provider value={{ open, setOpen }}>
444
+ {/*
445
+ * The reading direction has to be put back on this layer, and in both
446
+ * of its forms.
447
+ *
448
+ * A portal escapes the `Direction` the drawer was written inside twice
449
+ * over. It escapes the *context*, because an element reads context from
450
+ * where it is rendered rather than from where it was written — the same
451
+ * reason `Drawer.Close` needs its provider back. And it escapes the
452
+ * *style*, because the flip is Yoga's: `direction` mirrors the view
453
+ * subtree it is set on, and this one now hangs off the portal host
454
+ * instead. Without the style a `start` drawer in a right-to-left app
455
+ * docks to the left while its animation comes from the right; without
456
+ * the provider its rows and its text stay left-to-right inside it.
457
+ */}
458
+ <DirectionContext.Provider value={dir}>
459
+ <View className="absolute inset-0" style={directionStyle}>
460
+ <View className="absolute inset-0">
461
+ {/* Scrim draws the backdrop and its own fade; the Pressable over
462
+ it is what closes the drawer, since the scrim takes no
463
+ touches. */}
464
+ <Scrim blur={blur} />
465
+ <Pressable
466
+ accessibilityLabel="Close drawer"
467
+ className="flex-1"
468
+ onPress={dismissible ? close : undefined}
469
+ />
470
+ </View>
471
+ <GestureDetector gesture={pan}>
472
+ <Animated.View
473
+ entering={entering
474
+ .springify()
475
+ .damping(24)
476
+ .stiffness(260)
477
+ .mass(0.7)}
478
+ exiting={exiting.duration(200)}
479
+ accessibilityViewIsModal
480
+ className={panelVariants({ side, className })}
481
+ {...props}
482
+ // After the spread, and folding the caller's own `style` in
483
+ // rather than replacing the array: spread last, a caller
484
+ // passing `style` would silently drop the drag transform and
485
+ // the safe-area padding with it.
486
+ style={[
487
+ panelStyle,
488
+ padding,
489
+ horizontal ? { width: extent } : { height: extent },
490
+ props.style,
491
+ ]}
492
+ >
493
+ <DrawerSurfaceContext.Provider value={surface}>
494
+ {textChildren(children)}
495
+ {/*
496
+ * Last, and lifted above the content, so a header that spans
497
+ * the panel cannot bury it — in React Native a later sibling
498
+ * wins the touch, and a close button drawn first under a
499
+ * full-width title reads as a button that only works near its
500
+ * top edge.
501
+ *
502
+ * Offset by the panel's own top padding rather than pinned
503
+ * with `top-0`. An absolutely positioned child's containing
504
+ * block is the padding *edge*, so padding moves the content
505
+ * around it and leaves it where it was — `top-0` put the
506
+ * button at the top of the panel, which on the three sides
507
+ * that reach the top of the screen is behind the status bar.
508
+ * Taking the same number the padding does is what keeps it
509
+ * level with the first line of the header on every side.
510
+ *
511
+ * The corner follows the docked edge. An `end` drawer's own
512
+ * edge is the trailing one, so its button moves to the
513
+ * leading side rather than sitting against the screen edge
514
+ * the drawer came out of.
515
+ */}
516
+ {showClose ? (
517
+ <Pressable
518
+ accessibilityRole="button"
519
+ accessibilityLabel="Close"
520
+ onPress={close}
521
+ hitSlop={8}
522
+ className={cn(
523
+ 'absolute z-10 h-8 w-8 items-center justify-center rounded-full bg-muted active:opacity-70',
524
+ side === 'end' ? 'start-4' : 'end-4'
525
+ )}
526
+ style={{ top: padding.paddingTop }}
527
+ >
528
+ <XIcon
529
+ size={16}
530
+ color={
531
+ typeof closeTint === 'string' ? closeTint : undefined
532
+ }
533
+ />
534
+ </Pressable>
535
+ ) : null}
536
+ </DrawerSurfaceContext.Provider>
537
+ </Animated.View>
538
+ </GestureDetector>
539
+ </View>
540
+ </DirectionContext.Provider>
541
+ </DrawerContext.Provider>
542
+ </Portal>
543
+ );
544
+ }
545
+
546
+ export interface DrawerHeaderProps extends ViewProps {
547
+ className?: string;
548
+ /** Heading for the drawer. Strings are wrapped; anything else is drawn as given. */
549
+ title?: ReactNode;
550
+ /** A line under the title, for what the drawer is for. */
551
+ description?: ReactNode;
552
+ children?: ReactNode;
553
+ }
554
+
555
+ function DrawerHeader({
556
+ className,
557
+ title,
558
+ description,
559
+ children,
560
+ ...props
561
+ }: DrawerHeaderProps) {
562
+ const surface = useContext(DrawerSurfaceContext);
563
+
564
+ /*
565
+ * Padding on whichever side the close button took, so a long title wraps
566
+ * above it rather than running underneath it. Nothing to clear when the
567
+ * button was turned off, and a header used outside a panel keeps its plain
568
+ * padding rather than reserving a corner for a button that is not there.
569
+ */
570
+ const clearance = !surface?.showClose
571
+ ? undefined
572
+ : surface.side === 'end'
573
+ ? 'ps-14'
574
+ : 'pe-14';
575
+
576
+ return (
577
+ <View className={cn('gap-1 px-5 pb-3', clearance, className)} {...props}>
578
+ {typeof title === 'string' ? (
579
+ <Text size="lg" weight="semibold">
580
+ {title}
581
+ </Text>
582
+ ) : (
583
+ title
584
+ )}
585
+ {typeof description === 'string' ? (
586
+ <Text className="text-sm text-muted-foreground">{description}</Text>
587
+ ) : (
588
+ description
589
+ )}
590
+ {textChildren(children)}
591
+ </View>
592
+ );
593
+ }
594
+
595
+ export interface DrawerBodyProps extends ScrollViewProps {
596
+ className?: string;
597
+ /**
598
+ * Scroll the body when it overflows. Pass `false` to lay the content out
599
+ * plainly instead — for a drawer whose content is known to fit, and for one
600
+ * that brings its own list, since a scroller nested inside this one leaves
601
+ * neither of them scrolling properly.
602
+ */
603
+ scrollable?: boolean;
604
+ children?: ReactNode;
605
+ }
606
+
607
+ function DrawerBody({
608
+ className,
609
+ scrollable = true,
610
+ children,
611
+ contentContainerStyle,
612
+ ...props
613
+ }: DrawerBodyProps) {
614
+ if (!scrollable) {
615
+ return (
616
+ <View className={cn('flex-1 px-5', className)}>
617
+ {textChildren(children)}
618
+ </View>
619
+ );
620
+ }
621
+
622
+ return (
623
+ <ScrollView
624
+ className={cn('flex-1', className)}
625
+ showsVerticalScrollIndicator={false}
626
+ // The drawer's own drag runs across this axis and fails out of a mostly
627
+ // vertical touch, so the two never have to be reconciled.
628
+ contentContainerStyle={[{ paddingHorizontal: 20 }, contentContainerStyle]}
629
+ {...props}
630
+ >
631
+ {textChildren(children)}
632
+ </ScrollView>
633
+ );
634
+ }
635
+
636
+ export interface DrawerFooterProps extends ViewProps {
637
+ className?: string;
638
+ children?: ReactNode;
639
+ }
640
+
641
+ function DrawerFooter({ className, children, ...props }: DrawerFooterProps) {
642
+ return (
643
+ <View
644
+ className={cn(
645
+ 'flex-row items-center gap-2 border-t border-border px-5 pt-3',
646
+ className
647
+ )}
648
+ {...props}
649
+ >
650
+ {textChildren(children)}
651
+ </View>
652
+ );
653
+ }
654
+
655
+ export const Drawer = Object.assign(DrawerRoot, {
656
+ Trigger: DrawerTrigger,
657
+ Content: DrawerContent,
658
+ Header: DrawerHeader,
659
+ Body: DrawerBody,
660
+ Footer: DrawerFooter,
661
+ Close: DrawerClose,
662
+ });