@mrmeg/expo-ui 0.20.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 CHANGED
@@ -3,6 +3,156 @@
3
3
  All notable changes to `@mrmeg/expo-ui` are documented here. This project
4
4
  adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
5
5
 
6
+ ## [0.21.0]
7
+
8
+ ### Added
9
+
10
+ - **`Carousel` dots are pressable and scroll to their slide.** Each dot is a
11
+ `Pressable` that jumps the scroller to its page (animated) and reports the
12
+ new index through `onIndexChange`. The `tablist`/`tab` roles and `selected`
13
+ state the dots already carried are now honest: a screen reader that
14
+ announced N activatable tabs can finally focus and activate them, and on web
15
+ each dot is a keyboard tab-stop.
16
+
17
+ The visible dot stays 8px. The press target is a 24px wrapper plus vertical
18
+ `hitSlop` (44px total), and the row's `gap` moved into that wrapper so
19
+ neighboring targets sit flush with no dead strip between them. The slop is
20
+ vertical-only so a tap can't be ambiguous between two dots.
21
+
22
+ The selected dot also sets `aria-selected` explicitly: react-native-web
23
+ never maps `accessibilityState` to the DOM, so on web the tabs previously
24
+ exposed no selected state at all. Native maps `aria-selected` back into
25
+ `accessibilityState`, so the two props agree.
26
+
27
+ - **`Label` accepts `htmlFor`.** `@rn-primitives/label`'s web build only emits a
28
+ real `<label for="…">` — the thing that actually associates a label with an
29
+ input and makes clicking the label focus it — when `htmlFor` is passed, and
30
+ `Label` never forwarded it. No consumer could produce an associated label.
31
+ `htmlFor` now reaches the primitive's `Text`, which is where the prop is
32
+ declared and where the web build consumes it. Native ignores it, matching the
33
+ primitive.
34
+
35
+ Association takes **two distinct ids**:
36
+
37
+ ```tsx
38
+ <Label nativeID="email-label" htmlFor="email-input">Email</Label>
39
+ <TextInput nativeID="email-input" />
40
+ ```
41
+
42
+ `nativeID` is the label's own id (react-native-web maps it to `id`; on native
43
+ it's what a control's `accessibilityLabelledBy` points at). `htmlFor` is the
44
+ input's id.
45
+
46
+ ### Changed
47
+
48
+ - **`Carousel` re-clamps its active index when `children` shrink.** The index
49
+ was seeded once, so removing slides while a late page was active left the
50
+ page announcement reading e.g. "5 of 3" with no active dot until the next
51
+ scroll. It now clamps to the last slide, reports the clamped value through
52
+ `onIndexChange`, and scrolls back to that page — the scroller was sitting
53
+ past the new content width.
54
+
55
+ - **`Carousel` index tracking is split by platform.** Native subscribes to
56
+ `onMomentumScrollEnd` — plus a velocity-gated `onScrollEndDrag`, for a drag
57
+ that releases at rest and so is never followed by a momentum event — and no
58
+ longer subscribes to `onScroll` at all, so a drag costs no per-frame JS
59
+ callback to compute a page the settle reports anyway. A drag past a slide
60
+ midpoint that snaps back to the starting page now reports nothing instead of
61
+ firing the intermediate page and then the original. Web keeps the throttled
62
+ `onScroll` path, which remains the only channel there — react-native-web
63
+ emits no momentum events.
64
+
65
+ The `onIndexChange` doc no longer claims "once per settle" unconditionally.
66
+ That now holds on native; on web (wheel/trackpad) it still fires once per
67
+ page *crossed*, and only consecutive duplicate indices are suppressed.
68
+
69
+ - **New `Carousel` testID: `<testID>-dot-<i>-indicator`.** `-dot-<i>` is now
70
+ the pressable wrapper; the visible dot — and therefore its
71
+ `backgroundColor` — is the `-indicator` node inside it. Tests asserting on a
72
+ dot's color need the new suffix.
73
+
74
+ - **`useDimensions` seeds from a constant, not context.** The first web render
75
+ uses the new `DEFAULT_VIEWPORT_WIDTH` / `DEFAULT_VIEWPORT_HEIGHT` exports
76
+ (1280×800, the previous context defaults) and switches to real dimensions
77
+ after mount. The hook no longer reads a context or writes an `mrmeg-vw`
78
+ cookie.
79
+
80
+ - **Theme persistence key is exported as `THEME_STORAGE_KEY`** (value
81
+ unchanged: `user-theme-preference`), replacing `THEME_COOKIE_NAME` as the
82
+ name callers reference.
83
+
84
+ ### Removed
85
+
86
+ - **Server-rendering support surface.** The template that hosts this package
87
+ no longer server-renders web routes (routes are client-rendered from an
88
+ HTML shell), so the per-request plumbing that existed only to make a server
89
+ render and the browser's first render agree is gone:
90
+
91
+ - `SsrThemeSeedContext`, `SsrThemeSeed`, `SSR_THEME_SEED_DEFAULT`, and
92
+ `useSsrThemeSeed` (added in 0.20.0).
93
+ - `SsrViewportContext`, `SSR_VIEWPORT_DEFAULT_WIDTH`, and
94
+ `SSR_VIEWPORT_DEFAULT_HEIGHT`.
95
+ - `THEME_COOKIE_NAME`, and the `document.cookie` write `setTheme` performed
96
+ alongside its persisted write on web.
97
+
98
+ Hosts that server-render can keep doing so, but they now own the seeding
99
+ themselves; `useTheme` resolves from the store only.
100
+
101
+ ### Fixed
102
+
103
+ - **`Label`'s documented pairing pattern produced duplicate DOM ids.** The usage
104
+ docs recommended the *same* `nativeID` on the `Label` and its `TextInput`.
105
+ react-native-web maps `nativeID` to `id`, so that renders two elements sharing
106
+ one id — invalid HTML, and no association either way. The docs now show the
107
+ two-id pattern above.
108
+
109
+ - **`Avatar` renders image sources that carry no `uri`.** The internal source
110
+ key — which exists so a failure flag survives the fresh `{ uri }` object that
111
+ an inline literal allocates every render — returned `null` for any object
112
+ without a truthy `uri`, and `null` means "no image at all". An iOS
113
+ `{ bundle }` source, or a resolved asset object, therefore silently degraded
114
+ to initials and the image was never attempted. Every shape React Native
115
+ accepts is now keyed (numbers as `asset:<n>`, uri strings as themselves,
116
+ anything else by a stable serialization of its fields); `null` is reserved
117
+ for a missing, empty-object, or empty-array source. Retry-on-change and
118
+ keep-the-failure-on-re-render behavior now applies to those shapes too.
119
+
120
+ - **`AvatarGroup` members are announceable again (accessibility change).** The
121
+ group wrapper was `accessible` with `accessibilityRole="image"` and an
122
+ `"<n> avatars"` label, which is a leaf role on the web and swallows
123
+ descendants on iOS — so no member's name could be reached inside a group. The
124
+ wrapper is now a plain container with no accessibility semantics of its own,
125
+ and the count rides along as a visually clipped text node ahead of the
126
+ members, so a screen reader reads the summary and then each avatar. Passing
127
+ `accessibilityLabel` still replaces the count text. Apps that asserted on the
128
+ group by its accessibility label (`getByLabelText("3 avatars")`) should query
129
+ the text instead (`getByText("3 avatars")`).
130
+
131
+ - **`AvatarGroup`'s `max` is clamped to a whole count ≥ 0.** A negative `max`
132
+ reached `slice(0, max)`, which reads a negative end as an offset from the end
133
+ — `max={-3}` on three children rendered all of them, silently disabling the
134
+ clamp — and a fractional `max` truncated arbitrarily. `max` is now
135
+ `Math.max(0, Math.floor(max))`, so `max={0}` renders only the `+N` tile.
136
+
137
+ - **`Avatar` shows its initials/icon fallback while the image loads.** A slow
138
+ or large image left a bare `muted` tile for the whole request. The fallback
139
+ now stays mounted until the image reports `onLoad`, with the image layered
140
+ over it, so an avatar is never a blank circle in flight.
141
+
142
+ - **`Avatar`'s image carries its own corner radius.** Rounding relied solely on
143
+ the wrapper's `overflow: "hidden"`, which Android drops for a child image in
144
+ several cases, leaving square corners on a circular avatar.
145
+
146
+ ### Documentation
147
+
148
+ - **`Carousel`'s `itemWidth` caveat.** A fractional `itemWidth` measures
149
+ against the viewport until the first `onLayout`, so a carousel inside a
150
+ horizontally constrained parent (a padded column, a `MaxWidthContainer`, a
151
+ sidebar pane) renders its first frame — and the exported HTML shell — with
152
+ slides sized to the window. Documented on the prop, with the workaround:
153
+ pass an absolute `itemWidth` (`> 1`) when the parent is narrower than the
154
+ viewport and that first frame matters.
155
+
6
156
  ## [0.20.0]
7
157
 
8
158
  ### Added
package/LLM_USAGE.md CHANGED
@@ -201,7 +201,7 @@ Use this table before creating a new app-local primitive.
201
201
  | `BottomSheet` | Mobile-first modal sheets | Custom absolute-position sheets | Action pickers, mobile filters, keyboard-aware quick edit forms |
202
202
  | `Button` | Commands and CTAs | Pressable plus custom text styling | Submit, save, cancel, delete, navigation CTAs |
203
203
  | `Card`, `CardHeader`, `CardTitle`, `CardDescription`, `CardContent`, `CardFooter` | Framed content groups | Ad hoc bordered panels | List items, pricing plans, settings sections, summaries |
204
- | `Carousel` | Horizontally snapping slide rows with dot indicators | Hand-rolled snap `ScrollView` plus manual offset math | Testimonial strips, onboarding pages, feature highlights, image galleries |
204
+ | `Carousel` | Horizontally snapping slide rows with pressable dot indicators | Hand-rolled snap `ScrollView` plus manual offset math | Testimonial strips, onboarding pages, feature highlights, image galleries |
205
205
  | `Checkbox` | Boolean selection | Custom checkmark controls | Terms consent, checklist items, multi-select filters |
206
206
  | `Collapsible`, `CollapsibleTrigger`, `CollapsibleContent` | One-off disclosure | Local animated height wrappers | Advanced settings, hidden helper text |
207
207
  | `Dialog`, `AlertDialog` | Modal decisions and custom modal content | Custom modal overlays | Confirm delete, edit profile, invite user |
@@ -238,10 +238,11 @@ Use this table before creating a new app-local primitive.
238
238
  - Use `RadioGroup` for small mutually exclusive choices and `Select` for longer option sets.
239
239
  - Use `Dialog` for blocking decisions, `Popover` for contextual controls, `Tooltip` for short explanations, and `DropdownMenu` for action lists.
240
240
  - Use `Card` for individual repeated or framed items, not as a wrapper around full page sections.
241
- - Use `Carousel` for a horizontal snap row of a known, small set of slides. It renders every child (no virtualization), so slides survive server rendering; reach for `FlatList` when the data set is large or unbounded.
241
+ - Use `Carousel` for a horizontal snap row of a known, small set of slides. It renders every child (no virtualization), so slides survive into the exported HTML shell and the first client frame; reach for `FlatList` when the data set is large or unbounded. Its dots are pressable and jump to their slide. A fractional `itemWidth` measures the viewport until the first layout, so pass absolute pixels (`> 1`) when the parent is narrower than the window and the first frame matters.
242
242
  - Use `EmptyState` for no-data or recoverable error regions.
243
243
  - Use `Skeleton` for loading content with stable layout.
244
- - Use `Avatar` with both `source` and `name` whenever both exist: `name` supplies the initials shown when the image is absent or fails, plus the default accessibility label. Never hand-roll a circle with a nested `Image` and initials `Text`. Inside `AvatarGroup`, set `size`/`shape` on the group — children inherit them and gain the ring.
244
+ - Use `Avatar` with both `source` and `name` whenever both exist: `name` supplies the initials shown when the image is absent, still loading, or failed, plus the default accessibility label. Never hand-roll a circle with a nested `Image` and initials `Text`. Inside `AvatarGroup`, set `size`/`shape` on the group — children inherit them and gain the ring; the group's count is a hidden summary node, so each member stays individually announceable and the group needs no `accessible` wrapper.
245
+ - Pair a standalone `Label` with its control using two DISTINCT ids: `nativeID` is the label's own id, `htmlFor` is the input's id (`<Label nativeID="email-label" htmlFor="email-input">` + `<TextInput nativeID="email-input" />`). Putting the same id on both renders duplicate ids on web and associates nothing. Prefer `TextInput`'s own `label` prop when you don't need a separate label element.
245
246
  - Use `Progress` for real progress or indeterminate long-running work.
246
247
  - Use `Drawer.Header` with `icon`, `title`, and `action` for a compact app-brand row; place `Drawer.ToggleCollapse` in `action` for a trailing rail control.
247
248
 
package/README.md CHANGED
@@ -302,7 +302,7 @@ All components are exported from `@mrmeg/expo-ui/components`; direct imports suc
302
302
  | `BottomSheet` | Mobile-first modal sheets | Custom absolute-position sheets | Action pickers, mobile filters, keyboard-aware quick edit forms, contextual details |
303
303
  | `Button` | Commands and CTAs | Pressable plus custom text styling | Submit, save, cancel, delete, navigation CTAs, icon-accessory buttons; loading state preserves resting width |
304
304
  | `Card`, `CardHeader`, `CardTitle`, `CardDescription`, `CardContent`, `CardFooter` | Framed content groups | Ad hoc bordered panels | List items, pricing plans, settings sections, summaries, dashboards |
305
- | `Carousel` | Horizontally snapping slide rows with dot indicators | Hand-rolled snap `ScrollView` plus manual offset math | Testimonial strips, onboarding pages, feature highlights, image galleries; `itemWidth` below 1 is a fraction of the carousel width so the next slide peeks |
305
+ | `Carousel` | Horizontally snapping slide rows with pressable dot indicators | Hand-rolled snap `ScrollView` plus manual offset math | Testimonial strips, onboarding pages, feature highlights, image galleries; `itemWidth` below 1 is a fraction of the carousel width so the next slide peeks, but that fraction measures the viewport until the first layout — pass absolute pixels (`> 1`) inside a constrained parent |
306
306
  | `Checkbox` | Boolean selection | Custom checkmark controls | Terms consent, checklist items, multi-select filters, notification opt-ins |
307
307
  | `Collapsible`, `CollapsibleTrigger`, `CollapsibleContent` | One-off disclosure | Local animated height wrappers | Advanced settings, hidden helper text, optional details |
308
308
  | `Dialog`, `AlertDialog` | Modal decisions and custom modal content | Custom modal overlays | Confirm delete, edit profile, invite user, blocking warnings |
@@ -384,7 +384,7 @@ Use `Skeleton` components for loading content with stable dimensions, `EmptyStat
384
384
 
385
385
  Use `Toggle` for one pressed state, `ToggleGroup` for a related set of pressed states, `Switch` for binary settings, `RadioGroup` for small mutually exclusive choices, and `Select` for longer option sets.
386
386
 
387
- Pass `Avatar` both a `source` and a `name` whenever you have them: `name` supplies the initials shown while the image is missing or after it fails, and the default accessibility label. `SkeletonAvatar` is the loading placeholder, not a substitute. Inside `AvatarGroup`, child avatars inherit the group's `size` and `shape` and gain the ring, so set those on the group rather than per child.
387
+ Pass `Avatar` both a `source` and a `name` whenever you have them: `name` supplies the initials shown while the image is missing, still loading, or after it fails, and the default accessibility label. `SkeletonAvatar` is the loading placeholder, not a substitute. Inside `AvatarGroup`, child avatars inherit the group's `size` and `shape` and gain the ring, so set those on the group rather than per child. `AvatarGroup` announces its count as a hidden summary and leaves each member individually announceable, so don't wrap it in an `accessible` container of your own.
388
388
 
389
389
  ### Quick Examples
390
390
 
@@ -18,7 +18,11 @@ export type AvatarShape = "circle" | "square";
18
18
  */
19
19
  export declare function getAvatarInitials(name?: string): string;
20
20
  export interface AvatarProps {
21
- /** Image to display. Falls back to initials (then the icon) if it fails to load. */
21
+ /**
22
+ * Image to display. Any source React Native accepts — a uri object, a
23
+ * `require()`d asset, a uri-less descriptor, a multi-resolution array. The
24
+ * initials (then the icon) show while it loads and if it fails.
25
+ */
22
26
  source?: ImageSourcePropType;
23
27
  /** Display name; supplies the fallback initials and the default accessibility label. */
24
28
  name?: string;
@@ -39,8 +43,9 @@ export interface AvatarProps {
39
43
  * Avatar Component
40
44
  *
41
45
  * A circular (or rounded-square) profile image with a graceful fallback chain:
42
- * image → initials → icon. A broken or slow-failing image downgrades to
43
- * initials at runtime, so a dead URL never leaves an empty hole.
46
+ * image → initials → icon. The fallback shows while the image is in flight and
47
+ * stays put if the image fails, so neither a slow network nor a dead URL ever
48
+ * leaves an empty hole.
44
49
  *
45
50
  * Usage:
46
51
  * ```tsx
@@ -52,13 +57,19 @@ export interface AvatarProps {
52
57
  declare function Avatar({ source, name, icon, size, shape, accessibilityLabel, style: styleOverride, }: AvatarProps): React.JSX.Element;
53
58
  export interface AvatarGroupProps {
54
59
  children?: React.ReactNode;
55
- /** Maximum number of avatars to render; the rest collapse into a `+N` tile. */
60
+ /**
61
+ * Maximum number of avatars to render; the rest collapse into a `+N` tile.
62
+ * Clamped to a whole count ≥ 0, so `max={0}` renders only the `+N` tile.
63
+ */
56
64
  max?: number;
57
65
  /** Size applied to child avatars that don't set their own. @default "md" */
58
66
  size?: AvatarSize;
59
67
  /** Shape applied to child avatars that don't set their own. @default "circle" */
60
68
  shape?: AvatarShape;
61
- /** Defaults to `"<n> avatars"`, counting the collapsed overflow. */
69
+ /**
70
+ * Summary announced ahead of the members, as a visually hidden text node.
71
+ * Defaults to `"<n> avatars"`, counting the collapsed overflow.
72
+ */
62
73
  accessibilityLabel?: string;
63
74
  style?: StyleProp<ViewStyle>;
64
75
  }
@@ -69,6 +80,9 @@ export interface AvatarGroupProps {
69
80
  * the stack reads on any surface. Earlier avatars sit on top; anything past
70
81
  * `max` collapses into a `+N` tile.
71
82
  *
83
+ * The group is not an accessibility element itself — its member avatars each
84
+ * announce their own name, preceded by a hidden count summary.
85
+ *
72
86
  * Usage:
73
87
  * ```tsx
74
88
  * <AvatarGroup max={3}>
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import React, { use, useState } from "react";
3
- import { Image, StyleSheet, View, } from "react-native";
3
+ import { Image, StyleSheet, Text, View, } from "react-native";
4
4
  import { useTheme } from "../hooks/useTheme.js";
5
5
  import { spacing } from "../constants/spacing.js";
6
6
  import { Icon } from "./Icon.js";
@@ -29,6 +29,19 @@ function contentSize(px) {
29
29
  function overlap(px) {
30
30
  return -Math.round(px * 0.3);
31
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
+ }
32
45
  /**
33
46
  * Derive 1–2 initials from a display name.
34
47
  *
@@ -59,9 +72,14 @@ export function getAvatarInitials(name) {
59
72
  /**
60
73
  * Identity of an image source by content, not object identity.
61
74
  *
62
- * `source={{ uri }}` allocates a new object on every render, so a failure
63
- * flag keyed on object identity would reset immediately and loop
64
- * image → error → image. Returns `null` when there is no usable image.
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.
65
83
  */
66
84
  function getSourceKey(source) {
67
85
  if (source == null)
@@ -69,18 +87,28 @@ function getSourceKey(source) {
69
87
  if (typeof source === "number")
70
88
  return `asset:${source}`;
71
89
  if (Array.isArray(source)) {
72
- const key = source.map((entry) => entry?.uri ?? "").filter(Boolean).join("|");
73
- return key || null;
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;
74
93
  }
75
- return source.uri || null;
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("&");
76
103
  }
77
104
  const AvatarGroupContext = React.createContext(null);
78
105
  /**
79
106
  * Avatar Component
80
107
  *
81
108
  * A circular (or rounded-square) profile image with a graceful fallback chain:
82
- * image → initials → icon. A broken or slow-failing image downgrades to
83
- * initials at runtime, so a dead URL never leaves an empty hole.
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.
84
112
  *
85
113
  * Usage:
86
114
  * ```tsx
@@ -96,13 +124,17 @@ function Avatar({ source, name, icon, size, shape, accessibilityLabel, style: st
96
124
  const px = resolveSize(size ?? group?.size ?? "md");
97
125
  const resolvedShape = shape ?? group?.shape ?? "circle";
98
126
  const sourceKey = getSourceKey(source);
99
- // Keyed on the source's content, so a retry with a new URL clears the
100
- // failure while a re-rendered identical URL keeps it.
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.
101
129
  const [failedKey, setFailedKey] = useState(null);
130
+ const [loadedKey, setLoadedKey] = useState(null);
102
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;
103
135
  const initials = getAvatarInitials(name);
104
136
  const label = accessibilityLabel ?? name;
105
- return (_jsx(AvatarSurface, { px: px, shape: resolvedShape, ring: group?.ring ?? false, accessibilityLabel: label, style: [styles.fallback, styleOverride], children: showImage ? (_jsx(Image, { source: source, resizeMode: "cover", style: geometry.image, onError: () => setFailedKey(sourceKey) })) : initials ? (_jsx(AvatarLabel, { px: px, style: styles.fallbackText, children: initials })) : (_jsx(Icon, { name: icon ?? "user", size: contentSize(px).iconSize, color: "mutedForeground", decorative: true })) }));
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] }));
106
138
  }
107
139
  /**
108
140
  * Shared chrome for an avatar-shaped tile — used by `Avatar` and by
@@ -143,6 +175,9 @@ function AvatarLabel({ px, style, children, }) {
143
175
  * the stack reads on any surface. Earlier avatars sit on top; anything past
144
176
  * `max` collapses into a `+N` tile.
145
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
+ *
146
181
  * Usage:
147
182
  * ```tsx
148
183
  * <AvatarGroup max={3}>
@@ -156,20 +191,23 @@ function AvatarGroup({ children, max, size = "md", shape = "circle", accessibili
156
191
  // toArray drops null/undefined/false children so a conditionally rendered
157
192
  // avatar doesn't inflate the count or the overflow tally.
158
193
  const items = React.Children.toArray(children);
159
- const visible = max != null && max >= 0 ? items.slice(0, max) : items;
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;
160
198
  const overflowCount = items.length - visible.length;
161
199
  const px = resolveSize(size);
162
200
  const token = SIZE_TOKEN_FOR_PX[px];
163
201
  const stackStyle = token ? geometry[`${token}Stack`] : { marginLeft: overlap(px) };
164
202
  const totalTiles = visible.length + (overflowCount > 0 ? 1 : 0);
165
203
  const contextValue = React.useMemo(() => ({ size, shape, ring: true }), [size, shape]);
166
- return (_jsx(View, { accessible: true, accessibilityRole: "image", accessibilityLabel: accessibilityLabel ?? `${items.length} avatars`, style: [geometry.group, styleOverride], children: _jsxs(AvatarGroupContext.Provider, { value: contextValue, children: [visible.map((child, index) => (_jsx(View
167
- // toArray assigns every child a stable key, so reordering the list
168
- // moves the wrapper with its avatar instead of remounting both.
169
- , {
170
- // Earlier avatars paint over later ones, so the stack reads
171
- // left-to-right instead of the DOM's later-wins order.
172
- 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] }) }));
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] })] }));
173
211
  }
174
212
  /** px → token, so a size passed as a raw number still hits the registered rules. */
175
213
  const SIZE_TOKEN_FOR_PX = {
@@ -178,8 +216,8 @@ const SIZE_TOKEN_FOR_PX = {
178
216
  [SIZE_TOKENS.lg]: "lg",
179
217
  };
180
218
  // Geometry carries no theme values, so a plain module-scope sheet is enough to
181
- // get these rules into the server-rendered <head>. Only colors go through
182
- // createThemedStyles below. See docs/ssr-hydration.md.
219
+ // get these rules into the prerendered <head>. Only colors go through
220
+ // createThemedStyles below. See "Enable Server Output" in docs/server-guide.md.
183
221
  const geometry = StyleSheet.create({
184
222
  base: {
185
223
  alignItems: "center",
@@ -187,9 +225,18 @@ const geometry = StyleSheet.create({
187
225
  overflow: "hidden",
188
226
  },
189
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,
190
236
  width: "100%",
191
237
  height: "100%",
192
238
  },
239
+ imageCircle: { borderRadius: spacing.radiusFull },
193
240
  labelText: {
194
241
  // The tile is the touch/visual target; text must not add its own metrics.
195
242
  includeFontPadding: false,
@@ -203,6 +250,18 @@ const geometry = StyleSheet.create({
203
250
  flexDirection: "row",
204
251
  alignItems: "center",
205
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
+ },
206
265
  smCircle: { width: SIZE_TOKENS.sm, height: SIZE_TOKENS.sm, borderRadius: spacing.radiusFull },
207
266
  mdCircle: { width: SIZE_TOKENS.md, height: SIZE_TOKENS.md, borderRadius: spacing.radiusFull },
208
267
  lgCircle: { width: SIZE_TOKENS.lg, height: SIZE_TOKENS.lg, borderRadius: spacing.radiusFull },
@@ -212,6 +271,9 @@ const geometry = StyleSheet.create({
212
271
  smStack: { marginLeft: overlap(SIZE_TOKENS.sm) },
213
272
  mdStack: { marginLeft: overlap(SIZE_TOKENS.md) },
214
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) },
215
277
  });
216
278
  const createStyles = (theme) => StyleSheet.create({
217
279
  fallback: {
@@ -7,6 +7,14 @@ export interface CarouselProps {
7
7
  * Slide width. Values `<= 1` are a fraction of the carousel's own width —
8
8
  * the default `0.85` leaves the next slide peeking. Values `> 1` are
9
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.
10
18
  * @default 0.85
11
19
  */
12
20
  itemWidth?: number;
@@ -14,11 +22,25 @@ export interface CarouselProps {
14
22
  gap?: number;
15
23
  /** Horizontal inset before the first and after the last slide. @default spacing.lg */
16
24
  contentPadding?: number;
17
- /** Render the dot indicators. Auto-hidden for a single slide. @default true */
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
+ */
18
30
  showDots?: boolean;
19
31
  /** Slide shown on mount. @default 0 */
20
32
  initialIndex?: number;
21
- /** Called with the new active index whenever it changes. */
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
+ */
22
44
  onIndexChange?: (index: number) => void;
23
45
  /** Snap to slide boundaries. @default true */
24
46
  snap?: boolean;
@@ -28,7 +50,8 @@ export interface CarouselProps {
28
50
  contentContainerStyle?: StyleProp<ViewStyle>;
29
51
  /**
30
52
  * testID prefix. Emits `<testID>`, `-scroll`, `-item-<i>`, `-dots`,
31
- * `-dot-<i>`, and `-status`.
53
+ * `-dot-<i>` (the pressable), `-dot-<i>-indicator` (the visible dot), and
54
+ * `-status`.
32
55
  * @default "carousel"
33
56
  */
34
57
  testID?: string;
@@ -54,11 +77,14 @@ export declare function resolveCarouselItemWidth(itemWidth: number, containerWid
54
77
  * resolved item width.
55
78
  *
56
79
  * Built on `ScrollView` — not a virtualized list — on purpose: every slide is
57
- * in the server-rendered tree, so web SSR ships the real content instead of an
58
- * empty scroller that only fills in after measurement. Snapping uses the
59
- * platform scroller (`snapToInterval` on native, CSS scroll-snap on web); no
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
60
83
  * animation library is involved.
61
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
+ *
62
88
  * @example
63
89
  * ```tsx
64
90
  * <Carousel itemWidth={0.8} onIndexChange={setPage}>