panelui-native 0.30.0 → 0.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -2
- package/lib/module/components/button/index.js +106 -11
- package/lib/module/components/button/index.js.map +1 -1
- package/lib/module/components/calendar/index.js +107 -24
- package/lib/module/components/calendar/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/heatmap-chart/index.js +9 -8
- package/lib/module/components/heatmap-chart/index.js.map +1 -1
- package/lib/module/components/map/index.js +50 -4
- package/lib/module/components/map/index.js.map +1 -1
- package/lib/module/components/panelside/index.js +1024 -0
- package/lib/module/components/panelside/index.js.map +1 -0
- package/lib/module/components/plan/index.js +307 -21
- package/lib/module/components/plan/index.js.map +1 -1
- package/lib/module/components/post/index.js +992 -0
- package/lib/module/components/post/index.js.map +1 -0
- package/lib/module/components/response/index.js +296 -0
- package/lib/module/components/response/index.js.map +1 -0
- package/lib/module/components/response/markdown.js +463 -0
- package/lib/module/components/response/markdown.js.map +1 -0
- package/lib/module/components/select/index.js +111 -18
- package/lib/module/components/select/index.js.map +1 -1
- package/lib/module/components/table/index.js +632 -0
- package/lib/module/components/table/index.js.map +1 -0
- package/lib/module/icons/index.js +220 -0
- package/lib/module/icons/index.js.map +1 -1
- package/lib/module/index.js +6 -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/module/utils/date.js +129 -22
- package/lib/module/utils/date.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/calendar/index.d.ts +12 -3
- package/lib/typescript/src/components/calendar/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/heatmap-chart/index.d.ts.map +1 -1
- package/lib/typescript/src/components/map/index.d.ts +8 -1
- package/lib/typescript/src/components/map/index.d.ts.map +1 -1
- 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/plan/index.d.ts +121 -10
- package/lib/typescript/src/components/plan/index.d.ts.map +1 -1
- package/lib/typescript/src/components/post/index.d.ts +358 -0
- package/lib/typescript/src/components/post/index.d.ts.map +1 -0
- package/lib/typescript/src/components/response/index.d.ts +91 -0
- package/lib/typescript/src/components/response/index.d.ts.map +1 -0
- package/lib/typescript/src/components/response/markdown.d.ts +88 -0
- package/lib/typescript/src/components/response/markdown.d.ts.map +1 -0
- package/lib/typescript/src/components/select/index.d.ts +27 -2
- package/lib/typescript/src/components/select/index.d.ts.map +1 -1
- package/lib/typescript/src/components/table/index.d.ts +274 -0
- package/lib/typescript/src/components/table/index.d.ts.map +1 -0
- package/lib/typescript/src/icons/index.d.ts +41 -1
- package/lib/typescript/src/icons/index.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +7 -2
- 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/lib/typescript/src/utils/date.d.ts +49 -5
- package/lib/typescript/src/utils/date.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/button/index.tsx +140 -8
- package/src/components/calendar/index.tsx +136 -23
- package/src/components/drawer/index.tsx +662 -0
- package/src/components/heatmap-chart/index.tsx +9 -9
- package/src/components/map/index.tsx +53 -4
- package/src/components/panelside/index.tsx +1358 -0
- package/src/components/plan/index.tsx +344 -21
- package/src/components/post/index.tsx +1116 -0
- package/src/components/response/index.tsx +390 -0
- package/src/components/response/markdown.ts +489 -0
- package/src/components/select/index.tsx +155 -15
- package/src/components/table/index.tsx +747 -0
- package/src/icons/index.tsx +176 -1
- package/src/index.ts +93 -0
- package/src/native/index.ts +51 -0
- package/src/utils/date.ts +146 -22
|
@@ -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
|
+
});
|