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