panelui-native 0.32.0 → 0.35.2

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 (46) hide show
  1. package/README.md +2 -1
  2. package/lib/module/components/button/index.js +106 -11
  3. package/lib/module/components/button/index.js.map +1 -1
  4. package/lib/module/components/drawer/index.js +477 -0
  5. package/lib/module/components/drawer/index.js.map +1 -0
  6. package/lib/module/components/panelside/index.js +1024 -0
  7. package/lib/module/components/panelside/index.js.map +1 -0
  8. package/lib/module/components/progress/index.js +64 -24
  9. package/lib/module/components/progress/index.js.map +1 -1
  10. package/lib/module/components/select/index.js +14 -3
  11. package/lib/module/components/select/index.js.map +1 -1
  12. package/lib/module/components/swipe/index.js +537 -0
  13. package/lib/module/components/swipe/index.js.map +1 -0
  14. package/lib/module/icons/index.js +20 -0
  15. package/lib/module/icons/index.js.map +1 -1
  16. package/lib/module/index.js +4 -1
  17. package/lib/module/index.js.map +1 -1
  18. package/lib/module/native/index.js +30 -0
  19. package/lib/module/native/index.js.map +1 -1
  20. package/lib/typescript/src/components/button/index.d.ts +22 -1
  21. package/lib/typescript/src/components/button/index.d.ts.map +1 -1
  22. package/lib/typescript/src/components/drawer/index.d.ts +146 -0
  23. package/lib/typescript/src/components/drawer/index.d.ts.map +1 -0
  24. package/lib/typescript/src/components/panelside/index.d.ts +354 -0
  25. package/lib/typescript/src/components/panelside/index.d.ts.map +1 -0
  26. package/lib/typescript/src/components/progress/index.d.ts +25 -4
  27. package/lib/typescript/src/components/progress/index.d.ts.map +1 -1
  28. package/lib/typescript/src/components/select/index.d.ts.map +1 -1
  29. package/lib/typescript/src/components/swipe/index.d.ts +206 -0
  30. package/lib/typescript/src/components/swipe/index.d.ts.map +1 -0
  31. package/lib/typescript/src/icons/index.d.ts +1 -0
  32. package/lib/typescript/src/icons/index.d.ts.map +1 -1
  33. package/lib/typescript/src/index.d.ts +4 -1
  34. package/lib/typescript/src/index.d.ts.map +1 -1
  35. package/lib/typescript/src/native/index.d.ts +20 -0
  36. package/lib/typescript/src/native/index.d.ts.map +1 -1
  37. package/package.json +1 -1
  38. package/src/components/button/index.tsx +140 -8
  39. package/src/components/drawer/index.tsx +662 -0
  40. package/src/components/panelside/index.tsx +1358 -0
  41. package/src/components/progress/index.tsx +93 -25
  42. package/src/components/select/index.tsx +23 -3
  43. package/src/components/swipe/index.tsx +687 -0
  44. package/src/icons/index.tsx +14 -0
  45. package/src/index.ts +42 -0
  46. package/src/native/index.ts +51 -0
@@ -0,0 +1,1358 @@
1
+ /**
2
+ * Panelside — a navigation panel that moves the app aside instead of covering
3
+ * it.
4
+ *
5
+ * ```tsx
6
+ * <Panelside>
7
+ * <Panelside.Panel>
8
+ * <Panelside.Header title="Assistant">
9
+ * <Panelside.Search value={query} onChangeText={setQuery} />
10
+ * </Panelside.Header>
11
+ * <Panelside.Content>
12
+ * <Panelside.Group>
13
+ * <Panelside.GroupLabel>Recents</Panelside.GroupLabel>
14
+ * <Panelside.Item label="Launch thread" />
15
+ * </Panelside.Group>
16
+ * </Panelside.Content>
17
+ * <Panelside.Footer>
18
+ * <Panelside.Cta label="New chat" onPress={compose} />
19
+ * </Panelside.Footer>
20
+ * </Panelside.Panel>
21
+ *
22
+ * <Panelside.Scene>
23
+ * <Panelside.Trigger />
24
+ * <Conversation />
25
+ * </Panelside.Scene>
26
+ * </Panelside>
27
+ * ```
28
+ *
29
+ * ## Why it is not a Drawer
30
+ *
31
+ * A drawer is an overlay: it mounts through a portal, lands above everything
32
+ * and dims what it hid. That is the wrong shape here, because the whole point
33
+ * of this pattern is that the app *stays legible* — it slides across, shrinks,
34
+ * rounds its corners and waits, so the panel reads as a layer behind the app
35
+ * rather than a sheet on top of it. A portal cannot do that: its content is
36
+ * above the app content by construction, and the app content is somewhere else
37
+ * in the tree entirely, unreachable.
38
+ *
39
+ * So Panelside owns both halves. It renders inline, keeps the panel and the
40
+ * app screen as siblings under one clipping container, and gives them a single
41
+ * `progress` value to move against. `Panelside.Scene` is the wrapper you put
42
+ * around your own screen; without it there is nothing to push, which is why it
43
+ * is explicit rather than inferred.
44
+ *
45
+ * ## The scene maths
46
+ *
47
+ * React Native scales a view about its centre, so a scene scaled to `s` has
48
+ * already pulled its left edge `W * (1 - s) / 2` inward before any translation
49
+ * is applied. Translating by the panel width alone would therefore leave a gap
50
+ * that grows with the scale, and the panel would look mis-measured. Subtracting
51
+ * that inset is what puts the scene's *visible* edge exactly where the panel
52
+ * ends:
53
+ *
54
+ * scale = 1 - (1 - s) * p
55
+ * translateX = p * (width + gap) - W * (1 - scale) / 2
56
+ *
57
+ * Both are driven from one shared value, so a half-finished drag is a real
58
+ * halfway state rather than an interpolation between two snapshots.
59
+ *
60
+ * ## One gesture, both directions
61
+ *
62
+ * A single pan opens and closes. By default it listens across the whole
63
+ * surface, because that is the behaviour this pattern is known for: a sideways
64
+ * drag anywhere on the app brings the panel in, from wherever your thumb
65
+ * already was. What keeps a list usable underneath it is the pair of
66
+ * thresholds — the drag gives itself up on twelve points of vertical travel
67
+ * and only claims the touch at fourteen horizontal, so anything even slightly
68
+ * vertical resolves as a scroll.
69
+ *
70
+ * `swipeFrom="edge"` narrows the closed-state hit area to a strip at the
71
+ * leading screen edge instead. That is for a scene with its own use for a
72
+ * horizontal drag — a carousel, a wide table, a pannable chart — which would
73
+ * otherwise fight the panel and lose.
74
+ *
75
+ * Reanimated's default `ReduceMotion.System` applies throughout: with the
76
+ * accessibility setting on, every spring here resolves instantly to its target
77
+ * rather than travelling.
78
+ */
79
+ import {
80
+ cloneElement,
81
+ createContext,
82
+ isValidElement,
83
+ useCallback,
84
+ useContext,
85
+ useEffect,
86
+ useMemo,
87
+ useState,
88
+ type ReactElement,
89
+ type ReactNode,
90
+ } from 'react';
91
+ import {
92
+ Platform,
93
+ Pressable,
94
+ ScrollView,
95
+ StyleSheet,
96
+ TextInput,
97
+ useWindowDimensions,
98
+ View,
99
+ type LayoutChangeEvent,
100
+ type PressableProps,
101
+ type ScrollViewProps,
102
+ type TextInputProps,
103
+ type ViewProps,
104
+ } from 'react-native';
105
+ import { Gesture, GestureDetector } from 'react-native-gesture-handler';
106
+ import Animated, {
107
+ runOnJS,
108
+ useAnimatedProps,
109
+ useAnimatedStyle,
110
+ useDerivedValue,
111
+ useSharedValue,
112
+ withSpring,
113
+ type SharedValue,
114
+ } from 'react-native-reanimated';
115
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
116
+ import { tv } from 'tailwind-variants';
117
+ import { useCSSVariable } from 'uniwind';
118
+ import { LinearGradient } from 'expo-linear-gradient';
119
+ import { EllipsisIcon, IconColorProvider, MenuIcon, SearchIcon } from '../../icons';
120
+ import { Button } from '../button';
121
+ import { AnimatedPressable } from '../../primitives/animated-pressable';
122
+ import { Text, textChildren } from '../../primitives/text';
123
+ import { useBackHandler } from '../../hooks/use-back-handler';
124
+ import { useDirectionSign } from '../../hooks/use-direction';
125
+ import { cn } from '../../utils/cn';
126
+
127
+ const SPRING = { damping: 24, stiffness: 300, mass: 0.7 } as const;
128
+
129
+ /**
130
+ * How wide the leading-edge strip that starts a swipe is, in points.
131
+ *
132
+ * Wider than the system's own edge gestures, because this one has to be found
133
+ * without a bezel to feel for: a thumb reaching for the side of a phone lands
134
+ * anywhere in the first 40-odd points, and a strip narrower than that reads as
135
+ * a gesture that does not work rather than one that was missed.
136
+ */
137
+ const EDGE_WIDTH = 48;
138
+ /** A drag has to clear this before releasing it changes the open state. */
139
+ const COMMIT_DISTANCE = 60;
140
+ /** A flick this fast commits regardless of how far it got. */
141
+ const COMMIT_VELOCITY = 500;
142
+ /**
143
+ * How much travel claims the drag, and how much gives it up — different on
144
+ * each axis, and different again depending on where the swipe may start.
145
+ *
146
+ * From the edge strip the horizontal claim is small and the vertical give-up
147
+ * generous: the target is narrow, so the gesture has to win early, and nobody
148
+ * swipes in a straight line from the side of a phone.
149
+ *
150
+ * From anywhere the numbers invert, because now the whole screen is competing.
151
+ * Giving up sooner than it claims is what makes a lazy diagonal resolve as a
152
+ * scroll rather than as the panel: a list keeps every drag that is even
153
+ * slightly vertical, and only a deliberate sideways one opens the panel.
154
+ */
155
+ const OFFSETS = {
156
+ edge: { activate: 6, fail: 16 },
157
+ anywhere: { activate: 14, fail: 12 },
158
+ } as const;
159
+ /** Below this the drag is a tap that wobbled, and velocity is not consulted. */
160
+ const MIN_OFFSET = 5;
161
+
162
+ /** Gap left between the panel's edge and the pushed scene. */
163
+ const GAP = 12;
164
+ /**
165
+ * How small the scene gets at full travel. One, by default: it does not shrink.
166
+ *
167
+ * Scaling is the obvious way to make the pushed screen read as a card, and it
168
+ * is the wrong one. A scale is applied about the centre, so it insets the
169
+ * scene at the top and the bottom as well as the side — the screen lifts away
170
+ * from the status bar and the home indicator, and the two strips of panel that
171
+ * appear above and below it are strips of nothing. What the apps this pattern
172
+ * comes from do instead is keep the screen full height, running behind the
173
+ * status bar exactly as it did before, and let the corner radius and the dim
174
+ * carry the whole effect. Only the *content* respects the safe area, which it
175
+ * was already doing.
176
+ *
177
+ * Set it below one for the shrinking version; nothing else has to change.
178
+ */
179
+ const SCENE_SCALE = 1;
180
+ /** The corner radius the scene picks up at full travel. */
181
+ const SCENE_RADIUS = 44;
182
+ /** How far the scene is dimmed at full travel. */
183
+ const SCENE_DIM = 0.45;
184
+ /**
185
+ * How strongly the line along the scene's edge reads at full travel.
186
+ *
187
+ * One, because the token it is drawn in already carries its own alpha — 6%
188
+ * white in a dark theme, 8% black in a light one. Holding it back further would
189
+ * be dimming a colour that is already almost entirely transparent.
190
+ */
191
+ const EDGE_OPACITY = 1;
192
+ /**
193
+ * How thick that line is.
194
+ *
195
+ * A point rather than `StyleSheet.hairlineWidth`. A hairline is one physical
196
+ * pixel, which is right for a divider on a flat surface and too little on a
197
+ * corner this round — most of the line is curve, and a third of a point of
198
+ * curve antialiases away to nothing.
199
+ */
200
+ const EDGE_WIDTH_PT = 1;
201
+
202
+ /**
203
+ * How far behind the scene the panel starts.
204
+ *
205
+ * The panel is never actually off-screen in push mode — it is simply covered.
206
+ * Moving it a little anyway is what stops the reveal reading as a photograph
207
+ * sliding off a poster: the two layers travel at different rates, so the panel
208
+ * settles into place rather than having been there all along.
209
+ */
210
+ const PARALLAX = 0.18;
211
+
212
+ /**
213
+ * Fraction of the container the panel takes, and the cap it never passes.
214
+ *
215
+ * Wide on purpose. The sliver of app left showing is not a preview of it — it
216
+ * is a handle and a reminder, and the moment it is wide enough to read as a
217
+ * column the screen turns into a two-pane layout that neither pane fits. The
218
+ * cap keeps that true on a tablet, where the fraction alone would produce a
219
+ * navigation list with a field of whitespace beside it.
220
+ */
221
+ const WIDTH_FRACTION = 0.8;
222
+ const WIDTH_MAX = 360;
223
+
224
+ /**
225
+ * The same, for a docked panel — and nothing like it, because the job changed.
226
+ *
227
+ * An overlay panel can take most of the width, since the app is behind it and
228
+ * gets it all back on close. A docked panel keeps what it takes: every point
229
+ * of it is a point the app does not have, and 80% of the container leaves a
230
+ * column too narrow to put anything in. A third, capped, is a sidebar.
231
+ */
232
+ const DOCK_WIDTH_FRACTION = 0.32;
233
+ const DOCK_WIDTH_MAX = 320;
234
+
235
+ /** How far above the floating footer the list starts dissolving into it. */
236
+ const FOOTER_FADE = 28;
237
+
238
+ /** Progress past which a layer is treated as fully hidden for accessibility. */
239
+ const HIDDEN_EPSILON = 0.05;
240
+
241
+ const clamp = (value: number, min: number, max: number) => {
242
+ 'worklet';
243
+ return Math.min(Math.max(value, min), max);
244
+ };
245
+
246
+ export type PanelsideMode = 'push' | 'overlay';
247
+ export type PanelsideSwipeFrom = 'anywhere' | 'edge';
248
+
249
+ const itemVariants = tv({
250
+ // No width: in a group it stretches on its own, and pinning it to full width
251
+ // would stop it sharing a footer row with anything else. `shrink` because
252
+ // React Native defaults `flexShrink` to 0 — in a footer beside a button, on a
253
+ // panel narrow enough for the two not to fit, nothing would give way and
254
+ // both would simply hang off the edge.
255
+ base: 'shrink flex-row items-center gap-3 rounded-xl px-3 py-2.5',
256
+ variants: {
257
+ active: { true: 'bg-secondary' },
258
+ disabled: { true: 'opacity-40' },
259
+ },
260
+ });
261
+
262
+ const ctaVariants = tv({
263
+ // Taller and wider than a list row's control, and set a step up. It is the
264
+ // one thing in the panel you are meant to reach for without reading, so it
265
+ // should not be the same size as the eight chat titles above it.
266
+ base: 'h-12 shrink flex-row items-center justify-center gap-2 rounded-full px-6',
267
+ variants: {
268
+ variant: {
269
+ primary: 'bg-primary',
270
+ secondary: 'bg-secondary',
271
+ },
272
+ },
273
+ defaultVariants: {
274
+ variant: 'primary',
275
+ },
276
+ });
277
+
278
+ interface PanelsideContextValue {
279
+ open: boolean;
280
+ setOpen: (open: boolean) => void;
281
+ toggle: () => void;
282
+ /** 0 closed, 1 open. The one value every layer animates against. */
283
+ progress: SharedValue<number>;
284
+ /** Panel width in points. */
285
+ width: number;
286
+ mode: PanelsideMode;
287
+ /** True when the panel is laid out beside the scene rather than behind it. */
288
+ docked: boolean;
289
+ dismissible: boolean;
290
+ }
291
+
292
+ const PanelsideContext = createContext<PanelsideContextValue | null>(null);
293
+
294
+ function usePanelsideContext(component: string): PanelsideContextValue {
295
+ const context = useContext(PanelsideContext);
296
+ if (!context) {
297
+ throw new Error(`${component} must be used within a <Panelside>`);
298
+ }
299
+ return context;
300
+ }
301
+
302
+ export interface UsePanelsideResult {
303
+ open: boolean;
304
+ setOpen: (open: boolean) => void;
305
+ toggle: () => void;
306
+ /**
307
+ * How far the panel has travelled, 0 to 1, on the UI thread. Read it to move
308
+ * something of your own with the panel — a header that fades, a title that
309
+ * slides — without a re-render per frame.
310
+ */
311
+ progress: SharedValue<number>;
312
+ /** True while the panel is docked open beside the scene. */
313
+ docked: boolean;
314
+ }
315
+
316
+ /**
317
+ * The panel's state, from anywhere inside a `<Panelside>` — including your own
318
+ * screen inside `Panelside.Scene`, which is where a custom open button usually
319
+ * lives.
320
+ */
321
+ export function usePanelside(): UsePanelsideResult {
322
+ const { open, setOpen, toggle, progress, docked } = usePanelsideContext('usePanelside');
323
+ return { open, setOpen, toggle, progress, docked };
324
+ }
325
+
326
+ /**
327
+ * What the panel's own parts share: the floating footer's height, so the
328
+ * scroller can leave room for it. The footer overlays the list rather than
329
+ * taking a row of its own, which means nothing else can know how tall it is
330
+ * until it has laid itself out.
331
+ */
332
+ interface PanelsideSurfaceValue {
333
+ footerHeight: number;
334
+ setFooterHeight: (height: number) => void;
335
+ }
336
+
337
+ const PanelsideSurfaceContext = createContext<PanelsideSurfaceValue | null>(null);
338
+
339
+ export interface PanelsideProps {
340
+ children: ReactNode;
341
+ /** Open state, when you want to own it. Pair with `onOpenChange`. */
342
+ open?: boolean;
343
+ /** Called with the next open state, whether a gesture or you caused it. */
344
+ onOpenChange?: (open: boolean) => void;
345
+ /** Open state to start at when you are not controlling it. */
346
+ defaultOpen?: boolean;
347
+ /**
348
+ * How the two layers relate. `push` moves the scene aside and curves it,
349
+ * which is the point of this component. `overlay` slides the panel over a
350
+ * scene that stays put — the same navigation, for a screen whose content
351
+ * cannot afford to move.
352
+ */
353
+ mode?: PanelsideMode;
354
+ /**
355
+ * Panel width in points. Defaults to 80% of the container capped at 360,
356
+ * and to a third of it capped at 320 once docked — an overlay panel gives
357
+ * the width back when it closes and a docked one keeps it, so they are not
358
+ * the same measurement. The caps are what stop a tablet getting a navigation
359
+ * list with a field of whitespace beside it.
360
+ */
361
+ width?: number;
362
+ /**
363
+ * Container width at or above which the panel stops being an overlay and
364
+ * becomes a permanent sidebar: laid out beside the scene, always open, with
365
+ * the gesture and the trigger switched off. A docked panel also narrows to a
366
+ * third of the container, capped at 320 — docked, every point it takes is a
367
+ * point the app does not get back.
368
+ *
369
+ * Off by default, and deliberately not a guess — a large phone in landscape
370
+ * is wider than a small tablet in portrait, so no single number is right for
371
+ * every app. Set it high enough that what is left over is still a screen:
372
+ * around 700 is the first width where both halves have room.
373
+ */
374
+ dock?: number | false;
375
+ /** Swipe to open, and drag the scene to close. Default true. */
376
+ swipeEnabled?: boolean;
377
+ /**
378
+ * Where a swipe may begin. `anywhere` is the default and the behaviour this
379
+ * pattern is known for — a sideways drag across the app opens the panel from
380
+ * wherever your thumb already was.
381
+ *
382
+ * `edge` narrows it to a strip at the leading screen edge, for a scene that
383
+ * has its own use for a horizontal drag: a carousel, a wide table, a chart
384
+ * you can pan. Anything like that under an `anywhere` panel will fight it,
385
+ * and the panel usually wins.
386
+ */
387
+ swipeFrom?: PanelsideSwipeFrom;
388
+ /**
389
+ * How wide the leading-edge strip that starts a swipe is, when `swipeFrom`
390
+ * is `edge`. Default 48 — wider than the system's own edge gestures, because
391
+ * there is no bezel to feel for. Ignored otherwise.
392
+ */
393
+ edgeWidth?: number;
394
+ /**
395
+ * Tapping the pushed scene, or the Android back button, closes the panel.
396
+ * Default true.
397
+ */
398
+ dismissible?: boolean;
399
+ className?: string;
400
+ }
401
+
402
+ function PanelsideRoot({
403
+ children,
404
+ open: controlledOpen,
405
+ onOpenChange,
406
+ defaultOpen = false,
407
+ mode = 'push',
408
+ width: widthProp,
409
+ dock = false,
410
+ swipeEnabled = true,
411
+ swipeFrom = 'anywhere',
412
+ edgeWidth = EDGE_WIDTH,
413
+ dismissible = true,
414
+ className,
415
+ }: PanelsideProps) {
416
+ const { width: windowWidth } = useWindowDimensions();
417
+ const sign = useDirectionSign();
418
+
419
+ const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
420
+ const controlled = controlledOpen !== undefined;
421
+ const open = controlled ? controlledOpen : uncontrolledOpen;
422
+
423
+ /*
424
+ * Measured rather than taken from the window, because Panelside does not
425
+ * have to be the whole screen — it can be one tab of a larger layout, and
426
+ * the push distance is a fraction of whatever it actually got. The window
427
+ * width is only the value to use until the first layout arrives.
428
+ */
429
+ const [containerWidth, setContainerWidth] = useState(windowWidth);
430
+
431
+ const onLayout = useCallback((event: LayoutChangeEvent) => {
432
+ setContainerWidth(event.nativeEvent.layout.width);
433
+ }, []);
434
+
435
+ const docked = dock !== false && containerWidth >= dock;
436
+ const width =
437
+ widthProp ??
438
+ (docked
439
+ ? Math.min(containerWidth * DOCK_WIDTH_FRACTION, DOCK_WIDTH_MAX)
440
+ : Math.min(containerWidth * WIDTH_FRACTION, WIDTH_MAX));
441
+
442
+ /**
443
+ * How far the drag runs. In push mode the scene clears the gap too.
444
+ *
445
+ * Floored at 1 because it is a divisor: a container measured at zero — one
446
+ * frame during a collapsed layout is enough — would otherwise make progress
447
+ * infinite and park both layers somewhere off the screen for good.
448
+ */
449
+ const extent = Math.max(1, mode === 'push' ? width + GAP : width);
450
+
451
+ const translation = useSharedValue(open ? extent : 0);
452
+ const progress = useDerivedValue(() => translation.value / extent, [extent]);
453
+
454
+ /*
455
+ * Which end state a spring is already heading for, so the effect below does
456
+ * not restart an animation the gesture just launched with velocity. Without
457
+ * it, committing a flick re-springs from mid-flight at zero velocity, which
458
+ * is visible as a stutter right where the motion should feel fastest.
459
+ */
460
+ const animatingTo = useSharedValue<'open' | 'close' | null>(null);
461
+
462
+ const settle = useCallback(
463
+ (next: boolean, velocity?: number) => {
464
+ 'worklet';
465
+ const target = next ? extent : 0;
466
+ if (translation.value === target) return;
467
+ if (animatingTo.value === (next ? 'open' : 'close')) return;
468
+
469
+ /*
470
+ * A velocity pointing away from the target would fight the spring, and
471
+ * the spring wins — so the only thing it contributes is a hitch at the
472
+ * start. Drop it and let the spring do the whole trip.
473
+ */
474
+ const aligned =
475
+ velocity !== undefined &&
476
+ ((target > translation.value && velocity > 0) ||
477
+ (target < translation.value && velocity < 0));
478
+
479
+ animatingTo.value = next ? 'open' : 'close';
480
+ translation.value = withSpring(
481
+ target,
482
+ { ...SPRING, velocity: aligned ? velocity : 0 },
483
+ () => {
484
+ animatingTo.value = null;
485
+ }
486
+ );
487
+ },
488
+ [animatingTo, extent, translation]
489
+ );
490
+
491
+ const setOpen = useCallback(
492
+ (next: boolean) => {
493
+ if (!controlled) setUncontrolledOpen(next);
494
+ onOpenChange?.(next);
495
+ },
496
+ [controlled, onOpenChange]
497
+ );
498
+
499
+ const toggle = useCallback(() => setOpen(!open), [open, setOpen]);
500
+ const close = useCallback(() => setOpen(false), [setOpen]);
501
+
502
+ useBackHandler(open && dismissible && !docked, close);
503
+
504
+ /*
505
+ * A docked panel is open by definition, and it must not animate there — on
506
+ * a rotation into a docked layout the panel is already beside the scene, so
507
+ * a spring would slide furniture that never moved.
508
+ */
509
+ useEffect(() => {
510
+ if (docked) {
511
+ translation.value = extent;
512
+ return;
513
+ }
514
+ settle(open);
515
+ }, [docked, extent, open, settle, translation]);
516
+
517
+ const offsets = OFFSETS[swipeFrom];
518
+
519
+ const pan = useMemo(() => {
520
+ let gesture = Gesture.Pan()
521
+ .enabled(swipeEnabled && !docked)
522
+ .activeOffsetX([-offsets.activate, offsets.activate])
523
+ // Real vertical intent hands the touch back, so a list inside the panel
524
+ // and a scroller inside the app both keep their own drags.
525
+ .failOffsetY([-offsets.fail, offsets.fail])
526
+ .onChange((event) => {
527
+ translation.value = clamp(translation.value + event.changeX * sign, 0, extent);
528
+ })
529
+ .onEnd((event) => {
530
+ const distance = event.translationX * sign;
531
+ const velocity = event.velocityX * sign;
532
+ const decisive =
533
+ (Math.abs(distance) > MIN_OFFSET && Math.abs(velocity) > COMMIT_VELOCITY) ||
534
+ Math.abs(distance) > COMMIT_DISTANCE;
535
+ // Below the threshold the drag was not an instruction: go back to
536
+ // wherever the panel already was.
537
+ const next = decisive ? (velocity === 0 ? distance : velocity) > 0 : open;
538
+
539
+ // `velocity` is already in travel space; the spring runs on the same
540
+ // axis, so it must not be converted back to screen space here.
541
+ settle(next, velocity);
542
+ runOnJS(setOpen)(next);
543
+ });
544
+
545
+ /*
546
+ * The strip is only ever a closed-state restriction. Open, it is dropped
547
+ * whatever `swipeFrom` says: the panel is already out, so there is no app
548
+ * underneath left to compete for the same horizontal swipe.
549
+ */
550
+ if (!open && swipeFrom === 'edge') {
551
+ gesture = gesture.hitSlop(
552
+ sign === 1 ? { left: 0, width: edgeWidth } : { right: 0, width: edgeWidth }
553
+ );
554
+ }
555
+
556
+ return gesture;
557
+ }, [
558
+ docked,
559
+ edgeWidth,
560
+ extent,
561
+ offsets,
562
+ open,
563
+ setOpen,
564
+ settle,
565
+ sign,
566
+ swipeEnabled,
567
+ swipeFrom,
568
+ translation,
569
+ ]);
570
+
571
+ const context = useMemo<PanelsideContextValue>(
572
+ () => ({ open, setOpen, toggle, progress, width, mode, docked, dismissible }),
573
+ [dismissible, docked, mode, open, progress, setOpen, toggle, width]
574
+ );
575
+
576
+ return (
577
+ <PanelsideContext.Provider value={context}>
578
+ <View
579
+ onLayout={onLayout}
580
+ // Clipping is what lets the panel sit at the edge with a parallax
581
+ // offset without a sliver of it hanging outside the container.
582
+ className={cn('flex-1 overflow-hidden', docked && 'flex-row', className)}
583
+ >
584
+ <GestureDetector gesture={pan}>
585
+ <Animated.View className={cn('flex-1', docked && 'flex-row')}>
586
+ {children}
587
+ </Animated.View>
588
+ </GestureDetector>
589
+ </View>
590
+ </PanelsideContext.Provider>
591
+ );
592
+ }
593
+
594
+ export interface PanelsidePanelProps extends ViewProps {
595
+ className?: string;
596
+ children?: ReactNode;
597
+ }
598
+
599
+ function PanelsidePanel({ className, children, style, ...props }: PanelsidePanelProps) {
600
+ const { progress, width, mode, docked } = usePanelsideContext('Panelside.Panel');
601
+ const [footerHeight, setFooterHeight] = useState(0);
602
+
603
+ const animatedStyle = useAnimatedStyle(() => {
604
+ const p = docked ? 1 : progress.value;
605
+ const distance = mode === 'overlay' ? width : width * PARALLAX;
606
+ return { transform: [{ translateX: -(1 - p) * distance }] };
607
+ }, [docked, mode, width]);
608
+
609
+ /*
610
+ * Out of the accessibility tree until it is nearly all the way in. A panel
611
+ * at rest is behind the app, fully covered and not a thing you can reach —
612
+ * but nothing about being covered says that to a screen reader, which will
613
+ * happily read out a navigation list nobody can see.
614
+ */
615
+ const animatedProps = useAnimatedProps<ViewProps>(() => {
616
+ const hidden = !docked && progress.value < 1 - HIDDEN_EPSILON;
617
+ return Platform.OS === 'android'
618
+ ? { importantForAccessibility: hidden ? 'no-hide-descendants' : 'auto' }
619
+ : { accessibilityElementsHidden: hidden };
620
+ }, [docked]);
621
+
622
+ const surface = useMemo<PanelsideSurfaceValue>(
623
+ () => ({ footerHeight, setFooterHeight }),
624
+ [footerHeight]
625
+ );
626
+
627
+ return (
628
+ <PanelsideSurfaceContext.Provider value={surface}>
629
+ <Animated.View
630
+ animatedProps={animatedProps}
631
+ className={cn(
632
+ 'bg-background',
633
+ docked ? 'h-full border-e border-border' : 'absolute bottom-0 start-0 top-0',
634
+ // In overlay mode the panel comes in *over* the app, but it is the
635
+ // first child and the scene is the second — so without this it slides
636
+ // in behind the very thing it is supposed to cover, and all you see
637
+ // is the scrim.
638
+ !docked && mode === 'overlay' && 'z-10 shadow-lg',
639
+ className
640
+ )}
641
+ // Animated style before the caller's, so a className or style cannot
642
+ // silently drop the transform the panel is being moved by.
643
+ style={[{ width }, animatedStyle, style]}
644
+ {...props}
645
+ >
646
+ {children}
647
+ </Animated.View>
648
+ </PanelsideSurfaceContext.Provider>
649
+ );
650
+ }
651
+
652
+ export interface PanelsideHeaderProps extends ViewProps {
653
+ className?: string;
654
+ /** Rendered as the heading. Omit it and supply your own in `children`. */
655
+ title?: string;
656
+ /** A single element pinned to the trailing end of the title row. */
657
+ action?: ReactNode;
658
+ /** Anything below the title row — a search field, a workspace switcher. */
659
+ children?: ReactNode;
660
+ }
661
+
662
+ function PanelsideHeader({
663
+ className,
664
+ title,
665
+ action,
666
+ children,
667
+ style,
668
+ ...props
669
+ }: PanelsideHeaderProps) {
670
+ const insets = useSafeAreaInsets();
671
+
672
+ return (
673
+ <View
674
+ // The panel draws behind the status bar on purpose, so it reads as a
675
+ // full-height surface rather than a card. That makes the inset the
676
+ // header's to clear, and it stacks with its own padding rather than
677
+ // being maxed against it.
678
+ style={[{ paddingTop: insets.top + 12 }, style]}
679
+ // `px-3` matches the scroller below it, so the search field and the rows
680
+ // share one edge. A header inset further would leave the field floating
681
+ // a few points inside the list it filters.
682
+ className={cn('gap-3 px-3 pb-3', className)}
683
+ {...props}
684
+ >
685
+ {(title || action) && (
686
+ <View className="h-9 flex-row items-center justify-between gap-2">
687
+ {title ? (
688
+ <Text size="xl" weight="semibold" numberOfLines={1} className="flex-1">
689
+ {title}
690
+ </Text>
691
+ ) : (
692
+ <View className="flex-1" />
693
+ )}
694
+ {action}
695
+ </View>
696
+ )}
697
+ {textChildren(children)}
698
+ </View>
699
+ );
700
+ }
701
+
702
+ export interface PanelsideSearchProps extends TextInputProps {
703
+ className?: string;
704
+ containerClassName?: string;
705
+ }
706
+
707
+ /**
708
+ * A compact filter field for the panel.
709
+ *
710
+ * Deliberately not the library's `Input`: that field carries a label, a
711
+ * description, an error slot and keyboard avoidance, none of which a panel
712
+ * search row wants, and all of which would have to be switched off at every
713
+ * call site.
714
+ */
715
+ function PanelsideSearch({
716
+ className,
717
+ containerClassName,
718
+ placeholder = 'Search',
719
+ ...props
720
+ }: PanelsideSearchProps) {
721
+ const placeholderTint = useCSSVariable('--color-muted-foreground');
722
+ const textTint = useCSSVariable('--color-foreground');
723
+ const muted = typeof placeholderTint === 'string' ? placeholderTint : undefined;
724
+
725
+ return (
726
+ <View
727
+ className={cn(
728
+ 'h-10 flex-row items-center gap-2 rounded-xl bg-secondary px-3',
729
+ containerClassName
730
+ )}
731
+ >
732
+ <SearchIcon size={16} color={muted} />
733
+ <TextInput
734
+ placeholder={placeholder}
735
+ placeholderTextColor={muted}
736
+ /*
737
+ * `text-[16px]`, not `text-base`. A `text-*` step sets a size and a
738
+ * line height together — 16px glyphs in a 24px line box — and in a
739
+ * field of fixed height the extra leading lands above them, so the
740
+ * text and the placeholder sit below the middle of the row. A length
741
+ * sets the size alone and leaves the line box the font's own.
742
+ */
743
+ className={cn('h-full flex-1 text-[16px] text-foreground', className)}
744
+ style={typeof textTint === 'string' ? { color: textTint } : undefined}
745
+ accessibilityRole="search"
746
+ returnKeyType="search"
747
+ clearButtonMode="while-editing"
748
+ {...props}
749
+ />
750
+ </View>
751
+ );
752
+ }
753
+
754
+ export interface PanelsideContentProps extends ScrollViewProps {
755
+ className?: string;
756
+ contentContainerClassName?: string;
757
+ children?: ReactNode;
758
+ }
759
+
760
+ function PanelsideContent({
761
+ className,
762
+ contentContainerClassName,
763
+ contentContainerStyle,
764
+ children,
765
+ ...props
766
+ }: PanelsideContentProps) {
767
+ const surface = useContext(PanelsideSurfaceContext);
768
+
769
+ return (
770
+ <ScrollView
771
+ className={cn('flex-1', className)}
772
+ contentContainerClassName={cn('gap-1 px-3 pb-3', contentContainerClassName)}
773
+ // Room for the footer, which floats over this list rather than taking a
774
+ // row below it — so the last item can be scrolled clear of the pill
775
+ // instead of living permanently underneath it.
776
+ contentContainerStyle={[{ paddingBottom: (surface?.footerHeight ?? 0) + 12 }, contentContainerStyle]}
777
+ showsVerticalScrollIndicator={false}
778
+ keyboardShouldPersistTaps="handled"
779
+ {...props}
780
+ >
781
+ {textChildren(children)}
782
+ </ScrollView>
783
+ );
784
+ }
785
+
786
+ export interface PanelsideGroupProps extends ViewProps {
787
+ className?: string;
788
+ children?: ReactNode;
789
+ }
790
+
791
+ function PanelsideGroup({ className, children, ...props }: PanelsideGroupProps) {
792
+ return (
793
+ <View className={cn('gap-0.5 pb-2', className)} {...props}>
794
+ {textChildren(children)}
795
+ </View>
796
+ );
797
+ }
798
+
799
+ export interface PanelsideGroupLabelProps extends ViewProps {
800
+ className?: string;
801
+ children?: ReactNode;
802
+ }
803
+
804
+ function PanelsideGroupLabel({ className, children, ...props }: PanelsideGroupLabelProps) {
805
+ return (
806
+ <View
807
+ className={cn('px-3 pb-1 pt-3', className)}
808
+ accessibilityRole="header"
809
+ {...props}
810
+ >
811
+ {textChildren(children, (text) => (
812
+ <Text size="xs" weight="medium" muted>
813
+ {text}
814
+ </Text>
815
+ ))}
816
+ </View>
817
+ );
818
+ }
819
+
820
+ export interface PanelsideItemProps extends Omit<PressableProps, 'children'> {
821
+ className?: string;
822
+ /** Leading element — an icon, an avatar, a coloured dot. */
823
+ icon?: ReactNode;
824
+ /** The row's text. Truncated to one line, since chat titles run long. */
825
+ label?: string;
826
+ /** Marks the row as the current destination. */
827
+ active?: boolean;
828
+ /**
829
+ * Trailing count or status. A number or string renders as a pill; anything
830
+ * else renders as given.
831
+ */
832
+ badge?: ReactNode;
833
+ disabled?: boolean;
834
+ /** Trailing content — usually a `Panelside.Action`. */
835
+ children?: ReactNode;
836
+ }
837
+
838
+ function PanelsideItem({
839
+ className,
840
+ icon,
841
+ label,
842
+ active = false,
843
+ badge,
844
+ disabled = false,
845
+ children,
846
+ ...props
847
+ }: PanelsideItemProps) {
848
+ const restTint = useCSSVariable('--color-muted-foreground');
849
+ const activeTint = useCSSVariable('--color-foreground');
850
+
851
+ const tint = active
852
+ ? typeof activeTint === 'string'
853
+ ? activeTint
854
+ : undefined
855
+ : typeof restTint === 'string'
856
+ ? restTint
857
+ : undefined;
858
+
859
+ return (
860
+ <AnimatedPressable
861
+ className={itemVariants({ active, disabled, className })}
862
+ disabled={disabled}
863
+ accessibilityRole="button"
864
+ accessibilityState={{ selected: active, disabled }}
865
+ accessibilityLabel={label}
866
+ pressScale={0.985}
867
+ {...props}
868
+ >
869
+ {/* Icons inherit the row's state rather than each caller passing a
870
+ colour that stops being right the moment the row goes active. */}
871
+ {icon ? <IconColorProvider color={tint}>{icon}</IconColorProvider> : null}
872
+
873
+ {label ? (
874
+ <Text
875
+ size="base"
876
+ weight={active ? 'medium' : 'normal'}
877
+ muted={!active}
878
+ numberOfLines={1}
879
+ className="flex-1"
880
+ >
881
+ {label}
882
+ </Text>
883
+ ) : (
884
+ <View className="flex-1" />
885
+ )}
886
+
887
+ {typeof badge === 'string' || typeof badge === 'number' ? (
888
+ <View className="rounded-full bg-secondary px-2 py-0.5">
889
+ <Text size="xs" muted>
890
+ {badge}
891
+ </Text>
892
+ </View>
893
+ ) : (
894
+ badge
895
+ )}
896
+
897
+ {children}
898
+ </AnimatedPressable>
899
+ );
900
+ }
901
+
902
+ export interface PanelsideActionProps extends Omit<PressableProps, 'children'> {
903
+ className?: string;
904
+ /**
905
+ * What a screen reader announces. The default control is an unlabelled glyph,
906
+ * so this is the only description it has.
907
+ */
908
+ label?: string;
909
+ /** Replaces the default overflow glyph. */
910
+ children?: ReactNode;
911
+ }
912
+
913
+ function PanelsideAction({
914
+ className,
915
+ label = 'More options',
916
+ children,
917
+ ...props
918
+ }: PanelsideActionProps) {
919
+ const tint = useCSSVariable('--color-muted-foreground');
920
+ const color = typeof tint === 'string' ? tint : undefined;
921
+
922
+ return (
923
+ <AnimatedPressable
924
+ className={cn('h-7 w-7 items-center justify-center rounded-lg', className)}
925
+ accessibilityRole="button"
926
+ accessibilityLabel={label}
927
+ // The glyph is small and sits next to a row-sized target, so it takes
928
+ // the difference back as slop rather than as layout.
929
+ hitSlop={8}
930
+ {...props}
931
+ >
932
+ {children ?? <EllipsisIcon size={18} color={color} />}
933
+ </AnimatedPressable>
934
+ );
935
+ }
936
+
937
+ export interface PanelsideFooterProps extends ViewProps {
938
+ className?: string;
939
+ /**
940
+ * Overlay the scrolling list instead of taking a row below it. Default true —
941
+ * the list runs the full height of the panel behind it, and `Panelside.Content`
942
+ * leaves exactly this footer's height of room at the end.
943
+ */
944
+ floating?: boolean;
945
+ children?: ReactNode;
946
+ }
947
+
948
+ function PanelsideFooter({
949
+ className,
950
+ floating = true,
951
+ children,
952
+ style,
953
+ ...props
954
+ }: PanelsideFooterProps) {
955
+ const insets = useSafeAreaInsets();
956
+ const surface = useContext(PanelsideSurfaceContext);
957
+ const setFooterHeight = surface?.setFooterHeight;
958
+ const background = useCSSVariable('--color-background');
959
+ const solid = typeof background === 'string' ? background : '#000000';
960
+
961
+ const onLayout = useCallback(
962
+ (event: LayoutChangeEvent) => {
963
+ setFooterHeight?.(event.nativeEvent.layout.height);
964
+ },
965
+ [setFooterHeight]
966
+ );
967
+
968
+ return (
969
+ <View
970
+ onLayout={floating ? onLayout : undefined}
971
+ style={[
972
+ { paddingBottom: Math.max(insets.bottom, 12) },
973
+ floating ? { paddingTop: FOOTER_FADE } : null,
974
+ style,
975
+ ]}
976
+ className={cn(
977
+ // `gap-3` and a wider inset: the compose control is the one thing in
978
+ // the panel that is not a list row, and a native one brings its own
979
+ // metrics — it needs room around it rather than the row spacing the
980
+ // list uses.
981
+ 'flex-row items-center gap-3 px-4',
982
+ floating ? 'absolute bottom-0 end-0 start-0' : 'border-t border-border bg-background pt-2',
983
+ className
984
+ )}
985
+ {...props}
986
+ >
987
+ {/*
988
+ A floating footer has no edge and no bar. A solid one cuts a strip out
989
+ of the bottom of the list; a transparent one lets rows slide under the
990
+ controls and show through the labels. So it is neither: the top
991
+ `FOOTER_FADE` points are a gradient the list dissolves into, and
992
+ everything below that — the band the controls actually sit in — is
993
+ plain background.
994
+
995
+ The fade has to finish *above* the first control, not run through it.
996
+ Two layers rather than one gradient across the whole box, because a
997
+ gradient sized to the box puts its midpoint wherever the box happens to
998
+ be tall, which is exactly where the labels are.
999
+
1000
+ Both are inside the footer's own bounds, so neither depends on a parent
1001
+ that does not clip its children.
1002
+ */}
1003
+ {floating ? (
1004
+ <>
1005
+ <LinearGradient
1006
+ colors={[`${solid}00`, solid]}
1007
+ start={{ x: 0, y: 0 }}
1008
+ end={{ x: 0, y: 1 }}
1009
+ pointerEvents="none"
1010
+ style={[styles.fade, { height: FOOTER_FADE }]}
1011
+ />
1012
+ <View
1013
+ pointerEvents="none"
1014
+ className="absolute bottom-0 end-0 start-0 bg-background"
1015
+ style={{ top: FOOTER_FADE }}
1016
+ />
1017
+ </>
1018
+ ) : null}
1019
+ {textChildren(children)}
1020
+ </View>
1021
+ );
1022
+ }
1023
+
1024
+ const styles = StyleSheet.create({
1025
+ fade: { position: 'absolute', top: 0, left: 0, right: 0 },
1026
+ });
1027
+
1028
+ export interface PanelsideCtaProps extends Omit<PressableProps, 'children'> {
1029
+ className?: string;
1030
+ /** The button's text. */
1031
+ label?: string;
1032
+ /** Leading element, usually an icon. */
1033
+ icon?: ReactNode;
1034
+ /** `primary` is the filled accent pill; `secondary` is the quiet one. */
1035
+ variant?: 'primary' | 'secondary';
1036
+ /**
1037
+ * Render the platform's own button instead of the pill. Requires the
1038
+ * optional `@expo/ui` package; without it this prop does nothing.
1039
+ *
1040
+ * **Theme tokens do not apply** — the platform draws the button, so
1041
+ * `className` and `icon` are ignored and it sizes itself to `label`.
1042
+ */
1043
+ native?: boolean;
1044
+ /**
1045
+ * Draw the native button in the platform's Liquid Glass material. Requires
1046
+ * `native`, and iOS 26 or later; ignored anywhere else.
1047
+ */
1048
+ glass?: boolean;
1049
+ children?: ReactNode;
1050
+ }
1051
+
1052
+ function PanelsideCta({
1053
+ className,
1054
+ label,
1055
+ icon,
1056
+ variant = 'primary',
1057
+ native = false,
1058
+ glass = false,
1059
+ disabled,
1060
+ children,
1061
+ ...props
1062
+ }: PanelsideCtaProps) {
1063
+ const primaryTint = useCSSVariable('--color-primary-foreground');
1064
+ const secondaryTint = useCSSVariable('--color-secondary-foreground');
1065
+ const raw = variant === 'primary' ? primaryTint : secondaryTint;
1066
+ const tint = typeof raw === 'string' ? raw : undefined;
1067
+
1068
+ /*
1069
+ * Delegated to Button rather than reaching for the native bridge here.
1070
+ * Button already resolves the package lazily, maps the variant onto the
1071
+ * platform's own style and falls back when it is missing — reimplementing
1072
+ * that would be a second copy to keep in step with the first.
1073
+ */
1074
+ if (native) {
1075
+ return (
1076
+ <Button
1077
+ native
1078
+ glass={glass}
1079
+ // The platform sizes a native button from its label, so the step up
1080
+ // has to be asked for rather than styled on.
1081
+ size="lg"
1082
+ variant={variant}
1083
+ accessibilityLabel={label}
1084
+ // Pressable allows `null` for disabled; Button does not.
1085
+ disabled={disabled ?? undefined}
1086
+ {...props}
1087
+ >
1088
+ {/*
1089
+ A plain string, not a hosted view.
1090
+
1091
+ An icon button pads its glyph in React, because the glyph *is* a
1092
+ React Native view and the platform draws the background around it.
1093
+ That works because an icon button is a square this component sizes,
1094
+ so the hosted view has a fixed reference to measure against. A label
1095
+ has neither half of that: a string is the platform's own text with no
1096
+ React view in it to pad, and hosting one to get a view leaves a width
1097
+ nothing knows in advance.
1098
+
1099
+ So this one is sized rather than padded — `size="lg"` reaches the
1100
+ platform as a control size, which scales the room the style leaves
1101
+ around the label and the label with it.
1102
+ */}
1103
+ {label ?? children}
1104
+ </Button>
1105
+ );
1106
+ }
1107
+
1108
+ return (
1109
+ <AnimatedPressable
1110
+ className={ctaVariants({ variant, className })}
1111
+ accessibilityRole="button"
1112
+ accessibilityLabel={label}
1113
+ disabled={disabled}
1114
+ {...props}
1115
+ >
1116
+ <IconColorProvider color={tint}>
1117
+ {icon}
1118
+ {label ? (
1119
+ <Text
1120
+ size="lg"
1121
+ weight="medium"
1122
+ // The pill gives way before the panel does, so the label has to be
1123
+ // able to end somewhere rather than pushing the button off the edge.
1124
+ numberOfLines={1}
1125
+ className={cn(
1126
+ 'shrink',
1127
+ variant === 'primary' ? 'text-primary-foreground' : 'text-secondary-foreground'
1128
+ )}
1129
+ >
1130
+ {label}
1131
+ </Text>
1132
+ ) : null}
1133
+ {textChildren(children)}
1134
+ </IconColorProvider>
1135
+ </AnimatedPressable>
1136
+ );
1137
+ }
1138
+
1139
+ export interface PanelsideSceneProps extends ViewProps {
1140
+ className?: string;
1141
+ /**
1142
+ * How small the scene gets at full travel. Default 1 — the screen keeps its
1143
+ * full height and stays behind the status bar, and the radius and dim do the
1144
+ * work. Below one it shrinks about its centre, which insets it top and bottom
1145
+ * as well as at the side.
1146
+ */
1147
+ scale?: number;
1148
+ /** The corner radius the scene reaches at full travel. Default 44. */
1149
+ radius?: number;
1150
+ /** How far the scene dims at full travel, 0 to 1. Default 0.45. */
1151
+ dim?: number;
1152
+ children?: ReactNode;
1153
+ }
1154
+
1155
+ function PanelsideScene({
1156
+ className,
1157
+ scale = SCENE_SCALE,
1158
+ radius = SCENE_RADIUS,
1159
+ dim = SCENE_DIM,
1160
+ children,
1161
+ style,
1162
+ ...props
1163
+ }: PanelsideSceneProps) {
1164
+ const { progress, width, mode, docked, dismissible, open, setOpen } =
1165
+ usePanelsideContext('Panelside.Scene');
1166
+ const [sceneWidth, setSceneWidth] = useState(0);
1167
+ const sign = useDirectionSign();
1168
+ /*
1169
+ * The same border token every other edge in the library is drawn in, so this
1170
+ * one belongs to the same set rather than being a line of its own invention.
1171
+ * It already inverts with the theme — white at 6% in a dark one, black at 8%
1172
+ * in a light one — which is what makes it read on both sides of a boundary
1173
+ * between two surfaces of the same colour.
1174
+ *
1175
+ * It only failed to show before because it was drawn *under* the scrim. Above
1176
+ * it, at a full point, the token is enough on its own.
1177
+ */
1178
+ const edge = useCSSVariable('--color-border');
1179
+ const edgeColor = typeof edge === 'string' ? edge : undefined;
1180
+
1181
+ const onLayout = useCallback((event: LayoutChangeEvent) => {
1182
+ setSceneWidth(event.nativeEvent.layout.width);
1183
+ }, []);
1184
+
1185
+ const close = useCallback(() => setOpen(false), [setOpen]);
1186
+
1187
+ /*
1188
+ * `pushes` rather than a branch inside the worklet, so the style always
1189
+ * returns the same set of properties. Reanimated keeps a property it has
1190
+ * seen once; dropping it from a later frame leaves the last value applied
1191
+ * instead of resetting it.
1192
+ */
1193
+ const pushes = mode === 'push' && !docked;
1194
+
1195
+ const animatedStyle = useAnimatedStyle(() => {
1196
+ const p = pushes ? progress.value : 0;
1197
+ const s = 1 - (1 - scale) * p;
1198
+ return {
1199
+ transform: [
1200
+ // Subtracting the inset a centre-origin scale already applied is what
1201
+ // lands the scene's visible edge on the panel's, rather than near it.
1202
+ { translateX: sign * (p * (width + GAP) - (sceneWidth * (1 - s)) / 2) },
1203
+ { scale: s },
1204
+ ],
1205
+ borderRadius: p * radius,
1206
+ };
1207
+ }, [pushes, radius, scale, sceneWidth, sign, width]);
1208
+
1209
+ const scrimStyle = useAnimatedStyle(() => {
1210
+ const p = docked ? 0 : progress.value;
1211
+ return { opacity: p * dim };
1212
+ }, [dim, docked]);
1213
+
1214
+ /*
1215
+ * The edge is drawn as its own layer rather than as a border on the scene.
1216
+ * A border is a layout property: put one on the scene itself and it insets
1217
+ * everything inside by its width for the whole life of the screen, open or
1218
+ * shut, to show a line that is only wanted while the panel is out. A ring
1219
+ * over the top costs nothing when it is invisible.
1220
+ */
1221
+ const ringStyle = useAnimatedStyle(() => {
1222
+ const p = docked ? 0 : progress.value;
1223
+ return { opacity: p * EDGE_OPACITY, borderRadius: p * radius };
1224
+ }, [docked, radius]);
1225
+
1226
+ const animatedProps = useAnimatedProps<ViewProps>(() => {
1227
+ const hidden = !docked && progress.value > 1 - HIDDEN_EPSILON;
1228
+ return Platform.OS === 'android'
1229
+ ? { importantForAccessibility: hidden ? 'no-hide-descendants' : 'auto' }
1230
+ : { accessibilityElementsHidden: hidden };
1231
+ }, [docked]);
1232
+
1233
+ return (
1234
+ <Animated.View
1235
+ onLayout={onLayout}
1236
+ className={cn('flex-1 overflow-hidden bg-background', className)}
1237
+ style={[animatedStyle, style]}
1238
+ {...props}
1239
+ >
1240
+ <Animated.View animatedProps={animatedProps} className="flex-1">
1241
+ {children}
1242
+ </Animated.View>
1243
+
1244
+ {/* Layered over the scene rather than under it, so it dims the app and
1245
+ catches the tap in the scene's own space — which means it inherits
1246
+ the corner radius instead of having to reproduce it.
1247
+
1248
+ `pointerEvents` is a prop driven by state, not an animated style.
1249
+ A view at zero opacity still takes touches, so getting this wrong
1250
+ does not look like anything — it silently eats every tap on the app,
1251
+ including the one on the button that opens the panel. */}
1252
+ <Animated.View
1253
+ pointerEvents={open && !docked ? 'auto' : 'none'}
1254
+ className="absolute bottom-0 end-0 start-0 top-0 bg-black"
1255
+ style={scrimStyle}
1256
+ >
1257
+ {dismissible ? (
1258
+ <Pressable
1259
+ onPress={close}
1260
+ className="flex-1"
1261
+ accessibilityRole="button"
1262
+ accessibilityLabel="Close navigation panel"
1263
+ />
1264
+ ) : null}
1265
+ </Animated.View>
1266
+
1267
+ {/* A hairline where the scene meets the panel. Without it two surfaces
1268
+ of the same colour meet at a corner and the radius is the only thing
1269
+ saying they are separate — which reads as a rendering artefact rather
1270
+ than as an edge. */}
1271
+ {edgeColor ? (
1272
+ <Animated.View
1273
+ pointerEvents="none"
1274
+ style={[
1275
+ StyleSheet.absoluteFill,
1276
+ { borderWidth: EDGE_WIDTH_PT, borderColor: edgeColor },
1277
+ ringStyle,
1278
+ ]}
1279
+ />
1280
+ ) : null}
1281
+ </Animated.View>
1282
+ );
1283
+ }
1284
+
1285
+ export interface PanelsideTriggerProps extends Omit<PressableProps, 'children'> {
1286
+ className?: string;
1287
+ /** What a screen reader announces. */
1288
+ label?: string;
1289
+ /**
1290
+ * A single pressable element to use instead of the default button. Its own
1291
+ * `onPress` still runs.
1292
+ */
1293
+ children?: ReactElement<{ onPress?: (...args: unknown[]) => void }>;
1294
+ }
1295
+
1296
+ function PanelsideTrigger({
1297
+ className,
1298
+ label = 'Open navigation panel',
1299
+ children,
1300
+ ...props
1301
+ }: PanelsideTriggerProps) {
1302
+ const { toggle, docked } = usePanelsideContext('Panelside.Trigger');
1303
+ const tint = useCSSVariable('--color-foreground');
1304
+ const color = typeof tint === 'string' ? tint : undefined;
1305
+
1306
+ // A docked panel is already open and cannot be closed, so a control for it
1307
+ // would be a button that does nothing.
1308
+ if (docked) return null;
1309
+
1310
+ if (children && isValidElement(children)) {
1311
+ return cloneElement(children, {
1312
+ onPress: (...args: unknown[]) => {
1313
+ children.props.onPress?.(...args);
1314
+ toggle();
1315
+ },
1316
+ });
1317
+ }
1318
+
1319
+ return (
1320
+ <AnimatedPressable
1321
+ onPress={toggle}
1322
+ className={cn('h-10 w-10 items-center justify-center rounded-full', className)}
1323
+ accessibilityRole="button"
1324
+ accessibilityLabel={label}
1325
+ {...props}
1326
+ >
1327
+ <MenuIcon size={20} color={color} />
1328
+ </AnimatedPressable>
1329
+ );
1330
+ }
1331
+
1332
+ PanelsidePanel.displayName = 'Panelside.Panel';
1333
+ PanelsideHeader.displayName = 'Panelside.Header';
1334
+ PanelsideSearch.displayName = 'Panelside.Search';
1335
+ PanelsideContent.displayName = 'Panelside.Content';
1336
+ PanelsideGroup.displayName = 'Panelside.Group';
1337
+ PanelsideGroupLabel.displayName = 'Panelside.GroupLabel';
1338
+ PanelsideItem.displayName = 'Panelside.Item';
1339
+ PanelsideAction.displayName = 'Panelside.Action';
1340
+ PanelsideFooter.displayName = 'Panelside.Footer';
1341
+ PanelsideCta.displayName = 'Panelside.Cta';
1342
+ PanelsideScene.displayName = 'Panelside.Scene';
1343
+ PanelsideTrigger.displayName = 'Panelside.Trigger';
1344
+
1345
+ export const Panelside = Object.assign(PanelsideRoot, {
1346
+ Panel: PanelsidePanel,
1347
+ Header: PanelsideHeader,
1348
+ Search: PanelsideSearch,
1349
+ Content: PanelsideContent,
1350
+ Group: PanelsideGroup,
1351
+ GroupLabel: PanelsideGroupLabel,
1352
+ Item: PanelsideItem,
1353
+ Action: PanelsideAction,
1354
+ Footer: PanelsideFooter,
1355
+ Cta: PanelsideCta,
1356
+ Scene: PanelsideScene,
1357
+ Trigger: PanelsideTrigger,
1358
+ });