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