@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.
- package/CHANGELOG.md +283 -0
- package/LLM_USAGE.md +14 -0
- package/README.md +7 -1
- package/dist/components/Avatar.d.ts +94 -0
- package/dist/components/Avatar.js +296 -0
- package/dist/components/Carousel.d.ts +99 -0
- package/dist/components/Carousel.js +246 -0
- package/dist/components/Drawer.js +5 -2
- package/dist/components/Label.d.ts +30 -10
- package/dist/components/Label.js +19 -10
- package/dist/components/index.d.ts +2 -0
- package/dist/components/index.js +2 -0
- package/dist/constants/colors.d.ts +4 -0
- package/dist/constants/colors.js +12 -4
- package/dist/constants/fonts.js +5 -4
- package/dist/hooks/useDimensions.d.ts +19 -9
- package/dist/hooks/useDimensions.js +24 -32
- package/dist/hooks/useResources.d.ts +0 -14
- package/dist/hooks/useResources.js +7 -39
- package/dist/lib/themedStyles.d.ts +7 -7
- package/dist/lib/themedStyles.js +7 -7
- package/dist/state/index.d.ts +0 -1
- package/dist/state/index.js +0 -1
- package/dist/state/themeStore.d.ts +15 -0
- package/dist/state/themeStore.js +38 -10
- package/llms-full.md +9 -4
- package/package.json +1 -1
- package/dist/state/SsrViewportContext.d.ts +0 -41
- package/dist/state/SsrViewportContext.js +0 -42
|
@@ -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
|
-
|
|
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
|
-
|
|
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,
|