@delacour/react-native-ui 0.2.0-alpha.20261016011950 → 0.3.0-alpha.20261017084646
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 +3 -1
- package/src/components/carousel/AGENTS.md +182 -0
- package/src/components/carousel/carousel-caption.tsx +43 -0
- package/src/components/carousel/carousel-content.tsx +276 -0
- package/src/components/carousel/carousel-controls.tsx +17 -0
- package/src/components/carousel/carousel-dots.tsx +120 -0
- package/src/components/carousel/carousel-item.tsx +115 -0
- package/src/components/carousel/carousel-next.tsx +40 -0
- package/src/components/carousel/carousel-previous.tsx +40 -0
- package/src/components/carousel/carousel.context.tsx +195 -0
- package/src/components/carousel/carousel.tsx +352 -0
- package/src/components/carousel/carousel.types.ts +14 -0
- package/src/components/carousel/carousel.variants.test.ts +402 -0
- package/src/components/carousel/carousel.variants.ts +261 -0
- package/src/components/carousel/index.ts +57 -0
- package/src/components/carousel/use-carousel-autoplay.ts +74 -0
- package/src/lib/paging.test.ts +240 -0
- package/src/lib/paging.ts +209 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@delacour/react-native-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0-alpha.20261017084646",
|
|
4
4
|
"description": "React Native UI components — Uniwind, Reanimated, Gesture Handler",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
"./button": "./src/components/button/index.ts",
|
|
42
42
|
"./calendar": "./src/components/calendar/index.ts",
|
|
43
43
|
"./card": "./src/components/card/index.ts",
|
|
44
|
+
"./carousel": "./src/components/carousel/index.ts",
|
|
44
45
|
"./chart": "./src/components/chart/index.ts",
|
|
45
46
|
"./checkbox": "./src/components/checkbox/index.ts",
|
|
46
47
|
"./chip": "./src/components/chip/index.ts",
|
|
@@ -100,6 +101,7 @@
|
|
|
100
101
|
"./lib/keyboard-animation": "./src/lib/keyboard-animation.ts",
|
|
101
102
|
"./lib/merge-props": "./src/lib/merge-props.ts",
|
|
102
103
|
"./lib/navigation-theme": "./src/lib/navigation-theme.ts",
|
|
104
|
+
"./lib/paging": "./src/lib/paging.ts",
|
|
103
105
|
"./lib/slot": "./src/lib/slot.tsx",
|
|
104
106
|
"./lib/tv": "./src/lib/tv.ts",
|
|
105
107
|
"./icons/central": "./src/icons/central.ts"
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# Carousel
|
|
2
|
+
|
|
3
|
+
A swipeable run of slides on one fractional position — peeking, looping,
|
|
4
|
+
vertical, autoplaying. Compound root plus `Carousel.Content`, `Carousel.Item`,
|
|
5
|
+
`Carousel.Caption`, `Carousel.Dots`, `Carousel.Previous`, `Carousel.Next` and
|
|
6
|
+
`Carousel.Controls`, and a `useCarousel()` hook for custom controls.
|
|
7
|
+
|
|
8
|
+
`import { Carousel } from "@delacour/react-native-ui/carousel";`
|
|
9
|
+
|
|
10
|
+
## Files
|
|
11
|
+
|
|
12
|
+
| File | What it holds |
|
|
13
|
+
| --- | --- |
|
|
14
|
+
| `index.ts` | → `@delacour/react-native-ui/carousel` |
|
|
15
|
+
| `carousel.tsx` | Root + the `Object.assign` surface — owns the index, the reconcile and autoplay |
|
|
16
|
+
| `carousel-content.tsx` | `Carousel.Content` — the viewport, the Item walk, the window, the pan, the adjustable element |
|
|
17
|
+
| `carousel-item.tsx` | `Carousel.Item` — one absolutely placed slide and its animated style |
|
|
18
|
+
| `carousel-caption.tsx` | `Carousel.Caption` — a `Text` on a scrim, faded by its slide's offset |
|
|
19
|
+
| `carousel-dots.tsx` | `Carousel.Dots` and the internal dot — decoration driven by `position` |
|
|
20
|
+
| `carousel-previous.tsx` | `Carousel.Previous` — an icon `Button` |
|
|
21
|
+
| `carousel-next.tsx` | `Carousel.Next` — an icon `Button` |
|
|
22
|
+
| `carousel-controls.tsx` | `Carousel.Controls` — a row (a column when vertical), layout only |
|
|
23
|
+
| `carousel.context.tsx` | Four contexts split by rate of change, with their hooks |
|
|
24
|
+
| `carousel.types.ts` | Prop types shared by two or more parts |
|
|
25
|
+
| `carousel.variants.ts` | Pure `tv()` slots, constants and the geometry, coverflow, dot and a11y resolvers |
|
|
26
|
+
| `carousel.variants.test.ts` | |
|
|
27
|
+
| `use-carousel-autoplay.ts` | The JS interval and every reason it does not run |
|
|
28
|
+
|
|
29
|
+
The paging maths — wrapped offsets, rubber band, pan origin and position, settle
|
|
30
|
+
target, visible window — is **not here**. It lives in
|
|
31
|
+
[`src/lib/paging.ts`](../../lib/paging.ts), because `ImageViewer` pages through
|
|
32
|
+
the same kind of run and neither component should import the other. Keep that
|
|
33
|
+
file stable: its functions are the contract both consume.
|
|
34
|
+
|
|
35
|
+
## Design
|
|
36
|
+
|
|
37
|
+
- **Variants**: `track` (flat slides side by side) and `coverflow` (slides turn,
|
|
38
|
+
shrink and fade with their distance from the centre — `resolveCoverflow`).
|
|
39
|
+
**Orientations**: `horizontal`, `vertical`, which also turn the controls row
|
|
40
|
+
and the dots into columns. **Tone** (dots only): `default`, and `overlay` for
|
|
41
|
+
dots sitting on a photo — `primary-foreground`, never a raw white. **No size
|
|
42
|
+
axis**: a slide's size is `itemSize`, in points, because it is a layout
|
|
43
|
+
measurement and not a step on the type scale.
|
|
44
|
+
- **`itemSize` is the peek.** Omitted, a slide is the viewport's length and its
|
|
45
|
+
neighbours sit a gap off-screen. Smaller, the active slide is centred
|
|
46
|
+
(`resolveItemInset`) and the neighbours show either side. Larger than the
|
|
47
|
+
viewport, it is clamped to it — a slide that overflows its own viewport cannot
|
|
48
|
+
be centred.
|
|
49
|
+
- **Slides are found by walking `Content`'s direct children**, as `ListGroup`
|
|
50
|
+
finds its rows and `Tabs` its panels. The walk is the count, the order and the
|
|
51
|
+
window, so it is one place. An Item behind a wrapper is not found, and a
|
|
52
|
+
development warning says so: a slide that silently vanishes is worse than a
|
|
53
|
+
warning.
|
|
54
|
+
- **No `variant="stack"` or fan layout in v1.** Both are a different
|
|
55
|
+
`resolve*` transform on the same position; they wait until `coverflow` has
|
|
56
|
+
proven that seam.
|
|
57
|
+
- **No virtualised `data` / `renderItem`.** This holds a handful of hand-written
|
|
58
|
+
slides. A feed is a list (`@legendapp/list`).
|
|
59
|
+
|
|
60
|
+
## Value flow
|
|
61
|
+
|
|
62
|
+
- **The whole component is one shared value**, `position`, a float slide index.
|
|
63
|
+
A drag writes it, a spring settles it, and every slide's transform, every dot's
|
|
64
|
+
width and every caption's opacity is a reading of it. Two clocks is how a dot
|
|
65
|
+
ends up a frame behind the slide it marks.
|
|
66
|
+
- **`onIndexChange` fires once per gesture, from the pan's `onFinalize`**, before
|
|
67
|
+
the spring lands — never per frame and never on crossing a midpoint. A per-frame
|
|
68
|
+
report re-renders every slide mid-drag; `Tabs` paid for that lesson.
|
|
69
|
+
- **The reconcile effect registers no cleanup.** The commit that re-runs it is
|
|
70
|
+
usually the one the gesture just caused, and the gesture has already started the
|
|
71
|
+
settle spring. A cleanup that cancelled it would leave the effect's `none`
|
|
72
|
+
branch nothing to restart, and the run would freeze between two slides. `Tabs`
|
|
73
|
+
shipped that bug once; this copies its fix, not `Checkbox`'s `cancelAnimation`.
|
|
74
|
+
- **A rejected controlled change springs back.** Every commit also bumps a
|
|
75
|
+
reducer token, so a controlled parent that refuses the change — and therefore
|
|
76
|
+
re-renders nothing — still re-runs the reconcile, which springs to the index the
|
|
77
|
+
parent kept. `Tabs` and `Switch` carry the same token.
|
|
78
|
+
- **The pan's settle records its target before it commits** (`commitFromPan`), so
|
|
79
|
+
an accepted change makes the reconcile a no-op and the fling keeps its momentum.
|
|
80
|
+
- **A shrinking run never leaves a dead index.** The drawn index is clamped into
|
|
81
|
+
`[0, count - 1]` and the clamp is reported through `onIndexChange` once. A count
|
|
82
|
+
change jumps rather than springs: the slides under the old position are gone.
|
|
83
|
+
- **Loop has no clones.** Each slide's offset is `resolveWrappedOffset` — the
|
|
84
|
+
short way round on a ring — so the first slide sits beside the last and nothing
|
|
85
|
+
ever jumps back. `position` is unbounded while looping; the reconcile aims at
|
|
86
|
+
`resolveNearestPosition`, so `scrollTo` and the arrows take the short way.
|
|
87
|
+
|
|
88
|
+
## Gesture
|
|
89
|
+
|
|
90
|
+
- **The pan claims its axis and yields the other**: `activeOffset` ±10 on the
|
|
91
|
+
travel axis under `failOffset` ±10 across it. That is what lets a horizontal
|
|
92
|
+
carousel sit in `Screen.ScrollArea` and a `Pressable` in a slide still tap. Not
|
|
93
|
+
`blocksExternalGesture` — React Native's `ScrollView` has no handler tag.
|
|
94
|
+
- **The origin is back-computed at activation** (`resolvePanOrigin`), or the slide
|
|
95
|
+
jumps by the 10 points it took to activate. A grab mid-spring resumes from where
|
|
96
|
+
the slide is.
|
|
97
|
+
- **The spring is cancelled in `onStart`, never `onBegin`.** Most touches fail on
|
|
98
|
+
the cross axis; cancelling on touch-down would freeze the run for every vertical
|
|
99
|
+
scroll that began on it.
|
|
100
|
+
- **The settle is in `onFinalize`**, the one callback on END, FAILED and
|
|
101
|
+
CANCELLED, guarded by an `isDragging` flag so a tap that never activated does not
|
|
102
|
+
retarget anything.
|
|
103
|
+
- **A flick moves at most one slide.** `resolveSettleTarget` commits on 300 pt/s
|
|
104
|
+
or a quarter of a pitch, and never further than the next slide, however hard the
|
|
105
|
+
throw. Past a non-looping end the drag is a rubber band capped at half a slide.
|
|
106
|
+
- **Worklets call only self-contained worklets.** Every `lib/paging` and
|
|
107
|
+
`carousel.variants` function used on the UI thread is marked `"worklet"` and
|
|
108
|
+
calls nothing else — a module-scope worklet calling another crashes the UI
|
|
109
|
+
thread (`pressable/AGENTS.md`). Axis-dependent choices (`translationX` or `Y`,
|
|
110
|
+
`width` or `height`) are made once on the JS thread and captured as keys.
|
|
111
|
+
|
|
112
|
+
## Rendering
|
|
113
|
+
|
|
114
|
+
- **Slides are absolutely placed and moved by transform, never by layout.** One
|
|
115
|
+
`onLayout` on the viewport measures it; the geometry (`viewport`, `size`,
|
|
116
|
+
`pitch`, `inset`) is computed on the JS thread and published as **one** shared
|
|
117
|
+
value, so the UI thread never reads a pitch from one layout and a size from
|
|
118
|
+
another. Until it lands, slides draw at `opacity: 0` — the first paint never
|
|
119
|
+
shows them stacked at the origin.
|
|
120
|
+
- **Only the window is mounted**: `windowSize` (default 2) slides either side of
|
|
121
|
+
the committed index, wrapped when looping. Slides outside it are unmounted from
|
|
122
|
+
the committed index on the JS thread, and any mounted slide further out than
|
|
123
|
+
the window is `display: none` on the UI thread before React catches up.
|
|
124
|
+
- **Dots stretch by width, not `scaleX`** — a scaled `rounded-full` is an ellipse
|
|
125
|
+
(`Tabs.Indicator`'s reason). Past `maxDots` (default 7) the row is a window
|
|
126
|
+
centred on the active dot.
|
|
127
|
+
- **The caption fades with its slide's offset**, full on the active slide and
|
|
128
|
+
gone one slide out, so a peeking neighbour shows its picture and not its words.
|
|
129
|
+
Colour sits on the `Text` (rule 1); the frame carries layout and the scrim.
|
|
130
|
+
|
|
131
|
+
## Autoplay
|
|
132
|
+
|
|
133
|
+
- **Stops for good on the first touch** — the pan's `onBegin`, an arrow or a
|
|
134
|
+
caller's `scrollTo` — and at the end of a run that does not loop. Content that
|
|
135
|
+
keeps moving after the user took hold of it is fighting them.
|
|
136
|
+
- **Never runs under reduce motion, the app's `isMotionCalm`, or a screen
|
|
137
|
+
reader** (WCAG 2.2.2). The screen reader is subscribed, not sampled.
|
|
138
|
+
- **Pauses while the app is backgrounded.** A carousel scrolled off-screen keeps
|
|
139
|
+
ticking: detecting that cheaply is not possible, so it is documented rather than
|
|
140
|
+
solved.
|
|
141
|
+
- **A tick is one more caller of the reconcile**, not a second clock.
|
|
142
|
+
|
|
143
|
+
## Accessibility
|
|
144
|
+
|
|
145
|
+
- **`Carousel.Content` is the adjustable element**, not the root, so the arrows
|
|
146
|
+
stay reachable in their own right. Its value is "2 of 5"
|
|
147
|
+
(`resolveCarouselA11yValue`); `increment`/`decrement` map to `next`/`previous`,
|
|
148
|
+
and the OS reads the new value.
|
|
149
|
+
- **Off-screen slides are hidden from assistive tech** unless the carousel peeks,
|
|
150
|
+
when every mounted slide is visible and stays reachable
|
|
151
|
+
(`resolveSlideAccessibility`).
|
|
152
|
+
- **Dots are decoration**: hidden from assistive tech and not tappable — at six
|
|
153
|
+
points they are far below a 44-point target. The arrows are the control, labelled
|
|
154
|
+
"Previous slide" / "Next slide", disabled at the ends unless looping.
|
|
155
|
+
- **Reduce motion** settles by a 150 ms timing instead of a spring, collapses
|
|
156
|
+
coverflow to the track transform, and disables autoplay.
|
|
157
|
+
|
|
158
|
+
## Cross-category
|
|
159
|
+
|
|
160
|
+
- **No Pagination import.** `Carousel.Dots` is a minimal internal indicator; a
|
|
161
|
+
later Pagination component may wrap or replace it.
|
|
162
|
+
- **No Image component required.** Slides are caller-supplied children.
|
|
163
|
+
|
|
164
|
+
## Notes
|
|
165
|
+
|
|
166
|
+
- **A demo has to stretch.** The root is `w-full`; inside a shrink-wrapped parent
|
|
167
|
+
that resolves to zero, the viewport measures 0 and no slide shows. Every demo
|
|
168
|
+
sets `align: "stretch"`, as every container demo does.
|
|
169
|
+
- **Simulator check, 2026-10-05** (iPhone 17 sim, dev client): track, peek
|
|
170
|
+
centring, coverflow and vertical draw correctly; a ~290 pt fling moves exactly
|
|
171
|
+
one slide; the arrows step and `Next` disables at the non-looping end, where
|
|
172
|
+
further swipes rubber-band back; loop wraps both ways with no jump; autoplay
|
|
173
|
+
advances and stops for good after a swipe; a controlled `index` jumps from an
|
|
174
|
+
outside button and a swipe reports back through `onIndexChange`.
|
|
175
|
+
- **Not yet verified on a device**: release mid-drag then scroll the page; inside
|
|
176
|
+
`Screen.ScrollArea`; a `Pressable` in a slide; VoiceOver's adjust gesture and
|
|
177
|
+
the "n of m" announcement; autoplay off under Reduce Motion / VoiceOver; a
|
|
178
|
+
Release build.
|
|
179
|
+
- **Previews** (2026-10-06, dev client, iPhone 17 Pro): `hero-track`, `coverflow` and
|
|
180
|
+
`controls` are clips whose flows return to the first slide; `peek` and
|
|
181
|
+
`vertical` are stills. The photos load from the network, so check every poster
|
|
182
|
+
after a capture — the first `peek` poster caught its active slide still empty.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { ReactElement } from "react";
|
|
2
|
+
import Animated, { useAnimatedStyle } from "react-native-reanimated";
|
|
3
|
+
import { resolveWrappedOffset } from "../../lib/paging";
|
|
4
|
+
import { Text, type TextProps } from "../text";
|
|
5
|
+
import {
|
|
6
|
+
useCarouselMotionPart,
|
|
7
|
+
useCarouselOptionsPart,
|
|
8
|
+
useCarouselPart,
|
|
9
|
+
useCarouselSlidePart,
|
|
10
|
+
} from "./carousel.context";
|
|
11
|
+
import { carouselVariants, resolveCaptionOpacity } from "./carousel.variants";
|
|
12
|
+
|
|
13
|
+
export type CarouselCaptionProps = TextProps & {
|
|
14
|
+
className?: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A slide's caption, pinned to its foot on a scrim so it reads over a photo.
|
|
19
|
+
*
|
|
20
|
+
* Fades with its slide's offset from `position` — full on the active slide, gone
|
|
21
|
+
* one slide out — so a peeking neighbour shows its picture and not its words. The
|
|
22
|
+
* colour sits on the `Text` (rule 1); the frame carries layout and the scrim only.
|
|
23
|
+
*/
|
|
24
|
+
export function CarouselCaption({ className, ...props }: CarouselCaptionProps): ReactElement {
|
|
25
|
+
const { count } = useCarouselPart("Carousel.Caption");
|
|
26
|
+
const { loop, orientation, variant } = useCarouselOptionsPart("Carousel.Caption");
|
|
27
|
+
const { position } = useCarouselMotionPart("Carousel.Caption");
|
|
28
|
+
const { index } = useCarouselSlidePart("Carousel.Caption");
|
|
29
|
+
|
|
30
|
+
const animatedStyle = useAnimatedStyle(() => {
|
|
31
|
+
const offset = loop ? resolveWrappedOffset(index, position.value, count) : index - position.value;
|
|
32
|
+
return { opacity: resolveCaptionOpacity(offset) };
|
|
33
|
+
}, [count, index, loop]);
|
|
34
|
+
|
|
35
|
+
const slots = carouselVariants({ orientation, variant });
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<Animated.View className={slots.captionFrame()} pointerEvents="none" style={animatedStyle}>
|
|
39
|
+
<Text className={slots.caption({ className })} numberOfLines={2} {...props} />
|
|
40
|
+
</Animated.View>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
CarouselCaption.displayName = "DelacourUI.Carousel.Caption";
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Children,
|
|
3
|
+
isValidElement,
|
|
4
|
+
type ReactElement,
|
|
5
|
+
type ReactNode,
|
|
6
|
+
useCallback,
|
|
7
|
+
useLayoutEffect,
|
|
8
|
+
useMemo,
|
|
9
|
+
useState,
|
|
10
|
+
} from "react";
|
|
11
|
+
import type { AccessibilityActionEvent, LayoutChangeEvent, ViewProps } from "react-native";
|
|
12
|
+
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
|
13
|
+
import Animated, { cancelAnimation, useSharedValue, withSpring, withTiming } from "react-native-reanimated";
|
|
14
|
+
import { scheduleOnRN } from "react-native-worklets";
|
|
15
|
+
import { cn } from "../../lib/cn";
|
|
16
|
+
import { resolvePanOrigin, resolvePanPosition, resolveSettleTarget, resolveVisibleRange } from "../../lib/paging";
|
|
17
|
+
import {
|
|
18
|
+
CarouselSlideProvider,
|
|
19
|
+
useCarouselMotionPart,
|
|
20
|
+
useCarouselOptionsPart,
|
|
21
|
+
useCarouselPart,
|
|
22
|
+
} from "./carousel.context";
|
|
23
|
+
import {
|
|
24
|
+
CAROUSEL_CALM_DURATION_MS,
|
|
25
|
+
CAROUSEL_PAN,
|
|
26
|
+
CAROUSEL_SPRING,
|
|
27
|
+
carouselVariants,
|
|
28
|
+
resolveCarouselA11yValue,
|
|
29
|
+
resolveItemInset,
|
|
30
|
+
resolveItemPitch,
|
|
31
|
+
resolveItemSize,
|
|
32
|
+
} from "./carousel.variants";
|
|
33
|
+
import { CarouselItem } from "./carousel-item";
|
|
34
|
+
|
|
35
|
+
export type CarouselContentProps = ViewProps & {
|
|
36
|
+
/** `Carousel.Item` elements, as direct children. Their source order is the order of record. */
|
|
37
|
+
children: ReactNode;
|
|
38
|
+
/** Horizontal only. Sets the viewport's height from its width; a vertical carousel needs a height class. */
|
|
39
|
+
aspectRatio?: number;
|
|
40
|
+
className?: string;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The clipped viewport the slides are placed in, the surface the pan is claimed
|
|
45
|
+
* on, and the element assistive tech adjusts.
|
|
46
|
+
*
|
|
47
|
+
* **It walks its direct children for `Carousel.Item`s**, as `ListGroup` finds its
|
|
48
|
+
* rows and `Tabs` its panels: the walk is the count, the order and the window. An
|
|
49
|
+
* Item behind a wrapper is not found, so a wrapper is warned about in development
|
|
50
|
+
* rather than silently dropping slides.
|
|
51
|
+
*
|
|
52
|
+
* **Only the window is mounted** — `windowSize` slides either side of the
|
|
53
|
+
* committed index, wrapped when looping — so a run of thirty photos decodes five.
|
|
54
|
+
*
|
|
55
|
+
* **The pan claims its axis and yields the other**: `activeOffset` on the travel
|
|
56
|
+
* axis under `failOffset` across it, so a horizontal carousel scrolls inside
|
|
57
|
+
* `Screen.ScrollArea` and a `Pressable` in a slide still taps. Not
|
|
58
|
+
* `blocksExternalGesture` — React Native's `ScrollView` has no handler tag.
|
|
59
|
+
*
|
|
60
|
+
* **It is the adjustable element**, not the root, so `Carousel.Previous` and
|
|
61
|
+
* `Carousel.Next` stay reachable in their own right. A VoiceOver or TalkBack
|
|
62
|
+
* swipe up or down moves a slide and the OS reads the new "2 of 5".
|
|
63
|
+
*/
|
|
64
|
+
export function CarouselContent({
|
|
65
|
+
children,
|
|
66
|
+
aspectRatio,
|
|
67
|
+
className,
|
|
68
|
+
style,
|
|
69
|
+
onLayout,
|
|
70
|
+
...props
|
|
71
|
+
}: CarouselContentProps): ReactElement {
|
|
72
|
+
const { index, count, next, previous } = useCarouselPart("Carousel.Content");
|
|
73
|
+
const {
|
|
74
|
+
accessibilityLabel,
|
|
75
|
+
gap,
|
|
76
|
+
isDisabled,
|
|
77
|
+
isScrollEnabled,
|
|
78
|
+
itemSize,
|
|
79
|
+
isCalm,
|
|
80
|
+
loop,
|
|
81
|
+
orientation,
|
|
82
|
+
setCount,
|
|
83
|
+
variant,
|
|
84
|
+
windowSize,
|
|
85
|
+
} = useCarouselOptionsPart("Carousel.Content");
|
|
86
|
+
const { commitFromPan, geometry, position, stopAutoplay } = useCarouselMotionPart("Carousel.Content");
|
|
87
|
+
|
|
88
|
+
const items = useMemo(() => collectItems(children), [children]);
|
|
89
|
+
|
|
90
|
+
useLayoutEffect(() => {
|
|
91
|
+
setCount(items.length);
|
|
92
|
+
}, [items.length, setCount]);
|
|
93
|
+
|
|
94
|
+
const isHorizontal = orientation === "horizontal";
|
|
95
|
+
const [viewport, setViewport] = useState(0);
|
|
96
|
+
|
|
97
|
+
const handleLayout = useCallback(
|
|
98
|
+
(event: LayoutChangeEvent) => {
|
|
99
|
+
onLayout?.(event);
|
|
100
|
+
const { height, width } = event.nativeEvent.layout;
|
|
101
|
+
const length = isHorizontal ? width : height;
|
|
102
|
+
setViewport(length);
|
|
103
|
+
},
|
|
104
|
+
[isHorizontal, onLayout]
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
// The geometry is computed here, on the JS thread, and published as one shared
|
|
108
|
+
// value — one write, so the UI thread never reads a pitch from one layout and a
|
|
109
|
+
// size from another.
|
|
110
|
+
useLayoutEffect(() => {
|
|
111
|
+
geometry.value = {
|
|
112
|
+
inset: resolveItemInset(viewport, itemSize),
|
|
113
|
+
pitch: resolveItemPitch(viewport, itemSize, gap),
|
|
114
|
+
size: resolveItemSize(viewport, itemSize),
|
|
115
|
+
viewport,
|
|
116
|
+
};
|
|
117
|
+
}, [gap, geometry, itemSize, viewport]);
|
|
118
|
+
|
|
119
|
+
const isPeek = itemSize !== undefined && viewport > 0 && itemSize < viewport;
|
|
120
|
+
|
|
121
|
+
const panStart = useSharedValue(0);
|
|
122
|
+
// Whether the pan actually activated. `onFinalize` fires for every touch,
|
|
123
|
+
// including the taps and cross-axis scrolls that never did, and must not
|
|
124
|
+
// retarget a spring it never disturbed.
|
|
125
|
+
const isDragging = useSharedValue(false);
|
|
126
|
+
|
|
127
|
+
const panGesture = useMemo(() => {
|
|
128
|
+
const activate = [-CAROUSEL_PAN.activate, CAROUSEL_PAN.activate] as [number, number];
|
|
129
|
+
const fail = [-CAROUSEL_PAN.fail, CAROUSEL_PAN.fail] as [number, number];
|
|
130
|
+
const translationKey = isHorizontal ? "translationX" : "translationY";
|
|
131
|
+
const velocityKey = isHorizontal ? "velocityX" : "velocityY";
|
|
132
|
+
const pan = Gesture.Pan().enabled(!isDisabled && isScrollEnabled && count > 1);
|
|
133
|
+
const axis = isHorizontal
|
|
134
|
+
? pan.activeOffsetX(activate).failOffsetY(fail)
|
|
135
|
+
: pan.activeOffsetY(activate).failOffsetX(fail);
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
axis
|
|
139
|
+
// Autoplay stops for good on the first touch, activated or not.
|
|
140
|
+
.onBegin(() => {
|
|
141
|
+
"worklet";
|
|
142
|
+
scheduleOnRN(stopAutoplay);
|
|
143
|
+
})
|
|
144
|
+
// The spring is cancelled on ACTIVATION, never on touch-down: most
|
|
145
|
+
// touches go on to fail against the cross axis, and cancelling for one
|
|
146
|
+
// of those would freeze the run between two slides with nothing to
|
|
147
|
+
// restart it. `Tabs` found that by dragging, then scrolling the page.
|
|
148
|
+
.onStart((event) => {
|
|
149
|
+
"worklet";
|
|
150
|
+
cancelAnimation(position);
|
|
151
|
+
isDragging.value = true;
|
|
152
|
+
panStart.value = resolvePanOrigin(position.value, event[translationKey], geometry.value.pitch);
|
|
153
|
+
})
|
|
154
|
+
.onUpdate((event) => {
|
|
155
|
+
"worklet";
|
|
156
|
+
position.value = resolvePanPosition({
|
|
157
|
+
count,
|
|
158
|
+
loop,
|
|
159
|
+
pitch: geometry.value.pitch,
|
|
160
|
+
start: panStart.value,
|
|
161
|
+
translation: event[translationKey],
|
|
162
|
+
});
|
|
163
|
+
})
|
|
164
|
+
// `onFinalize`, the one callback on every path out — END, FAILED and
|
|
165
|
+
// CANCELLED — so a drag the OS cancels still settles.
|
|
166
|
+
.onFinalize((event, success) => {
|
|
167
|
+
"worklet";
|
|
168
|
+
if (!isDragging.value) return;
|
|
169
|
+
isDragging.value = false;
|
|
170
|
+
|
|
171
|
+
const pitch = geometry.value.pitch;
|
|
172
|
+
const velocity = success ? event[velocityKey] : 0;
|
|
173
|
+
const destination = resolveSettleTarget({
|
|
174
|
+
count,
|
|
175
|
+
loop,
|
|
176
|
+
pitch,
|
|
177
|
+
position: position.value,
|
|
178
|
+
startIndex: Math.round(panStart.value),
|
|
179
|
+
velocity,
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
if (isCalm) {
|
|
183
|
+
position.value = withTiming(destination, { duration: CAROUSEL_CALM_DURATION_MS });
|
|
184
|
+
} else {
|
|
185
|
+
const slidesPerSecond = pitch > 0 ? -velocity / pitch : 0;
|
|
186
|
+
position.value = withSpring(destination, { ...CAROUSEL_SPRING, velocity: slidesPerSecond });
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
scheduleOnRN(commitFromPan, destination);
|
|
190
|
+
})
|
|
191
|
+
);
|
|
192
|
+
}, [
|
|
193
|
+
commitFromPan,
|
|
194
|
+
count,
|
|
195
|
+
geometry,
|
|
196
|
+
isCalm,
|
|
197
|
+
isDisabled,
|
|
198
|
+
isDragging,
|
|
199
|
+
isHorizontal,
|
|
200
|
+
isScrollEnabled,
|
|
201
|
+
loop,
|
|
202
|
+
panStart,
|
|
203
|
+
position,
|
|
204
|
+
stopAutoplay,
|
|
205
|
+
]);
|
|
206
|
+
|
|
207
|
+
const handleAccessibilityAction = useCallback(
|
|
208
|
+
(event: AccessibilityActionEvent) => {
|
|
209
|
+
if (isDisabled) return;
|
|
210
|
+
if (event.nativeEvent.actionName === "increment") next();
|
|
211
|
+
else if (event.nativeEvent.actionName === "decrement") previous();
|
|
212
|
+
},
|
|
213
|
+
[isDisabled, next, previous]
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
const visible = resolveVisibleRange(index, items.length, loop, windowSize);
|
|
217
|
+
const slots = carouselVariants({ orientation, variant });
|
|
218
|
+
|
|
219
|
+
return (
|
|
220
|
+
<GestureDetector gesture={panGesture}>
|
|
221
|
+
<Animated.View
|
|
222
|
+
accessibilityActions={count > 1 ? ACCESSIBILITY_ACTIONS : undefined}
|
|
223
|
+
accessibilityLabel={accessibilityLabel}
|
|
224
|
+
accessibilityRole="adjustable"
|
|
225
|
+
accessibilityState={{ disabled: isDisabled }}
|
|
226
|
+
accessibilityValue={{ text: resolveCarouselA11yValue(index, count) }}
|
|
227
|
+
accessible
|
|
228
|
+
className={cn(slots.viewport(), className)}
|
|
229
|
+
onAccessibilityAction={handleAccessibilityAction}
|
|
230
|
+
onLayout={handleLayout}
|
|
231
|
+
style={[isHorizontal && aspectRatio !== undefined ? { aspectRatio } : null, style]}
|
|
232
|
+
{...props}
|
|
233
|
+
>
|
|
234
|
+
{visible.map((slide) => (
|
|
235
|
+
<CarouselSlideProvider
|
|
236
|
+
key={slideKey(items[slide], slide)}
|
|
237
|
+
value={{ index: slide, isActive: slide === index, isPeek }}
|
|
238
|
+
>
|
|
239
|
+
{items[slide]}
|
|
240
|
+
</CarouselSlideProvider>
|
|
241
|
+
))}
|
|
242
|
+
</Animated.View>
|
|
243
|
+
</GestureDetector>
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
CarouselContent.displayName = "DelacourUI.Carousel.Content";
|
|
247
|
+
|
|
248
|
+
/** The two actions an assistive swipe on an `adjustable` maps to. */
|
|
249
|
+
const ACCESSIBILITY_ACTIONS = [{ name: "increment" }, { name: "decrement" }];
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* The `Carousel.Item`s among `children`, in source order.
|
|
253
|
+
*
|
|
254
|
+
* `Children.toArray` drops the nulls a conditional slide leaves and keys what it
|
|
255
|
+
* keeps. Anything else is warned about in development: a wrapper around an Item
|
|
256
|
+
* hides it from the walk, and a slide that silently vanishes is worse than a
|
|
257
|
+
* warning.
|
|
258
|
+
*/
|
|
259
|
+
function collectItems(children: ReactNode): ReactElement[] {
|
|
260
|
+
const items: ReactElement[] = [];
|
|
261
|
+
for (const child of Children.toArray(children)) {
|
|
262
|
+
if (isValidElement(child) && child.type === CarouselItem) {
|
|
263
|
+
items.push(child);
|
|
264
|
+
} else if (process.env.NODE_ENV !== "production") {
|
|
265
|
+
console.warn(
|
|
266
|
+
"Carousel.Content: only Carousel.Item elements are slides, and they must be direct children. A wrapper hides the slide inside it."
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return items;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** A slide's key: the caller's, which `Children.toArray` has already made unique, or its index. */
|
|
274
|
+
function slideKey(item: ReactElement | undefined, slide: number): string {
|
|
275
|
+
return item?.key ?? `slide-${slide}`;
|
|
276
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ReactElement } from "react";
|
|
2
|
+
import { View } from "react-native";
|
|
3
|
+
import { useCarouselOptionsPart } from "./carousel.context";
|
|
4
|
+
import type { CarouselSlotProps } from "./carousel.types";
|
|
5
|
+
import { carouselVariants } from "./carousel.variants";
|
|
6
|
+
|
|
7
|
+
export type CarouselControlsProps = CarouselSlotProps;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A row for `Carousel.Previous` · `Carousel.Dots` · `Carousel.Next`, spread to the
|
|
11
|
+
* ends — a column when the carousel is vertical. Layout only; it reads no state.
|
|
12
|
+
*/
|
|
13
|
+
export function CarouselControls({ className, ...props }: CarouselControlsProps): ReactElement {
|
|
14
|
+
const { orientation, variant } = useCarouselOptionsPart("Carousel.Controls");
|
|
15
|
+
return <View className={carouselVariants({ orientation, variant }).controls({ className })} {...props} />;
|
|
16
|
+
}
|
|
17
|
+
CarouselControls.displayName = "DelacourUI.Carousel.Controls";
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type { ReactElement } from "react";
|
|
2
|
+
import { View, type ViewProps } from "react-native";
|
|
3
|
+
import Animated, { type SharedValue, useAnimatedStyle } from "react-native-reanimated";
|
|
4
|
+
import { resolveWrappedOffset } from "../../lib/paging";
|
|
5
|
+
import { useCarouselMotionPart, useCarouselOptionsPart, useCarouselPart } from "./carousel.context";
|
|
6
|
+
import {
|
|
7
|
+
CAROUSEL_DEFAULT_MAX_DOTS,
|
|
8
|
+
type CarouselDotTone,
|
|
9
|
+
type CarouselOrientation,
|
|
10
|
+
carouselVariants,
|
|
11
|
+
resolveCaptionOpacity,
|
|
12
|
+
resolveDotLength,
|
|
13
|
+
resolveDotsWindow,
|
|
14
|
+
} from "./carousel.variants";
|
|
15
|
+
|
|
16
|
+
export type CarouselDotsProps = ViewProps & {
|
|
17
|
+
/** Cap on dots drawn; beyond it a sliding window of this many. Default 7. */
|
|
18
|
+
maxDots?: number;
|
|
19
|
+
/** `overlay` draws light dots for sitting on photos. Default `default`. */
|
|
20
|
+
tone?: CarouselDotTone;
|
|
21
|
+
className?: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* One dot per slide, the active one stretched to a pill — a reading of `position`,
|
|
26
|
+
* so the pill travels with a finger rather than jumping on release.
|
|
27
|
+
*
|
|
28
|
+
* **Decoration, not a control.** The group is hidden from assistive tech, because
|
|
29
|
+
* the adjustable viewport already says "2 of 5", and the dots are not tappable:
|
|
30
|
+
* at six points they are far below a 44-point target. The arrows are the control.
|
|
31
|
+
*
|
|
32
|
+
* Past `maxDots` the row is a window centred on the active dot, chosen from the
|
|
33
|
+
* committed index. Renders nothing with one slide or none.
|
|
34
|
+
*/
|
|
35
|
+
export function CarouselDots({
|
|
36
|
+
maxDots = CAROUSEL_DEFAULT_MAX_DOTS,
|
|
37
|
+
tone = "default",
|
|
38
|
+
className,
|
|
39
|
+
...props
|
|
40
|
+
}: CarouselDotsProps): ReactElement | null {
|
|
41
|
+
const { count, index } = useCarouselPart("Carousel.Dots");
|
|
42
|
+
const { loop, orientation, variant } = useCarouselOptionsPart("Carousel.Dots");
|
|
43
|
+
const { position } = useCarouselMotionPart("Carousel.Dots");
|
|
44
|
+
|
|
45
|
+
if (count <= 1) return null;
|
|
46
|
+
|
|
47
|
+
const { start, end } = resolveDotsWindow(count, index, maxDots);
|
|
48
|
+
const slots = carouselVariants({ orientation, tone, variant });
|
|
49
|
+
const dots: number[] = [];
|
|
50
|
+
for (let dot = start; dot < end; dot++) dots.push(dot);
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<View
|
|
54
|
+
accessibilityElementsHidden
|
|
55
|
+
className={slots.dots({ className })}
|
|
56
|
+
importantForAccessibility="no-hide-descendants"
|
|
57
|
+
pointerEvents="none"
|
|
58
|
+
{...props}
|
|
59
|
+
>
|
|
60
|
+
{dots.map((dot) => (
|
|
61
|
+
<CarouselDot
|
|
62
|
+
activeClassName={slots.dotActive()}
|
|
63
|
+
className={slots.dot()}
|
|
64
|
+
count={count}
|
|
65
|
+
index={dot}
|
|
66
|
+
key={dot}
|
|
67
|
+
loop={loop}
|
|
68
|
+
orientation={orientation}
|
|
69
|
+
position={position}
|
|
70
|
+
/>
|
|
71
|
+
))}
|
|
72
|
+
</View>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
CarouselDots.displayName = "DelacourUI.Carousel.Dots";
|
|
76
|
+
|
|
77
|
+
type CarouselDotProps = {
|
|
78
|
+
index: number;
|
|
79
|
+
count: number;
|
|
80
|
+
loop: boolean;
|
|
81
|
+
orientation: CarouselOrientation;
|
|
82
|
+
position: SharedValue<number>;
|
|
83
|
+
className: string;
|
|
84
|
+
activeClassName: string;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* One dot. Its length along the row and its active fill's opacity both follow its
|
|
89
|
+
* offset from `position`; width rather than `scaleX`, because a scaled
|
|
90
|
+
* `rounded-full` is an ellipse.
|
|
91
|
+
*/
|
|
92
|
+
function CarouselDot({
|
|
93
|
+
index,
|
|
94
|
+
count,
|
|
95
|
+
loop,
|
|
96
|
+
orientation,
|
|
97
|
+
position,
|
|
98
|
+
className,
|
|
99
|
+
activeClassName,
|
|
100
|
+
}: CarouselDotProps): ReactElement {
|
|
101
|
+
const isHorizontal = orientation === "horizontal";
|
|
102
|
+
|
|
103
|
+
const dotStyle = useAnimatedStyle(() => {
|
|
104
|
+
const offset = loop ? resolveWrappedOffset(index, position.value, count) : index - position.value;
|
|
105
|
+
const length = resolveDotLength(offset);
|
|
106
|
+
return isHorizontal ? { width: length } : { height: length };
|
|
107
|
+
}, [count, index, isHorizontal, loop]);
|
|
108
|
+
|
|
109
|
+
const fillStyle = useAnimatedStyle(() => {
|
|
110
|
+
const offset = loop ? resolveWrappedOffset(index, position.value, count) : index - position.value;
|
|
111
|
+
return { opacity: resolveCaptionOpacity(offset) };
|
|
112
|
+
}, [count, index, loop]);
|
|
113
|
+
|
|
114
|
+
return (
|
|
115
|
+
<Animated.View className={className} style={dotStyle}>
|
|
116
|
+
<Animated.View className={activeClassName} style={fillStyle} />
|
|
117
|
+
</Animated.View>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
CarouselDot.displayName = "DelacourUI.Carousel.Dots.Dot";
|