@mrmeg/expo-ui 0.18.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,296 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import React, { use, useState } from "react";
3
+ import { Image, StyleSheet, Text, View, } from "react-native";
4
+ import { useTheme } from "../hooks/useTheme.js";
5
+ import { spacing } from "../constants/spacing.js";
6
+ import { Icon } from "./Icon.js";
7
+ import { StyledText } from "./StyledText.js";
8
+ import { createThemedStyles } from "../lib/themedStyles.js";
9
+ const SIZE_TOKENS = {
10
+ sm: spacing.iconLg, // 32
11
+ md: spacing.iconLg + spacing.sm, // 40
12
+ lg: spacing.iconXl, // 48
13
+ };
14
+ function isSizeToken(size) {
15
+ return typeof size === "string";
16
+ }
17
+ function resolveSize(size) {
18
+ return isSizeToken(size) ? SIZE_TOKENS[size] : size;
19
+ }
20
+ /** Rounded-square radius scales with the avatar so 32px and 88px read the same. */
21
+ function squareRadius(px) {
22
+ return Math.round(px / 4);
23
+ }
24
+ /** Initials/icon scale with the avatar rather than jumping between text tokens. */
25
+ function contentSize(px) {
26
+ return { fontSize: Math.round(px * 0.4), iconSize: Math.round(px * 0.5) };
27
+ }
28
+ /** Overlap for stacked avatars — 30% keeps ~70% of each face visible. */
29
+ function overlap(px) {
30
+ return -Math.round(px * 0.3);
31
+ }
32
+ /**
33
+ * Radius for the inner `Image`.
34
+ *
35
+ * The wrapper already clips with `overflow: "hidden"`, but Android drops that
36
+ * clip on a child image in several cases (elevation, some resize modes), so
37
+ * the image carries the radius itself as well.
38
+ */
39
+ function imageRadius(px, shape) {
40
+ if (shape === "circle")
41
+ return geometry.imageCircle;
42
+ const token = SIZE_TOKEN_FOR_PX[px];
43
+ return token ? geometry[`${token}ImageSquare`] : { borderRadius: squareRadius(px) };
44
+ }
45
+ /**
46
+ * Derive 1–2 initials from a display name.
47
+ *
48
+ * First + last word (middle names are skipped, as in most avatar stacks), one
49
+ * initial for a single-word name, and `""` when there is nothing to derive —
50
+ * callers treat that as "fall through to the icon".
51
+ *
52
+ * Grapheme-aware by way of `Array.from`, so an accented character written as
53
+ * base + combining mark keeps its accent and an astral code point (emoji) is
54
+ * not sliced into a lone surrogate. `toLocaleUpperCase` is deliberate: a
55
+ * locale-sensitive uppercase is right for names.
56
+ */
57
+ export function getAvatarInitials(name) {
58
+ if (!name)
59
+ return "";
60
+ const words = name.trim().split(/\s+/).filter(Boolean);
61
+ if (words.length === 0)
62
+ return "";
63
+ const picked = words.length === 1 ? [words[0]] : [words[0], words[words.length - 1]];
64
+ return picked
65
+ .map((word) => {
66
+ const [first] = Array.from(word.normalize("NFC"));
67
+ return first ?? "";
68
+ })
69
+ .join("")
70
+ .toLocaleUpperCase();
71
+ }
72
+ /**
73
+ * Identity of an image source by content, not object identity.
74
+ *
75
+ * `source={{ uri }}` allocates a new object on every render, so load/failure
76
+ * flags keyed on object identity would reset immediately and loop
77
+ * image → error → image.
78
+ *
79
+ * `null` means "no source" and nothing else: every shape React Native accepts
80
+ * gets a key, including the ones that carry no `uri` at all (iOS
81
+ * `{ bundle, name }`, a resolved asset object). Keying those as "no image"
82
+ * silently degraded a perfectly good source to initials.
83
+ */
84
+ function getSourceKey(source) {
85
+ if (source == null)
86
+ return null;
87
+ if (typeof source === "number")
88
+ return `asset:${source}`;
89
+ if (Array.isArray(source)) {
90
+ // An empty array carries no image; anything else does.
91
+ const keys = source.map((entry) => getSourceKey(entry) ?? "").filter(Boolean);
92
+ return keys.length > 0 ? keys.join("|") : null;
93
+ }
94
+ if (source.uri)
95
+ return source.uri;
96
+ // Whatever is left is a descriptor object without a uri. Serialize it, with
97
+ // keys sorted so two equivalent literals written in a different order still
98
+ // key the same. `{}` is the one object that carries no image.
99
+ const fields = Object.keys(source).sort();
100
+ if (fields.length === 0)
101
+ return null;
102
+ return fields.map((field) => `${field}=${JSON.stringify(source[field])}`).join("&");
103
+ }
104
+ const AvatarGroupContext = React.createContext(null);
105
+ /**
106
+ * Avatar Component
107
+ *
108
+ * A circular (or rounded-square) profile image with a graceful fallback chain:
109
+ * image → initials → icon. The fallback shows while the image is in flight and
110
+ * stays put if the image fails, so neither a slow network nor a dead URL ever
111
+ * leaves an empty hole.
112
+ *
113
+ * Usage:
114
+ * ```tsx
115
+ * <Avatar source={{ uri: user.avatarUrl }} name={user.fullName} />
116
+ * <Avatar name="Ada Lovelace" size="lg" />
117
+ * <Avatar icon="camera" shape="square" />
118
+ * ```
119
+ */
120
+ function Avatar({ source, name, icon, size, shape, accessibilityLabel, style: styleOverride, }) {
121
+ const { theme } = useTheme();
122
+ const styles = themedStyles(theme);
123
+ const group = use(AvatarGroupContext);
124
+ const px = resolveSize(size ?? group?.size ?? "md");
125
+ const resolvedShape = shape ?? group?.shape ?? "circle";
126
+ const sourceKey = getSourceKey(source);
127
+ // Both flags are keyed on the source's content, so a retry with a new URL
128
+ // clears them while a re-rendered identical URL keeps them.
129
+ const [failedKey, setFailedKey] = useState(null);
130
+ const [loadedKey, setLoadedKey] = useState(null);
131
+ const showImage = sourceKey !== null && failedKey !== sourceKey;
132
+ // The fallback stays mounted under a still-loading image, so a slow network
133
+ // shows initials rather than an empty muted tile.
134
+ const showFallback = !showImage || loadedKey !== sourceKey;
135
+ const initials = getAvatarInitials(name);
136
+ const label = accessibilityLabel ?? name;
137
+ return (_jsxs(AvatarSurface, { px: px, shape: resolvedShape, ring: group?.ring ?? false, accessibilityLabel: label, style: [styles.fallback, styleOverride], children: [showFallback ? (initials ? (_jsx(AvatarLabel, { px: px, style: styles.fallbackText, children: initials })) : (_jsx(Icon, { name: icon ?? "user", size: contentSize(px).iconSize, color: "mutedForeground", decorative: true }))) : null, showImage ? (_jsx(Image, { source: source, resizeMode: "cover", style: [geometry.image, imageRadius(px, resolvedShape)], onLoad: () => setLoadedKey(sourceKey), onError: () => setFailedKey(sourceKey) })) : null] }));
138
+ }
139
+ /**
140
+ * Shared chrome for an avatar-shaped tile — used by `Avatar` and by
141
+ * `AvatarGroup`'s `+N` overflow tile so both stay the same size, shape, and
142
+ * ring without duplicating the geometry rules.
143
+ */
144
+ function AvatarSurface({ px, shape, ring, accessibilityLabel, style, children, }) {
145
+ const { theme } = useTheme();
146
+ const styles = themedStyles(theme);
147
+ const token = SIZE_TOKEN_FOR_PX[px];
148
+ return (_jsx(View
149
+ // Only join the accessibility tree when there is something to announce;
150
+ // an unlabeled avatar is decorative next to the name it sits beside.
151
+ , {
152
+ // Only join the accessibility tree when there is something to announce;
153
+ // an unlabeled avatar is decorative next to the name it sits beside.
154
+ accessible: accessibilityLabel ? true : undefined,
155
+ // RN's role name is "image"; RNW maps it to aria-role "img" on the web.
156
+ accessibilityRole: accessibilityLabel ? "image" : undefined, accessibilityLabel: accessibilityLabel, style: [
157
+ geometry.base,
158
+ // Token sizes hit a pre-registered rule; a custom pixel size falls back
159
+ // to an inline style (which always ships in the SSR HTML).
160
+ token
161
+ ? geometry[shape === "circle" ? `${token}Circle` : `${token}Square`]
162
+ : { width: px, height: px, borderRadius: shape === "circle" ? spacing.radiusFull : squareRadius(px) },
163
+ ring && geometry.ring,
164
+ ring && styles.ring,
165
+ style,
166
+ ], children: children }));
167
+ }
168
+ function AvatarLabel({ px, style, children, }) {
169
+ return (_jsx(StyledText, { accessible: false, selectable: false, fontWeight: "semibold", style: [geometry.labelText, { fontSize: contentSize(px).fontSize }, style], children: children }));
170
+ }
171
+ /**
172
+ * AvatarGroup Component
173
+ *
174
+ * Overlapping stack of avatars, each ringed in the theme `background` color so
175
+ * the stack reads on any surface. Earlier avatars sit on top; anything past
176
+ * `max` collapses into a `+N` tile.
177
+ *
178
+ * The group is not an accessibility element itself — its member avatars each
179
+ * announce their own name, preceded by a hidden count summary.
180
+ *
181
+ * Usage:
182
+ * ```tsx
183
+ * <AvatarGroup max={3}>
184
+ * {members.map((m) => <Avatar key={m.id} source={{ uri: m.avatarUrl }} name={m.name} />)}
185
+ * </AvatarGroup>
186
+ * ```
187
+ */
188
+ function AvatarGroup({ children, max, size = "md", shape = "circle", accessibilityLabel, style: styleOverride, }) {
189
+ const { theme } = useTheme();
190
+ const styles = themedStyles(theme);
191
+ // toArray drops null/undefined/false children so a conditionally rendered
192
+ // avatar doesn't inflate the count or the overflow tally.
193
+ const items = React.Children.toArray(children);
194
+ // A whole count, never negative: `slice` would otherwise read a negative max
195
+ // as "from the end" (rendering every child) and truncate a fractional one.
196
+ const limit = max != null ? Math.max(0, Math.floor(max)) : null;
197
+ const visible = limit != null ? items.slice(0, limit) : items;
198
+ const overflowCount = items.length - visible.length;
199
+ const px = resolveSize(size);
200
+ const token = SIZE_TOKEN_FOR_PX[px];
201
+ const stackStyle = token ? geometry[`${token}Stack`] : { marginLeft: overlap(px) };
202
+ const totalTiles = visible.length + (overflowCount > 0 ? 1 : 0);
203
+ const contextValue = React.useMemo(() => ({ size, shape, ring: true }), [size, shape]);
204
+ return (_jsxs(View, { style: [geometry.group, styleOverride], children: [_jsx(View, { style: geometry.summary, children: _jsx(Text, { style: geometry.summaryText, children: accessibilityLabel ?? `${items.length} avatars` }) }), _jsxs(AvatarGroupContext.Provider, { value: contextValue, children: [visible.map((child, index) => (_jsx(View
205
+ // toArray assigns every child a stable key, so reordering the list
206
+ // moves the wrapper with its avatar instead of remounting both.
207
+ , {
208
+ // Earlier avatars paint over later ones, so the stack reads
209
+ // left-to-right instead of the DOM's later-wins order.
210
+ style: [index > 0 && stackStyle, { zIndex: totalTiles - index }], children: child }, React.isValidElement(child) ? child.key : index))), overflowCount > 0 ? (_jsx(View, { style: [visible.length > 0 && stackStyle, { zIndex: 0 }], children: _jsx(AvatarSurface, { px: px, shape: shape, ring: true, style: styles.overflow, children: _jsx(AvatarLabel, { px: px, style: styles.overflowText, children: `+${overflowCount}` }) }) })) : null] })] }));
211
+ }
212
+ /** px → token, so a size passed as a raw number still hits the registered rules. */
213
+ const SIZE_TOKEN_FOR_PX = {
214
+ [SIZE_TOKENS.sm]: "sm",
215
+ [SIZE_TOKENS.md]: "md",
216
+ [SIZE_TOKENS.lg]: "lg",
217
+ };
218
+ // Geometry carries no theme values, so a plain module-scope sheet is enough to
219
+ // get these rules into the prerendered <head>. Only colors go through
220
+ // createThemedStyles below. See "Enable Server Output" in docs/server-guide.md.
221
+ const geometry = StyleSheet.create({
222
+ base: {
223
+ alignItems: "center",
224
+ justifyContent: "center",
225
+ overflow: "hidden",
226
+ },
227
+ image: {
228
+ // Absolutely positioned so the fallback underneath it keeps the tile's
229
+ // centered layout while the image is still loading. The explicit 100%
230
+ // size must stay alongside the inset: react-native-web does not resolve
231
+ // an Image's size from the inset box and falls back to the asset's
232
+ // natural pixels, blowing a 1024px image out of a 48px tile.
233
+ position: "absolute",
234
+ top: 0,
235
+ left: 0,
236
+ width: "100%",
237
+ height: "100%",
238
+ },
239
+ imageCircle: { borderRadius: spacing.radiusFull },
240
+ labelText: {
241
+ // The tile is the touch/visual target; text must not add its own metrics.
242
+ includeFontPadding: false,
243
+ textAlign: "center",
244
+ userSelect: "none",
245
+ },
246
+ ring: {
247
+ borderWidth: spacing.xxs,
248
+ },
249
+ group: {
250
+ flexDirection: "row",
251
+ alignItems: "center",
252
+ },
253
+ // Clipped to 1x1 rather than removed: screen readers skip
254
+ // display:none/opacity:0 nodes, and there is no visual affordance to show.
255
+ summary: {
256
+ position: "absolute",
257
+ width: 1,
258
+ height: 1,
259
+ overflow: "hidden",
260
+ },
261
+ summaryText: {
262
+ fontSize: 1,
263
+ color: "transparent",
264
+ },
265
+ smCircle: { width: SIZE_TOKENS.sm, height: SIZE_TOKENS.sm, borderRadius: spacing.radiusFull },
266
+ mdCircle: { width: SIZE_TOKENS.md, height: SIZE_TOKENS.md, borderRadius: spacing.radiusFull },
267
+ lgCircle: { width: SIZE_TOKENS.lg, height: SIZE_TOKENS.lg, borderRadius: spacing.radiusFull },
268
+ smSquare: { width: SIZE_TOKENS.sm, height: SIZE_TOKENS.sm, borderRadius: squareRadius(SIZE_TOKENS.sm) },
269
+ mdSquare: { width: SIZE_TOKENS.md, height: SIZE_TOKENS.md, borderRadius: squareRadius(SIZE_TOKENS.md) },
270
+ lgSquare: { width: SIZE_TOKENS.lg, height: SIZE_TOKENS.lg, borderRadius: squareRadius(SIZE_TOKENS.lg) },
271
+ smStack: { marginLeft: overlap(SIZE_TOKENS.sm) },
272
+ mdStack: { marginLeft: overlap(SIZE_TOKENS.md) },
273
+ lgStack: { marginLeft: overlap(SIZE_TOKENS.lg) },
274
+ smImageSquare: { borderRadius: squareRadius(SIZE_TOKENS.sm) },
275
+ mdImageSquare: { borderRadius: squareRadius(SIZE_TOKENS.md) },
276
+ lgImageSquare: { borderRadius: squareRadius(SIZE_TOKENS.lg) },
277
+ });
278
+ const createStyles = (theme) => StyleSheet.create({
279
+ fallback: {
280
+ backgroundColor: theme.colors.muted,
281
+ },
282
+ fallbackText: {
283
+ color: theme.colors.mutedForeground,
284
+ },
285
+ ring: {
286
+ borderColor: theme.colors.background,
287
+ },
288
+ overflow: {
289
+ backgroundColor: theme.colors.accent,
290
+ },
291
+ overflowText: {
292
+ color: theme.colors.accentForeground,
293
+ },
294
+ });
295
+ const themedStyles = createThemedStyles(createStyles);
296
+ export { Avatar, AvatarGroup };
@@ -0,0 +1,99 @@
1
+ import React from "react";
2
+ import { type StyleProp, type ViewStyle } from "react-native";
3
+ export interface CarouselProps {
4
+ /** Slides to page through. Each child is wrapped in a fixed-width slide. */
5
+ children?: React.ReactNode;
6
+ /**
7
+ * Slide width. Values `<= 1` are a fraction of the carousel's own width —
8
+ * the default `0.85` leaves the next slide peeking. Values `> 1` are
9
+ * absolute pixels.
10
+ *
11
+ * Caveat for fractional values: "the carousel's own width" is only known
12
+ * after the first `onLayout`. Until then it is seeded from the viewport
13
+ * width, so a carousel inside a horizontally constrained parent (a padded
14
+ * column, a `MaxWidthContainer`, a sidebar pane) renders its first frame —
15
+ * and the exported HTML shell — with slides sized to the *window* and
16
+ * corrects on layout. Pass an absolute `itemWidth` (`> 1`) when the parent
17
+ * is narrower than the viewport and that first frame matters.
18
+ * @default 0.85
19
+ */
20
+ itemWidth?: number;
21
+ /** Space between slides. @default spacing.md */
22
+ gap?: number;
23
+ /** Horizontal inset before the first and after the last slide. @default spacing.lg */
24
+ contentPadding?: number;
25
+ /**
26
+ * Render the dot indicators. Auto-hidden for a single slide. Dots are
27
+ * pressable and scroll to their slide.
28
+ * @default true
29
+ */
30
+ showDots?: boolean;
31
+ /** Slide shown on mount. @default 0 */
32
+ initialIndex?: number;
33
+ /**
34
+ * Called with the new active index whenever it changes.
35
+ *
36
+ * On native this is once per settle: momentum end, or drag end for a release
37
+ * that stops at rest. On web it is once per page *crossed*, because
38
+ * react-native-web emits no momentum events and throttled scroll ticks are
39
+ * the only channel — a wheel scroll spanning three pages reports all three.
40
+ * Consecutive duplicate indices are always suppressed. Also fires on a dot
41
+ * press, and when `children` shrink below the active index and it has to be
42
+ * re-clamped.
43
+ */
44
+ onIndexChange?: (index: number) => void;
45
+ /** Snap to slide boundaries. @default true */
46
+ snap?: boolean;
47
+ /** Custom style override for the outer container. */
48
+ style?: StyleProp<ViewStyle>;
49
+ /** Custom style override for the scroll content row. */
50
+ contentContainerStyle?: StyleProp<ViewStyle>;
51
+ /**
52
+ * testID prefix. Emits `<testID>`, `-scroll`, `-item-<i>`, `-dots`,
53
+ * `-dot-<i>` (the pressable), `-dot-<i>-indicator` (the visible dot), and
54
+ * `-status`.
55
+ * @default "carousel"
56
+ */
57
+ testID?: string;
58
+ }
59
+ /**
60
+ * Active slide index for a horizontal scroll offset.
61
+ *
62
+ * `interval` is the slide pitch (item width + gap), so page `i` sits at
63
+ * `i * interval` for both the native snap offsets and web's CSS scroll-snap
64
+ * (see `scrollPaddingLeft` below). Clamped to `[0, count - 1]` so rubber-band
65
+ * overscroll at either end can't report an out-of-range page. Returns `0` for
66
+ * a degenerate interval — which is also the pre-measurement state on the
67
+ * server.
68
+ */
69
+ export declare function getCarouselIndex(offsetX: number, interval: number, count: number): number;
70
+ /** Resolves the `itemWidth` prop against a container width (fraction vs px). */
71
+ export declare function resolveCarouselItemWidth(itemWidth: number, containerWidth: number): number;
72
+ /**
73
+ * Carousel Component
74
+ *
75
+ * Horizontally snapping row of slides with dot indicators and an optional
76
+ * peek of the next slide. Children-based: every child becomes one slide at the
77
+ * resolved item width.
78
+ *
79
+ * Built on `ScrollView` — not a virtualized list — on purpose: every slide is
80
+ * in the prerendered tree, so a web build's HTML shell ships the real content
81
+ * instead of an empty scroller that only fills in after measurement. Snapping
82
+ * uses the platform scroller (`snapToInterval` on native, CSS scroll-snap on web); no
83
+ * animation library is involved.
84
+ *
85
+ * Slides sized by a fractional `itemWidth` measure against the viewport until
86
+ * the first `onLayout` lands — see the `itemWidth` caveat on `CarouselProps`.
87
+ *
88
+ * @example
89
+ * ```tsx
90
+ * <Carousel itemWidth={0.8} onIndexChange={setPage}>
91
+ * {testimonials.map((t) => (
92
+ * <Card key={t.name}>
93
+ * <BodyText>{t.quote}</BodyText>
94
+ * </Card>
95
+ * ))}
96
+ * </Carousel>
97
+ * ```
98
+ */
99
+ export declare function Carousel({ children, itemWidth, gap, contentPadding, showDots, initialIndex, onIndexChange, snap, style: styleOverride, contentContainerStyle, testID, }: CarouselProps): React.JSX.Element;
@@ -0,0 +1,246 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import React, { useCallback, useEffect, useRef, useState } from "react";
3
+ import { Platform, Pressable, ScrollView, StyleSheet, Text, View, } from "react-native";
4
+ import { useTheme } from "../hooks/useTheme.js";
5
+ import { useDimensions } from "../hooks/useDimensions.js";
6
+ import { spacing } from "../constants/spacing.js";
7
+ import { createThemedStyles } from "../lib/themedStyles.js";
8
+ // ============================================================================
9
+ // Index math
10
+ // ============================================================================
11
+ /**
12
+ * Active slide index for a horizontal scroll offset.
13
+ *
14
+ * `interval` is the slide pitch (item width + gap), so page `i` sits at
15
+ * `i * interval` for both the native snap offsets and web's CSS scroll-snap
16
+ * (see `scrollPaddingLeft` below). Clamped to `[0, count - 1]` so rubber-band
17
+ * overscroll at either end can't report an out-of-range page. Returns `0` for
18
+ * a degenerate interval — which is also the pre-measurement state on the
19
+ * server.
20
+ */
21
+ export function getCarouselIndex(offsetX, interval, count) {
22
+ if (!Number.isFinite(offsetX) || !Number.isFinite(interval))
23
+ return 0;
24
+ if (interval <= 0 || count <= 0)
25
+ return 0;
26
+ const raw = Math.round(offsetX / interval);
27
+ if (raw < 0)
28
+ return 0;
29
+ if (raw > count - 1)
30
+ return count - 1;
31
+ return raw;
32
+ }
33
+ /** Resolves the `itemWidth` prop against a container width (fraction vs px). */
34
+ export function resolveCarouselItemWidth(itemWidth, containerWidth) {
35
+ if (!Number.isFinite(itemWidth) || itemWidth <= 0)
36
+ return 0;
37
+ if (itemWidth > 1)
38
+ return Math.round(itemWidth);
39
+ if (!Number.isFinite(containerWidth) || containerWidth <= 0)
40
+ return 0;
41
+ return Math.round(containerWidth * itemWidth);
42
+ }
43
+ function clampIndex(index, count) {
44
+ if (count <= 0)
45
+ return 0;
46
+ return Math.min(count - 1, Math.max(0, Math.trunc(index)));
47
+ }
48
+ // ============================================================================
49
+ // Carousel
50
+ // ============================================================================
51
+ /**
52
+ * Carousel Component
53
+ *
54
+ * Horizontally snapping row of slides with dot indicators and an optional
55
+ * peek of the next slide. Children-based: every child becomes one slide at the
56
+ * resolved item width.
57
+ *
58
+ * Built on `ScrollView` — not a virtualized list — on purpose: every slide is
59
+ * in the prerendered tree, so a web build's HTML shell ships the real content
60
+ * instead of an empty scroller that only fills in after measurement. Snapping
61
+ * uses the platform scroller (`snapToInterval` on native, CSS scroll-snap on web); no
62
+ * animation library is involved.
63
+ *
64
+ * Slides sized by a fractional `itemWidth` measure against the viewport until
65
+ * the first `onLayout` lands — see the `itemWidth` caveat on `CarouselProps`.
66
+ *
67
+ * @example
68
+ * ```tsx
69
+ * <Carousel itemWidth={0.8} onIndexChange={setPage}>
70
+ * {testimonials.map((t) => (
71
+ * <Card key={t.name}>
72
+ * <BodyText>{t.quote}</BodyText>
73
+ * </Card>
74
+ * ))}
75
+ * </Carousel>
76
+ * ```
77
+ */
78
+ export function Carousel({ children, itemWidth = 0.85, gap = spacing.md, contentPadding = spacing.lg, showDots = true, initialIndex = 0, onIndexChange, snap = true, style: styleOverride, contentContainerStyle, testID = "carousel", }) {
79
+ const { theme } = useTheme();
80
+ const styles = themedStyles(theme);
81
+ const items = React.Children.toArray(children);
82
+ const count = items.length;
83
+ // Width source of truth: the measured container after mount, seeded from the
84
+ // viewport so a prerendered HTML shell and the client's first render agree.
85
+ // `useDimensions` seeds from a constant default rather than `window` on that
86
+ // first render, which is what keeps the seed hydration-safe.
87
+ const { width: viewportWidth } = useDimensions();
88
+ const [measuredWidth, setMeasuredWidth] = useState(null);
89
+ const containerWidth = measuredWidth ?? viewportWidth;
90
+ const slideWidth = resolveCarouselItemWidth(itemWidth, containerWidth);
91
+ const interval = slideWidth + gap;
92
+ const [activeIndex, setActiveIndex] = useState(() => clampIndex(initialIndex, count));
93
+ // Mirrors activeIndex so the scroll handler can dedupe without re-reading
94
+ // state: web's throttled ticks report the same page many times over, and
95
+ // onIndexChange must fire once per page.
96
+ const activeIndexRef = useRef(activeIndex);
97
+ const scrollRef = useRef(null);
98
+ const hasScrolledToInitial = useRef(false);
99
+ const commitIndex = useCallback((next) => {
100
+ if (next === activeIndexRef.current)
101
+ return;
102
+ activeIndexRef.current = next;
103
+ setActiveIndex(next);
104
+ onIndexChange?.(next);
105
+ }, [onIndexChange]);
106
+ const isWeb = Platform.OS === "web";
107
+ const handleScroll = useCallback((event) => {
108
+ commitIndex(getCarouselIndex(event.nativeEvent.contentOffset.x, interval, count));
109
+ }, [commitIndex, count, interval]);
110
+ // Native fallback for a drag that releases with the content at rest: no
111
+ // momentum follows, so `onMomentumScrollEnd` never arrives and the settle
112
+ // would go unreported.
113
+ //
114
+ // Gated on velocity because an ungated drag-end handler is exactly the
115
+ // double-fire this replaced: a flick past a slide midpoint that snaps back
116
+ // reports the midpoint page on release and the original page on settle. A
117
+ // release that *will* glide reports a non-zero velocity (points/ms on iOS,
118
+ // density-scaled px/s on Android — either way, orders of magnitude above
119
+ // this epsilon), and momentum end is left to speak for it. A release at rest
120
+ // reports 0, and there `getCarouselIndex`'s round-to-nearest already agrees
121
+ // with where `snapToInterval` would land, so the two paths can't disagree.
122
+ const handleScrollEndDrag = useCallback((event) => {
123
+ const velocityX = event.nativeEvent.velocity?.x ?? 0;
124
+ if (Math.abs(velocityX) > 0.01)
125
+ return;
126
+ handleScroll(event);
127
+ }, [handleScroll]);
128
+ const handleLayout = useCallback((event) => {
129
+ const next = event.nativeEvent.layout.width;
130
+ if (!(next > 0))
131
+ return;
132
+ setMeasuredWidth((current) => current !== null && Math.abs(current - next) < 1 ? current : next);
133
+ }, []);
134
+ // Jump to initialIndex once, after the first real layout — the seeded
135
+ // viewport width would put the offset in the wrong place. `contentOffset` is
136
+ // iOS-only, so drive it imperatively for Android/web parity.
137
+ const targetIndex = clampIndex(initialIndex, count);
138
+ useEffect(() => {
139
+ if (hasScrolledToInitial.current || measuredWidth === null || interval <= 0)
140
+ return;
141
+ hasScrolledToInitial.current = true;
142
+ if (targetIndex === 0)
143
+ return;
144
+ scrollRef.current?.scrollTo({ x: targetIndex * interval, animated: false });
145
+ }, [interval, measuredWidth, targetIndex]);
146
+ // Re-clamp when children shrink. Without this the status line reads "5 of 3"
147
+ // with no active dot until the next scroll, because activeIndex is only
148
+ // seeded once and the scroller — already past the new content width — emits
149
+ // nothing on its own. Scrolling back is what makes the offset agree with the
150
+ // reported page again.
151
+ useEffect(() => {
152
+ if (count <= 0 || activeIndexRef.current < count)
153
+ return;
154
+ const clamped = count - 1;
155
+ commitIndex(clamped);
156
+ if (interval > 0) {
157
+ scrollRef.current?.scrollTo({ x: clamped * interval, animated: false });
158
+ }
159
+ }, [commitIndex, count, interval]);
160
+ const handleDotPress = useCallback((index) => {
161
+ commitIndex(index);
162
+ if (interval > 0) {
163
+ scrollRef.current?.scrollTo({ x: index * interval, animated: true });
164
+ }
165
+ }, [commitIndex, interval]);
166
+ const useCssSnap = isWeb && snap;
167
+ const dotsVisible = showDots && count > 1;
168
+ return (_jsxs(View, { style: [styles.container, styleOverride], onLayout: handleLayout, testID: testID, children: [_jsx(ScrollView, { ref: scrollRef, testID: `${testID}-scroll`, horizontal: true, showsHorizontalScrollIndicator: false,
169
+ // Native snapping. react-native-web drops these (they aren't in its
170
+ // forwarded-prop list), so web uses the CSS scroll-snap styles instead.
171
+ snapToInterval: snap && interval > 0 ? interval : undefined, snapToAlignment: "start", decelerationRate: snap ? "fast" : "normal", ...(isWeb
172
+ ? { scrollEventThrottle: 16, onScroll: handleScroll }
173
+ : { onMomentumScrollEnd: handleScroll, onScrollEndDrag: handleScrollEndDrag }), style: useCssSnap
174
+ ? [webStyles.scroller, { scrollPaddingLeft: contentPadding }]
175
+ : undefined, contentContainerStyle: [{ paddingHorizontal: contentPadding, gap }, contentContainerStyle], children: items.map((child, index) => (_jsx(View, { testID: `${testID}-item-${index}`, style: [slideWidth > 0 && { width: slideWidth }, useCssSnap && webStyles.item], children: child }, React.isValidElement(child) && child.key != null ? child.key : index))) }), count > 1 && (_jsx(View, { testID: `${testID}-status`, style: styles.status, "aria-live": "polite", children: _jsx(Text, { style: styles.statusText, children: `${activeIndex + 1} of ${count}` }) })), dotsVisible && (_jsx(View, { testID: `${testID}-dots`, style: styles.dots, accessibilityRole: "tablist", children: items.map((_, index) => (_jsx(Pressable, { testID: `${testID}-dot-${index}`, accessibilityRole: "tab", accessibilityState: { selected: index === activeIndex }, "aria-selected": index === activeIndex, accessibilityLabel: `Slide ${index + 1} of ${count}`, hitSlop: DOT_HIT_SLOP, onPress: () => handleDotPress(index), style: styles.dotButton, children: _jsx(View, { testID: `${testID}-dot-${index}-indicator`, style: [styles.dot, index === activeIndex ? styles.dotActive : styles.dotInactive] }) }, index))) }))] }));
176
+ }
177
+ // ============================================================================
178
+ // Styles
179
+ // ============================================================================
180
+ const DOT_SIZE = 8;
181
+ // Press target around each 8px dot. The square is what separates the dots
182
+ // visually (the row itself has no gap, so neighboring targets are flush — no
183
+ // dead strip between them), and `DOT_HIT_SLOP` extends it vertically to
184
+ // `spacing.touchTarget` (24 + 2 x 10 = 44). The slop is vertical-only on
185
+ // purpose: horizontal slop would overlap the neighboring dot's target and make
186
+ // which dot a tap lands on ambiguous.
187
+ const DOT_HIT_SIZE = 24;
188
+ const DOT_HIT_SLOP = {
189
+ top: (spacing.touchTarget - DOT_HIT_SIZE) / 2,
190
+ bottom: (spacing.touchTarget - DOT_HIT_SIZE) / 2,
191
+ left: 0,
192
+ right: 0,
193
+ };
194
+ // Web-only CSS scroll-snap. `scrollPaddingLeft` is applied inline at the call
195
+ // site (it tracks the contentPadding prop) so the leading inset survives the
196
+ // browser snapping slide 0 to the scrollport start — which also makes page `i`
197
+ // land on `i * interval`, matching the native snap offsets.
198
+ const webStyles = StyleSheet.create({
199
+ scroller: { scrollSnapType: "x mandatory" },
200
+ item: { scrollSnapAlign: "start" },
201
+ });
202
+ const createStyles = (theme) => StyleSheet.create({
203
+ container: {
204
+ width: "100%",
205
+ },
206
+ // Clipped to 1x1 rather than removed: screen readers skip
207
+ // display:none/opacity:0 nodes, and there is no visual affordance to show.
208
+ status: {
209
+ position: "absolute",
210
+ width: 1,
211
+ height: 1,
212
+ overflow: "hidden",
213
+ },
214
+ statusText: {
215
+ fontSize: 1,
216
+ color: "transparent",
217
+ },
218
+ dots: {
219
+ flexDirection: "row",
220
+ alignItems: "center",
221
+ justifyContent: "center",
222
+ // No `gap`: the DOT_HIT_SIZE squares carry the visual spacing, and a gap
223
+ // would open an unpressable strip between adjacent targets. marginTop
224
+ // absorbs the wrapper's own vertical padding so the row sits where the
225
+ // pre-Pressable dots did.
226
+ marginTop: spacing.md - (DOT_HIT_SIZE - DOT_SIZE) / 2,
227
+ },
228
+ dotButton: {
229
+ width: DOT_HIT_SIZE,
230
+ height: DOT_HIT_SIZE,
231
+ alignItems: "center",
232
+ justifyContent: "center",
233
+ },
234
+ dot: {
235
+ width: DOT_SIZE,
236
+ height: DOT_SIZE,
237
+ borderRadius: spacing.radiusFull,
238
+ },
239
+ dotActive: {
240
+ backgroundColor: theme.colors.accent,
241
+ },
242
+ dotInactive: {
243
+ backgroundColor: theme.colors.border,
244
+ },
245
+ });
246
+ const themedStyles = createThemedStyles(createStyles);
@@ -395,7 +395,9 @@ function DrawerOverlayContent({ swipeEnabled = true, swipeThreshold = 0.3, veloc
395
395
  bottom: 0,
396
396
  width,
397
397
  [side]: 0,
398
- backgroundColor: theme.colors.background,
398
+ // App chrome sits on the sunken tier so it reads as a layer beneath the
399
+ // content pane rather than the same surface.
400
+ backgroundColor: theme.colors.surfaceSunken,
399
401
  borderColor: theme.colors.border,
400
402
  ...(side === "left"
401
403
  ? { borderRightWidth: 1 }
@@ -502,7 +504,8 @@ swipeEnabled: _swipeEnabled, swipeThreshold: _swipeThreshold, velocityThreshold:
502
504
  const panelStyle = {
503
505
  width: Platform.OS === "web" ? targetWidth : widthAnim,
504
506
  overflow: "hidden",
505
- backgroundColor: theme.colors.background,
507
+ // Rail chrome sits on the sunken tier, one step below the content pane.
508
+ backgroundColor: theme.colors.surfaceSunken,
506
509
  borderColor: theme.colors.border,
507
510
  ...(side === "left" ? { borderRightWidth: 1 } : { borderLeftWidth: 1 }),
508
511
  paddingTop: insets.top,