@delacour/react-native-ui 0.1.0-alpha.20261007130133 → 0.1.0-alpha.20261009010050
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/package.json +4 -3
- package/src/components/stack-card/AGENTS.md +169 -0
- package/src/components/stack-card/index.ts +37 -0
- package/src/components/stack-card/stack-card-action.tsx +66 -0
- package/src/components/stack-card/stack-card-actions.tsx +18 -0
- package/src/components/stack-card/stack-card-card.tsx +71 -0
- package/src/components/stack-card/stack-card-empty.tsx +30 -0
- package/src/components/stack-card/stack-card-slot.tsx +90 -0
- package/src/components/stack-card/stack-card-stamp.tsx +76 -0
- package/src/components/stack-card/stack-card.context.tsx +117 -0
- package/src/components/stack-card/stack-card.tsx +557 -0
- package/src/components/stack-card/stack-card.types.ts +26 -0
- package/src/components/stack-card/stack-card.variants.test.ts +355 -0
- package/src/components/stack-card/stack-card.variants.ts +360 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@delacour/react-native-ui",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.20261009010050",
|
|
4
4
|
"description": "React Native UI components — Uniwind, Reanimated, Gesture Handler",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -69,6 +69,7 @@
|
|
|
69
69
|
"./skeleton": "./src/components/skeleton/index.ts",
|
|
70
70
|
"./slider": "./src/components/slider/index.ts",
|
|
71
71
|
"./spinner": "./src/components/spinner/index.ts",
|
|
72
|
+
"./stack-card": "./src/components/stack-card/index.ts",
|
|
72
73
|
"./steps": "./src/components/steps/index.ts",
|
|
73
74
|
"./surface": "./src/components/surface/index.ts",
|
|
74
75
|
"./swipe": "./src/components/swipe/index.ts",
|
|
@@ -109,8 +110,8 @@
|
|
|
109
110
|
},
|
|
110
111
|
"peerDependencies": {
|
|
111
112
|
"@central-icons-react-native/round-outlined-radius-1-stroke-1.5": "^1.1",
|
|
112
|
-
"@delacour/react-native-bottom-sheet": "0.1.0-alpha.
|
|
113
|
-
"@delacour/react-native-charts": "0.1.0-alpha.
|
|
113
|
+
"@delacour/react-native-bottom-sheet": "0.1.0-alpha.20261009010050",
|
|
114
|
+
"@delacour/react-native-charts": "0.1.0-alpha.20261009010050",
|
|
114
115
|
"@legendapp/list": ">=3.3",
|
|
115
116
|
"expo-linear-gradient": ">=15",
|
|
116
117
|
"expo-router": ">=57",
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# StackCard
|
|
2
|
+
|
|
3
|
+
A pile of cards taken one at a time by **throwing the top one off**. Compound
|
|
4
|
+
root plus `Card`, `Stamp`, `Empty`, `Actions` and `Action`. For a queue where
|
|
5
|
+
each item gets one decision and is then gone — a review queue, flashcards,
|
|
6
|
+
suggestions. It shows one card, so it is wrong for anything the reader must
|
|
7
|
+
compare or skim: browsing wants a carousel, and one row's actions want a swipe
|
|
8
|
+
row.
|
|
9
|
+
|
|
10
|
+
`import { StackCard } from "@delacour/react-native-ui/stack-card";`
|
|
11
|
+
|
|
12
|
+
```tsx
|
|
13
|
+
<StackCard className="h-[460px]" onSwipe={(dir, i) => decide(people[i], dir)}>
|
|
14
|
+
<StackCard.Stamp direction="right" color="success">Yes</StackCard.Stamp>
|
|
15
|
+
<StackCard.Stamp direction="left" color="destructive">No</StackCard.Stamp>
|
|
16
|
+
{people.map((p) => <StackCard.Card key={p.id}>…</StackCard.Card>)}
|
|
17
|
+
<StackCard.Empty>All caught up</StackCard.Empty>
|
|
18
|
+
<StackCard.Actions>
|
|
19
|
+
<StackCard.Action action="left" icon={IconCrossSmall} label="Skip" />
|
|
20
|
+
<StackCard.Action action="undo" icon={IconArrowRotateCounterClockwise} />
|
|
21
|
+
<StackCard.Action action="right" icon={IconCheckmark2} label="Save" />
|
|
22
|
+
</StackCard.Actions>
|
|
23
|
+
</StackCard>
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Files
|
|
27
|
+
|
|
28
|
+
| File | What it holds |
|
|
29
|
+
| --- | --- |
|
|
30
|
+
| `index.ts` | → `@delacour/react-native-ui/stack-card` |
|
|
31
|
+
| `stack-card.tsx` | Root: partitions children, owns the pan, the throw, undo, decline, the mounted window |
|
|
32
|
+
| `stack-card-slot.tsx` | Internal animated box one card sits in — reads the shared offset, places itself |
|
|
33
|
+
| `stack-card-card.tsx` | `StackCard.Card` — the card's look, and the top card's accessibility actions |
|
|
34
|
+
| `stack-card-stamp.tsx` | `StackCard.Stamp` — fades in with progress toward its direction |
|
|
35
|
+
| `stack-card-empty.tsx` | `StackCard.Empty` |
|
|
36
|
+
| `stack-card-actions.tsx` | `StackCard.Actions` — the centred row under the pile |
|
|
37
|
+
| `stack-card-action.tsx` | `StackCard.Action` — a round `Button` that throws or undoes |
|
|
38
|
+
| `stack-card.context.tsx` | `StackCardProvider`, `useStackCard()`, `useStackCardContext()`, `useStackCardPart()`, the slot context |
|
|
39
|
+
| `stack-card.types.ts` | `StackCardHandle`, `StackCardState` — shared by the root and the context |
|
|
40
|
+
| `stack-card.variants.ts` | The slotted `tv()` and every pure resolver, the gesture's worklets included — no RN imports |
|
|
41
|
+
| `stack-card.variants.test.ts` | |
|
|
42
|
+
|
|
43
|
+
## Axes
|
|
44
|
+
|
|
45
|
+
- **`directions`**: any of `left`, `right`, `up`, `down`. Default left and right.
|
|
46
|
+
- **`layout`** (the cards behind only): `stack` steps each down 8 and smaller by
|
|
47
|
+
4%; `fan` rotates them ±3° per step, alternating, about the bottom centre;
|
|
48
|
+
`flat` hides them.
|
|
49
|
+
- **`depth`**: cards drawn behind the top, 0..4, default 2.
|
|
50
|
+
- **`threshold`**: the fraction of the card's width (or height) a drag must
|
|
51
|
+
cover, default 0.3.
|
|
52
|
+
- **Stamp `color`**: `primary`, `success`, `warning`, `info`, `destructive` —
|
|
53
|
+
each a token declared in both themes, which the test checks.
|
|
54
|
+
|
|
55
|
+
## Design
|
|
56
|
+
|
|
57
|
+
- **One shared value drives everything: the top card's `{x, y}`.** The top card
|
|
58
|
+
follows it and tilts `x / width · 12°`, the stamps read progress toward their
|
|
59
|
+
direction from it, and the cards behind interpolate toward the slot ahead of
|
|
60
|
+
them on the furthest progress (`resolveDragProgress`). A drag writes shared
|
|
61
|
+
values and nothing else, so **nothing re-renders during a drag** — React only
|
|
62
|
+
hears about a throw after it lands.
|
|
63
|
+
- **The card behind is in place at the moment of release.** Progress reaches 1
|
|
64
|
+
at the threshold, and `resolveBehindTransform` at progress 1 is exactly the
|
|
65
|
+
transform of the slot ahead — a test asserts it for every layout. So when the
|
|
66
|
+
top card is thrown, the next card is already where the top was, and advancing
|
|
67
|
+
moves nothing. Without it the pile would jump a step on every throw.
|
|
68
|
+
- **The top moves on the UI thread, ahead of React.** `top` is a shared value the
|
|
69
|
+
throw sets the frame it lands; every slot places itself from `cardIndex − top`.
|
|
70
|
+
Waiting for React to re-render the new index would leave a frame where the
|
|
71
|
+
thrown card is gone and the next one has not been promoted.
|
|
72
|
+
- **Each worklet is self-contained**, and the resolvers it calls each carry
|
|
73
|
+
`"worklet"` and call no other module helper (see
|
|
74
|
+
[Pressable](../pressable/AGENTS.md) for the crash a module-scope helper
|
|
75
|
+
causes). `resolveBehindTransform`'s inner `at` is declared inside its body for
|
|
76
|
+
that reason.
|
|
77
|
+
- **Release:** `resolveStackRelease` projects the position 0.15 s along the
|
|
78
|
+
release velocity, so a flick throws from a short drag and a flick back cancels
|
|
79
|
+
a long one. Each axis is measured as a fraction of the card's own size along
|
|
80
|
+
it, and the dominant axis alone decides. A direction the deck does not allow
|
|
81
|
+
gives `null` and the card springs back.
|
|
82
|
+
- **A disallowed direction still gives a quarter** (`resolveDragOffset`) and
|
|
83
|
+
returns. A card that does not budge at all reads as frozen, where one that
|
|
84
|
+
gives and returns reads as "not that way".
|
|
85
|
+
- **A horizontal-only deck lets vertical drags through** — `activeOffsetX` and
|
|
86
|
+
`failOffsetY`, so it sits inside a vertical `ScrollView`. Vertical-only is the
|
|
87
|
+
mirror. A deck that throws all four ways claims both axes with `minDistance`
|
|
88
|
+
and **must not be nested in a scroller** — the doc comment says so. This is
|
|
89
|
+
the opposite of `Slider`'s `minDistance(0)`: a slider must win every touch on
|
|
90
|
+
its track, a deck must give up the ones that are scrolls.
|
|
91
|
+
- **A controlled deck that does not move `index` declines.** A throw calls
|
|
92
|
+
`onSwipe`, then `onIndexChange`, and records the throw as pending. The effect
|
|
93
|
+
after the next render compares: an index that moved is accepted and pushed to
|
|
94
|
+
the undo history; an index that stayed put flies the card back in from the
|
|
95
|
+
side it left. The pending record is state, not a ref, so the deck re-renders
|
|
96
|
+
and checks even when the parent's `onIndexChange` changes nothing. A confirm
|
|
97
|
+
step is built on this: decline, ask, and on "yes" call `ref.swipe(dir)` again
|
|
98
|
+
and accept it — the `controlled-decline` demo.
|
|
99
|
+
- **Undo restores from the side it left.** The deck keeps a history of the
|
|
100
|
+
directions it threw; `undo` pops one, steps the index back and flies the card
|
|
101
|
+
in from that side. `onSwipe` is not called for an undo. An index changed from
|
|
102
|
+
outside (a refill) clears the history, since the cards it remembers may not be
|
|
103
|
+
the ones now in the deck.
|
|
104
|
+
- **A card returns on a critically damped spring.** An underdamped return
|
|
105
|
+
overshoots rest to the other side, and the stamp for the opposite direction
|
|
106
|
+
fades in there — a card undone from the left would land flashing the
|
|
107
|
+
right-hand answer. The damping sits just above `2·√(stiffness·mass)`, so the
|
|
108
|
+
card arrives without crossing.
|
|
109
|
+
- **Only a window is mounted:** `[index − 1, index + depth + 1]` —
|
|
110
|
+
one behind for undo, one beyond the visible depth so the next card fades in
|
|
111
|
+
rather than popping. A deck of 500 mounts what a deck of five does
|
|
112
|
+
(`resolveMountedWindow`). The one beyond starts at opacity 0 and reaches the
|
|
113
|
+
last visible slot's opacity at progress 1.
|
|
114
|
+
- **Slots render deepest first**, so the top card draws over the ones behind and
|
|
115
|
+
the card just thrown — kept mounted for undo, hidden — draws over everything
|
|
116
|
+
when it flies back in.
|
|
117
|
+
- **Every transform pivots on the bottom centre.** That is what makes `fan` fan,
|
|
118
|
+
and what makes a smaller `stack` card's lower edge peek out below the one in
|
|
119
|
+
front. The pile keeps `depth · 8` points clear under the cards in `stack` so
|
|
120
|
+
that edge is not clipped by whatever sits below.
|
|
121
|
+
- **Anything that is not a `Stamp`, `Empty` or `Actions` is a card.** A caller
|
|
122
|
+
who wraps `StackCard.Card` in a component of their own still gets a card,
|
|
123
|
+
rather than a child silently dropped for having the wrong type
|
|
124
|
+
(`partitionStackChildren` takes the predicate as an argument to stay free of
|
|
125
|
+
the parts and of React Native).
|
|
126
|
+
- **Stamps are declared once on the deck and drawn on the top card**, on the side
|
|
127
|
+
they answer for — a right-hand stamp sits top-start, tilted −12°, where a card
|
|
128
|
+
leaning right lifts it into view. A stamp stays dark while the other axis
|
|
129
|
+
dominates, so a diagonal drag in a four-way deck shows one answer, not two.
|
|
130
|
+
Their text colour is on `stampLabel`, never on the stamp (rule 1).
|
|
131
|
+
- **An action's glyph takes the colour of its direction's stamp**, so the button
|
|
132
|
+
and the stamp read as one answer. `undo` is a `ghost` button; the directions
|
|
133
|
+
are `secondary`; all are `icon-lg` circles. A direction button works whether or
|
|
134
|
+
not the gesture allows that direction — a button is an explicit answer.
|
|
135
|
+
- **Haptics default to `selection`**, which is a deliberate departure from the
|
|
136
|
+
package's off-by-default: the tick when a drag crosses the threshold is the
|
|
137
|
+
feedback that says "let go now", and a deck without it feels uncertain. It
|
|
138
|
+
re-arms when the drag crosses back. A throw knocks with `medium`. `haptic={false}`
|
|
139
|
+
silences both.
|
|
140
|
+
|
|
141
|
+
## Reduced motion
|
|
142
|
+
|
|
143
|
+
- **A card leaves by fading, not flying**, and an undone or declined card fades
|
|
144
|
+
back in at rest. Which card is on top is the information, so the swap itself
|
|
145
|
+
is kept.
|
|
146
|
+
- **The pile swaps without stepping:** under reduced motion the cards behind do
|
|
147
|
+
not interpolate during a drag; they take their new places when the throw lands.
|
|
148
|
+
- A short drag released returns at once rather than springing.
|
|
149
|
+
|
|
150
|
+
## Accessibility
|
|
151
|
+
|
|
152
|
+
- **The top card is one `accessible` element** with a custom action per allowed
|
|
153
|
+
direction — named by `directionLabels[dir]`, or "Swipe left" and so on — plus
|
|
154
|
+
"Undo" while there is a card to bring back. No role is set: it is not
|
|
155
|
+
`adjustable`, since the actions are not a value going up and down.
|
|
156
|
+
- **Every card but the top is hidden** — `importantForAccessibility="no-hide-descendants"`
|
|
157
|
+
on Android, `accessibilityElementsHidden` on iOS — and takes no touches.
|
|
158
|
+
- Because the top card is one element, a control inside it is not separately
|
|
159
|
+
reachable. The deck's controls belong in `StackCard.Actions`, whose buttons
|
|
160
|
+
carry the same labels.
|
|
161
|
+
- Stamps are hidden from assistive technology; the actions say the same thing in
|
|
162
|
+
words.
|
|
163
|
+
|
|
164
|
+
## Out of scope
|
|
165
|
+
|
|
166
|
+
- A carousel or any browsing mode: one card shows, by design.
|
|
167
|
+
- RTL mirroring of stamp placement — the stamps sit by physical side, because the
|
|
168
|
+
direction they answer for is physical too.
|
|
169
|
+
- A render callback per card, or virtualising beyond the mounted window.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export { StackCard, type StackCardProps } from "./stack-card";
|
|
2
|
+
export {
|
|
3
|
+
type StackCardContextValue,
|
|
4
|
+
StackCardProvider,
|
|
5
|
+
useStackCard,
|
|
6
|
+
useStackCardContext,
|
|
7
|
+
} from "./stack-card.context";
|
|
8
|
+
export type { StackCardHandle, StackCardState } from "./stack-card.types";
|
|
9
|
+
export {
|
|
10
|
+
partitionStackChildren,
|
|
11
|
+
resolveBehindTransform,
|
|
12
|
+
resolveDragOffset,
|
|
13
|
+
resolveDragProgress,
|
|
14
|
+
resolveExitOffset,
|
|
15
|
+
resolveMountedWindow,
|
|
16
|
+
resolveStackDepth,
|
|
17
|
+
resolveStackRelease,
|
|
18
|
+
resolveStampOpacity,
|
|
19
|
+
STACK_CARD_DEFAULT_DIRECTION_LABELS,
|
|
20
|
+
STACK_CARD_DEFAULT_DIRECTIONS,
|
|
21
|
+
STACK_CARD_DEFAULT_THRESHOLD,
|
|
22
|
+
STACK_CARD_DIRECTIONS,
|
|
23
|
+
STACK_CARD_LAYOUTS,
|
|
24
|
+
STACK_CARD_STAMP_COLORS,
|
|
25
|
+
type StackCardChildKind,
|
|
26
|
+
type StackCardDirection,
|
|
27
|
+
type StackCardLayout,
|
|
28
|
+
type StackCardStampColor,
|
|
29
|
+
type StackCardTransform,
|
|
30
|
+
type StackCardVariantProps,
|
|
31
|
+
stackCardVariants,
|
|
32
|
+
} from "./stack-card.variants";
|
|
33
|
+
export type { StackCardActionProps } from "./stack-card-action";
|
|
34
|
+
export type { StackCardActionsProps } from "./stack-card-actions";
|
|
35
|
+
export type { StackCardCardProps } from "./stack-card-card";
|
|
36
|
+
export type { StackCardEmptyProps } from "./stack-card-empty";
|
|
37
|
+
export type { StackCardStampProps } from "./stack-card-stamp";
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { type ReactElement, useCallback } from "react";
|
|
2
|
+
import { Button } from "../button";
|
|
3
|
+
import { Icon, type IconComponent } from "../icon";
|
|
4
|
+
import { useStackCardPart } from "./stack-card.context";
|
|
5
|
+
import type { StackCardDirection } from "./stack-card.variants";
|
|
6
|
+
|
|
7
|
+
export type StackCardActionProps = {
|
|
8
|
+
/** Throw the top card this way, or bring the last one back. */
|
|
9
|
+
action: StackCardDirection | "undo";
|
|
10
|
+
icon: IconComponent;
|
|
11
|
+
/**
|
|
12
|
+
* What a screen reader calls the button. Defaults to the deck's
|
|
13
|
+
* `directionLabels` for the direction, or "Undo".
|
|
14
|
+
*/
|
|
15
|
+
label?: string;
|
|
16
|
+
/** Called before the deck acts. */
|
|
17
|
+
onPress?: () => void;
|
|
18
|
+
className?: string;
|
|
19
|
+
testID?: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A round button that answers for the top card without a drag.
|
|
24
|
+
*
|
|
25
|
+
* A direction throws the card exactly as a release would — `onSwipe`, then
|
|
26
|
+
* `onIndexChange` — and `undo` brings the last one back. A direction's glyph is
|
|
27
|
+
* tinted with the colour of the stamp answering for that direction, so the
|
|
28
|
+
* button and the stamp read as one answer. `undo` is a quiet ghost button,
|
|
29
|
+
* disabled while there is nothing to bring back; the directions disable once
|
|
30
|
+
* the deck is empty.
|
|
31
|
+
*/
|
|
32
|
+
export function StackCardAction({
|
|
33
|
+
action,
|
|
34
|
+
icon,
|
|
35
|
+
label,
|
|
36
|
+
onPress,
|
|
37
|
+
className,
|
|
38
|
+
testID,
|
|
39
|
+
}: StackCardActionProps): ReactElement {
|
|
40
|
+
const { swipe, undo, canUndo, index, count, isDisabled, labelFor, stampColors } =
|
|
41
|
+
useStackCardPart("StackCard.Action");
|
|
42
|
+
const isUndo = action === "undo";
|
|
43
|
+
const isActionDisabled = isDisabled || (isUndo ? !canUndo : index >= count);
|
|
44
|
+
const color = isUndo ? undefined : stampColors[action];
|
|
45
|
+
|
|
46
|
+
const handlePress = useCallback(() => {
|
|
47
|
+
onPress?.();
|
|
48
|
+
if (action === "undo") undo();
|
|
49
|
+
else swipe(action);
|
|
50
|
+
}, [action, onPress, swipe, undo]);
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<Button
|
|
54
|
+
accessibilityLabel={label ?? (action === "undo" ? "Undo" : labelFor(action))}
|
|
55
|
+
className={className}
|
|
56
|
+
isDisabled={isActionDisabled}
|
|
57
|
+
onPress={handlePress}
|
|
58
|
+
size="icon-lg"
|
|
59
|
+
testID={testID}
|
|
60
|
+
variant={isUndo ? "ghost" : "secondary"}
|
|
61
|
+
>
|
|
62
|
+
<Icon color={color} icon={icon} />
|
|
63
|
+
</Button>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
StackCardAction.displayName = "DelacourUI.StackCard.Action";
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ReactElement } from "react";
|
|
2
|
+
import { View, type ViewProps } from "react-native";
|
|
3
|
+
import { stackCardVariants } from "./stack-card.variants";
|
|
4
|
+
|
|
5
|
+
export type StackCardActionsProps = ViewProps & {
|
|
6
|
+
className?: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A centred row of `StackCard.Action` buttons under the pile.
|
|
11
|
+
*
|
|
12
|
+
* Wherever it is written among the deck's children, the deck renders it below
|
|
13
|
+
* the pile, which takes the height left over.
|
|
14
|
+
*/
|
|
15
|
+
export function StackCardActions({ className, ...props }: StackCardActionsProps): ReactElement {
|
|
16
|
+
return <View className={stackCardVariants().actions({ className })} {...props} />;
|
|
17
|
+
}
|
|
18
|
+
StackCardActions.displayName = "DelacourUI.StackCard.Actions";
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { type ReactElement, useCallback, useMemo } from "react";
|
|
2
|
+
import { type AccessibilityActionEvent, type AccessibilityActionInfo, View, type ViewProps } from "react-native";
|
|
3
|
+
import { useStackCardPart, useStackCardSlot } from "./stack-card.context";
|
|
4
|
+
import { STACK_CARD_DIRECTIONS, type StackCardDirection, stackCardVariants } from "./stack-card.variants";
|
|
5
|
+
|
|
6
|
+
export type StackCardCardProps = ViewProps & {
|
|
7
|
+
className?: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const UNDO_ACTION = "undo";
|
|
11
|
+
|
|
12
|
+
/** The accessibility action name a direction is announced under. */
|
|
13
|
+
function actionName(direction: StackCardDirection): string {
|
|
14
|
+
return `swipe-${direction}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* One card in the deck.
|
|
19
|
+
*
|
|
20
|
+
* A card's look — the card fill, a hairline and the card corner — absolutely
|
|
21
|
+
* filling the pile, so every card occupies one box and only a transform tells
|
|
22
|
+
* them apart. Its position, tilt and opacity are the deck's, applied by the slot
|
|
23
|
+
* around it; the card itself holds no gesture.
|
|
24
|
+
*
|
|
25
|
+
* The top card is one `accessible` element whose custom actions are the deck's
|
|
26
|
+
* allowed directions — named by `directionLabels`, or "Swipe left" and so on —
|
|
27
|
+
* plus "Undo" while there is a card to bring back. A screen reader swipes the
|
|
28
|
+
* deck from the actions rotor rather than by dragging. Because the card is one
|
|
29
|
+
* element, a control inside it is not separately reachable: put the deck's
|
|
30
|
+
* controls in `StackCard.Actions`, not on the card.
|
|
31
|
+
*/
|
|
32
|
+
export function StackCardCard({ className, children, ...props }: StackCardCardProps): ReactElement {
|
|
33
|
+
const { directions, labelFor, swipe, undo, canUndo } = useStackCardPart("StackCard.Card");
|
|
34
|
+
const slot = useStackCardSlot();
|
|
35
|
+
const isTop = slot?.isTop ?? false;
|
|
36
|
+
|
|
37
|
+
const actions = useMemo<AccessibilityActionInfo[]>(() => {
|
|
38
|
+
const list: AccessibilityActionInfo[] = directions.map((direction) => ({
|
|
39
|
+
label: labelFor(direction),
|
|
40
|
+
name: actionName(direction),
|
|
41
|
+
}));
|
|
42
|
+
if (canUndo) list.push({ label: "Undo", name: UNDO_ACTION });
|
|
43
|
+
return list;
|
|
44
|
+
}, [canUndo, directions, labelFor]);
|
|
45
|
+
|
|
46
|
+
const handleAction = useCallback(
|
|
47
|
+
(event: AccessibilityActionEvent) => {
|
|
48
|
+
const name = event.nativeEvent.actionName;
|
|
49
|
+
if (name === UNDO_ACTION) {
|
|
50
|
+
undo();
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const direction = STACK_CARD_DIRECTIONS.find((candidate) => actionName(candidate) === name);
|
|
54
|
+
if (direction) swipe(direction);
|
|
55
|
+
},
|
|
56
|
+
[swipe, undo]
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<View
|
|
61
|
+
accessibilityActions={isTop ? actions : undefined}
|
|
62
|
+
accessible={isTop}
|
|
63
|
+
className={stackCardVariants().card({ className })}
|
|
64
|
+
onAccessibilityAction={isTop ? handleAction : undefined}
|
|
65
|
+
{...props}
|
|
66
|
+
>
|
|
67
|
+
{children}
|
|
68
|
+
</View>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
StackCardCard.displayName = "DelacourUI.StackCard.Card";
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { ReactElement } from "react";
|
|
2
|
+
import { View, type ViewProps } from "react-native";
|
|
3
|
+
import { Text } from "../text";
|
|
4
|
+
import { stackCardVariants } from "./stack-card.variants";
|
|
5
|
+
|
|
6
|
+
export type StackCardEmptyProps = ViewProps & {
|
|
7
|
+
className?: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* What the pile shows once every card has gone — centred where the cards were.
|
|
12
|
+
*
|
|
13
|
+
* Rendered by the deck only while its index has reached the card count, so an
|
|
14
|
+
* `EmptyState` or a refill button goes here rather than behind the cards. Bare
|
|
15
|
+
* text is wrapped in a muted paragraph.
|
|
16
|
+
*/
|
|
17
|
+
export function StackCardEmpty({ className, children, ...props }: StackCardEmptyProps): ReactElement {
|
|
18
|
+
return (
|
|
19
|
+
<View className={stackCardVariants().empty({ className })} {...props}>
|
|
20
|
+
{typeof children === "string" ? (
|
|
21
|
+
<Text align="center" color="muted" variant="paragraph">
|
|
22
|
+
{children}
|
|
23
|
+
</Text>
|
|
24
|
+
) : (
|
|
25
|
+
children
|
|
26
|
+
)}
|
|
27
|
+
</View>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
StackCardEmpty.displayName = "DelacourUI.StackCard.Empty";
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { type ReactElement, type ReactNode, useMemo } from "react";
|
|
2
|
+
import Animated, { useAnimatedStyle } from "react-native-reanimated";
|
|
3
|
+
import { StackCardSlotProvider, useStackCardPart } from "./stack-card.context";
|
|
4
|
+
import { resolveBehindTransform, resolveDragProgress } from "./stack-card.variants";
|
|
5
|
+
|
|
6
|
+
export type StackCardSlotProps = {
|
|
7
|
+
/** This card's index in the deck. */
|
|
8
|
+
cardIndex: number;
|
|
9
|
+
/** Whether this card is the top one, as React last rendered it. */
|
|
10
|
+
isTop: boolean;
|
|
11
|
+
/** The points the pile keeps clear below the cards, for the stack's step. */
|
|
12
|
+
inset: number;
|
|
13
|
+
children: ReactNode;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The animated box one card sits in. Internal — the deck renders one per
|
|
18
|
+
* mounted card.
|
|
19
|
+
*
|
|
20
|
+
* **Every slot reads the same two shared values**, the top card's `x` and `y`,
|
|
21
|
+
* and works out its own place from how far behind the UI thread's top it sits:
|
|
22
|
+
*
|
|
23
|
+
* - the top follows the drag, tilting `x / width · 12°`;
|
|
24
|
+
* - a card behind sits at `resolveBehindTransform`, interpolated toward the slot
|
|
25
|
+
* ahead by the drag's progress, so it is already in place when a release
|
|
26
|
+
* throws the top card;
|
|
27
|
+
* - a card ahead of the top — the one just thrown, kept mounted for undo — is
|
|
28
|
+
* hidden.
|
|
29
|
+
*
|
|
30
|
+
* Nothing here re-renders during a drag, and the top moving is a shared value
|
|
31
|
+
* changing, so a throw lands without waiting on React. Under reduced motion the
|
|
32
|
+
* cards behind do not step during the drag: the pile swaps when the throw lands.
|
|
33
|
+
*
|
|
34
|
+
* Every transform pivots on the bottom centre, which is what makes `fan` fan,
|
|
35
|
+
* and makes a smaller `stack` card's lower edge peek out below the one in front.
|
|
36
|
+
* Only the top card takes touches or is visible to assistive technology.
|
|
37
|
+
*/
|
|
38
|
+
export function StackCardSlot({ cardIndex, isTop, inset, children }: StackCardSlotProps): ReactElement {
|
|
39
|
+
const { x, y, top, fade, width, height, threshold, layout, depth, isReducedMotion } =
|
|
40
|
+
useStackCardPart("StackCard.Card");
|
|
41
|
+
|
|
42
|
+
const animatedStyle = useAnimatedStyle(() => {
|
|
43
|
+
const position = cardIndex - top.value;
|
|
44
|
+
if (position < 0) {
|
|
45
|
+
return {
|
|
46
|
+
opacity: 0,
|
|
47
|
+
transform: [{ translateX: 0 }, { translateY: 0 }, { scale: 1 }, { rotate: "0deg" }],
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
if (position === 0) {
|
|
51
|
+
const rotate = width.value > 0 ? (x.value / width.value) * 12 : 0;
|
|
52
|
+
return {
|
|
53
|
+
opacity: fade.value,
|
|
54
|
+
transform: [{ translateX: x.value }, { translateY: y.value }, { scale: 1 }, { rotate: `${rotate}deg` }],
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
const progress = isReducedMotion
|
|
58
|
+
? 0
|
|
59
|
+
: resolveDragProgress({ height: height.value, threshold, width: width.value, x: x.value, y: y.value });
|
|
60
|
+
const behind = resolveBehindTransform({ depth, layout, position, progress });
|
|
61
|
+
return {
|
|
62
|
+
opacity: behind.opacity,
|
|
63
|
+
transform: [
|
|
64
|
+
{ translateX: 0 },
|
|
65
|
+
{ translateY: behind.translateY },
|
|
66
|
+
{ scale: behind.scale },
|
|
67
|
+
{ rotate: `${behind.rotate}deg` },
|
|
68
|
+
],
|
|
69
|
+
};
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const slot = useMemo(() => ({ cardIndex, isTop }), [cardIndex, isTop]);
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<StackCardSlotProvider value={slot}>
|
|
76
|
+
<Animated.View
|
|
77
|
+
accessibilityElementsHidden={!isTop}
|
|
78
|
+
importantForAccessibility={isTop ? "auto" : "no-hide-descendants"}
|
|
79
|
+
pointerEvents={isTop ? "box-none" : "none"}
|
|
80
|
+
style={[
|
|
81
|
+
{ bottom: inset, left: 0, position: "absolute", right: 0, top: 0, transformOrigin: "50% 100%" },
|
|
82
|
+
animatedStyle,
|
|
83
|
+
]}
|
|
84
|
+
>
|
|
85
|
+
{children}
|
|
86
|
+
</Animated.View>
|
|
87
|
+
</StackCardSlotProvider>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
StackCardSlot.displayName = "DelacourUI.StackCard.Slot";
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { ReactElement, ReactNode } from "react";
|
|
2
|
+
import type { ViewProps } from "react-native";
|
|
3
|
+
import Animated, { useAnimatedStyle } from "react-native-reanimated";
|
|
4
|
+
import { Text } from "../text";
|
|
5
|
+
import { useStackCardPart } from "./stack-card.context";
|
|
6
|
+
import {
|
|
7
|
+
resolveStampOpacity,
|
|
8
|
+
STACK_CARD_STAMP_ROTATION,
|
|
9
|
+
type StackCardDirection,
|
|
10
|
+
type StackCardStampColor,
|
|
11
|
+
stackCardVariants,
|
|
12
|
+
} from "./stack-card.variants";
|
|
13
|
+
|
|
14
|
+
export type StackCardStampProps = Omit<ViewProps, "children" | "style"> & {
|
|
15
|
+
/** The direction this stamp answers for. Defaults to `right`. */
|
|
16
|
+
direction?: StackCardDirection;
|
|
17
|
+
/** The stamp's colour — and the tint of the action button for the same direction. */
|
|
18
|
+
color?: StackCardStampColor;
|
|
19
|
+
/** The stamp's word. Bare text is wrapped in the stamp's label. */
|
|
20
|
+
children: ReactNode;
|
|
21
|
+
className?: string;
|
|
22
|
+
labelClassName?: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The answer a throw gives, printed on the top card as it is dragged.
|
|
27
|
+
*
|
|
28
|
+
* Declared once on the deck and drawn on whichever card is on top, on the side
|
|
29
|
+
* it answers for — a right-hand "yes" sits top-start, tilted the way a rubber
|
|
30
|
+
* stamp lands. It fades in with progress toward its direction and reaches full
|
|
31
|
+
* strength at the threshold, so a stamp at full strength means "let go and this
|
|
32
|
+
* is the answer". Hidden from assistive technology: the card's actions say the
|
|
33
|
+
* same thing in words.
|
|
34
|
+
*/
|
|
35
|
+
export function StackCardStamp({
|
|
36
|
+
direction = "right",
|
|
37
|
+
color = "primary",
|
|
38
|
+
children,
|
|
39
|
+
className,
|
|
40
|
+
labelClassName,
|
|
41
|
+
...props
|
|
42
|
+
}: StackCardStampProps): ReactElement {
|
|
43
|
+
const { x, y, width, height, threshold } = useStackCardPart("StackCard.Stamp");
|
|
44
|
+
const rotation = STACK_CARD_STAMP_ROTATION[direction];
|
|
45
|
+
const slots = stackCardVariants({ color, direction });
|
|
46
|
+
|
|
47
|
+
const animatedStyle = useAnimatedStyle(() => ({
|
|
48
|
+
opacity: resolveStampOpacity({
|
|
49
|
+
direction,
|
|
50
|
+
height: height.value,
|
|
51
|
+
threshold,
|
|
52
|
+
width: width.value,
|
|
53
|
+
x: x.value,
|
|
54
|
+
y: y.value,
|
|
55
|
+
}),
|
|
56
|
+
transform: [{ rotate: `${rotation}deg` }],
|
|
57
|
+
}));
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<Animated.View
|
|
61
|
+
accessibilityElementsHidden
|
|
62
|
+
className={slots.stamp({ className })}
|
|
63
|
+
importantForAccessibility="no-hide-descendants"
|
|
64
|
+
pointerEvents="none"
|
|
65
|
+
style={animatedStyle}
|
|
66
|
+
{...props}
|
|
67
|
+
>
|
|
68
|
+
{typeof children === "string" || typeof children === "number" ? (
|
|
69
|
+
<Text className={slots.stampLabel({ className: labelClassName })}>{children}</Text>
|
|
70
|
+
) : (
|
|
71
|
+
children
|
|
72
|
+
)}
|
|
73
|
+
</Animated.View>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
StackCardStamp.displayName = "DelacourUI.StackCard.Stamp";
|