panelui-native 0.97.0 → 0.98.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 +1 -0
- package/lib/module/components/stack-card/index.js +1204 -0
- package/lib/module/components/stack-card/index.js.map +1 -0
- package/lib/module/components/stack-card/stack-card-geometry.js +261 -0
- package/lib/module/components/stack-card/stack-card-geometry.js.map +1 -0
- package/lib/module/index.js +1 -0
- package/lib/module/index.js.map +1 -1
- package/lib/typescript/src/components/stack-card/index.d.ts +472 -0
- package/lib/typescript/src/components/stack-card/index.d.ts.map +1 -0
- package/lib/typescript/src/components/stack-card/stack-card-geometry.d.ts +126 -0
- package/lib/typescript/src/components/stack-card/stack-card-geometry.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 +8 -1
- package/src/components/stack-card/index.tsx +1423 -0
- package/src/components/stack-card/stack-card-geometry.ts +279 -0
- package/src/index.ts +14 -0
|
@@ -0,0 +1,1423 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* StackCard — a pile of cards, taken one at a time by throwing the top one off.
|
|
3
|
+
*
|
|
4
|
+
* ```tsx
|
|
5
|
+
* <StackCard className="h-[460px]" onSwipe={(direction, index) => decide(people[index], direction)}>
|
|
6
|
+
* <StackCard.Stamp direction="right" color="success">Yes</StackCard.Stamp>
|
|
7
|
+
* <StackCard.Stamp direction="left" color="destructive">No</StackCard.Stamp>
|
|
8
|
+
* {people.map((person) => (
|
|
9
|
+
* <StackCard.Card key={person.id}>
|
|
10
|
+
* <Text>{person.name}</Text>
|
|
11
|
+
* </StackCard.Card>
|
|
12
|
+
* ))}
|
|
13
|
+
* <StackCard.Empty>
|
|
14
|
+
* <Text muted>Nobody left</Text>
|
|
15
|
+
* </StackCard.Empty>
|
|
16
|
+
* </StackCard>
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* For a queue of things each answered with one decision and then gone: a
|
|
20
|
+
* review queue, a set of flashcards, an inbox of suggestions. The gesture is
|
|
21
|
+
* the answer, which is what makes it quicker than a list of rows with buttons
|
|
22
|
+
* on them — and what makes it wrong for anything the reader has to compare,
|
|
23
|
+
* skim or come back to. A deck shows one card and hides the rest.
|
|
24
|
+
*
|
|
25
|
+
* For a run of slides the reader browses rather than disposes of, use
|
|
26
|
+
* [Carousel](../carousel); for one row's actions in a list, [Swipe](../swipe).
|
|
27
|
+
*
|
|
28
|
+
* ## The pile is one drag, read by everything
|
|
29
|
+
*
|
|
30
|
+
* The top card's `x` and `y` are the only values a gesture writes, and every
|
|
31
|
+
* other moving part is derived from them: each stamp fades in on how far the
|
|
32
|
+
* drag has carried the card toward its own direction, and the cards behind
|
|
33
|
+
* climb toward the top position on the furthest of those.
|
|
34
|
+
*
|
|
35
|
+
* That derivation is also what makes a dismissal seamless. By the time the top
|
|
36
|
+
* card has been carried far enough to leave, the second card is already
|
|
37
|
+
* exactly where the top card sits — so when the deck advances there is nothing
|
|
38
|
+
* left for it to move, and no frame in which the pile re-arranges itself.
|
|
39
|
+
*
|
|
40
|
+
* ## The deck moves on the UI thread, and React catches up
|
|
41
|
+
*
|
|
42
|
+
* Which card is on top is held twice: as React state, for what is mounted and
|
|
43
|
+
* for the callbacks, and as a shared value, for what is drawn. A throw is
|
|
44
|
+
* handled entirely on the UI thread — the card leaves on the frame the finger
|
|
45
|
+
* lets go, and once it is off the screen the top card, the offset and the fade
|
|
46
|
+
* all move in one step — and only then is the new index requested from React.
|
|
47
|
+
*
|
|
48
|
+
* Nothing on the screen waits for a render, so a busy JavaScript thread cannot
|
|
49
|
+
* stall a throw or leave a frame where the two halves disagree. What React
|
|
50
|
+
* renders afterwards is kept from showing before the UI thread agrees with it:
|
|
51
|
+
* a stamp is only drawn on the card the UI thread has on top.
|
|
52
|
+
*
|
|
53
|
+
* A controlled deck is still the owner's to decide. An owner that declines the
|
|
54
|
+
* new index gets the card back, flown in from the way it went; one that
|
|
55
|
+
* accepts it sees nothing move at all, because it already has.
|
|
56
|
+
*
|
|
57
|
+
* One card behind the pile's stated depth stays mounted so it can fade in as
|
|
58
|
+
* it takes the last visible place, and one card ahead of the top stays mounted
|
|
59
|
+
* so `undo` has something to fly back in. The rest are unmounted, which is
|
|
60
|
+
* what makes a deck of five hundred cost what a deck of five costs.
|
|
61
|
+
*
|
|
62
|
+
* ## A deck is not reachable by a gesture alone
|
|
63
|
+
*
|
|
64
|
+
* A throw is not available to a screen reader, and neither is a card that can
|
|
65
|
+
* only be answered by throwing it. So the top card publishes an accessibility
|
|
66
|
+
* action for every direction the deck accepts, and `StackCard.Action` renders
|
|
67
|
+
* the same decisions as ordinary buttons — which sighted people reach for too,
|
|
68
|
+
* on the card they are not sure about.
|
|
69
|
+
*/
|
|
70
|
+
import {
|
|
71
|
+
Children,
|
|
72
|
+
cloneElement,
|
|
73
|
+
createContext,
|
|
74
|
+
forwardRef,
|
|
75
|
+
isValidElement,
|
|
76
|
+
useCallback,
|
|
77
|
+
useContext,
|
|
78
|
+
useEffect,
|
|
79
|
+
useImperativeHandle,
|
|
80
|
+
useMemo,
|
|
81
|
+
useReducer,
|
|
82
|
+
useRef,
|
|
83
|
+
type ReactElement,
|
|
84
|
+
type ReactNode,
|
|
85
|
+
} from 'react';
|
|
86
|
+
import {
|
|
87
|
+
View,
|
|
88
|
+
type AccessibilityActionEvent,
|
|
89
|
+
type LayoutChangeEvent,
|
|
90
|
+
type ViewProps,
|
|
91
|
+
} from 'react-native';
|
|
92
|
+
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
|
|
93
|
+
import Animated, {
|
|
94
|
+
cancelAnimation,
|
|
95
|
+
Easing,
|
|
96
|
+
interpolate,
|
|
97
|
+
runOnJS,
|
|
98
|
+
runOnUI,
|
|
99
|
+
useAnimatedReaction,
|
|
100
|
+
useAnimatedStyle,
|
|
101
|
+
useDerivedValue,
|
|
102
|
+
useReducedMotion,
|
|
103
|
+
useSharedValue,
|
|
104
|
+
withSpring,
|
|
105
|
+
withTiming,
|
|
106
|
+
type SharedValue,
|
|
107
|
+
} from 'react-native-reanimated';
|
|
108
|
+
import { useCSSVariable } from 'uniwind';
|
|
109
|
+
import { tv, type VariantProps } from 'tailwind-variants';
|
|
110
|
+
import { IconColorProvider } from '../../icons';
|
|
111
|
+
import { AnimatedPressable } from '../../primitives/animated-pressable';
|
|
112
|
+
import { useControllableState } from '../../primitives/controllable-state';
|
|
113
|
+
import { Text } from '../../primitives/text';
|
|
114
|
+
import { cn } from '../../utils/cn';
|
|
115
|
+
import { impactKnock, selectionTick } from '../../utils/haptics';
|
|
116
|
+
import {
|
|
117
|
+
depthOpacity,
|
|
118
|
+
directionProgress,
|
|
119
|
+
effectiveDepth,
|
|
120
|
+
exitDuration,
|
|
121
|
+
exitTarget,
|
|
122
|
+
lever,
|
|
123
|
+
releaseProgress,
|
|
124
|
+
releasedDirection,
|
|
125
|
+
resist,
|
|
126
|
+
tiltAngle,
|
|
127
|
+
type StackCardDirection,
|
|
128
|
+
} from './stack-card-geometry';
|
|
129
|
+
|
|
130
|
+
export type { StackCardDirection } from './stack-card-geometry';
|
|
131
|
+
|
|
132
|
+
/** Puts a card back when the release did not send it anywhere. */
|
|
133
|
+
const RETURN_SPRING = { damping: 20, stiffness: 220, mass: 0.7 } as const;
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Brings an undone card back in. Heavier than the one that recovers a drag: a
|
|
137
|
+
* card arriving from off the screen has further to travel and nothing under
|
|
138
|
+
* the finger to explain a fast stop.
|
|
139
|
+
*/
|
|
140
|
+
const ARRIVE_SPRING = { damping: 22, stiffness: 160, mass: 0.9 } as const;
|
|
141
|
+
|
|
142
|
+
/*
|
|
143
|
+
* How a card leaves: a gentle ease-out that starts twice as fast as its
|
|
144
|
+
* average, timed so that start is the finger's speed.
|
|
145
|
+
*
|
|
146
|
+
* Not a spring. A spring pulls in proportion to how far it has to go, and a
|
|
147
|
+
* card's destination is off the screen, so it accelerated away from the finger
|
|
148
|
+
* and was gone in about 100ms — a throw that read as the card being snatched.
|
|
149
|
+
*
|
|
150
|
+
* Gentle, because only the first part of the curve is seen. The card is aimed
|
|
151
|
+
* well past the edge so it clears it tilted, and it is out of sight about two
|
|
152
|
+
* thirds of the way there; a steeper curve spends most of its time on the
|
|
153
|
+
* part nobody sees. The durations are set for what is visible — roughly a
|
|
154
|
+
* quarter of a second of card crossing the screen.
|
|
155
|
+
*/
|
|
156
|
+
const EXIT_EASE = Easing.bezier(0.33, 0.66, 0.4, 1);
|
|
157
|
+
/** `EXIT_EASE`'s speed at its start, as a multiple of its average. */
|
|
158
|
+
const EXIT_SLOPE = 2;
|
|
159
|
+
/** The quickest a card may leave, however hard it was thrown, in milliseconds. */
|
|
160
|
+
const EXIT_SHORTEST = 420;
|
|
161
|
+
/** The longest a card takes to leave, and what a button's card takes. */
|
|
162
|
+
const EXIT_LONGEST = 540;
|
|
163
|
+
|
|
164
|
+
/** How long a fade stands in for a throw under reduce motion. */
|
|
165
|
+
const FADE_DURATION = 160;
|
|
166
|
+
|
|
167
|
+
/** How far a finger gets on an axis the deck does not accept, at most. */
|
|
168
|
+
const LOCKED_AXIS_GIVE = 0.18;
|
|
169
|
+
|
|
170
|
+
/*
|
|
171
|
+
* The default, out here rather than in the destructure. A literal in the
|
|
172
|
+
* parameter list is a new array on every render, and this one is a dependency
|
|
173
|
+
* of the pan gesture — so it would rebuild the gesture, and re-attach the
|
|
174
|
+
* handler, while a finger was still down on it.
|
|
175
|
+
*/
|
|
176
|
+
const SIDEWAYS: StackCardDirection[] = ['left', 'right'];
|
|
177
|
+
|
|
178
|
+
/** Degrees the top card turns through at most. */
|
|
179
|
+
const MAX_TILT = 14;
|
|
180
|
+
|
|
181
|
+
/** How much lower each card behind the top one sits, in points. */
|
|
182
|
+
const PEEK = 14;
|
|
183
|
+
|
|
184
|
+
/** How much smaller each card behind the top one is drawn. */
|
|
185
|
+
const SHRINK = 0.055;
|
|
186
|
+
|
|
187
|
+
/** Degrees a fanned card is turned per place back in the pile. */
|
|
188
|
+
const FAN_TILT = 4;
|
|
189
|
+
|
|
190
|
+
/** Sideways splay of a fanned card per place back, in points. */
|
|
191
|
+
const FAN_SPREAD = 10;
|
|
192
|
+
|
|
193
|
+
/** Card sizes to fall back on before the pile has been measured. */
|
|
194
|
+
const UNMEASURED_WIDTH = 320;
|
|
195
|
+
const UNMEASURED_HEIGHT = 420;
|
|
196
|
+
|
|
197
|
+
/* -------------------------------------------------------------------------- */
|
|
198
|
+
/* Context */
|
|
199
|
+
/* -------------------------------------------------------------------------- */
|
|
200
|
+
|
|
201
|
+
interface StackCardContextValue {
|
|
202
|
+
/** The top card's offset. Written only by the pan and the exit animation. */
|
|
203
|
+
x: SharedValue<number>;
|
|
204
|
+
y: SharedValue<number>;
|
|
205
|
+
/** The top card's opacity, which only reduce motion ever moves. */
|
|
206
|
+
fade: SharedValue<number>;
|
|
207
|
+
/** Which way the top card pivots, from where it was taken hold of. */
|
|
208
|
+
pivot: SharedValue<number>;
|
|
209
|
+
/** The furthest any accepted direction has been carried, 0 to 1. */
|
|
210
|
+
release: SharedValue<number>;
|
|
211
|
+
/** Which card is on top, as the UI thread draws it. */
|
|
212
|
+
active: SharedValue<number>;
|
|
213
|
+
width: SharedValue<number>;
|
|
214
|
+
height: SharedValue<number>;
|
|
215
|
+
threshold: number;
|
|
216
|
+
/** Where the deck is in React, which is what a caller reads. */
|
|
217
|
+
index: number;
|
|
218
|
+
count: number;
|
|
219
|
+
disabled: boolean;
|
|
220
|
+
canUndo: boolean;
|
|
221
|
+
send: (direction: StackCardDirection) => void;
|
|
222
|
+
undo: () => void;
|
|
223
|
+
reset: () => void;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const StackCardContext = createContext<StackCardContextValue | null>(null);
|
|
227
|
+
|
|
228
|
+
/*
|
|
229
|
+
* Which card a stamp is drawn on.
|
|
230
|
+
*
|
|
231
|
+
* Stamps are handed to whichever card React has on top, and straight after a
|
|
232
|
+
* throw React is a render behind the UI thread — so for that render they sit
|
|
233
|
+
* on the next card while the offset still says the last one went all the way.
|
|
234
|
+
* A stamp reads it to show only on the card the UI thread is drawing on top.
|
|
235
|
+
*/
|
|
236
|
+
const StackCardSlotIndex = createContext<number | null>(null);
|
|
237
|
+
|
|
238
|
+
function useStackCardContext(component: string) {
|
|
239
|
+
const context = useContext(StackCardContext);
|
|
240
|
+
if (!context) throw new Error(`${component} must be used within a <StackCard>`);
|
|
241
|
+
return context;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* The deck's state, for a control that lives outside the pile — a counter, a
|
|
246
|
+
* progress bar, a button in a toolbar.
|
|
247
|
+
*
|
|
248
|
+
* `release` is a shared value running 0 to 1 as the top card is carried toward
|
|
249
|
+
* leaving, so something beside the deck can move with the drag rather than
|
|
250
|
+
* starting a second animation next to it.
|
|
251
|
+
*/
|
|
252
|
+
export function useStackCard() {
|
|
253
|
+
const { index, count, canUndo, release, send, undo, reset } =
|
|
254
|
+
useStackCardContext('useStackCard');
|
|
255
|
+
return {
|
|
256
|
+
/** How many cards have been answered, which is also the top card's index. */
|
|
257
|
+
index,
|
|
258
|
+
/** How many cards the deck was given. */
|
|
259
|
+
count,
|
|
260
|
+
/** How many are left, the top one included. */
|
|
261
|
+
remaining: Math.max(0, count - index),
|
|
262
|
+
/** Whether there is a card to bring back. */
|
|
263
|
+
canUndo,
|
|
264
|
+
release,
|
|
265
|
+
send,
|
|
266
|
+
undo,
|
|
267
|
+
reset,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/* -------------------------------------------------------------------------- */
|
|
272
|
+
/* Root */
|
|
273
|
+
/* -------------------------------------------------------------------------- */
|
|
274
|
+
|
|
275
|
+
/** How the cards behind the top one are arranged. */
|
|
276
|
+
export type StackCardLayout = 'stack' | 'fan' | 'flat';
|
|
277
|
+
|
|
278
|
+
export interface StackCardHandle {
|
|
279
|
+
/** Send the top card away as though it had been thrown that way. */
|
|
280
|
+
swipe: (direction: StackCardDirection) => void;
|
|
281
|
+
/** Bring the last card back, and with it the decision that removed it. */
|
|
282
|
+
undo: () => void;
|
|
283
|
+
/** Put every card back. */
|
|
284
|
+
reset: () => void;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const stackCardVariants = tv({
|
|
288
|
+
slots: {
|
|
289
|
+
root: 'w-full',
|
|
290
|
+
/*
|
|
291
|
+
* The cards are laid over each other, so the pile has no height of its
|
|
292
|
+
* own and takes what is left after anything else in the root. That is what
|
|
293
|
+
* lets a caller state one height for the whole control and get a row of
|
|
294
|
+
* buttons under a pile that fills the rest.
|
|
295
|
+
*/
|
|
296
|
+
pile: 'relative w-full flex-1',
|
|
297
|
+
card: 'absolute inset-0',
|
|
298
|
+
},
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
export interface StackCardProps extends Omit<ViewProps, 'children'> {
|
|
302
|
+
/**
|
|
303
|
+
* A `StackCard.Card` for each card, plus any of `StackCard.Stamp`,
|
|
304
|
+
* `StackCard.Empty` and `StackCard.Actions`, in any order. Anything else is
|
|
305
|
+
* laid out under the pile.
|
|
306
|
+
*/
|
|
307
|
+
children?: ReactNode;
|
|
308
|
+
/**
|
|
309
|
+
* Which card is on top, when the caller holds it. Leave unset to let the
|
|
310
|
+
* deck keep its own. A controlled deck that declines a request stays where
|
|
311
|
+
* it is and the thrown card comes back, so this is also how a decision is
|
|
312
|
+
* confirmed before it is taken.
|
|
313
|
+
*/
|
|
314
|
+
index?: number;
|
|
315
|
+
/** Which card an uncontrolled deck starts on. */
|
|
316
|
+
defaultIndex?: number;
|
|
317
|
+
/** Fires whenever the deck asks to move, with the index it is asking for. */
|
|
318
|
+
onIndexChange?: (index: number) => void;
|
|
319
|
+
/**
|
|
320
|
+
* Fires when a card leaves, with the way it went and the index it was at.
|
|
321
|
+
* It fires before `onIndexChange` asks for the next index. Not called by
|
|
322
|
+
* `undo` — the index going back is what reports that.
|
|
323
|
+
*/
|
|
324
|
+
onSwipe?: (direction: StackCardDirection, index: number) => void;
|
|
325
|
+
/** Fires once when the last card leaves. */
|
|
326
|
+
onEmpty?: () => void;
|
|
327
|
+
/**
|
|
328
|
+
* Which ways a card may be thrown. Left and right by default.
|
|
329
|
+
*
|
|
330
|
+
* A direction left out still follows the finger a little and then comes
|
|
331
|
+
* back, rather than refusing to move at all — a card that does not budge
|
|
332
|
+
* reads as a frozen screen.
|
|
333
|
+
*/
|
|
334
|
+
directions?: readonly StackCardDirection[];
|
|
335
|
+
/**
|
|
336
|
+
* How the cards behind the top one are arranged. `stack` steps them down and
|
|
337
|
+
* back; `fan` turns them alternately, like a hand of cards; `flat` hides them
|
|
338
|
+
* entirely, for full-bleed cards where a peeking edge is only clutter.
|
|
339
|
+
*/
|
|
340
|
+
layout?: StackCardLayout;
|
|
341
|
+
/** How many cards are drawn behind the top one. Two is a pile; five is a mess. */
|
|
342
|
+
depth?: number;
|
|
343
|
+
/**
|
|
344
|
+
* How far a card has to be taken for a release to send it away, as a
|
|
345
|
+
* fraction of the card. Momentum counts toward it, so a flick clears it
|
|
346
|
+
* without travelling.
|
|
347
|
+
*/
|
|
348
|
+
threshold?: number;
|
|
349
|
+
/** Stop the deck taking a gesture, without changing how it looks. */
|
|
350
|
+
disabled?: boolean;
|
|
351
|
+
/** A tick when a drag first reaches the point of no return, and a knock as the card goes. */
|
|
352
|
+
haptics?: boolean;
|
|
353
|
+
/**
|
|
354
|
+
* What a screen reader is offered for each direction, in place of "Swipe
|
|
355
|
+
* left". Name the decision — `{ left: 'Skip', right: 'Save' }`.
|
|
356
|
+
*/
|
|
357
|
+
directionLabels?: Partial<Record<StackCardDirection, string>>;
|
|
358
|
+
/** Classes for the whole control. Give it a height; the pile fills what is left. */
|
|
359
|
+
className?: string;
|
|
360
|
+
/** Classes for the box the cards are laid out in. */
|
|
361
|
+
pileClassName?: string;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const StackCardRoot = forwardRef<StackCardHandle, StackCardProps>(
|
|
365
|
+
(
|
|
366
|
+
{
|
|
367
|
+
children,
|
|
368
|
+
index: indexProp,
|
|
369
|
+
defaultIndex = 0,
|
|
370
|
+
onIndexChange,
|
|
371
|
+
onSwipe,
|
|
372
|
+
onEmpty,
|
|
373
|
+
directions = SIDEWAYS,
|
|
374
|
+
layout = 'stack',
|
|
375
|
+
depth = 2,
|
|
376
|
+
threshold = 0.3,
|
|
377
|
+
disabled = false,
|
|
378
|
+
haptics = true,
|
|
379
|
+
directionLabels,
|
|
380
|
+
className,
|
|
381
|
+
pileClassName,
|
|
382
|
+
...props
|
|
383
|
+
},
|
|
384
|
+
ref
|
|
385
|
+
) => {
|
|
386
|
+
const cards: ReactElement[] = [];
|
|
387
|
+
const stamps: ReactElement[] = [];
|
|
388
|
+
let empty: ReactNode = null;
|
|
389
|
+
const below: ReactNode[] = [];
|
|
390
|
+
for (const child of Children.toArray(children)) {
|
|
391
|
+
if (!isValidElement(child)) continue;
|
|
392
|
+
if (child.type === StackCardCard) cards.push(child);
|
|
393
|
+
else if (child.type === StackCardStamp) stamps.push(child);
|
|
394
|
+
else if (child.type === StackCardEmpty) empty = child;
|
|
395
|
+
else below.push(child);
|
|
396
|
+
}
|
|
397
|
+
const count = cards.length;
|
|
398
|
+
|
|
399
|
+
const { value: index, setValue: setIndex } = useControllableState({
|
|
400
|
+
value: indexProp,
|
|
401
|
+
defaultValue: defaultIndex,
|
|
402
|
+
onChange: onIndexChange,
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
const x = useSharedValue(0);
|
|
406
|
+
const y = useSharedValue(0);
|
|
407
|
+
const fade = useSharedValue(1);
|
|
408
|
+
const pivot = useSharedValue(1);
|
|
409
|
+
const active = useSharedValue(index);
|
|
410
|
+
const width = useSharedValue(0);
|
|
411
|
+
const height = useSharedValue(0);
|
|
412
|
+
const reduceMotion = useReducedMotion();
|
|
413
|
+
|
|
414
|
+
/*
|
|
415
|
+
* The accepted directions as one stable array, keyed on what is in it
|
|
416
|
+
* rather than on the identity of the prop.
|
|
417
|
+
*
|
|
418
|
+
* A caller writing `directions={['left', 'right']}` hands over a new array
|
|
419
|
+
* on every render, and the whole drag path hangs off this value: the
|
|
420
|
+
* gesture that reads it, the axis constraint built from it, and the
|
|
421
|
+
* accessibility actions published from it. Keyed on contents, all three
|
|
422
|
+
* are rebuilt when the directions genuinely change and at no other time.
|
|
423
|
+
*
|
|
424
|
+
* A plain array rather than a shared value, because a worklet may capture
|
|
425
|
+
* one directly — which is how every other gesture in the library reaches
|
|
426
|
+
* its configuration, and it costs no per-frame allocation across the
|
|
427
|
+
* bridge. The gesture is rebuilt when the contents change, which is also
|
|
428
|
+
* exactly when the axis constraint below has to be rebuilt anyway.
|
|
429
|
+
*/
|
|
430
|
+
const directionKey = directions.join('');
|
|
431
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
432
|
+
const allowed = useMemo(() => [...directions], [directionKey]);
|
|
433
|
+
|
|
434
|
+
const release = useDerivedValue(
|
|
435
|
+
() => releaseProgress(allowed, x.value, y.value, width.value, height.value, threshold),
|
|
436
|
+
[allowed, threshold]
|
|
437
|
+
);
|
|
438
|
+
|
|
439
|
+
/*
|
|
440
|
+
* Which way each answered card went, so `undo` can put one back out where
|
|
441
|
+
* it came from before bringing it in. A ref rather than state: nothing is
|
|
442
|
+
* rendered from it, and a decision recorded at the end of an animation
|
|
443
|
+
* must not schedule a render of its own.
|
|
444
|
+
*/
|
|
445
|
+
const history = useRef<StackCardDirection[]>([]);
|
|
446
|
+
/** Set when a card should arrive rather than simply appear. */
|
|
447
|
+
const arriving = useRef<StackCardDirection | null>(null);
|
|
448
|
+
const reportedEmpty = useRef(false);
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* The card the UI thread is drawing on top, as far as this side knows. It
|
|
452
|
+
* runs ahead of `index` for the length of one render after every throw.
|
|
453
|
+
*/
|
|
454
|
+
const drawn = useRef(index);
|
|
455
|
+
/** The throw React has been asked to accept, until the next render answers. */
|
|
456
|
+
const requested = useRef<{ from: number; direction: StackCardDirection } | null>(null);
|
|
457
|
+
/*
|
|
458
|
+
* Guarantees that render. An owner declining a request need not render at
|
|
459
|
+
* all, and without one the deck would never find out it had been declined.
|
|
460
|
+
*/
|
|
461
|
+
const [, answer] = useReducer((renders: number) => renders + 1, 0);
|
|
462
|
+
|
|
463
|
+
/** A card is on its way out. One at a time, whoever asked. */
|
|
464
|
+
const leaving = useSharedValue(false);
|
|
465
|
+
/** Where the drag picked the card up, so it carries on from there. */
|
|
466
|
+
const originX = useSharedValue(0);
|
|
467
|
+
const originY = useSharedValue(0);
|
|
468
|
+
|
|
469
|
+
useEffect(
|
|
470
|
+
() => () => {
|
|
471
|
+
cancelAnimation(x);
|
|
472
|
+
cancelAnimation(y);
|
|
473
|
+
cancelAnimation(fade);
|
|
474
|
+
},
|
|
475
|
+
[fade, x, y]
|
|
476
|
+
);
|
|
477
|
+
|
|
478
|
+
/*
|
|
479
|
+
* Puts the deck at `next` in a single step on the UI thread: which card is
|
|
480
|
+
* on top, where the offset is, and whether it is showing.
|
|
481
|
+
*
|
|
482
|
+
* All of it in one worklet, because these are three shared values that
|
|
483
|
+
* the pile reads together. Written one at a time from React, each lands
|
|
484
|
+
* whenever the UI thread picks it up — and a frame that has the offset
|
|
485
|
+
* back at the middle while the old card is still the top one draws that
|
|
486
|
+
* card back in the middle of the screen, for as long as the gap lasts.
|
|
487
|
+
*/
|
|
488
|
+
const settle = useCallback(
|
|
489
|
+
(next: number, fromX: number, fromY: number, arrive: boolean) => {
|
|
490
|
+
'worklet';
|
|
491
|
+
cancelAnimation(x);
|
|
492
|
+
cancelAnimation(y);
|
|
493
|
+
cancelAnimation(fade);
|
|
494
|
+
leaving.value = false;
|
|
495
|
+
active.value = next;
|
|
496
|
+
fade.value = 1;
|
|
497
|
+
if (!arrive) {
|
|
498
|
+
x.value = 0;
|
|
499
|
+
y.value = 0;
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
x.value = fromX;
|
|
503
|
+
y.value = fromY;
|
|
504
|
+
x.value = withSpring(0, ARRIVE_SPRING);
|
|
505
|
+
y.value = withSpring(0, ARRIVE_SPRING);
|
|
506
|
+
},
|
|
507
|
+
[active, fade, leaving, x, y]
|
|
508
|
+
);
|
|
509
|
+
|
|
510
|
+
/*
|
|
511
|
+
* React catching up with the deck, after every render.
|
|
512
|
+
*
|
|
513
|
+
* A thrown card moves the deck on the UI thread before React hears about
|
|
514
|
+
* it, so by the time this runs an accepted throw has nothing left to do.
|
|
515
|
+
* What it handles is everything that did not start as a throw — `undo`,
|
|
516
|
+
* `reset`, an owner moving `index` — and a throw that was declined, which
|
|
517
|
+
* is already off the screen and has to be brought back the way it went.
|
|
518
|
+
*/
|
|
519
|
+
useEffect(() => {
|
|
520
|
+
const asked = requested.current;
|
|
521
|
+
requested.current = null;
|
|
522
|
+
if (asked && index === asked.from) {
|
|
523
|
+
history.current.pop();
|
|
524
|
+
arriving.current = asked.direction;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
if (drawn.current === index) return;
|
|
528
|
+
drawn.current = index;
|
|
529
|
+
|
|
530
|
+
const entrance = arriving.current;
|
|
531
|
+
arriving.current = null;
|
|
532
|
+
if (!entrance || reduceMotion) {
|
|
533
|
+
runOnUI(settle)(index, 0, 0, false);
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const from = exitTarget(
|
|
538
|
+
entrance,
|
|
539
|
+
width.value || UNMEASURED_WIDTH,
|
|
540
|
+
height.value || UNMEASURED_HEIGHT,
|
|
541
|
+
0,
|
|
542
|
+
0
|
|
543
|
+
);
|
|
544
|
+
runOnUI(settle)(index, from.x, from.y, true);
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
useEffect(() => {
|
|
548
|
+
if (index >= count && count > 0 && !reportedEmpty.current) {
|
|
549
|
+
reportedEmpty.current = true;
|
|
550
|
+
onEmpty?.();
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
if (index < count) reportedEmpty.current = false;
|
|
554
|
+
}, [count, index, onEmpty]);
|
|
555
|
+
|
|
556
|
+
/*
|
|
557
|
+
* Runs once the outgoing card is off the screen, and the deck has already
|
|
558
|
+
* moved on. `onSwipe` goes first, so an owner deciding whether to accept
|
|
559
|
+
* the index has already heard which way the card went.
|
|
560
|
+
*/
|
|
561
|
+
const requestNext = useCallback(
|
|
562
|
+
(direction: StackCardDirection, from: number) => {
|
|
563
|
+
drawn.current = from + 1;
|
|
564
|
+
requested.current = { from, direction };
|
|
565
|
+
history.current.push(direction);
|
|
566
|
+
onSwipe?.(direction, from);
|
|
567
|
+
setIndex(from + 1);
|
|
568
|
+
answer();
|
|
569
|
+
if (haptics) impactKnock();
|
|
570
|
+
},
|
|
571
|
+
[haptics, onSwipe, setIndex]
|
|
572
|
+
);
|
|
573
|
+
|
|
574
|
+
/*
|
|
575
|
+
* `requestNext` closes over the caller's `onSwipe`, which an owner passing
|
|
576
|
+
* an inline arrow makes a new function on every render. The throw below is
|
|
577
|
+
* part of the gesture, so reaching it through a ref is what lets the
|
|
578
|
+
* gesture be built once and keep the touch it already has.
|
|
579
|
+
*/
|
|
580
|
+
const latestRequestNext = useRef(requestNext);
|
|
581
|
+
latestRequestNext.current = requestNext;
|
|
582
|
+
const reportGone = useCallback((direction: StackCardDirection, from: number) => {
|
|
583
|
+
latestRequestNext.current(direction, from);
|
|
584
|
+
}, []);
|
|
585
|
+
|
|
586
|
+
/*
|
|
587
|
+
* Sends the top card off, on the UI thread, whoever asked.
|
|
588
|
+
*
|
|
589
|
+
* A release calls this from inside the gesture, so the card keeps moving
|
|
590
|
+
* on the frame the finger lets go rather than waiting for the JavaScript
|
|
591
|
+
* thread to hear about it — which, in a development build or on a busy
|
|
592
|
+
* screen, was long enough to see the card stop and set off again, and
|
|
593
|
+
* long enough for a second finger to catch the card that had just left.
|
|
594
|
+
*
|
|
595
|
+
* A thrown card leaves at the speed the finger let go of it, and a card
|
|
596
|
+
* sent by a button at the pace of an unhurried throw. Both finish with the
|
|
597
|
+
* card clear of the screen, and the axis it leaves along decides when.
|
|
598
|
+
*/
|
|
599
|
+
const launch = useCallback(
|
|
600
|
+
(direction: StackCardDirection, velocityX: number, velocityY: number) => {
|
|
601
|
+
'worklet';
|
|
602
|
+
const from = active.value;
|
|
603
|
+
if (leaving.value || from >= count) return;
|
|
604
|
+
leaving.value = true;
|
|
605
|
+
|
|
606
|
+
const gone = (finished?: boolean) => {
|
|
607
|
+
'worklet';
|
|
608
|
+
// Caught mid-flight: the finger has it now, and the deck has not moved.
|
|
609
|
+
if (!finished) {
|
|
610
|
+
leaving.value = false;
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
settle(from + 1, 0, 0, false);
|
|
614
|
+
runOnJS(reportGone)(direction, from);
|
|
615
|
+
};
|
|
616
|
+
|
|
617
|
+
if (reduceMotion) {
|
|
618
|
+
// The throw is the part that moves, and moving is the part the
|
|
619
|
+
// setting is about. The card still goes; it goes by fading.
|
|
620
|
+
fade.value = withTiming(0, { duration: FADE_DURATION }, gone);
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
const target = exitTarget(
|
|
625
|
+
direction,
|
|
626
|
+
width.value || UNMEASURED_WIDTH,
|
|
627
|
+
height.value || UNMEASURED_HEIGHT,
|
|
628
|
+
x.value,
|
|
629
|
+
y.value
|
|
630
|
+
);
|
|
631
|
+
const sideways = direction === 'left' || direction === 'right';
|
|
632
|
+
const distance = sideways ? target.x - x.value : target.y - y.value;
|
|
633
|
+
const along = sideways ? velocityX : velocityY;
|
|
634
|
+
// Only speed toward the way out counts; a card flicked back the other
|
|
635
|
+
// way and sent on by distance is leaving from a standstill.
|
|
636
|
+
const speed = along * distance > 0 ? Math.abs(along) : 0;
|
|
637
|
+
const timing = {
|
|
638
|
+
duration: exitDuration(distance, speed, EXIT_SLOPE, EXIT_SHORTEST, EXIT_LONGEST),
|
|
639
|
+
easing: EXIT_EASE,
|
|
640
|
+
};
|
|
641
|
+
x.value = withTiming(target.x, timing, sideways ? gone : undefined);
|
|
642
|
+
y.value = withTiming(target.y, timing, sideways ? undefined : gone);
|
|
643
|
+
},
|
|
644
|
+
[active, count, fade, height, leaving, reduceMotion, reportGone, settle, width, x, y]
|
|
645
|
+
);
|
|
646
|
+
|
|
647
|
+
const send = useCallback(
|
|
648
|
+
(direction: StackCardDirection) => {
|
|
649
|
+
runOnUI(launch)(direction, 0, 0);
|
|
650
|
+
},
|
|
651
|
+
[launch]
|
|
652
|
+
);
|
|
653
|
+
|
|
654
|
+
const undo = useCallback(() => {
|
|
655
|
+
// `drawn`, not `index`: straight after a throw React is a render behind.
|
|
656
|
+
const current = drawn.current;
|
|
657
|
+
if (current <= 0) return;
|
|
658
|
+
arriving.current = history.current.pop() ?? 'left';
|
|
659
|
+
setIndex(current - 1);
|
|
660
|
+
}, [setIndex]);
|
|
661
|
+
|
|
662
|
+
const reset = useCallback(() => {
|
|
663
|
+
history.current = [];
|
|
664
|
+
arriving.current = null;
|
|
665
|
+
requested.current = null;
|
|
666
|
+
drawn.current = 0;
|
|
667
|
+
runOnUI(settle)(0, 0, 0, false);
|
|
668
|
+
setIndex(0);
|
|
669
|
+
answer();
|
|
670
|
+
}, [setIndex, settle]);
|
|
671
|
+
|
|
672
|
+
useImperativeHandle(ref, () => ({ swipe: send, undo, reset }), [send, undo, reset]);
|
|
673
|
+
|
|
674
|
+
/** Stable for the life of the deck, for the accessibility actions below. */
|
|
675
|
+
const latestSend = useRef(send);
|
|
676
|
+
latestSend.current = send;
|
|
677
|
+
const dispatch = useCallback((direction: StackCardDirection) => {
|
|
678
|
+
latestSend.current(direction);
|
|
679
|
+
}, []);
|
|
680
|
+
|
|
681
|
+
/*
|
|
682
|
+
* One tick, when the drag first reaches the point where letting go would
|
|
683
|
+
* send the card. Latched on the reaction's own previous value, so dragging
|
|
684
|
+
* back and forth across the line does not rattle — and fired at the
|
|
685
|
+
* crossing rather than at the release, because the crossing is the moment
|
|
686
|
+
* worth knowing about while there is still a choice.
|
|
687
|
+
*/
|
|
688
|
+
useAnimatedReaction(
|
|
689
|
+
() => release.value >= 1,
|
|
690
|
+
(past, wasPast) => {
|
|
691
|
+
if (wasPast === null || past === wasPast || !past) return;
|
|
692
|
+
runOnJS(tick)(haptics);
|
|
693
|
+
},
|
|
694
|
+
[haptics]
|
|
695
|
+
);
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* Whether a finger can take a card at all. A boolean, so it changes at
|
|
699
|
+
* most twice in a deck's life and the gesture keeps its identity across
|
|
700
|
+
* every advance.
|
|
701
|
+
*/
|
|
702
|
+
const enabled = !disabled && index < count && allowed.length > 0;
|
|
703
|
+
|
|
704
|
+
const gesture = useMemo(() => {
|
|
705
|
+
/*
|
|
706
|
+
* Which axes the deck answers to. An activation constraint is part of
|
|
707
|
+
* how a gesture is constructed rather than something a handler can
|
|
708
|
+
* decide, so both are read here, from the array this memo is keyed on.
|
|
709
|
+
*/
|
|
710
|
+
const sideways = allowed.indexOf('left') >= 0 || allowed.indexOf('right') >= 0;
|
|
711
|
+
const upright = allowed.indexOf('up') >= 0 || allowed.indexOf('down') >= 0;
|
|
712
|
+
|
|
713
|
+
/*
|
|
714
|
+
* Built as one chain from `Gesture.Pan()`, and each handler says
|
|
715
|
+
* `'worklet'` for itself. Both matter: the callbacks are only compiled
|
|
716
|
+
* for the UI thread when they can be recognised as a gesture's, and a
|
|
717
|
+
* handler that quietly stays on the JS thread is not an error anywhere
|
|
718
|
+
* — it is a drag that reports a frame late and writes shared values from
|
|
719
|
+
* the wrong side.
|
|
720
|
+
*/
|
|
721
|
+
const pan = Gesture.Pan()
|
|
722
|
+
.enabled(enabled)
|
|
723
|
+
.onBegin((event) => {
|
|
724
|
+
'worklet';
|
|
725
|
+
pivot.value = lever(event.y, height.value);
|
|
726
|
+
})
|
|
727
|
+
.onStart((event) => {
|
|
728
|
+
'worklet';
|
|
729
|
+
/*
|
|
730
|
+
* Taken hold of here, when the pan activates, and not on touch-down.
|
|
731
|
+
* A finger that lands and lifts without moving is not a drag, and
|
|
732
|
+
* stopping the card on contact would leave a card that was on its
|
|
733
|
+
* way back frozen wherever the tap caught it.
|
|
734
|
+
*
|
|
735
|
+
* The origin is where the card is now, less the distance the finger
|
|
736
|
+
* travelled to activate the pan. Without it the card jumps by that
|
|
737
|
+
* distance on the first frame of every drag, and a card caught in
|
|
738
|
+
* flight jumps back under the finger's starting point.
|
|
739
|
+
*/
|
|
740
|
+
cancelAnimation(x);
|
|
741
|
+
cancelAnimation(y);
|
|
742
|
+
// Under reduce motion a leaving card fades rather than flies, and a
|
|
743
|
+
// card caught while it fades has to stop going as well as moving.
|
|
744
|
+
cancelAnimation(fade);
|
|
745
|
+
fade.value = 1;
|
|
746
|
+
originX.value = x.value - event.translationX;
|
|
747
|
+
originY.value = y.value - event.translationY;
|
|
748
|
+
})
|
|
749
|
+
.onUpdate((event) => {
|
|
750
|
+
'worklet';
|
|
751
|
+
const nextX = originX.value + event.translationX;
|
|
752
|
+
const nextY = originY.value + event.translationY;
|
|
753
|
+
x.value = sideways ? nextX : resist(nextX, width.value, LOCKED_AXIS_GIVE);
|
|
754
|
+
y.value = upright ? nextY : resist(nextY, height.value, LOCKED_AXIS_GIVE);
|
|
755
|
+
})
|
|
756
|
+
.onEnd((event) => {
|
|
757
|
+
'worklet';
|
|
758
|
+
const direction = releasedDirection(
|
|
759
|
+
allowed,
|
|
760
|
+
x.value,
|
|
761
|
+
y.value,
|
|
762
|
+
event.velocityX,
|
|
763
|
+
event.velocityY,
|
|
764
|
+
width.value,
|
|
765
|
+
height.value,
|
|
766
|
+
threshold
|
|
767
|
+
);
|
|
768
|
+
if (direction) {
|
|
769
|
+
launch(direction, event.velocityX, event.velocityY);
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
// The velocity goes into the spring, so there is no seam between
|
|
773
|
+
// the finger letting go and the card carrying on.
|
|
774
|
+
x.value = withSpring(0, { ...RETURN_SPRING, velocity: event.velocityX });
|
|
775
|
+
y.value = withSpring(0, { ...RETURN_SPRING, velocity: event.velocityY });
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
/*
|
|
779
|
+
* The axis constraint goes on last, after the chain the plugin had to
|
|
780
|
+
* see. A pan with no declared axis inside a scrolling screen wins every
|
|
781
|
+
* scroll that starts on the card, and the screen reads as broken in a
|
|
782
|
+
* way that looks like a scrolling bug rather than a gesture one. So a
|
|
783
|
+
* deck that answers to one axis says which, and a deck that answers to
|
|
784
|
+
* both declares nothing — it has no scroll to give way to that it would
|
|
785
|
+
* not also have to take a card from. Declaring a 1px threshold is not
|
|
786
|
+
* the same as declaring nothing: it activates on almost any movement and
|
|
787
|
+
* still takes the scroll.
|
|
788
|
+
*/
|
|
789
|
+
if (sideways && !upright) return pan.activeOffsetX([-10, 10]);
|
|
790
|
+
if (upright && !sideways) return pan.activeOffsetY([-10, 10]);
|
|
791
|
+
return pan;
|
|
792
|
+
}, [
|
|
793
|
+
allowed,
|
|
794
|
+
enabled,
|
|
795
|
+
fade,
|
|
796
|
+
height,
|
|
797
|
+
launch,
|
|
798
|
+
originX,
|
|
799
|
+
originY,
|
|
800
|
+
pivot,
|
|
801
|
+
threshold,
|
|
802
|
+
width,
|
|
803
|
+
x,
|
|
804
|
+
y,
|
|
805
|
+
]);
|
|
806
|
+
|
|
807
|
+
const context = useMemo<StackCardContextValue>(
|
|
808
|
+
() => ({
|
|
809
|
+
x,
|
|
810
|
+
y,
|
|
811
|
+
fade,
|
|
812
|
+
pivot,
|
|
813
|
+
release,
|
|
814
|
+
active,
|
|
815
|
+
width,
|
|
816
|
+
height,
|
|
817
|
+
threshold,
|
|
818
|
+
index,
|
|
819
|
+
count,
|
|
820
|
+
disabled,
|
|
821
|
+
canUndo: index > 0,
|
|
822
|
+
send,
|
|
823
|
+
undo,
|
|
824
|
+
reset,
|
|
825
|
+
}),
|
|
826
|
+
[
|
|
827
|
+
active,
|
|
828
|
+
count,
|
|
829
|
+
disabled,
|
|
830
|
+
fade,
|
|
831
|
+
height,
|
|
832
|
+
index,
|
|
833
|
+
pivot,
|
|
834
|
+
release,
|
|
835
|
+
reset,
|
|
836
|
+
send,
|
|
837
|
+
threshold,
|
|
838
|
+
undo,
|
|
839
|
+
width,
|
|
840
|
+
x,
|
|
841
|
+
y,
|
|
842
|
+
]
|
|
843
|
+
);
|
|
844
|
+
|
|
845
|
+
const slots = stackCardVariants();
|
|
846
|
+
|
|
847
|
+
/*
|
|
848
|
+
* The window of cards that stay mounted: one behind the last visible
|
|
849
|
+
* place, so it fades in rather than appearing, and one ahead of the top,
|
|
850
|
+
* so `undo` has a card to fly back in.
|
|
851
|
+
*/
|
|
852
|
+
const first = Math.max(0, index - 1);
|
|
853
|
+
const last = Math.min(count - 1, index + depth + 1);
|
|
854
|
+
|
|
855
|
+
/*
|
|
856
|
+
* Keyed on the labels themselves, for the same reason `allowed` is: a
|
|
857
|
+
* caller writing the labels inline hands over a new object every render,
|
|
858
|
+
* and this array is a prop of the card the drag is moving.
|
|
859
|
+
*/
|
|
860
|
+
const labelKey = directions.map((direction) => directionLabels?.[direction] ?? '').join('');
|
|
861
|
+
const accessibilityActions = useMemo(
|
|
862
|
+
() =>
|
|
863
|
+
allowed.map((direction) => ({
|
|
864
|
+
name: direction,
|
|
865
|
+
label: directionLabels?.[direction] ?? DEFAULT_DIRECTION_LABELS[direction],
|
|
866
|
+
})),
|
|
867
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
868
|
+
[allowed, labelKey]
|
|
869
|
+
);
|
|
870
|
+
|
|
871
|
+
/** Stable for the life of the deck: `dispatch` closes over nothing. */
|
|
872
|
+
const onAccessibilityAction = useCallback(
|
|
873
|
+
(event: AccessibilityActionEvent) =>
|
|
874
|
+
dispatch(event.nativeEvent.actionName as StackCardDirection),
|
|
875
|
+
[dispatch]
|
|
876
|
+
);
|
|
877
|
+
|
|
878
|
+
return (
|
|
879
|
+
<StackCardContext.Provider value={context}>
|
|
880
|
+
<View {...props} className={slots.root({ className })}>
|
|
881
|
+
{/*
|
|
882
|
+
* One detector, on the pile, for the life of the deck.
|
|
883
|
+
*
|
|
884
|
+
* Not on the top card, which is the arrangement that reads as
|
|
885
|
+
* natural and is the one thing in this file that nothing else in the
|
|
886
|
+
* library does. A detector mounted per card moves as the deck
|
|
887
|
+
* advances, and a gesture object carries a single mutable handler
|
|
888
|
+
* tag that the detector registers on attach and reads back on
|
|
889
|
+
* cleanup — so the detector being unmounted can drop the tag the
|
|
890
|
+
* newly mounted one has just claimed, leaving a live detector
|
|
891
|
+
* pointing at a destroyed native handler. The next touch reaches
|
|
892
|
+
* freed memory, which is a crash with no JavaScript frames in it.
|
|
893
|
+
*
|
|
894
|
+
* On the pile it never moves, and the gesture has no reason to: it
|
|
895
|
+
* writes one offset that only the top card's style reads.
|
|
896
|
+
*/}
|
|
897
|
+
<GestureDetector gesture={gesture}>
|
|
898
|
+
<View
|
|
899
|
+
onLayout={(event: LayoutChangeEvent) => {
|
|
900
|
+
width.value = event.nativeEvent.layout.width;
|
|
901
|
+
height.value = event.nativeEvent.layout.height;
|
|
902
|
+
}}
|
|
903
|
+
className={slots.pile({ className: pileClassName })}
|
|
904
|
+
>
|
|
905
|
+
{index >= count ? empty : null}
|
|
906
|
+
{cards.map((card, cardIndex) => {
|
|
907
|
+
if (cardIndex < first || cardIndex > last) return null;
|
|
908
|
+
const top = cardIndex === index;
|
|
909
|
+
const live = top && !disabled;
|
|
910
|
+
return (
|
|
911
|
+
<StackCardSlot
|
|
912
|
+
key={cardIndex}
|
|
913
|
+
cardIndex={cardIndex}
|
|
914
|
+
depth={depth}
|
|
915
|
+
layout={layout}
|
|
916
|
+
top={top}
|
|
917
|
+
live={live}
|
|
918
|
+
accessibilityActions={live ? accessibilityActions : undefined}
|
|
919
|
+
onAccessibilityAction={live ? onAccessibilityAction : undefined}
|
|
920
|
+
>
|
|
921
|
+
{card}
|
|
922
|
+
{top ? stamps : null}
|
|
923
|
+
</StackCardSlot>
|
|
924
|
+
);
|
|
925
|
+
})}
|
|
926
|
+
</View>
|
|
927
|
+
</GestureDetector>
|
|
928
|
+
{below}
|
|
929
|
+
</View>
|
|
930
|
+
</StackCardContext.Provider>
|
|
931
|
+
);
|
|
932
|
+
}
|
|
933
|
+
);
|
|
934
|
+
|
|
935
|
+
/** What a screen reader is offered when the caller names nothing better. */
|
|
936
|
+
const DEFAULT_DIRECTION_LABELS: Record<StackCardDirection, string> = {
|
|
937
|
+
left: 'Swipe left',
|
|
938
|
+
right: 'Swipe right',
|
|
939
|
+
up: 'Swipe up',
|
|
940
|
+
down: 'Swipe down',
|
|
941
|
+
};
|
|
942
|
+
|
|
943
|
+
/**
|
|
944
|
+
* Scheduled from the threshold reaction, so the question of whether haptics
|
|
945
|
+
* are wanted at all is answered off the UI thread.
|
|
946
|
+
*/
|
|
947
|
+
function tick(enabled: boolean) {
|
|
948
|
+
if (enabled) selectionTick();
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
/* -------------------------------------------------------------------------- */
|
|
952
|
+
/* Slot */
|
|
953
|
+
/* -------------------------------------------------------------------------- */
|
|
954
|
+
|
|
955
|
+
interface StackCardSlotProps {
|
|
956
|
+
cardIndex: number;
|
|
957
|
+
depth: number;
|
|
958
|
+
layout: StackCardLayout;
|
|
959
|
+
/** Whether this is the card on top, which is the only one a reader is shown. */
|
|
960
|
+
top: boolean;
|
|
961
|
+
/** Whether it also takes touches — false while the deck is disabled. */
|
|
962
|
+
live: boolean;
|
|
963
|
+
children: ReactNode;
|
|
964
|
+
accessibilityActions?: { name: string; label: string }[];
|
|
965
|
+
onAccessibilityAction?: (event: AccessibilityActionEvent) => void;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
/**
|
|
969
|
+
* One place in the pile, and the rule that puts a card there.
|
|
970
|
+
*
|
|
971
|
+
* A slot styles itself from its own distance to the top rather than being told
|
|
972
|
+
* where to sit, so the deck advancing is one shared value changing and no
|
|
973
|
+
* re-render at all — and a card mid-flight is styled by the same rule as the
|
|
974
|
+
* pile behind it rather than by a second one that has to agree with it.
|
|
975
|
+
*/
|
|
976
|
+
function StackCardSlot({
|
|
977
|
+
cardIndex,
|
|
978
|
+
depth,
|
|
979
|
+
layout,
|
|
980
|
+
top,
|
|
981
|
+
live,
|
|
982
|
+
children,
|
|
983
|
+
accessibilityActions,
|
|
984
|
+
onAccessibilityAction,
|
|
985
|
+
}: StackCardSlotProps) {
|
|
986
|
+
const { x, y, fade, pivot, release, active, width } = useStackCardContext('StackCard.Card');
|
|
987
|
+
const { card } = stackCardVariants();
|
|
988
|
+
/** Fixed per card, so a fan does not re-deal itself as the deck advances. */
|
|
989
|
+
const side = cardIndex % 2 === 0 ? 1 : -1;
|
|
990
|
+
|
|
991
|
+
/*
|
|
992
|
+
* Every branch returns the same four transforms in the same order, and says
|
|
993
|
+
* what it does not use with an identity value rather than by leaving the
|
|
994
|
+
* entry out. A card crosses between these branches as the deck advances, and
|
|
995
|
+
* a transform list that changes length or order between two commits is read
|
|
996
|
+
* natively as a different list — which is a crash, not a jump.
|
|
997
|
+
*/
|
|
998
|
+
const style = useAnimatedStyle(() => {
|
|
999
|
+
const distance = cardIndex - active.value;
|
|
1000
|
+
|
|
1001
|
+
/*
|
|
1002
|
+
* Answered, and still mounted only so `undo` has something to bring back.
|
|
1003
|
+
* Drawn nowhere until it is asked for.
|
|
1004
|
+
*/
|
|
1005
|
+
if (distance < 0) {
|
|
1006
|
+
return {
|
|
1007
|
+
opacity: 0,
|
|
1008
|
+
zIndex: 0,
|
|
1009
|
+
transform: [{ translateX: 0 }, { translateY: 0 }, { rotate: '0deg' }, { scale: 1 }],
|
|
1010
|
+
};
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
if (distance === 0) {
|
|
1014
|
+
return {
|
|
1015
|
+
opacity: fade.value,
|
|
1016
|
+
zIndex: 200,
|
|
1017
|
+
transform: [
|
|
1018
|
+
{ translateX: x.value },
|
|
1019
|
+
{ translateY: y.value },
|
|
1020
|
+
{ rotate: `${tiltAngle(x.value, width.value, MAX_TILT, pivot.value)}deg` },
|
|
1021
|
+
{ scale: 1 },
|
|
1022
|
+
],
|
|
1023
|
+
};
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
const behind = effectiveDepth(distance, release.value);
|
|
1027
|
+
const opacity = depthOpacity(behind, depth);
|
|
1028
|
+
const zIndex = Math.round(100 - behind * 10);
|
|
1029
|
+
|
|
1030
|
+
if (layout === 'flat') {
|
|
1031
|
+
// Nothing is drawn behind the top card, so the next one waits exactly
|
|
1032
|
+
// where the top card is and is simply uncovered as that one leaves.
|
|
1033
|
+
return {
|
|
1034
|
+
opacity: behind < 1 ? opacity : 0,
|
|
1035
|
+
zIndex,
|
|
1036
|
+
transform: [{ translateX: 0 }, { translateY: 0 }, { rotate: '0deg' }, { scale: 1 }],
|
|
1037
|
+
};
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
if (layout === 'fan') {
|
|
1041
|
+
return {
|
|
1042
|
+
opacity,
|
|
1043
|
+
zIndex,
|
|
1044
|
+
transform: [
|
|
1045
|
+
{ translateX: side * behind * FAN_SPREAD },
|
|
1046
|
+
{ translateY: behind * PEEK * 0.35 },
|
|
1047
|
+
{ rotate: `${side * behind * FAN_TILT}deg` },
|
|
1048
|
+
{ scale: 1 - behind * SHRINK * 0.6 },
|
|
1049
|
+
],
|
|
1050
|
+
};
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
return {
|
|
1054
|
+
opacity,
|
|
1055
|
+
zIndex,
|
|
1056
|
+
transform: [
|
|
1057
|
+
{ translateX: 0 },
|
|
1058
|
+
{ translateY: behind * PEEK },
|
|
1059
|
+
{ rotate: '0deg' },
|
|
1060
|
+
{ scale: 1 - behind * SHRINK },
|
|
1061
|
+
],
|
|
1062
|
+
};
|
|
1063
|
+
});
|
|
1064
|
+
|
|
1065
|
+
return (
|
|
1066
|
+
<Animated.View
|
|
1067
|
+
style={style}
|
|
1068
|
+
pointerEvents={live ? 'auto' : 'none'}
|
|
1069
|
+
className={card()}
|
|
1070
|
+
// Every card but the top one is out of the reading order. A pile is one
|
|
1071
|
+
// card as far as a reader is concerned, and the rest are its shadow.
|
|
1072
|
+
accessibilityElementsHidden={!top}
|
|
1073
|
+
importantForAccessibility={top ? 'auto' : 'no-hide-descendants'}
|
|
1074
|
+
// A view's custom actions are only offered when the view itself is an
|
|
1075
|
+
// element a reader can focus, and a view full of text is not one. So the
|
|
1076
|
+
// top card is: it is read as one card, with a direction for each action.
|
|
1077
|
+
accessible={top}
|
|
1078
|
+
accessibilityActions={accessibilityActions}
|
|
1079
|
+
onAccessibilityAction={onAccessibilityAction}
|
|
1080
|
+
>
|
|
1081
|
+
<StackCardSlotIndex.Provider value={cardIndex}>{children}</StackCardSlotIndex.Provider>
|
|
1082
|
+
</Animated.View>
|
|
1083
|
+
);
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
/* -------------------------------------------------------------------------- */
|
|
1087
|
+
/* Card */
|
|
1088
|
+
/* -------------------------------------------------------------------------- */
|
|
1089
|
+
|
|
1090
|
+
export interface StackCardCardProps extends ViewProps {
|
|
1091
|
+
className?: string;
|
|
1092
|
+
children?: ReactNode;
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
/**
|
|
1096
|
+
* One card. Filled, bordered and rounded out of the box, so a deck of plain
|
|
1097
|
+
* content already reads as a deck, and restyled from `className` like anything
|
|
1098
|
+
* else. It fills the pile on both axes: the pile's box is the card's size.
|
|
1099
|
+
*/
|
|
1100
|
+
const StackCardCard = forwardRef<View, StackCardCardProps>(({ className, ...props }, ref) => (
|
|
1101
|
+
<View
|
|
1102
|
+
ref={ref}
|
|
1103
|
+
{...props}
|
|
1104
|
+
className={cn(
|
|
1105
|
+
'h-full w-full overflow-hidden rounded-3xl border border-border bg-card',
|
|
1106
|
+
className
|
|
1107
|
+
)}
|
|
1108
|
+
/>
|
|
1109
|
+
));
|
|
1110
|
+
|
|
1111
|
+
/* -------------------------------------------------------------------------- */
|
|
1112
|
+
/* Stamp */
|
|
1113
|
+
/* -------------------------------------------------------------------------- */
|
|
1114
|
+
|
|
1115
|
+
/**
|
|
1116
|
+
* A stamp is a filled block of colour with a word on it, turned a few degrees
|
|
1117
|
+
* so it reads as pressed onto the card rather than laid out on it.
|
|
1118
|
+
*
|
|
1119
|
+
* The fill is the status colour at full strength rather than a tint of it: a
|
|
1120
|
+
* stamp exists only for the moment it appears, and a six-per-cent wash of the
|
|
1121
|
+
* card's own surface is a smudge rather than an answer. The word is carried in
|
|
1122
|
+
* white, which is what the status colours are chosen to take — a status's
|
|
1123
|
+
* `-foreground` token is its darker text form, meant for a neutral surface,
|
|
1124
|
+
* and over the fill it is the same hue twice.
|
|
1125
|
+
*
|
|
1126
|
+
* It sits in the corner the card is being pulled away from, which is also the
|
|
1127
|
+
* corner the thumb is not over.
|
|
1128
|
+
*/
|
|
1129
|
+
const stampVariants = tv({
|
|
1130
|
+
slots: {
|
|
1131
|
+
root: 'absolute inset-x-6 z-10',
|
|
1132
|
+
pill: 'rounded-xl px-4 py-2',
|
|
1133
|
+
label: 'text-lg font-bold uppercase tracking-widest',
|
|
1134
|
+
},
|
|
1135
|
+
variants: {
|
|
1136
|
+
color: {
|
|
1137
|
+
default: { pill: 'bg-muted-foreground', label: 'text-background' },
|
|
1138
|
+
primary: { pill: 'bg-primary', label: 'text-primary-foreground' },
|
|
1139
|
+
success: { pill: 'bg-success', label: 'text-success-solid-foreground' },
|
|
1140
|
+
warning: { pill: 'bg-warning', label: 'text-warning-solid-foreground' },
|
|
1141
|
+
info: { pill: 'bg-info', label: 'text-info-solid-foreground' },
|
|
1142
|
+
destructive: {
|
|
1143
|
+
pill: 'bg-destructive',
|
|
1144
|
+
label: 'text-destructive-solid-foreground',
|
|
1145
|
+
},
|
|
1146
|
+
},
|
|
1147
|
+
direction: {
|
|
1148
|
+
left: { root: 'top-6 items-end', pill: 'rotate-12' },
|
|
1149
|
+
right: { root: 'top-6 items-start', pill: '-rotate-12' },
|
|
1150
|
+
up: { root: 'bottom-6 items-center' },
|
|
1151
|
+
down: { root: 'top-6 items-center' },
|
|
1152
|
+
},
|
|
1153
|
+
},
|
|
1154
|
+
defaultVariants: {
|
|
1155
|
+
color: 'default',
|
|
1156
|
+
direction: 'right',
|
|
1157
|
+
},
|
|
1158
|
+
});
|
|
1159
|
+
|
|
1160
|
+
export type StackCardStampColor =
|
|
1161
|
+
| 'default'
|
|
1162
|
+
| 'primary'
|
|
1163
|
+
| 'success'
|
|
1164
|
+
| 'warning'
|
|
1165
|
+
| 'info'
|
|
1166
|
+
| 'destructive';
|
|
1167
|
+
|
|
1168
|
+
export interface StackCardStampProps
|
|
1169
|
+
extends Omit<ViewProps, 'children'>,
|
|
1170
|
+
VariantProps<typeof stampVariants> {
|
|
1171
|
+
className?: string;
|
|
1172
|
+
/** The word, or anything else to draw on the stamp. */
|
|
1173
|
+
children?: ReactNode;
|
|
1174
|
+
/** Which direction the stamp answers for. Also where on the card it goes. */
|
|
1175
|
+
direction?: StackCardDirection;
|
|
1176
|
+
/** Extra classes for the label, when the stamp is given a string. */
|
|
1177
|
+
labelClassName?: string;
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
/**
|
|
1181
|
+
* The answer a throw is about to give, faded in as the card is carried toward
|
|
1182
|
+
* giving it.
|
|
1183
|
+
*
|
|
1184
|
+
* It reaches full strength exactly where letting go would commit, so a solid
|
|
1185
|
+
* stamp and the haptic tick are the same statement made twice — which is the
|
|
1186
|
+
* point, since the haptic is off for a lot of people and silent on most
|
|
1187
|
+
* Android hardware.
|
|
1188
|
+
*/
|
|
1189
|
+
const StackCardStamp = forwardRef<View, StackCardStampProps>(
|
|
1190
|
+
(
|
|
1191
|
+
{
|
|
1192
|
+
className,
|
|
1193
|
+
labelClassName,
|
|
1194
|
+
color = 'default',
|
|
1195
|
+
direction = 'right',
|
|
1196
|
+
children,
|
|
1197
|
+
style: styleProp,
|
|
1198
|
+
...props
|
|
1199
|
+
},
|
|
1200
|
+
ref
|
|
1201
|
+
) => {
|
|
1202
|
+
const { x, y, width, height, threshold, active } = useStackCardContext('StackCard.Stamp');
|
|
1203
|
+
const slotIndex = useContext(StackCardSlotIndex);
|
|
1204
|
+
const slots = stampVariants({ color, direction });
|
|
1205
|
+
|
|
1206
|
+
const style = useAnimatedStyle(() => {
|
|
1207
|
+
// Not on a card the UI thread has not put on top yet. Outside a slot
|
|
1208
|
+
// there is no card to disagree with, so it follows the drag as before.
|
|
1209
|
+
const onTop = slotIndex === null || slotIndex === active.value;
|
|
1210
|
+
const progress = onTop
|
|
1211
|
+
? directionProgress(direction, x.value, y.value, width.value, height.value, threshold)
|
|
1212
|
+
: 0;
|
|
1213
|
+
return {
|
|
1214
|
+
opacity: progress,
|
|
1215
|
+
// Landing rather than appearing: it is stamped on as the card commits.
|
|
1216
|
+
transform: [{ scale: interpolate(progress, [0, 1], [0.8, 1]) }],
|
|
1217
|
+
};
|
|
1218
|
+
});
|
|
1219
|
+
|
|
1220
|
+
return (
|
|
1221
|
+
<Animated.View
|
|
1222
|
+
ref={ref}
|
|
1223
|
+
{...props}
|
|
1224
|
+
// The animation last, so a caller's style adds to it rather than
|
|
1225
|
+
// replacing the opacity and scale that make it a stamp.
|
|
1226
|
+
style={[styleProp, style]}
|
|
1227
|
+
className={slots.root({ className })}
|
|
1228
|
+
>
|
|
1229
|
+
<View className={slots.pill()}>
|
|
1230
|
+
{typeof children === 'string' || typeof children === 'number' ? (
|
|
1231
|
+
<Text className={slots.label({ className: labelClassName })}>{children}</Text>
|
|
1232
|
+
) : (
|
|
1233
|
+
children
|
|
1234
|
+
)}
|
|
1235
|
+
</View>
|
|
1236
|
+
</Animated.View>
|
|
1237
|
+
);
|
|
1238
|
+
}
|
|
1239
|
+
);
|
|
1240
|
+
|
|
1241
|
+
/* -------------------------------------------------------------------------- */
|
|
1242
|
+
/* Empty */
|
|
1243
|
+
/* -------------------------------------------------------------------------- */
|
|
1244
|
+
|
|
1245
|
+
export interface StackCardEmptyProps extends ViewProps {
|
|
1246
|
+
className?: string;
|
|
1247
|
+
children?: ReactNode;
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
/**
|
|
1251
|
+
* What is under the deck once the last card has gone.
|
|
1252
|
+
*
|
|
1253
|
+
* Mounted only when the deck is exhausted, so a card flying off never crosses
|
|
1254
|
+
* it. A deck with none of these leaves the empty box the cards were in, which
|
|
1255
|
+
* is the right answer when something else on the screen already says the queue
|
|
1256
|
+
* is finished.
|
|
1257
|
+
*/
|
|
1258
|
+
const StackCardEmpty = forwardRef<View, StackCardEmptyProps>(({ className, ...props }, ref) => (
|
|
1259
|
+
<View
|
|
1260
|
+
ref={ref}
|
|
1261
|
+
{...props}
|
|
1262
|
+
className={cn(
|
|
1263
|
+
'absolute inset-0 items-center justify-center gap-2 rounded-3xl border border-dashed border-border p-6',
|
|
1264
|
+
className
|
|
1265
|
+
)}
|
|
1266
|
+
/>
|
|
1267
|
+
));
|
|
1268
|
+
|
|
1269
|
+
/* -------------------------------------------------------------------------- */
|
|
1270
|
+
/* Actions */
|
|
1271
|
+
/* -------------------------------------------------------------------------- */
|
|
1272
|
+
|
|
1273
|
+
export interface StackCardActionsProps extends ViewProps {
|
|
1274
|
+
className?: string;
|
|
1275
|
+
children?: ReactNode;
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
/**
|
|
1279
|
+
* The row of buttons under the pile. Laid out after it rather than over it, so
|
|
1280
|
+
* the buttons are not competing with the card for the same touches.
|
|
1281
|
+
*/
|
|
1282
|
+
const StackCardActions = forwardRef<View, StackCardActionsProps>(
|
|
1283
|
+
({ className, ...props }, ref) => (
|
|
1284
|
+
<View
|
|
1285
|
+
ref={ref}
|
|
1286
|
+
{...props}
|
|
1287
|
+
className={cn('flex-row items-center justify-center gap-4 pt-5', className)}
|
|
1288
|
+
/>
|
|
1289
|
+
)
|
|
1290
|
+
);
|
|
1291
|
+
|
|
1292
|
+
const actionVariants = tv({
|
|
1293
|
+
slots: {
|
|
1294
|
+
root: 'items-center justify-center rounded-full border',
|
|
1295
|
+
},
|
|
1296
|
+
variants: {
|
|
1297
|
+
color: {
|
|
1298
|
+
default: { root: 'border-border bg-card' },
|
|
1299
|
+
primary: { root: 'border-primary bg-primary' },
|
|
1300
|
+
success: { root: 'border-success bg-success' },
|
|
1301
|
+
warning: { root: 'border-warning bg-warning' },
|
|
1302
|
+
info: { root: 'border-info bg-info' },
|
|
1303
|
+
destructive: { root: 'border-destructive bg-destructive' },
|
|
1304
|
+
},
|
|
1305
|
+
size: {
|
|
1306
|
+
sm: { root: 'h-10 w-10' },
|
|
1307
|
+
md: { root: 'h-14 w-14' },
|
|
1308
|
+
lg: { root: 'h-16 w-16' },
|
|
1309
|
+
},
|
|
1310
|
+
},
|
|
1311
|
+
defaultVariants: {
|
|
1312
|
+
color: 'default',
|
|
1313
|
+
size: 'md',
|
|
1314
|
+
},
|
|
1315
|
+
});
|
|
1316
|
+
|
|
1317
|
+
/** How big a glyph is drawn on each button size. */
|
|
1318
|
+
const ACTION_ICON_SIZE = { sm: 18, md: 24, lg: 28 } as const;
|
|
1319
|
+
|
|
1320
|
+
export interface StackCardActionProps
|
|
1321
|
+
extends Omit<ViewProps, 'children'>,
|
|
1322
|
+
VariantProps<typeof actionVariants> {
|
|
1323
|
+
className?: string;
|
|
1324
|
+
/** What pressing it does: send the top card that way, or bring the last one back. */
|
|
1325
|
+
action: StackCardDirection | 'undo';
|
|
1326
|
+
/** The glyph. Sized and tinted by the button — pass neither. */
|
|
1327
|
+
icon?: ReactNode;
|
|
1328
|
+
/**
|
|
1329
|
+
* What a screen reader is offered. Falls back to "Undo", or to the plain
|
|
1330
|
+
* name of the direction.
|
|
1331
|
+
*/
|
|
1332
|
+
label?: string;
|
|
1333
|
+
/** Run after the deck has been told, for a sound or a log. */
|
|
1334
|
+
onPress?: () => void;
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
/**
|
|
1338
|
+
* One decision as an ordinary button.
|
|
1339
|
+
*
|
|
1340
|
+
* This is the accessible path through a deck, and it is also the path a lot of
|
|
1341
|
+
* sighted people take on the card they are unsure about: a throw looks
|
|
1342
|
+
* irreversible in a way a tap does not, so the pair is not redundant.
|
|
1343
|
+
*
|
|
1344
|
+
* `undo` disables itself on the first card, where there is nothing to bring
|
|
1345
|
+
* back, and every other action disables itself once the deck is empty — a
|
|
1346
|
+
* button that still looks pressable over an exhausted deck is the commonest
|
|
1347
|
+
* way one of these ends up feeling broken.
|
|
1348
|
+
*/
|
|
1349
|
+
const StackCardAction = forwardRef<View, StackCardActionProps>(
|
|
1350
|
+
({ className, action, icon, label, color = 'default', size = 'md', onPress, ...props }, ref) => {
|
|
1351
|
+
const context = useStackCardContext('StackCard.Action');
|
|
1352
|
+
const slots = actionVariants({ color, size });
|
|
1353
|
+
const tint = useActionTint(color as StackCardStampColor);
|
|
1354
|
+
|
|
1355
|
+
const isUndo = action === 'undo';
|
|
1356
|
+
const disabled =
|
|
1357
|
+
context.disabled || (isUndo ? !context.canUndo : context.index >= context.count);
|
|
1358
|
+
|
|
1359
|
+
return (
|
|
1360
|
+
<AnimatedPressable
|
|
1361
|
+
ref={ref}
|
|
1362
|
+
accessibilityRole="button"
|
|
1363
|
+
accessibilityLabel={label ?? (isUndo ? 'Undo' : DEFAULT_DIRECTION_LABELS[action])}
|
|
1364
|
+
accessibilityState={{ disabled }}
|
|
1365
|
+
disabled={disabled}
|
|
1366
|
+
onPress={() => {
|
|
1367
|
+
if (isUndo) context.undo();
|
|
1368
|
+
else context.send(action);
|
|
1369
|
+
onPress?.();
|
|
1370
|
+
}}
|
|
1371
|
+
{...props}
|
|
1372
|
+
className={slots.root({ className: cn(disabled && 'opacity-40', className) })}
|
|
1373
|
+
>
|
|
1374
|
+
<IconColorProvider color={tint}>{sizeIcon(icon, size ?? 'md')}</IconColorProvider>
|
|
1375
|
+
</AnimatedPressable>
|
|
1376
|
+
);
|
|
1377
|
+
}
|
|
1378
|
+
);
|
|
1379
|
+
|
|
1380
|
+
/**
|
|
1381
|
+
* The glyph at button size, unless the caller asked for one. Sized here rather
|
|
1382
|
+
* than at every call site, because a row of these reads as a set only while
|
|
1383
|
+
* the icons in it match.
|
|
1384
|
+
*/
|
|
1385
|
+
function sizeIcon(icon: ReactNode, size: 'sm' | 'md' | 'lg'): ReactNode {
|
|
1386
|
+
if (!isValidElement<{ size?: number }>(icon)) return icon;
|
|
1387
|
+
if (icon.props.size !== undefined) return icon;
|
|
1388
|
+
return cloneElement(icon, { size: ACTION_ICON_SIZE[size] });
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
/**
|
|
1392
|
+
* The colour a glyph is drawn in on a button — the token that reads against
|
|
1393
|
+
* its fill. Resolved from the theme wherever the theme has an answer, since a
|
|
1394
|
+
* hex stops being right the moment the theme inverts; white is the exception,
|
|
1395
|
+
* because a status fill is the same saturated colour in every theme and white
|
|
1396
|
+
* is what it carries.
|
|
1397
|
+
*
|
|
1398
|
+
* Both tokens are read on every render because a hook cannot be called for one
|
|
1399
|
+
* branch only. They are variable lookups, not work.
|
|
1400
|
+
*/
|
|
1401
|
+
function useActionTint(color: StackCardStampColor): string | undefined {
|
|
1402
|
+
const foreground = useCSSVariable('--color-foreground');
|
|
1403
|
+
const primary = useCSSVariable('--color-primary-foreground');
|
|
1404
|
+
|
|
1405
|
+
if (color === 'default') return typeof foreground === 'string' ? foreground : undefined;
|
|
1406
|
+
if (color === 'primary') return typeof primary === 'string' ? primary : undefined;
|
|
1407
|
+
return '#ffffff';
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
StackCardRoot.displayName = 'StackCard';
|
|
1411
|
+
StackCardCard.displayName = 'StackCard.Card';
|
|
1412
|
+
StackCardStamp.displayName = 'StackCard.Stamp';
|
|
1413
|
+
StackCardEmpty.displayName = 'StackCard.Empty';
|
|
1414
|
+
StackCardActions.displayName = 'StackCard.Actions';
|
|
1415
|
+
StackCardAction.displayName = 'StackCard.Action';
|
|
1416
|
+
|
|
1417
|
+
export const StackCard = Object.assign(StackCardRoot, {
|
|
1418
|
+
Card: StackCardCard,
|
|
1419
|
+
Stamp: StackCardStamp,
|
|
1420
|
+
Empty: StackCardEmpty,
|
|
1421
|
+
Actions: StackCardActions,
|
|
1422
|
+
Action: StackCardAction,
|
|
1423
|
+
});
|