@mrmeg/expo-ui 0.20.0 → 0.22.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.
Files changed (39) hide show
  1. package/CHANGELOG.md +202 -0
  2. package/LLM_USAGE.md +4 -3
  3. package/README.md +2 -2
  4. package/dist/components/Avatar.d.ts +19 -5
  5. package/dist/components/Avatar.js +84 -22
  6. package/dist/components/BottomSheet.js +1 -1
  7. package/dist/components/Carousel.d.ts +32 -6
  8. package/dist/components/Carousel.js +79 -17
  9. package/dist/components/DismissKeyboard.js +1 -1
  10. package/dist/components/KeyboardAvoidingView.d.ts +4 -2
  11. package/dist/components/KeyboardAvoidingView.js +5 -3
  12. package/dist/components/Label.d.ts +30 -10
  13. package/dist/components/Label.js +19 -10
  14. package/dist/components/Notification.js +7 -7
  15. package/dist/components/keyboardController.d.ts +46 -0
  16. package/dist/components/keyboardController.js +89 -0
  17. package/dist/components/keyboardController.native.d.ts +9 -0
  18. package/dist/components/keyboardController.native.js +9 -0
  19. package/dist/constants/colors.d.ts +34 -0
  20. package/dist/constants/colors.js +93 -3
  21. package/dist/constants/fonts.js +5 -4
  22. package/dist/hooks/useDimensions.d.ts +21 -9
  23. package/dist/hooks/useDimensions.js +29 -18
  24. package/dist/hooks/useResources.d.ts +10 -7
  25. package/dist/hooks/useResources.js +21 -24
  26. package/dist/hooks/useTheme.d.ts +18 -0
  27. package/dist/hooks/useTheme.js +45 -29
  28. package/dist/lib/themedStyles.d.ts +7 -7
  29. package/dist/lib/themedStyles.js +7 -7
  30. package/dist/state/SsrViewportContext.d.ts +9 -38
  31. package/dist/state/SsrViewportContext.js +9 -38
  32. package/dist/state/index.d.ts +1 -2
  33. package/dist/state/index.js +1 -2
  34. package/dist/state/themeStore.d.ts +9 -13
  35. package/dist/state/themeStore.js +19 -40
  36. package/llms-full.md +1 -1
  37. package/package.json +2 -2
  38. package/dist/state/ssrTheme.d.ts +0 -41
  39. package/dist/state/ssrTheme.js +0 -33
package/CHANGELOG.md CHANGED
@@ -3,6 +3,208 @@
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.22.0]
7
+
8
+ ### Added
9
+
10
+ - **CSS-variable theming on web.** On web, every semantic theme color
11
+ (`theme.colors.*`) now resolves to a CSS custom property
12
+ (`var(--c-<kebab-token>)`) instead of a literal hex string, so styles built
13
+ from the theme — including the HTML shells baked at export time — re-theme
14
+ purely in CSS when `html[data-theme]` changes. Native platforms keep
15
+ literal values. New exports from `constants`:
16
+ - `getThemeCssVariables(overrides?)` — the `--c-*` (and companion
17
+ `--c-*-rgb` triplet) definitions per scheme, for embedding in an app's
18
+ `+html.tsx` global `<style>`. Accepts per-scheme brand-color overrides.
19
+ - `rawThemeColors.light/.dark` — the literal hex/rgba maps on every
20
+ platform, for sinks that cannot take `var()` (e.g.
21
+ `<meta name="theme-color">`).
22
+ - `resolveRawColor(color, scheme)` — maps a `var(--c-*)` theme color back
23
+ to the given scheme's literal value; literal inputs pass through.
24
+ - **`withAlpha` is exported standalone** (in addition to the `useTheme`
25
+ return) so module-scope style factories can import it. It understands
26
+ `var(--c-*)` inputs and rewrites them onto the `--c-*-rgb` triplet
27
+ (`var(--c-unset, rgba(var(--c-x-rgb), a))` — the never-defined outer
28
+ variable makes react-native-web's compiler pass the value through), staying
29
+ pure CSS that re-themes with the variable. Hex-suffix alpha concatenation
30
+ (`theme.colors.x + "15"`) does not work with `var()` colors — use
31
+ `withAlpha(theme.colors.x, 0.08)`.
32
+
33
+ ### Changed
34
+
35
+ - The contrast helpers from `useTheme` (`getContrastingColor`,
36
+ `getContrastRatio`, `getTextColorForBackground`) resolve `var(--c-*)`
37
+ inputs to the active scheme's literal value before parsing, and
38
+ `getContrastingColor` returns the winning candidate as originally passed
39
+ (a `var()` input stays a `var()` output).
40
+ - The React Navigation theme maps (`colors.<scheme>.navigation`) stay
41
+ literal on web as well as native: @react-navigation internals parse their
42
+ theme colors with a color library, which cannot take `var()` references.
43
+
44
+ ## [0.21.1]
45
+
46
+ ### Fixed
47
+
48
+ - **Exported web HTML renders icon glyphs, fixing React hydration error
49
+ #418 on every cold load.** `useResources` registers the Feather icon font
50
+ per render scope during `expo export`'s Node rendering
51
+ (`ensureIconFontRegistered`), so the static shell emits the glyph and its
52
+ font styles. Previously the shell contained an empty icon text node while
53
+ the client's first render (font pre-loaded at module scope) contained the
54
+ glyph — React 19 threw #418 and regenerated the tree on every cold load.
55
+ The same mechanism existed during the request-time SSR era and was removed
56
+ with it; static export needs it too.
57
+
58
+ ## [0.21.0]
59
+
60
+ ### Added
61
+
62
+ - **`Carousel` dots are pressable and scroll to their slide.** Each dot is a
63
+ `Pressable` that jumps the scroller to its page (animated) and reports the
64
+ new index through `onIndexChange`. The `tablist`/`tab` roles and `selected`
65
+ state the dots already carried are now honest: a screen reader that
66
+ announced N activatable tabs can finally focus and activate them, and on web
67
+ each dot is a keyboard tab-stop.
68
+
69
+ The visible dot stays 8px. The press target is a 24px wrapper plus vertical
70
+ `hitSlop` (44px total), and the row's `gap` moved into that wrapper so
71
+ neighboring targets sit flush with no dead strip between them. The slop is
72
+ vertical-only so a tap can't be ambiguous between two dots.
73
+
74
+ The selected dot also sets `aria-selected` explicitly: react-native-web
75
+ never maps `accessibilityState` to the DOM, so on web the tabs previously
76
+ exposed no selected state at all. Native maps `aria-selected` back into
77
+ `accessibilityState`, so the two props agree.
78
+
79
+ - **`Label` accepts `htmlFor`.** `@rn-primitives/label`'s web build only emits a
80
+ real `<label for="…">` — the thing that actually associates a label with an
81
+ input and makes clicking the label focus it — when `htmlFor` is passed, and
82
+ `Label` never forwarded it. No consumer could produce an associated label.
83
+ `htmlFor` now reaches the primitive's `Text`, which is where the prop is
84
+ declared and where the web build consumes it. Native ignores it, matching the
85
+ primitive.
86
+
87
+ Association takes **two distinct ids**:
88
+
89
+ ```tsx
90
+ <Label nativeID="email-label" htmlFor="email-input">Email</Label>
91
+ <TextInput nativeID="email-input" />
92
+ ```
93
+
94
+ `nativeID` is the label's own id (react-native-web maps it to `id`; on native
95
+ it's what a control's `accessibilityLabelledBy` points at). `htmlFor` is the
96
+ input's id.
97
+
98
+ ### Changed
99
+
100
+ - **`Carousel` re-clamps its active index when `children` shrink.** The index
101
+ was seeded once, so removing slides while a late page was active left the
102
+ page announcement reading e.g. "5 of 3" with no active dot until the next
103
+ scroll. It now clamps to the last slide, reports the clamped value through
104
+ `onIndexChange`, and scrolls back to that page — the scroller was sitting
105
+ past the new content width.
106
+
107
+ - **`Carousel` index tracking is split by platform.** Native subscribes to
108
+ `onMomentumScrollEnd` — plus a velocity-gated `onScrollEndDrag`, for a drag
109
+ that releases at rest and so is never followed by a momentum event — and no
110
+ longer subscribes to `onScroll` at all, so a drag costs no per-frame JS
111
+ callback to compute a page the settle reports anyway. A drag past a slide
112
+ midpoint that snaps back to the starting page now reports nothing instead of
113
+ firing the intermediate page and then the original. Web keeps the throttled
114
+ `onScroll` path, which remains the only channel there — react-native-web
115
+ emits no momentum events.
116
+
117
+ The `onIndexChange` doc no longer claims "once per settle" unconditionally.
118
+ That now holds on native; on web (wheel/trackpad) it still fires once per
119
+ page *crossed*, and only consecutive duplicate indices are suppressed.
120
+
121
+ - **New `Carousel` testID: `<testID>-dot-<i>-indicator`.** `-dot-<i>` is now
122
+ the pressable wrapper; the visible dot — and therefore its
123
+ `backgroundColor` — is the `-indicator` node inside it. Tests asserting on a
124
+ dot's color need the new suffix.
125
+
126
+ - **`useDimensions` seeds from a constant, not context.** The first web render
127
+ uses the new `DEFAULT_VIEWPORT_WIDTH` / `DEFAULT_VIEWPORT_HEIGHT` exports
128
+ (1280×800, the previous context defaults) and switches to real dimensions
129
+ after mount. The hook no longer reads a context or writes an `mrmeg-vw`
130
+ cookie.
131
+
132
+ - **Theme persistence key is exported as `THEME_STORAGE_KEY`** (value
133
+ unchanged: `user-theme-preference`), replacing `THEME_COOKIE_NAME` as the
134
+ name callers reference.
135
+
136
+ ### Removed
137
+
138
+ - **Server-rendering support surface.** The template that hosts this package
139
+ no longer server-renders web routes (routes are client-rendered from an
140
+ HTML shell), so the per-request plumbing that existed only to make a server
141
+ render and the browser's first render agree is gone:
142
+
143
+ - `SsrThemeSeedContext`, `SsrThemeSeed`, `SSR_THEME_SEED_DEFAULT`, and
144
+ `useSsrThemeSeed` (added in 0.20.0).
145
+ - `SsrViewportContext`, `SSR_VIEWPORT_DEFAULT_WIDTH`, and
146
+ `SSR_VIEWPORT_DEFAULT_HEIGHT`.
147
+ - `THEME_COOKIE_NAME`, and the `document.cookie` write `setTheme` performed
148
+ alongside its persisted write on web.
149
+
150
+ Hosts that server-render can keep doing so, but they now own the seeding
151
+ themselves; `useTheme` resolves from the store only.
152
+
153
+ ### Fixed
154
+
155
+ - **`Label`'s documented pairing pattern produced duplicate DOM ids.** The usage
156
+ docs recommended the *same* `nativeID` on the `Label` and its `TextInput`.
157
+ react-native-web maps `nativeID` to `id`, so that renders two elements sharing
158
+ one id — invalid HTML, and no association either way. The docs now show the
159
+ two-id pattern above.
160
+
161
+ - **`Avatar` renders image sources that carry no `uri`.** The internal source
162
+ key — which exists so a failure flag survives the fresh `{ uri }` object that
163
+ an inline literal allocates every render — returned `null` for any object
164
+ without a truthy `uri`, and `null` means "no image at all". An iOS
165
+ `{ bundle }` source, or a resolved asset object, therefore silently degraded
166
+ to initials and the image was never attempted. Every shape React Native
167
+ accepts is now keyed (numbers as `asset:<n>`, uri strings as themselves,
168
+ anything else by a stable serialization of its fields); `null` is reserved
169
+ for a missing, empty-object, or empty-array source. Retry-on-change and
170
+ keep-the-failure-on-re-render behavior now applies to those shapes too.
171
+
172
+ - **`AvatarGroup` members are announceable again (accessibility change).** The
173
+ group wrapper was `accessible` with `accessibilityRole="image"` and an
174
+ `"<n> avatars"` label, which is a leaf role on the web and swallows
175
+ descendants on iOS — so no member's name could be reached inside a group. The
176
+ wrapper is now a plain container with no accessibility semantics of its own,
177
+ and the count rides along as a visually clipped text node ahead of the
178
+ members, so a screen reader reads the summary and then each avatar. Passing
179
+ `accessibilityLabel` still replaces the count text. Apps that asserted on the
180
+ group by its accessibility label (`getByLabelText("3 avatars")`) should query
181
+ the text instead (`getByText("3 avatars")`).
182
+
183
+ - **`AvatarGroup`'s `max` is clamped to a whole count ≥ 0.** A negative `max`
184
+ reached `slice(0, max)`, which reads a negative end as an offset from the end
185
+ — `max={-3}` on three children rendered all of them, silently disabling the
186
+ clamp — and a fractional `max` truncated arbitrarily. `max` is now
187
+ `Math.max(0, Math.floor(max))`, so `max={0}` renders only the `+N` tile.
188
+
189
+ - **`Avatar` shows its initials/icon fallback while the image loads.** A slow
190
+ or large image left a bare `muted` tile for the whole request. The fallback
191
+ now stays mounted until the image reports `onLoad`, with the image layered
192
+ over it, so an avatar is never a blank circle in flight.
193
+
194
+ - **`Avatar`'s image carries its own corner radius.** Rounding relied solely on
195
+ the wrapper's `overflow: "hidden"`, which Android drops for a child image in
196
+ several cases, leaving square corners on a circular avatar.
197
+
198
+ ### Documentation
199
+
200
+ - **`Carousel`'s `itemWidth` caveat.** A fractional `itemWidth` measures
201
+ against the viewport until the first `onLayout`, so a carousel inside a
202
+ horizontally constrained parent (a padded column, a `MaxWidthContainer`, a
203
+ sidebar pane) renders its first frame — and the exported HTML shell — with
204
+ slides sized to the window. Documented on the prop, with the workaround:
205
+ pass an absolute `itemWidth` (`> 1`) when the parent is narrower than the
206
+ viewport and that first frame matters.
207
+
6
208
  ## [0.20.0]
7
209
 
8
210
  ### 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: {
@@ -2,7 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import React, { createContext, use, useCallback, useEffect, useMemo, useReducer, useRef, useSyncExternalStore } from "react";
3
3
  import { View, Pressable, StyleSheet, ScrollView, Platform, Animated, useWindowDimensions, } from "react-native";
4
4
  import { BottomSheet as NativeBottomSheet } from "@expo/ui/community/bottom-sheet";
5
- import { KeyboardController } from "react-native-keyboard-controller";
5
+ import { KeyboardController } from "./keyboardController";
6
6
  import { useSafeAreaInsets, initialWindowMetrics } from "react-native-safe-area-context";
7
7
  import { useTheme } from "../hooks/useTheme.js";
8
8
  import { spacing } from "../constants/spacing.js";
@@ -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}>