panelui-native 0.34.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/progress/index.js +64 -24
- package/lib/module/components/progress/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/index.js +1 -0
- package/lib/module/index.js.map +1 -1
- 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/swipe/index.d.ts +206 -0
- package/lib/typescript/src/components/swipe/index.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +1 -0
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/progress/index.tsx +93 -25
- package/src/components/swipe/index.tsx +687 -0
- package/src/index.ts +9 -0
|
@@ -0,0 +1,687 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Swipe — a row that slides aside to reveal the things you can do to it.
|
|
3
|
+
*
|
|
4
|
+
* It is the one list interaction a phone has that a page does not: the actions
|
|
5
|
+
* are not on screen taking up room, they are behind the row, and the gesture
|
|
6
|
+
* that reveals them is the same one everywhere else in the OS. An inbox, a
|
|
7
|
+
* task list, a settings screen — all of them want it, and all of them
|
|
8
|
+
* otherwise end up with a trailing button too small to hit.
|
|
9
|
+
*
|
|
10
|
+
* ```tsx
|
|
11
|
+
* <Swipe>
|
|
12
|
+
* <Swipe.End>
|
|
13
|
+
* <Swipe.Action icon={<TrashIcon />} label="Delete" color="destructive" onPress={remove} />
|
|
14
|
+
* </Swipe.End>
|
|
15
|
+
* <Item variant="outline">
|
|
16
|
+
* <Item.Content><Item.Title>Invoice.pdf</Item.Title></Item.Content>
|
|
17
|
+
* </Item>
|
|
18
|
+
* </Swipe>
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* The sides are `start` and `end` rather than left and right, because the
|
|
22
|
+
* gesture mirrors with the reading direction: in a right-to-left app the row
|
|
23
|
+
* that opened toward the right has to open toward the left, and a caller
|
|
24
|
+
* should not have to write that twice. Yoga mirrors where the panels sit; the
|
|
25
|
+
* drag is measured in raw pixels and cannot be mirrored for us, so it reads
|
|
26
|
+
* the direction and turns itself around.
|
|
27
|
+
*
|
|
28
|
+
* Everything that moves runs on the UI thread. A row being dragged does not
|
|
29
|
+
* re-render — the only React work in a swipe is the callback at the end of it.
|
|
30
|
+
*/
|
|
31
|
+
import {
|
|
32
|
+
Children,
|
|
33
|
+
cloneElement,
|
|
34
|
+
createContext,
|
|
35
|
+
forwardRef,
|
|
36
|
+
isValidElement,
|
|
37
|
+
useCallback,
|
|
38
|
+
useContext,
|
|
39
|
+
useImperativeHandle,
|
|
40
|
+
useMemo,
|
|
41
|
+
useRef,
|
|
42
|
+
type ReactElement,
|
|
43
|
+
type ReactNode,
|
|
44
|
+
} from 'react';
|
|
45
|
+
import { View, type LayoutChangeEvent, type ViewProps } from 'react-native';
|
|
46
|
+
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
|
|
47
|
+
import Animated, {
|
|
48
|
+
runOnJS,
|
|
49
|
+
useAnimatedStyle,
|
|
50
|
+
useSharedValue,
|
|
51
|
+
withSpring,
|
|
52
|
+
type SharedValue,
|
|
53
|
+
} from 'react-native-reanimated';
|
|
54
|
+
import { useCSSVariable } from 'uniwind';
|
|
55
|
+
import { tv, type VariantProps } from 'tailwind-variants';
|
|
56
|
+
import { useDirectionSign } from '../../hooks/use-direction';
|
|
57
|
+
import { IconColorProvider } from '../../icons';
|
|
58
|
+
import { AnimatedPressable } from '../../primitives/animated-pressable';
|
|
59
|
+
import { Text } from '../../primitives/text';
|
|
60
|
+
import { cn } from '../../utils/cn';
|
|
61
|
+
import { selectionTick } from '../../utils/haptics';
|
|
62
|
+
|
|
63
|
+
const SPRING = { damping: 22, stiffness: 220, mass: 0.7 } as const;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Past its own width a panel has nothing left to reveal, so the row is let go
|
|
67
|
+
* of gradually rather than stopped dead. 8 is where the rubber band reads as
|
|
68
|
+
* resistance rather than as something broken.
|
|
69
|
+
*/
|
|
70
|
+
const OVERSHOOT_FRICTION = 8;
|
|
71
|
+
|
|
72
|
+
/** How far past the panel a drag has to reach before a release fires an action. */
|
|
73
|
+
const FULL_SWIPE_RATIO = 1.6;
|
|
74
|
+
|
|
75
|
+
/** Fraction of the panel a release has to clear for the row to stay open. */
|
|
76
|
+
const OPEN_RATIO = 0.5;
|
|
77
|
+
|
|
78
|
+
/** How much of a fling to count as distance already travelled, in seconds. */
|
|
79
|
+
const VELOCITY_LOOKAHEAD = 0.12;
|
|
80
|
+
|
|
81
|
+
type SwipeSide = 'start' | 'end';
|
|
82
|
+
|
|
83
|
+
/** The side a row is open on, or `null` while it is closed. */
|
|
84
|
+
export type SwipeOpenSide = SwipeSide | null;
|
|
85
|
+
|
|
86
|
+
export interface SwipeHandle {
|
|
87
|
+
/** Slide the row aside to reveal one side's actions. */
|
|
88
|
+
open: (side: SwipeSide) => void;
|
|
89
|
+
/** Put the row back. */
|
|
90
|
+
close: () => void;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
interface SwipeContextValue {
|
|
94
|
+
close: () => void;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const SwipeContext = createContext<SwipeContextValue | null>(null);
|
|
98
|
+
|
|
99
|
+
/* -------------------------------------------------------------------------- */
|
|
100
|
+
/* Action */
|
|
101
|
+
/* -------------------------------------------------------------------------- */
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* A tile is a filled block of colour with its content laid over it, which
|
|
105
|
+
* settles both halves of how it is coloured:
|
|
106
|
+
*
|
|
107
|
+
* - The fill is the status colour at full strength, never a tint of it. A tile
|
|
108
|
+
* only exists while the row is out of the way, so it has to be legible in the
|
|
109
|
+
* moment it appears; a 6%-alpha wash of the row's own background is not a
|
|
110
|
+
* tile at all, it is a hole with a glyph floating in it.
|
|
111
|
+
* - The content is white, because the status colours are chosen to be carried
|
|
112
|
+
* at full strength with white over them. The `-foreground` token of a status
|
|
113
|
+
* is the *darker text* form of that hue, meant for a neutral surface — laid
|
|
114
|
+
* over the fill it is the same hue twice and the label all but disappears.
|
|
115
|
+
*
|
|
116
|
+
* `default` and `primary` are the two that cannot take white. `primary`
|
|
117
|
+
* inverts with the theme and owns a true on-primary token, so it uses it;
|
|
118
|
+
* `default` is a mid grey in every theme, and takes the background colour,
|
|
119
|
+
* which is the neutral furthest from it in whichever direction the theme runs.
|
|
120
|
+
*/
|
|
121
|
+
const actionVariants = tv({
|
|
122
|
+
slots: {
|
|
123
|
+
root: 'h-full min-w-[80px] items-center justify-center gap-1.5 px-4',
|
|
124
|
+
label: 'text-center text-xs font-semibold',
|
|
125
|
+
},
|
|
126
|
+
variants: {
|
|
127
|
+
color: {
|
|
128
|
+
default: { root: 'bg-muted-foreground', label: 'text-background' },
|
|
129
|
+
primary: { root: 'bg-primary', label: 'text-primary-foreground' },
|
|
130
|
+
success: { root: 'bg-success', label: 'text-white' },
|
|
131
|
+
warning: { root: 'bg-warning', label: 'text-white' },
|
|
132
|
+
info: { root: 'bg-info', label: 'text-white' },
|
|
133
|
+
destructive: { root: 'bg-destructive', label: 'text-white' },
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
defaultVariants: {
|
|
137
|
+
color: 'default',
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
export type SwipeActionColor =
|
|
142
|
+
| 'default'
|
|
143
|
+
| 'primary'
|
|
144
|
+
| 'success'
|
|
145
|
+
| 'warning'
|
|
146
|
+
| 'info'
|
|
147
|
+
| 'destructive';
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The fill a panel takes behind its tiles, so that dragging past the tiles
|
|
151
|
+
* extends the outermost action's colour instead of opening a hole.
|
|
152
|
+
*/
|
|
153
|
+
const PANEL_FILL: Record<SwipeActionColor, string> = {
|
|
154
|
+
default: 'bg-muted-foreground',
|
|
155
|
+
primary: 'bg-primary',
|
|
156
|
+
success: 'bg-success',
|
|
157
|
+
warning: 'bg-warning',
|
|
158
|
+
info: 'bg-info',
|
|
159
|
+
destructive: 'bg-destructive',
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
export interface SwipeActionProps
|
|
163
|
+
extends Omit<ViewProps, 'children'>,
|
|
164
|
+
VariantProps<typeof actionVariants> {
|
|
165
|
+
className?: string;
|
|
166
|
+
/** What the action does. Also what a screen reader is offered. */
|
|
167
|
+
label: string;
|
|
168
|
+
/**
|
|
169
|
+
* Drawn above the label and tinted to match it. Pass the glyph, not a colour
|
|
170
|
+
* and not a size — a tile sizes it to read at a glance, since it is the part
|
|
171
|
+
* of an action the eye reaches before the word underneath it.
|
|
172
|
+
*/
|
|
173
|
+
icon?: ReactNode;
|
|
174
|
+
/** Run when the tile is tapped, or when a full swipe reaches it. */
|
|
175
|
+
onPress?: () => void;
|
|
176
|
+
/**
|
|
177
|
+
* Leave the row open after the action runs. Off by default: an action that
|
|
178
|
+
* has already happened has nothing left to offer, and a row left standing
|
|
179
|
+
* open is the most common way a swipe list ends up feeling stuck.
|
|
180
|
+
*/
|
|
181
|
+
keepOpen?: boolean;
|
|
182
|
+
/** Extra classes for the label. */
|
|
183
|
+
labelClassName?: string;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* One tile behind the row. Sized by its own content down to a minimum wide
|
|
188
|
+
* enough to hit, so a one-word action and a two-word one still line up.
|
|
189
|
+
*/
|
|
190
|
+
const SwipeAction = forwardRef<View, SwipeActionProps>(
|
|
191
|
+
(
|
|
192
|
+
{
|
|
193
|
+
className,
|
|
194
|
+
labelClassName,
|
|
195
|
+
color = 'default',
|
|
196
|
+
label,
|
|
197
|
+
icon,
|
|
198
|
+
onPress,
|
|
199
|
+
keepOpen = false,
|
|
200
|
+
...props
|
|
201
|
+
},
|
|
202
|
+
ref
|
|
203
|
+
) => {
|
|
204
|
+
const context = useContext(SwipeContext);
|
|
205
|
+
const slots = actionVariants({ color });
|
|
206
|
+
const tint = useActionTint(color);
|
|
207
|
+
|
|
208
|
+
return (
|
|
209
|
+
<AnimatedPressable
|
|
210
|
+
ref={ref}
|
|
211
|
+
accessibilityRole="button"
|
|
212
|
+
accessibilityLabel={label}
|
|
213
|
+
onPress={() => {
|
|
214
|
+
if (!keepOpen) context?.close();
|
|
215
|
+
onPress?.();
|
|
216
|
+
}}
|
|
217
|
+
className={slots.root({ className })}
|
|
218
|
+
{...props}
|
|
219
|
+
>
|
|
220
|
+
{icon ? (
|
|
221
|
+
<IconColorProvider color={tint}>{sizeIcon(icon)}</IconColorProvider>
|
|
222
|
+
) : null}
|
|
223
|
+
<Text className={slots.label({ className: labelClassName })} numberOfLines={1}>
|
|
224
|
+
{label}
|
|
225
|
+
</Text>
|
|
226
|
+
</AnimatedPressable>
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
);
|
|
230
|
+
SwipeAction.displayName = 'Swipe.Action';
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* How big a glyph is drawn on a tile. The icons default to the 16 that suits
|
|
234
|
+
* them inline in a row of text, which is too small here: a tile is 80 wide and
|
|
235
|
+
* mostly empty, and the icon is the part of it read first — at 16 it looks
|
|
236
|
+
* like a mistake rather than a target.
|
|
237
|
+
*/
|
|
238
|
+
const ICON_SIZE = 22;
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* The icon at tile size, unless the caller asked for one. Sizing it here rather
|
|
242
|
+
* than asking every call site to is what keeps two tiles beside each other
|
|
243
|
+
* matching, which is the whole reason a panel of them reads as a set.
|
|
244
|
+
*/
|
|
245
|
+
function sizeIcon(icon: ReactNode): ReactNode {
|
|
246
|
+
if (!isValidElement<{ size?: number }>(icon)) return icon;
|
|
247
|
+
if (icon.props.size !== undefined) return icon;
|
|
248
|
+
return cloneElement(icon, { size: ICON_SIZE });
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* The colour a tile's glyph is drawn in — the same colour as its label, so the
|
|
253
|
+
* two read as one thing. Resolved from the theme rather than written down as a
|
|
254
|
+
* hex wherever the theme has an answer, since a hex stops being right the
|
|
255
|
+
* moment the theme inverts; white is the exception, because a status fill is
|
|
256
|
+
* the same saturated colour in every theme and white is what it carries.
|
|
257
|
+
*
|
|
258
|
+
* Both tokens are resolved on every render because a hook cannot be called for
|
|
259
|
+
* one branch only. They are variable lookups, not work.
|
|
260
|
+
*/
|
|
261
|
+
function useActionTint(color: SwipeActionColor): string | undefined {
|
|
262
|
+
const background = useCSSVariable('--color-background');
|
|
263
|
+
const primary = useCSSVariable('--color-primary-foreground');
|
|
264
|
+
|
|
265
|
+
if (color === 'default') return typeof background === 'string' ? background : undefined;
|
|
266
|
+
if (color === 'primary') return typeof primary === 'string' ? primary : undefined;
|
|
267
|
+
return '#ffffff';
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/* -------------------------------------------------------------------------- */
|
|
271
|
+
/* Panels */
|
|
272
|
+
/* -------------------------------------------------------------------------- */
|
|
273
|
+
|
|
274
|
+
export interface SwipePanelProps extends Omit<ViewProps, 'children'> {
|
|
275
|
+
className?: string;
|
|
276
|
+
children?: ReactNode;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* The panels are markers rather than renderers: the root lifts their children
|
|
281
|
+
* out and lays them out itself, because it is the root that knows how wide the
|
|
282
|
+
* gap behind the row currently is. Declaring them as elements is still how a
|
|
283
|
+
* caller says which side an action belongs to, and it keeps both sides
|
|
284
|
+
* readable in source rather than hidden inside two render props.
|
|
285
|
+
*/
|
|
286
|
+
const SwipeStart = forwardRef<View, SwipePanelProps>(() => null);
|
|
287
|
+
SwipeStart.displayName = 'Swipe.Start';
|
|
288
|
+
|
|
289
|
+
const SwipeEnd = forwardRef<View, SwipePanelProps>(() => null);
|
|
290
|
+
SwipeEnd.displayName = 'Swipe.End';
|
|
291
|
+
|
|
292
|
+
/* -------------------------------------------------------------------------- */
|
|
293
|
+
/* Reading the declared actions */
|
|
294
|
+
/* -------------------------------------------------------------------------- */
|
|
295
|
+
|
|
296
|
+
interface DeclaredAction {
|
|
297
|
+
label: string;
|
|
298
|
+
color: SwipeActionColor;
|
|
299
|
+
onPress?: () => void;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* The actions a panel declared, in source order. Only direct `Swipe.Action`
|
|
304
|
+
* children count — anything else in a panel is decoration, and calling a
|
|
305
|
+
* stranger's `onPress` because it happened to have one would be worse than
|
|
306
|
+
* ignoring it.
|
|
307
|
+
*/
|
|
308
|
+
function collectActions(node: ReactNode): DeclaredAction[] {
|
|
309
|
+
const found: DeclaredAction[] = [];
|
|
310
|
+
|
|
311
|
+
for (const child of Children.toArray(node)) {
|
|
312
|
+
if (!isValidElement(child) || child.type !== SwipeAction) continue;
|
|
313
|
+
const { label, color, onPress } = (child as ReactElement<SwipeActionProps>).props;
|
|
314
|
+
found.push({ label, color: (color as SwipeActionColor) ?? 'default', onPress });
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return found;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/* -------------------------------------------------------------------------- */
|
|
321
|
+
/* Root */
|
|
322
|
+
/* -------------------------------------------------------------------------- */
|
|
323
|
+
|
|
324
|
+
export interface SwipeProps extends Omit<ViewProps, 'children'> {
|
|
325
|
+
className?: string;
|
|
326
|
+
/**
|
|
327
|
+
* The row itself, plus a `Swipe.Start` and/or `Swipe.End` holding its
|
|
328
|
+
* actions. Order does not matter — the panels are recognised by type.
|
|
329
|
+
*/
|
|
330
|
+
children?: ReactNode;
|
|
331
|
+
/**
|
|
332
|
+
* Let a drag carried well past the panel fire its outermost action on
|
|
333
|
+
* release, without the tile ever being tapped. On by default, and the reason
|
|
334
|
+
* the far end of a panel is the destructive slot by convention.
|
|
335
|
+
*/
|
|
336
|
+
fullSwipe?: boolean;
|
|
337
|
+
/** Turn the gesture off and leave the row static. The tiles stay tappable. */
|
|
338
|
+
disabled?: boolean;
|
|
339
|
+
/** Tick when a drag crosses the point at which letting go fires an action. */
|
|
340
|
+
haptics?: boolean;
|
|
341
|
+
/** Told which side opened, or `null` when the row closed. */
|
|
342
|
+
onOpenChange?: (side: SwipeOpenSide) => void;
|
|
343
|
+
/** Extra classes for the moving row. */
|
|
344
|
+
contentClassName?: string;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* A row that opens sideways.
|
|
349
|
+
*
|
|
350
|
+
* The panels sit behind the row rather than beside it, so nothing about the
|
|
351
|
+
* layout changes when one opens: the row is the only thing that moves, and it
|
|
352
|
+
* moves as a transform. Each panel stretches to exactly the gap the row has
|
|
353
|
+
* left behind, which is what keeps an overshoot showing the outermost action's
|
|
354
|
+
* colour rather than a hole through to the screen underneath.
|
|
355
|
+
*/
|
|
356
|
+
const SwipeRoot = forwardRef<SwipeHandle, SwipeProps>(
|
|
357
|
+
(
|
|
358
|
+
{
|
|
359
|
+
className,
|
|
360
|
+
contentClassName,
|
|
361
|
+
children,
|
|
362
|
+
fullSwipe = true,
|
|
363
|
+
disabled = false,
|
|
364
|
+
haptics = false,
|
|
365
|
+
onOpenChange,
|
|
366
|
+
...props
|
|
367
|
+
},
|
|
368
|
+
ref
|
|
369
|
+
) => {
|
|
370
|
+
const sign = useDirectionSign();
|
|
371
|
+
|
|
372
|
+
/** Logical offset of the row in points; positive reveals the start side. */
|
|
373
|
+
const offset = useSharedValue(0);
|
|
374
|
+
/**
|
|
375
|
+
* Where the offset stood when this drag began. A pan reports translation
|
|
376
|
+
* from the touch down, so without it a row that is already open snaps shut
|
|
377
|
+
* the instant a second drag starts.
|
|
378
|
+
*/
|
|
379
|
+
const origin = useSharedValue(0);
|
|
380
|
+
/** Natural width of each panel's tiles, measured once and then only read. */
|
|
381
|
+
const startWidth = useSharedValue(0);
|
|
382
|
+
const endWidth = useSharedValue(0);
|
|
383
|
+
/** Whether a full swipe is currently armed, so the tick fires just once. */
|
|
384
|
+
const armed = useSharedValue(false);
|
|
385
|
+
|
|
386
|
+
const { startNode, endNode, row } = useMemo(() => {
|
|
387
|
+
let start: ReactNode = null;
|
|
388
|
+
let end: ReactNode = null;
|
|
389
|
+
const rest: ReactNode[] = [];
|
|
390
|
+
|
|
391
|
+
for (const child of Children.toArray(children)) {
|
|
392
|
+
if (isValidElement(child) && child.type === SwipeStart) {
|
|
393
|
+
start = (child as ReactElement<SwipePanelProps>).props.children;
|
|
394
|
+
} else if (isValidElement(child) && child.type === SwipeEnd) {
|
|
395
|
+
end = (child as ReactElement<SwipePanelProps>).props.children;
|
|
396
|
+
} else {
|
|
397
|
+
rest.push(child);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
return { startNode: start, endNode: end, row: rest };
|
|
402
|
+
}, [children]);
|
|
403
|
+
|
|
404
|
+
const startActions = useMemo(() => collectActions(startNode), [startNode]);
|
|
405
|
+
const endActions = useMemo(() => collectActions(endNode), [endNode]);
|
|
406
|
+
|
|
407
|
+
const hasStart = startNode != null;
|
|
408
|
+
const hasEnd = endNode != null;
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* The side the row is open on, held in a ref rather than in state: nothing
|
|
412
|
+
* here renders from it, and a row that re-rendered every time a drag
|
|
413
|
+
* settled would be paying for the one thing this component exists to
|
|
414
|
+
* avoid.
|
|
415
|
+
*/
|
|
416
|
+
const openSide = useRef<SwipeOpenSide>(null);
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Only a genuine change is reported. The gesture cannot know whether the
|
|
420
|
+
* row was already closed when it settles it closed, so the comparison has
|
|
421
|
+
* to happen here rather than at each call site.
|
|
422
|
+
*
|
|
423
|
+
* The comparison and the callback both have to sit outside React's state,
|
|
424
|
+
* because `onOpenChange` is the caller's and will usually set state of its
|
|
425
|
+
* own. Run from inside a state updater — which React is free to call while
|
|
426
|
+
* rendering — that lands as a set during another component's render, and
|
|
427
|
+
* React says so.
|
|
428
|
+
*/
|
|
429
|
+
const reportOpen = useCallback(
|
|
430
|
+
(side: SwipeOpenSide) => {
|
|
431
|
+
if (openSide.current === side) return;
|
|
432
|
+
openSide.current = side;
|
|
433
|
+
onOpenChange?.(side);
|
|
434
|
+
},
|
|
435
|
+
[onOpenChange]
|
|
436
|
+
);
|
|
437
|
+
|
|
438
|
+
const close = useCallback(() => {
|
|
439
|
+
offset.value = withSpring(0, SPRING);
|
|
440
|
+
reportOpen(null);
|
|
441
|
+
}, [offset, reportOpen]);
|
|
442
|
+
|
|
443
|
+
useImperativeHandle(
|
|
444
|
+
ref,
|
|
445
|
+
() => ({
|
|
446
|
+
open: (side) => {
|
|
447
|
+
const width = side === 'start' ? startWidth.value : endWidth.value;
|
|
448
|
+
if (width === 0) return;
|
|
449
|
+
offset.value = withSpring(side === 'start' ? width : -width, SPRING);
|
|
450
|
+
reportOpen(side);
|
|
451
|
+
},
|
|
452
|
+
close,
|
|
453
|
+
}),
|
|
454
|
+
[offset, startWidth, endWidth, reportOpen, close]
|
|
455
|
+
);
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* The action a full swipe fires: the one furthest from the row, since that
|
|
459
|
+
* is the one the gesture travelled all the way to. The panels pack their
|
|
460
|
+
* tiles against the row, so on the start side that is the first declared
|
|
461
|
+
* and on the end side the last.
|
|
462
|
+
*/
|
|
463
|
+
const fire = useCallback(
|
|
464
|
+
(side: SwipeSide) => {
|
|
465
|
+
const action =
|
|
466
|
+
side === 'start' ? startActions[0] : endActions[endActions.length - 1];
|
|
467
|
+
action?.onPress?.();
|
|
468
|
+
},
|
|
469
|
+
[startActions, endActions]
|
|
470
|
+
);
|
|
471
|
+
|
|
472
|
+
const tick = useCallback(() => selectionTick(), []);
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* The drag has to lose to a scroll, or a list of swipeable rows could not
|
|
476
|
+
* be scrolled without rows twitching open under the finger.
|
|
477
|
+
* `activeOffsetX` makes it wait for clearly horizontal intent, and
|
|
478
|
+
* `failOffsetY` hands the touch to the scroller outright the moment the
|
|
479
|
+
* finger commits vertically.
|
|
480
|
+
*/
|
|
481
|
+
const pan = useMemo(
|
|
482
|
+
() =>
|
|
483
|
+
Gesture.Pan()
|
|
484
|
+
.enabled(!disabled && (hasStart || hasEnd))
|
|
485
|
+
.activeOffsetX([-12, 12])
|
|
486
|
+
.failOffsetY([-14, 14])
|
|
487
|
+
.onBegin(() => {
|
|
488
|
+
origin.value = offset.value;
|
|
489
|
+
armed.value = false;
|
|
490
|
+
})
|
|
491
|
+
.onUpdate((event) => {
|
|
492
|
+
// The gesture is raw pixels and the offset is logical, so a
|
|
493
|
+
// right-to-left subtree runs all of this backwards.
|
|
494
|
+
const next = origin.value + event.translationX * sign;
|
|
495
|
+
const limit = next > 0 ? startWidth.value : endWidth.value;
|
|
496
|
+
|
|
497
|
+
if (limit === 0) {
|
|
498
|
+
// Nothing to reveal on this side: hold the row at rest rather
|
|
499
|
+
// than letting it drift open over an empty panel.
|
|
500
|
+
offset.value = 0;
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const beyond = Math.abs(next) - limit;
|
|
505
|
+
offset.value =
|
|
506
|
+
beyond > 0
|
|
507
|
+
? Math.sign(next) * (limit + beyond / OVERSHOOT_FRICTION)
|
|
508
|
+
: next;
|
|
509
|
+
|
|
510
|
+
if (!fullSwipe) return;
|
|
511
|
+
const reached = Math.abs(offset.value) > limit * FULL_SWIPE_RATIO;
|
|
512
|
+
if (reached !== armed.value) {
|
|
513
|
+
armed.value = reached;
|
|
514
|
+
if (reached && haptics) runOnJS(tick)();
|
|
515
|
+
}
|
|
516
|
+
})
|
|
517
|
+
.onEnd((event) => {
|
|
518
|
+
const current = offset.value;
|
|
519
|
+
const toStart = current > 0;
|
|
520
|
+
const side: SwipeSide = toStart ? 'start' : 'end';
|
|
521
|
+
const limit = toStart ? startWidth.value : endWidth.value;
|
|
522
|
+
|
|
523
|
+
if (limit === 0) {
|
|
524
|
+
offset.value = withSpring(0, SPRING);
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
if (fullSwipe && armed.value) {
|
|
529
|
+
armed.value = false;
|
|
530
|
+
offset.value = withSpring(0, SPRING);
|
|
531
|
+
runOnJS(fire)(side);
|
|
532
|
+
runOnJS(reportOpen)(null);
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// Part of the fling counts as distance already covered, so a short
|
|
537
|
+
// flick opens the row where a slow drag to the same point does not.
|
|
538
|
+
const projected = Math.abs(
|
|
539
|
+
current + event.velocityX * sign * VELOCITY_LOOKAHEAD
|
|
540
|
+
);
|
|
541
|
+
|
|
542
|
+
if (projected > limit * OPEN_RATIO) {
|
|
543
|
+
offset.value = withSpring(toStart ? limit : -limit, SPRING);
|
|
544
|
+
runOnJS(reportOpen)(side);
|
|
545
|
+
} else {
|
|
546
|
+
offset.value = withSpring(0, SPRING);
|
|
547
|
+
runOnJS(reportOpen)(null);
|
|
548
|
+
}
|
|
549
|
+
}),
|
|
550
|
+
[
|
|
551
|
+
disabled,
|
|
552
|
+
hasStart,
|
|
553
|
+
hasEnd,
|
|
554
|
+
sign,
|
|
555
|
+
fullSwipe,
|
|
556
|
+
haptics,
|
|
557
|
+
offset,
|
|
558
|
+
origin,
|
|
559
|
+
armed,
|
|
560
|
+
startWidth,
|
|
561
|
+
endWidth,
|
|
562
|
+
fire,
|
|
563
|
+
tick,
|
|
564
|
+
reportOpen,
|
|
565
|
+
]
|
|
566
|
+
);
|
|
567
|
+
|
|
568
|
+
const contentStyle = useAnimatedStyle(() => ({
|
|
569
|
+
transform: [{ translateX: offset.value * sign }],
|
|
570
|
+
}));
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* A panel is exactly the gap the row has left behind — no wider, so it can
|
|
574
|
+
* never paint over the row, and no narrower, so an overshoot extends the
|
|
575
|
+
* outermost action's colour instead of opening a hole onto the screen
|
|
576
|
+
* underneath. At rest both are zero wide and neither is on screen at all.
|
|
577
|
+
*/
|
|
578
|
+
const startStyle = useAnimatedStyle(() => ({
|
|
579
|
+
width: Math.max(offset.value, 0),
|
|
580
|
+
}));
|
|
581
|
+
|
|
582
|
+
const endStyle = useAnimatedStyle(() => ({
|
|
583
|
+
width: Math.max(-offset.value, 0),
|
|
584
|
+
}));
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Measured from the tiles rather than from the panel around them. The panel
|
|
588
|
+
* is as wide as the gesture has made it, which is the number being derived
|
|
589
|
+
* here — reading it back off the panel would only ever report the animation
|
|
590
|
+
* to itself, and the width would stay at the zero it starts from.
|
|
591
|
+
*/
|
|
592
|
+
const measure = (target: SharedValue<number>) => (event: LayoutChangeEvent) => {
|
|
593
|
+
target.value = event.nativeEvent.layout.width;
|
|
594
|
+
};
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* A swipe is invisible to a screen reader, so every action is offered as an
|
|
598
|
+
* accessibility action on the row as well. That is the whole of the
|
|
599
|
+
* alternative path: there is no other way to reach a tile that stays off
|
|
600
|
+
* screen until a gesture nobody announced has been performed.
|
|
601
|
+
*/
|
|
602
|
+
const allActions = useMemo(
|
|
603
|
+
() => [...startActions, ...endActions],
|
|
604
|
+
[startActions, endActions]
|
|
605
|
+
);
|
|
606
|
+
|
|
607
|
+
const a11yActions = useMemo(
|
|
608
|
+
() => allActions.map(({ label }) => ({ name: label, label })),
|
|
609
|
+
[allActions]
|
|
610
|
+
);
|
|
611
|
+
|
|
612
|
+
const context = useMemo(() => ({ close }), [close]);
|
|
613
|
+
|
|
614
|
+
return (
|
|
615
|
+
<SwipeContext.Provider value={context}>
|
|
616
|
+
<View
|
|
617
|
+
accessibilityActions={a11yActions.length > 0 ? a11yActions : undefined}
|
|
618
|
+
onAccessibilityAction={(event) => {
|
|
619
|
+
allActions
|
|
620
|
+
.find(({ label }) => label === event.nativeEvent.actionName)
|
|
621
|
+
?.onPress?.();
|
|
622
|
+
}}
|
|
623
|
+
className={cn('relative w-full overflow-hidden', className)}
|
|
624
|
+
{...props}
|
|
625
|
+
>
|
|
626
|
+
{hasStart ? (
|
|
627
|
+
// Yoga puts the panel on the edge text begins at and mirrors it for
|
|
628
|
+
// us; only the transform above had to be turned around.
|
|
629
|
+
<Animated.View
|
|
630
|
+
className={cn(
|
|
631
|
+
'absolute bottom-0 start-0 top-0 overflow-hidden',
|
|
632
|
+
PANEL_FILL[startActions[0]?.color ?? 'default']
|
|
633
|
+
)}
|
|
634
|
+
style={startStyle}
|
|
635
|
+
>
|
|
636
|
+
{/*
|
|
637
|
+
* The tiles are absolute so they size to themselves rather than
|
|
638
|
+
* to the panel clipping them, and pinned to the edge nearest the
|
|
639
|
+
* row so they emerge from under it as it moves — rather than
|
|
640
|
+
* sitting at the far edge with a growing gap in front of them.
|
|
641
|
+
*/}
|
|
642
|
+
<View
|
|
643
|
+
className="absolute bottom-0 end-0 top-0 flex-row items-stretch"
|
|
644
|
+
onLayout={measure(startWidth)}
|
|
645
|
+
>
|
|
646
|
+
{startNode}
|
|
647
|
+
</View>
|
|
648
|
+
</Animated.View>
|
|
649
|
+
) : null}
|
|
650
|
+
|
|
651
|
+
{hasEnd ? (
|
|
652
|
+
<Animated.View
|
|
653
|
+
className={cn(
|
|
654
|
+
'absolute bottom-0 end-0 top-0 overflow-hidden',
|
|
655
|
+
PANEL_FILL[endActions[endActions.length - 1]?.color ?? 'default']
|
|
656
|
+
)}
|
|
657
|
+
style={endStyle}
|
|
658
|
+
>
|
|
659
|
+
<View
|
|
660
|
+
className="absolute bottom-0 start-0 top-0 flex-row items-stretch"
|
|
661
|
+
onLayout={measure(endWidth)}
|
|
662
|
+
>
|
|
663
|
+
{endNode}
|
|
664
|
+
</View>
|
|
665
|
+
</Animated.View>
|
|
666
|
+
) : null}
|
|
667
|
+
|
|
668
|
+
<GestureDetector gesture={pan}>
|
|
669
|
+
<Animated.View
|
|
670
|
+
style={contentStyle}
|
|
671
|
+
className={cn('w-full', contentClassName)}
|
|
672
|
+
>
|
|
673
|
+
{row}
|
|
674
|
+
</Animated.View>
|
|
675
|
+
</GestureDetector>
|
|
676
|
+
</View>
|
|
677
|
+
</SwipeContext.Provider>
|
|
678
|
+
);
|
|
679
|
+
}
|
|
680
|
+
);
|
|
681
|
+
SwipeRoot.displayName = 'Swipe';
|
|
682
|
+
|
|
683
|
+
export const Swipe = Object.assign(SwipeRoot, {
|
|
684
|
+
Start: SwipeStart,
|
|
685
|
+
End: SwipeEnd,
|
|
686
|
+
Action: SwipeAction,
|
|
687
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -620,6 +620,15 @@ export {
|
|
|
620
620
|
type StepsOrientation,
|
|
621
621
|
} from './components/steps';
|
|
622
622
|
export { Surface, type SurfaceProps } from './components/surface';
|
|
623
|
+
export {
|
|
624
|
+
Swipe,
|
|
625
|
+
type SwipeProps,
|
|
626
|
+
type SwipePanelProps,
|
|
627
|
+
type SwipeActionProps,
|
|
628
|
+
type SwipeActionColor,
|
|
629
|
+
type SwipeOpenSide,
|
|
630
|
+
type SwipeHandle,
|
|
631
|
+
} from './components/swipe';
|
|
623
632
|
export { Switch, type SwitchProps } from './components/switch';
|
|
624
633
|
export {
|
|
625
634
|
Timeline,
|