panelui-native 0.34.0 → 0.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +2 -1
  2. package/lib/module/components/input/index.js +135 -32
  3. package/lib/module/components/input/index.js.map +1 -1
  4. package/lib/module/components/otp-input/index.js +4 -1
  5. package/lib/module/components/otp-input/index.js.map +1 -1
  6. package/lib/module/components/panelside/index.js +1 -1
  7. package/lib/module/components/panelside/index.js.map +1 -1
  8. package/lib/module/components/progress/index.js +64 -24
  9. package/lib/module/components/progress/index.js.map +1 -1
  10. package/lib/module/components/swipe/index.js +537 -0
  11. package/lib/module/components/swipe/index.js.map +1 -0
  12. package/lib/module/components/textarea/index.js +3 -1
  13. package/lib/module/components/textarea/index.js.map +1 -1
  14. package/lib/module/components/time-picker/index.js +801 -0
  15. package/lib/module/components/time-picker/index.js.map +1 -0
  16. package/lib/module/icons/index.js +59 -0
  17. package/lib/module/icons/index.js.map +1 -1
  18. package/lib/module/index.js +4 -1
  19. package/lib/module/index.js.map +1 -1
  20. package/lib/module/utils/time.js +180 -0
  21. package/lib/module/utils/time.js.map +1 -0
  22. package/lib/typescript/src/components/input/index.d.ts +92 -0
  23. package/lib/typescript/src/components/input/index.d.ts.map +1 -1
  24. package/lib/typescript/src/components/otp-input/index.d.ts.map +1 -1
  25. package/lib/typescript/src/components/progress/index.d.ts +25 -4
  26. package/lib/typescript/src/components/progress/index.d.ts.map +1 -1
  27. package/lib/typescript/src/components/swipe/index.d.ts +206 -0
  28. package/lib/typescript/src/components/swipe/index.d.ts.map +1 -0
  29. package/lib/typescript/src/components/textarea/index.d.ts.map +1 -1
  30. package/lib/typescript/src/components/time-picker/index.d.ts +104 -0
  31. package/lib/typescript/src/components/time-picker/index.d.ts.map +1 -0
  32. package/lib/typescript/src/icons/index.d.ts +4 -0
  33. package/lib/typescript/src/icons/index.d.ts.map +1 -1
  34. package/lib/typescript/src/index.d.ts +4 -1
  35. package/lib/typescript/src/index.d.ts.map +1 -1
  36. package/lib/typescript/src/utils/time.d.ts +88 -0
  37. package/lib/typescript/src/utils/time.d.ts.map +1 -0
  38. package/package.json +1 -1
  39. package/src/components/input/index.tsx +171 -29
  40. package/src/components/otp-input/index.tsx +4 -1
  41. package/src/components/panelside/index.tsx +1 -1
  42. package/src/components/progress/index.tsx +93 -25
  43. package/src/components/swipe/index.tsx +687 -0
  44. package/src/components/textarea/index.tsx +3 -1
  45. package/src/components/time-picker/index.tsx +971 -0
  46. package/src/icons/index.tsx +47 -0
  47. package/src/index.ts +34 -0
  48. package/src/theme/use-theme.ts +1 -1
  49. package/src/utils/time.ts +192 -0
  50. package/theme.css +32 -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
+ });
@@ -66,7 +66,9 @@ const textareaVariants = tv({
66
66
  * the leading is wanted — between several lines it separates them, where in
67
67
  * a one-line field it would push the text off centre.
68
68
  */
69
- field: 'w-full rounded-lg border text-foreground',
69
+ /* `font-normal` so an app's --font-normal token reaches a TextInput,
70
+ which inherits nothing from the Text primitive. */
71
+ field: 'w-full rounded-lg border font-normal text-foreground',
70
72
  footer: 'w-full flex-row items-start justify-between gap-3',
71
73
  description: 'text-sm text-muted-foreground',
72
74
  error: 'text-sm text-destructive',