@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 CHANGED
@@ -3,6 +3,289 @@
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
+
156
+ ## [0.20.0]
157
+
158
+ ### Added
159
+
160
+ - **`surfaceSunken` semantic token.** The app-chrome tier below `background`,
161
+ so sidebars, rails, and side panels read as a layer *beneath* the content
162
+ pane instead of the same surface. Dark `#050506` (below zinc-950), light
163
+ `#FAFAFA` (zinc-50). Elevation is now a surface-tier ladder rather than a
164
+ shadow scale: `surfaceSunken` < `background` < `card`/`popover` < `muted`.
165
+
166
+ - **`borderStrong` semantic token.** A hairline for elements sitting on a
167
+ filled surface — chips on `muted`, raised panels — one visible step above
168
+ the fill, where `border` shares `muted`'s hex and disappears. Dark
169
+ `#3F3F46` (zinc-700), light `#D4D4D8` (zinc-300). `border` stays the
170
+ hairline for elements on `background`/`card`.
171
+
172
+ - **SSR theme seeding (`SsrThemeSeedContext`).** On a server-rendered web app,
173
+ `useTheme`/`useStyles` had no way to know the visitor's theme during the
174
+ server render, so every SSR page shipped a fully light-themed tree that
175
+ recolored after hydration. The store can't carry a per-request value — it's a
176
+ module singleton shared by concurrent SSR requests — so the new
177
+ `SsrThemeSeedContext` (with `SsrThemeSeed`, `SSR_THEME_SEED_DEFAULT`, and
178
+ `useSsrThemeSeed`) is the per-request channel. Provide it from a signal the
179
+ server and browser read identically (a cookie), and `useTheme` resolves from
180
+ it on web until persistence has loaded.
181
+
182
+ `SSR_THEME_SEED_DEFAULT` is `system`/light, i.e. what the store boots with —
183
+ so a host that receives no signal renders exactly as it did before the seed
184
+ existed. Note that this default is a *fallback*, not a reading: a host that
185
+ also writes the resolved scheme into its served HTML (`<html data-theme>`, a
186
+ blocking script's early-return, a `prefers-color-scheme` CSS fallback) must
187
+ distinguish "seed came from the visitor" from "seed is the default" and skip
188
+ the write in the latter case, or it will pin a dark-OS first-time visitor to
189
+ light with its own failsafes disabled.
190
+
191
+ - **`user-theme-preference` cookie mirror.** On web, `setTheme` now dual-writes
192
+ `localStorage` **and** a `user-theme-preference` cookie (`path=/;
193
+ max-age≈1y; SameSite=Lax`, exported as `THEME_COOKIE_NAME`), and `loadTheme`
194
+ backfills/repairs it from `localStorage`, so a server-rendered host can read
195
+ the visitor's theme off the request. `localStorage` remains the source of
196
+ truth; the cookie is a render hint only. Native persistence is unchanged.
197
+
198
+ - **`hasLoadedTheme` on the theme store.** False until `loadTheme()` has read
199
+ persistence (or `setTheme` was called). While it's false on web, `useTheme`
200
+ resolves from the SSR seed instead of store state; after that, store state
201
+ wins for good. Mirrors the `hasLoadedOnboarding` pattern.
202
+
203
+ ### Changed
204
+
205
+ - **Dim text is more readable in both schemes (visual change).**
206
+ `textDim`/`mutedForeground` moved from `#A1A1AA` → `#B0B0B8` in dark
207
+ (~9.2:1 on `background`, ~6.9:1 on `muted`, up from ~7.8:1/~5.9:1) and from
208
+ zinc-500 `#71717A` → zinc-600 `#52525B` in light (~7.7:1 on white, up from
209
+ ~4.8:1). Captions, timestamps, meta rows, and neutral `StatCard` changes all
210
+ render a step brighter (dark) or darker (light) than before; both stay
211
+ clearly dimmer than `text`, so hierarchy is preserved. A new package test
212
+ enforces the floors (≥7:1 on `background`/`card`/`popover`, ≥6:1 on
213
+ `muted`/`secondary`), so these tokens cannot be dimmed back down silently.
214
+ Apps that hardcoded the old hexes, or asserted them in tests, need to update
215
+ those literals; apps using the tokens get the change for free.
216
+
217
+ - **`Drawer` chrome paints `surfaceSunken`.** Both the overlay drawer panel and
218
+ the in-flow rail switched from `background` to `surfaceSunken`, so navigation
219
+ chrome is visibly darker than the content it sits beside (dark) or a faint
220
+ step off white (light). Override via the existing `style` props, or re-skin
221
+ `surfaceSunken` through `setColors`/`ThemeColorScope`, to restore the old
222
+ flat look.
223
+
224
+ - **`syncThemeFromEnvironment()` starts the OS listener before reading
225
+ persistence.** `loadTheme()` is what flips `hasLoadedTheme` (the moment web
226
+ renders stop trusting the SSR seed), so the real OS scheme is now read first
227
+ and a `system` user never sees a frame of the boot-default light.
228
+
229
+ ## [0.19.0]
230
+
231
+ ### Added
232
+
233
+ - **`Avatar` and `AvatarGroup`.** The package had `SkeletonAvatar` (a loading
234
+ placeholder) but no real avatar, so consumers hand-rolled circles. `Avatar`
235
+ renders an image with a graceful fallback chain — image → initials → icon —
236
+ and downgrades to initials at runtime when the image fails to load, so a
237
+ dead URL never leaves an empty hole. Accepts `source`, `name` (supplies 1–2
238
+ initials and the default accessibility label), `icon` (Feather, defaults to
239
+ `user`), `size` (`"sm" | "md" | "lg"` = 32/40/48, or an explicit pixel
240
+ diameter), and `shape` (`"circle" | "square"`). Initials derivation is
241
+ grapheme-aware: a decomposed accented character keeps its mark and an emoji
242
+ is not sliced into a lone surrogate.
243
+
244
+ `AvatarGroup` stacks children with a ring in the theme `background` color so
245
+ the stack reads on any surface, collapses anything past `max` into a `+N`
246
+ tile, propagates its `size` and `shape` to children that don't set their own,
247
+ and announces the total count (including the collapsed overflow) to screen
248
+ readers.
249
+
250
+ Uses React Native's own `Image`, so there is no new peer dependency.
251
+
252
+ - **`Carousel` — horizontally snapping slide row with dot indicators.**
253
+ Children-based: each child becomes one slide at the resolved item width.
254
+
255
+ ```tsx
256
+ <Carousel itemWidth={0.8} onIndexChange={setPage}>
257
+ {testimonials.map((t) => (
258
+ <Card key={t.name}>
259
+ <BodyText>{t.quote}</BodyText>
260
+ </Card>
261
+ ))}
262
+ </Carousel>
263
+ ```
264
+
265
+ `itemWidth` (default `0.85`) is a fraction of the carousel's own width when
266
+ `<= 1` — leaving the next slide peeking — and absolute pixels when `> 1`.
267
+ `gap`, `contentPadding`, `showDots`, `initialIndex`, `onIndexChange`, and
268
+ `snap` cover the rest; dots are plain themed views (active uses `accent`)
269
+ and auto-hide for a single slide. `onIndexChange` fires once per settle.
270
+
271
+ Built on `ScrollView`, not a virtualized list, so every slide is in the
272
+ server-rendered tree — web SSR ships the real slide content instead of an
273
+ empty scroller that fills in after measurement. No animation library is
274
+ involved: native snapping uses `snapToInterval`, and because
275
+ react-native-web drops that prop, web gets equivalent CSS scroll-snap
276
+ (`scroll-snap-type`/`scroll-snap-align` plus a `scroll-padding-left` that
277
+ keeps page `i` at `i * (itemWidth + gap)` on both platforms). Wheel and
278
+ trackpad scrolling keep the dots live via throttled scroll ticks, since RNW
279
+ emits no momentum events.
280
+
281
+ Accessibility: the dot row is a `tablist` of `tab`s carrying
282
+ `selected` state and per-slide labels, plus a visually clipped
283
+ `aria-live="polite"` "N of M" page announcement.
284
+
285
+ `getCarouselIndex(offsetX, interval, count)` and
286
+ `resolveCarouselItemWidth(itemWidth, containerWidth)` are exported for hosts
287
+ building custom controls on the same math.
288
+
6
289
  ## [0.18.0]
7
290
 
8
291
  ### Added
package/LLM_USAGE.md CHANGED
@@ -107,11 +107,13 @@ configureExpoUiI18n((key, options) => i18n.t(key, options));
107
107
  Useful theme tokens include:
108
108
 
109
109
  ```tsx
110
+ theme.colors.surfaceSunken;
110
111
  theme.colors.background;
111
112
  theme.colors.foreground;
112
113
  theme.colors.card;
113
114
  theme.colors.popover;
114
115
  theme.colors.border;
116
+ theme.colors.borderStrong;
115
117
  theme.colors.input;
116
118
  theme.colors.ring;
117
119
  theme.colors.primary;
@@ -131,6 +133,13 @@ Token intent:
131
133
  - `input`: form-control border color
132
134
  - `ring`: focus outline color
133
135
  - `popover`: elevated overlay surface
136
+ - `surfaceSunken`: app-chrome surface one tier below `background`
137
+ - `borderStrong`: hairline for elements on filled surfaces, where `border` would blend in
138
+
139
+ Elevation is a surface-tier ladder, not shadow depth:
140
+ `surfaceSunken` (chrome) < `background` (content) < `card`/`popover` (raised)
141
+ < `muted` (chips, insets). Border a raised or filled element with
142
+ `borderStrong`; `border` is for hairlines on `background`/`card`.
134
143
 
135
144
  Use `getShadowStyle()` for package surfaces that need elevation. It supports
136
145
  `base`, `soft`, `sharp`, `subtle`, `elevated`, `glow`, `glass`, `card`,
@@ -187,10 +196,12 @@ Use this table before creating a new app-local primitive.
187
196
  | `Accordion`, `AccordionItem`, `AccordionTrigger`, `AccordionContent` | Multi-section disclosure | Custom FAQ/settings expanders | FAQ lists, grouped settings, help sections |
188
197
  | `Alert` | Cross-platform imperative alerts | Direct `window.alert` or duplicated RN/web branching | Confirm destructive actions, native alert dialogs |
189
198
  | `AnimatedView` | Entrance and visibility animation | Hand-rolled one-off Animated wrappers | Staggered list rows, revealed panels, animated empty states |
199
+ | `Avatar`, `AvatarGroup` | Profile images with initials/icon fallback | Hand-rolled circles with a nested `Image` and initials `Text` | Account menu, comment authors, assignee pickers, overlapping team stacks |
190
200
  | `Badge` | Short status labels | Custom pill `View` + `Text` | Draft/active states, counts, plan labels, role tags |
191
201
  | `BottomSheet` | Mobile-first modal sheets | Custom absolute-position sheets | Action pickers, mobile filters, keyboard-aware quick edit forms |
192
202
  | `Button` | Commands and CTAs | Pressable plus custom text styling | Submit, save, cancel, delete, navigation CTAs |
193
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 pressable dot indicators | Hand-rolled snap `ScrollView` plus manual offset math | Testimonial strips, onboarding pages, feature highlights, image galleries |
194
205
  | `Checkbox` | Boolean selection | Custom checkmark controls | Terms consent, checklist items, multi-select filters |
195
206
  | `Collapsible`, `CollapsibleTrigger`, `CollapsibleContent` | One-off disclosure | Local animated height wrappers | Advanced settings, hidden helper text |
196
207
  | `Dialog`, `AlertDialog` | Modal decisions and custom modal content | Custom modal overlays | Confirm delete, edit profile, invite user |
@@ -227,8 +238,11 @@ Use this table before creating a new app-local primitive.
227
238
  - Use `RadioGroup` for small mutually exclusive choices and `Select` for longer option sets.
228
239
  - Use `Dialog` for blocking decisions, `Popover` for contextual controls, `Tooltip` for short explanations, and `DropdownMenu` for action lists.
229
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 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.
230
242
  - Use `EmptyState` for no-data or recoverable error regions.
231
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, 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.
232
246
  - Use `Progress` for real progress or indeterminate long-running work.
233
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.
234
248
 
package/README.md CHANGED
@@ -101,7 +101,9 @@ const styles = StyleSheet.create({
101
101
  });
102
102
  ```
103
103
 
104
- `useTheme()` returns the active `theme`, resolved `scheme`, persisted `currentTheme`, `setTheme`, `toggleTheme`, cross-platform shadow helpers, a web focus-ring helper, contrast helpers, and `withAlpha`. `getShadowStyle(type)` supports `base`, `soft`, `sharp`, `subtle`, `elevated`, `glow`, `glass`, `card`, `cardHover`, and `cardSubtle`. Use semantic tokens such as `theme.colors.background`, `foreground`, `card`, `popover`, `border`, `input`, `ring`, `primary`, `secondary`, `accent`, `mutedForeground`, `destructive`, `success`, and `warning`. `primary` is the neutral action color, `secondary` is a neutral secondary surface, `accent` is the teal highlight color, `input` is the default form-control border, and `ring` is the focus outline color.
104
+ `useTheme()` returns the active `theme`, resolved `scheme`, persisted `currentTheme`, `setTheme`, `toggleTheme`, cross-platform shadow helpers, a web focus-ring helper, contrast helpers, and `withAlpha`. `getShadowStyle(type)` supports `base`, `soft`, `sharp`, `subtle`, `elevated`, `glow`, `glass`, `card`, `cardHover`, and `cardSubtle`. Use semantic tokens such as `theme.colors.surfaceSunken`, `background`, `foreground`, `card`, `popover`, `border`, `borderStrong`, `input`, `ring`, `primary`, `secondary`, `accent`, `mutedForeground`, `destructive`, `success`, and `warning`. `primary` is the neutral action color, `secondary` is a neutral secondary surface, `accent` is the teal highlight color, `input` is the default form-control border, and `ring` is the focus outline color.
105
+
106
+ Surfaces are layered tiers rather than shadow depths: `surfaceSunken` (app chrome, e.g. the Drawer rail) sits below `background` (content), which sits below `card`/`popover` (raised panels), which sit below `muted` (chips and insets). `border` is the hairline for elements on `background`/`card`; use `borderStrong` when the element sits on a filled surface, where `border` would blend into the fill. `textDim`/`mutedForeground` are tuned for readability — at least 7:1 against `background`/`card` and 6:1 against `muted` in both schemes — and a package test enforces those floors.
105
107
 
106
108
  When `currentTheme` is `"system"`, the package tracks the OS color scheme and
107
109
  updates all `useTheme()` and `useStyles()` consumers through the package theme
@@ -295,10 +297,12 @@ All components are exported from `@mrmeg/expo-ui/components`; direct imports suc
295
297
  | `Accordion`, `AccordionItem`, `AccordionTrigger`, `AccordionContent` | Multi-section disclosure | Custom FAQ/settings expanders | FAQ lists, grouped settings, help sections, dense detail pages |
296
298
  | `Alert` | Cross-platform imperative alerts | Direct `window.alert` or duplicated RN/web branching | Confirm destructive actions, native alert dialogs, simple blocking messages |
297
299
  | `AnimatedView` | Entrance and visibility animation | Hand-rolled one-off Animated wrappers | Staggered list rows, revealed panels, animated empty states |
300
+ | `Avatar`, `AvatarGroup` | Profile images with initials/icon fallback | Hand-rolled circles with a nested `Image` and initials `Text` | Account menu, comment authors, assignee pickers, testimonial cards, overlapping team/collaborator stacks |
298
301
  | `Badge` | Short status labels | Custom pill `View` + `Text` | Draft/active states, counts, plan labels, role tags |
299
302
  | `BottomSheet` | Mobile-first modal sheets | Custom absolute-position sheets | Action pickers, mobile filters, keyboard-aware quick edit forms, contextual details |
300
303
  | `Button` | Commands and CTAs | Pressable plus custom text styling | Submit, save, cancel, delete, navigation CTAs, icon-accessory buttons; loading state preserves resting width |
301
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 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 |
302
306
  | `Checkbox` | Boolean selection | Custom checkmark controls | Terms consent, checklist items, multi-select filters, notification opt-ins |
303
307
  | `Collapsible`, `CollapsibleTrigger`, `CollapsibleContent` | One-off disclosure | Local animated height wrappers | Advanced settings, hidden helper text, optional details |
304
308
  | `Dialog`, `AlertDialog` | Modal decisions and custom modal content | Custom modal overlays | Confirm delete, edit profile, invite user, blocking warnings |
@@ -380,6 +384,8 @@ Use `Skeleton` components for loading content with stable dimensions, `EmptyStat
380
384
 
381
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.
382
386
 
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
+
383
389
  ### Quick Examples
384
390
 
385
391
  Card, badge, and CTA:
@@ -0,0 +1,94 @@
1
+ import React from "react";
2
+ import { type ImageSourcePropType, type StyleProp, type ViewStyle } from "react-native";
3
+ import { type IconName } from "./Icon";
4
+ /** Named size tokens, or an explicit pixel diameter. */
5
+ export type AvatarSize = "sm" | "md" | "lg" | number;
6
+ export type AvatarShape = "circle" | "square";
7
+ /**
8
+ * Derive 1–2 initials from a display name.
9
+ *
10
+ * First + last word (middle names are skipped, as in most avatar stacks), one
11
+ * initial for a single-word name, and `""` when there is nothing to derive —
12
+ * callers treat that as "fall through to the icon".
13
+ *
14
+ * Grapheme-aware by way of `Array.from`, so an accented character written as
15
+ * base + combining mark keeps its accent and an astral code point (emoji) is
16
+ * not sliced into a lone surrogate. `toLocaleUpperCase` is deliberate: a
17
+ * locale-sensitive uppercase is right for names.
18
+ */
19
+ export declare function getAvatarInitials(name?: string): string;
20
+ export interface AvatarProps {
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
+ */
26
+ source?: ImageSourcePropType;
27
+ /** Display name; supplies the fallback initials and the default accessibility label. */
28
+ name?: string;
29
+ /** Feather icon rendered when there is no image and no derivable initials. @default "user" */
30
+ icon?: IconName;
31
+ /** Size token or explicit pixel diameter. Inherited from `AvatarGroup` when unset. @default "md" */
32
+ size?: AvatarSize;
33
+ /** Inherited from `AvatarGroup` when unset. @default "circle" */
34
+ shape?: AvatarShape;
35
+ /**
36
+ * Accessibility label. Defaults to `name`; when neither is set the avatar is
37
+ * decorative and stays out of the accessibility tree.
38
+ */
39
+ accessibilityLabel?: string;
40
+ style?: StyleProp<ViewStyle>;
41
+ }
42
+ /**
43
+ * Avatar Component
44
+ *
45
+ * A circular (or rounded-square) profile image with a graceful fallback chain:
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.
49
+ *
50
+ * Usage:
51
+ * ```tsx
52
+ * <Avatar source={{ uri: user.avatarUrl }} name={user.fullName} />
53
+ * <Avatar name="Ada Lovelace" size="lg" />
54
+ * <Avatar icon="camera" shape="square" />
55
+ * ```
56
+ */
57
+ declare function Avatar({ source, name, icon, size, shape, accessibilityLabel, style: styleOverride, }: AvatarProps): React.JSX.Element;
58
+ export interface AvatarGroupProps {
59
+ children?: React.ReactNode;
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
+ */
64
+ max?: number;
65
+ /** Size applied to child avatars that don't set their own. @default "md" */
66
+ size?: AvatarSize;
67
+ /** Shape applied to child avatars that don't set their own. @default "circle" */
68
+ shape?: AvatarShape;
69
+ /**
70
+ * Summary announced ahead of the members, as a visually hidden text node.
71
+ * Defaults to `"<n> avatars"`, counting the collapsed overflow.
72
+ */
73
+ accessibilityLabel?: string;
74
+ style?: StyleProp<ViewStyle>;
75
+ }
76
+ /**
77
+ * AvatarGroup Component
78
+ *
79
+ * Overlapping stack of avatars, each ringed in the theme `background` color so
80
+ * the stack reads on any surface. Earlier avatars sit on top; anything past
81
+ * `max` collapses into a `+N` tile.
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
+ *
86
+ * Usage:
87
+ * ```tsx
88
+ * <AvatarGroup max={3}>
89
+ * {members.map((m) => <Avatar key={m.id} source={{ uri: m.avatarUrl }} name={m.name} />)}
90
+ * </AvatarGroup>
91
+ * ```
92
+ */
93
+ declare function AvatarGroup({ children, max, size, shape, accessibilityLabel, style: styleOverride, }: AvatarGroupProps): React.JSX.Element;
94
+ export { Avatar, AvatarGroup };