@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@delacour/react-native-ui",
3
- "version": "0.1.0-alpha.20261007130133",
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.20261007130133",
113
- "@delacour/react-native-charts": "0.1.0-alpha.20261007130133",
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";