@mrmeg/expo-ui 0.17.0 → 0.20.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,152 @@
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.20.0]
7
+
8
+ ### Added
9
+
10
+ - **`surfaceSunken` semantic token.** The app-chrome tier below `background`,
11
+ so sidebars, rails, and side panels read as a layer *beneath* the content
12
+ pane instead of the same surface. Dark `#050506` (below zinc-950), light
13
+ `#FAFAFA` (zinc-50). Elevation is now a surface-tier ladder rather than a
14
+ shadow scale: `surfaceSunken` < `background` < `card`/`popover` < `muted`.
15
+
16
+ - **`borderStrong` semantic token.** A hairline for elements sitting on a
17
+ filled surface — chips on `muted`, raised panels — one visible step above
18
+ the fill, where `border` shares `muted`'s hex and disappears. Dark
19
+ `#3F3F46` (zinc-700), light `#D4D4D8` (zinc-300). `border` stays the
20
+ hairline for elements on `background`/`card`.
21
+
22
+ - **SSR theme seeding (`SsrThemeSeedContext`).** On a server-rendered web app,
23
+ `useTheme`/`useStyles` had no way to know the visitor's theme during the
24
+ server render, so every SSR page shipped a fully light-themed tree that
25
+ recolored after hydration. The store can't carry a per-request value — it's a
26
+ module singleton shared by concurrent SSR requests — so the new
27
+ `SsrThemeSeedContext` (with `SsrThemeSeed`, `SSR_THEME_SEED_DEFAULT`, and
28
+ `useSsrThemeSeed`) is the per-request channel. Provide it from a signal the
29
+ server and browser read identically (a cookie), and `useTheme` resolves from
30
+ it on web until persistence has loaded.
31
+
32
+ `SSR_THEME_SEED_DEFAULT` is `system`/light, i.e. what the store boots with —
33
+ so a host that receives no signal renders exactly as it did before the seed
34
+ existed. Note that this default is a *fallback*, not a reading: a host that
35
+ also writes the resolved scheme into its served HTML (`<html data-theme>`, a
36
+ blocking script's early-return, a `prefers-color-scheme` CSS fallback) must
37
+ distinguish "seed came from the visitor" from "seed is the default" and skip
38
+ the write in the latter case, or it will pin a dark-OS first-time visitor to
39
+ light with its own failsafes disabled.
40
+
41
+ - **`user-theme-preference` cookie mirror.** On web, `setTheme` now dual-writes
42
+ `localStorage` **and** a `user-theme-preference` cookie (`path=/;
43
+ max-age≈1y; SameSite=Lax`, exported as `THEME_COOKIE_NAME`), and `loadTheme`
44
+ backfills/repairs it from `localStorage`, so a server-rendered host can read
45
+ the visitor's theme off the request. `localStorage` remains the source of
46
+ truth; the cookie is a render hint only. Native persistence is unchanged.
47
+
48
+ - **`hasLoadedTheme` on the theme store.** False until `loadTheme()` has read
49
+ persistence (or `setTheme` was called). While it's false on web, `useTheme`
50
+ resolves from the SSR seed instead of store state; after that, store state
51
+ wins for good. Mirrors the `hasLoadedOnboarding` pattern.
52
+
53
+ ### Changed
54
+
55
+ - **Dim text is more readable in both schemes (visual change).**
56
+ `textDim`/`mutedForeground` moved from `#A1A1AA` → `#B0B0B8` in dark
57
+ (~9.2:1 on `background`, ~6.9:1 on `muted`, up from ~7.8:1/~5.9:1) and from
58
+ zinc-500 `#71717A` → zinc-600 `#52525B` in light (~7.7:1 on white, up from
59
+ ~4.8:1). Captions, timestamps, meta rows, and neutral `StatCard` changes all
60
+ render a step brighter (dark) or darker (light) than before; both stay
61
+ clearly dimmer than `text`, so hierarchy is preserved. A new package test
62
+ enforces the floors (≥7:1 on `background`/`card`/`popover`, ≥6:1 on
63
+ `muted`/`secondary`), so these tokens cannot be dimmed back down silently.
64
+ Apps that hardcoded the old hexes, or asserted them in tests, need to update
65
+ those literals; apps using the tokens get the change for free.
66
+
67
+ - **`Drawer` chrome paints `surfaceSunken`.** Both the overlay drawer panel and
68
+ the in-flow rail switched from `background` to `surfaceSunken`, so navigation
69
+ chrome is visibly darker than the content it sits beside (dark) or a faint
70
+ step off white (light). Override via the existing `style` props, or re-skin
71
+ `surfaceSunken` through `setColors`/`ThemeColorScope`, to restore the old
72
+ flat look.
73
+
74
+ - **`syncThemeFromEnvironment()` starts the OS listener before reading
75
+ persistence.** `loadTheme()` is what flips `hasLoadedTheme` (the moment web
76
+ renders stop trusting the SSR seed), so the real OS scheme is now read first
77
+ and a `system` user never sees a frame of the boot-default light.
78
+
79
+ ## [0.19.0]
80
+
81
+ ### Added
82
+
83
+ - **`Avatar` and `AvatarGroup`.** The package had `SkeletonAvatar` (a loading
84
+ placeholder) but no real avatar, so consumers hand-rolled circles. `Avatar`
85
+ renders an image with a graceful fallback chain — image → initials → icon —
86
+ and downgrades to initials at runtime when the image fails to load, so a
87
+ dead URL never leaves an empty hole. Accepts `source`, `name` (supplies 1–2
88
+ initials and the default accessibility label), `icon` (Feather, defaults to
89
+ `user`), `size` (`"sm" | "md" | "lg"` = 32/40/48, or an explicit pixel
90
+ diameter), and `shape` (`"circle" | "square"`). Initials derivation is
91
+ grapheme-aware: a decomposed accented character keeps its mark and an emoji
92
+ is not sliced into a lone surrogate.
93
+
94
+ `AvatarGroup` stacks children with a ring in the theme `background` color so
95
+ the stack reads on any surface, collapses anything past `max` into a `+N`
96
+ tile, propagates its `size` and `shape` to children that don't set their own,
97
+ and announces the total count (including the collapsed overflow) to screen
98
+ readers.
99
+
100
+ Uses React Native's own `Image`, so there is no new peer dependency.
101
+
102
+ - **`Carousel` — horizontally snapping slide row with dot indicators.**
103
+ Children-based: each child becomes one slide at the resolved item width.
104
+
105
+ ```tsx
106
+ <Carousel itemWidth={0.8} onIndexChange={setPage}>
107
+ {testimonials.map((t) => (
108
+ <Card key={t.name}>
109
+ <BodyText>{t.quote}</BodyText>
110
+ </Card>
111
+ ))}
112
+ </Carousel>
113
+ ```
114
+
115
+ `itemWidth` (default `0.85`) is a fraction of the carousel's own width when
116
+ `<= 1` — leaving the next slide peeking — and absolute pixels when `> 1`.
117
+ `gap`, `contentPadding`, `showDots`, `initialIndex`, `onIndexChange`, and
118
+ `snap` cover the rest; dots are plain themed views (active uses `accent`)
119
+ and auto-hide for a single slide. `onIndexChange` fires once per settle.
120
+
121
+ Built on `ScrollView`, not a virtualized list, so every slide is in the
122
+ server-rendered tree — web SSR ships the real slide content instead of an
123
+ empty scroller that fills in after measurement. No animation library is
124
+ involved: native snapping uses `snapToInterval`, and because
125
+ react-native-web drops that prop, web gets equivalent CSS scroll-snap
126
+ (`scroll-snap-type`/`scroll-snap-align` plus a `scroll-padding-left` that
127
+ keeps page `i` at `i * (itemWidth + gap)` on both platforms). Wheel and
128
+ trackpad scrolling keep the dots live via throttled scroll ticks, since RNW
129
+ emits no momentum events.
130
+
131
+ Accessibility: the dot row is a `tablist` of `tab`s carrying
132
+ `selected` state and per-slide labels, plus a visually clipped
133
+ `aria-live="polite"` "N of M" page announcement.
134
+
135
+ `getCarouselIndex(offsetX, interval, count)` and
136
+ `resolveCarouselItemWidth(itemWidth, containerWidth)` are exported for hosts
137
+ building custom controls on the same math.
138
+
139
+ ## [0.18.0]
140
+
141
+ ### Added
142
+
143
+ - **`BottomSheet.Content` `backgroundStyle` pass-through.** The native sheet
144
+ surface (web vaul panel, Android `containerColor`, iOS
145
+ `presentationBackground`) previously hardcoded the themed card color.
146
+ `BottomSheet.Content` now accepts an optional `backgroundStyle` merged over
147
+ that default, so hosts can pass `{ backgroundColor: "transparent" }` to let
148
+ custom chrome (e.g. a Liquid Glass backdrop) show through. Omitting the prop
149
+ preserves the existing card background. The RN content column still paints
150
+ its own card fill; clear that via `style` when going transparent.
151
+
6
152
  ## [0.17.0]
7
153
 
8
154
  ### Added
package/LLM_USAGE.md CHANGED
@@ -76,6 +76,11 @@ On native, `BottomSheet.Content` avoids the soft keyboard by default with
76
76
  React Native keyboard events. Pass `avoidKeyboard={false}` to opt out for a
77
77
  specific sheet.
78
78
 
79
+ `BottomSheet.Content` themes the native sheet surface with the card color.
80
+ Pass `backgroundStyle={{ backgroundColor: "transparent" }}` (with a `style`
81
+ that clears the content column's card fill) when custom chrome such as a
82
+ glass backdrop should show through; omitting it keeps the card background.
83
+
79
84
  i18n is optional. Do not add app-level i18n setup just to use this package.
80
85
  Plain children and `text` props work without `i18next` or `react-i18next`.
81
86
  `tx` props render fallback text when provided and otherwise render the key
@@ -102,11 +107,13 @@ configureExpoUiI18n((key, options) => i18n.t(key, options));
102
107
  Useful theme tokens include:
103
108
 
104
109
  ```tsx
110
+ theme.colors.surfaceSunken;
105
111
  theme.colors.background;
106
112
  theme.colors.foreground;
107
113
  theme.colors.card;
108
114
  theme.colors.popover;
109
115
  theme.colors.border;
116
+ theme.colors.borderStrong;
110
117
  theme.colors.input;
111
118
  theme.colors.ring;
112
119
  theme.colors.primary;
@@ -126,6 +133,13 @@ Token intent:
126
133
  - `input`: form-control border color
127
134
  - `ring`: focus outline color
128
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`.
129
143
 
130
144
  Use `getShadowStyle()` for package surfaces that need elevation. It supports
131
145
  `base`, `soft`, `sharp`, `subtle`, `elevated`, `glow`, `glass`, `card`,
@@ -182,10 +196,12 @@ Use this table before creating a new app-local primitive.
182
196
  | `Accordion`, `AccordionItem`, `AccordionTrigger`, `AccordionContent` | Multi-section disclosure | Custom FAQ/settings expanders | FAQ lists, grouped settings, help sections |
183
197
  | `Alert` | Cross-platform imperative alerts | Direct `window.alert` or duplicated RN/web branching | Confirm destructive actions, native alert dialogs |
184
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 |
185
200
  | `Badge` | Short status labels | Custom pill `View` + `Text` | Draft/active states, counts, plan labels, role tags |
186
201
  | `BottomSheet` | Mobile-first modal sheets | Custom absolute-position sheets | Action pickers, mobile filters, keyboard-aware quick edit forms |
187
202
  | `Button` | Commands and CTAs | Pressable plus custom text styling | Submit, save, cancel, delete, navigation CTAs |
188
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 |
189
205
  | `Checkbox` | Boolean selection | Custom checkmark controls | Terms consent, checklist items, multi-select filters |
190
206
  | `Collapsible`, `CollapsibleTrigger`, `CollapsibleContent` | One-off disclosure | Local animated height wrappers | Advanced settings, hidden helper text |
191
207
  | `Dialog`, `AlertDialog` | Modal decisions and custom modal content | Custom modal overlays | Confirm delete, edit profile, invite user |
@@ -222,8 +238,10 @@ Use this table before creating a new app-local primitive.
222
238
  - Use `RadioGroup` for small mutually exclusive choices and `Select` for longer option sets.
223
239
  - Use `Dialog` for blocking decisions, `Popover` for contextual controls, `Tooltip` for short explanations, and `DropdownMenu` for action lists.
224
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.
225
242
  - Use `EmptyState` for no-data or recoverable error regions.
226
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.
227
245
  - Use `Progress` for real progress or indeterminate long-running work.
228
246
  - Use `Drawer.Header` with `icon`, `title`, and `action` for a compact app-brand row; place `Drawer.ToggleCollapse` in `action` for a trailing rail control.
229
247
 
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 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 |
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 |
@@ -374,12 +378,14 @@ Use `StyledText` or its aliases instead of raw `Text` whenever the text is part
374
378
  />
375
379
  ```
376
380
 
377
- Mount `UIProvider` once near the root before using `Dialog`, `AlertDialog`, `BottomSheet`, `Drawer`, `DropdownMenu`, `Popover`, `SelectContent`, `Tooltip`, or package notifications. On native, `UIProvider` also wraps app content in the package keyboard-avoiding root by default, so ordinary screens and fixed footers stay above the soft keyboard without repeated app-local wrappers; pass `keyboardAvoiding={false}` to opt out, or use `KeyboardAvoidingView` directly for a subtree with custom behavior. Web skips the root keyboard wrapper unless `keyboardAvoiding` is explicitly enabled. `BottomSheet.Content` listens to React Native keyboard events when `avoidKeyboard` is enabled; it defaults to `true` and can be disabled per sheet. Trigger transient feedback with `notify`.
381
+ Mount `UIProvider` once near the root before using `Dialog`, `AlertDialog`, `BottomSheet`, `Drawer`, `DropdownMenu`, `Popover`, `SelectContent`, `Tooltip`, or package notifications. On native, `UIProvider` also wraps app content in the package keyboard-avoiding root by default, so ordinary screens and fixed footers stay above the soft keyboard without repeated app-local wrappers; pass `keyboardAvoiding={false}` to opt out, or use `KeyboardAvoidingView` directly for a subtree with custom behavior. Web skips the root keyboard wrapper unless `keyboardAvoiding` is explicitly enabled. `BottomSheet.Content` listens to React Native keyboard events when `avoidKeyboard` is enabled; it defaults to `true` and can be disabled per sheet. `BottomSheet.Content` also accepts `backgroundStyle`, merged over the themed card default on the native sheet surface (web panel, Android `containerColor`, iOS `presentationBackground`) — pass `{ backgroundColor: "transparent" }`, plus a `style` clearing the content column's card fill, to let custom chrome like a glass backdrop show through. Trigger transient feedback with `notify`.
378
382
 
379
383
  Use `Skeleton` components for loading content with stable dimensions, `EmptyState` for no-data/recoverable errors, `Alert` for blocking confirm/alert dialogs, and `Notification` for transient global feedback.
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 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.
388
+
383
389
  ### Quick Examples
384
390
 
385
391
  Card, badge, and CTA:
@@ -0,0 +1,80 @@
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
+ /** Image to display. Falls back to initials (then the icon) if it fails to load. */
22
+ source?: ImageSourcePropType;
23
+ /** Display name; supplies the fallback initials and the default accessibility label. */
24
+ name?: string;
25
+ /** Feather icon rendered when there is no image and no derivable initials. @default "user" */
26
+ icon?: IconName;
27
+ /** Size token or explicit pixel diameter. Inherited from `AvatarGroup` when unset. @default "md" */
28
+ size?: AvatarSize;
29
+ /** Inherited from `AvatarGroup` when unset. @default "circle" */
30
+ shape?: AvatarShape;
31
+ /**
32
+ * Accessibility label. Defaults to `name`; when neither is set the avatar is
33
+ * decorative and stays out of the accessibility tree.
34
+ */
35
+ accessibilityLabel?: string;
36
+ style?: StyleProp<ViewStyle>;
37
+ }
38
+ /**
39
+ * Avatar Component
40
+ *
41
+ * 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.
44
+ *
45
+ * Usage:
46
+ * ```tsx
47
+ * <Avatar source={{ uri: user.avatarUrl }} name={user.fullName} />
48
+ * <Avatar name="Ada Lovelace" size="lg" />
49
+ * <Avatar icon="camera" shape="square" />
50
+ * ```
51
+ */
52
+ declare function Avatar({ source, name, icon, size, shape, accessibilityLabel, style: styleOverride, }: AvatarProps): React.JSX.Element;
53
+ export interface AvatarGroupProps {
54
+ children?: React.ReactNode;
55
+ /** Maximum number of avatars to render; the rest collapse into a `+N` tile. */
56
+ max?: number;
57
+ /** Size applied to child avatars that don't set their own. @default "md" */
58
+ size?: AvatarSize;
59
+ /** Shape applied to child avatars that don't set their own. @default "circle" */
60
+ shape?: AvatarShape;
61
+ /** Defaults to `"<n> avatars"`, counting the collapsed overflow. */
62
+ accessibilityLabel?: string;
63
+ style?: StyleProp<ViewStyle>;
64
+ }
65
+ /**
66
+ * AvatarGroup Component
67
+ *
68
+ * Overlapping stack of avatars, each ringed in the theme `background` color so
69
+ * the stack reads on any surface. Earlier avatars sit on top; anything past
70
+ * `max` collapses into a `+N` tile.
71
+ *
72
+ * Usage:
73
+ * ```tsx
74
+ * <AvatarGroup max={3}>
75
+ * {members.map((m) => <Avatar key={m.id} source={{ uri: m.avatarUrl }} name={m.name} />)}
76
+ * </AvatarGroup>
77
+ * ```
78
+ */
79
+ declare function AvatarGroup({ children, max, size, shape, accessibilityLabel, style: styleOverride, }: AvatarGroupProps): React.JSX.Element;
80
+ export { Avatar, AvatarGroup };
@@ -0,0 +1,234 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import React, { use, useState } from "react";
3
+ import { Image, StyleSheet, View, } from "react-native";
4
+ import { useTheme } from "../hooks/useTheme.js";
5
+ import { spacing } from "../constants/spacing.js";
6
+ import { Icon } from "./Icon.js";
7
+ import { StyledText } from "./StyledText.js";
8
+ import { createThemedStyles } from "../lib/themedStyles.js";
9
+ const SIZE_TOKENS = {
10
+ sm: spacing.iconLg, // 32
11
+ md: spacing.iconLg + spacing.sm, // 40
12
+ lg: spacing.iconXl, // 48
13
+ };
14
+ function isSizeToken(size) {
15
+ return typeof size === "string";
16
+ }
17
+ function resolveSize(size) {
18
+ return isSizeToken(size) ? SIZE_TOKENS[size] : size;
19
+ }
20
+ /** Rounded-square radius scales with the avatar so 32px and 88px read the same. */
21
+ function squareRadius(px) {
22
+ return Math.round(px / 4);
23
+ }
24
+ /** Initials/icon scale with the avatar rather than jumping between text tokens. */
25
+ function contentSize(px) {
26
+ return { fontSize: Math.round(px * 0.4), iconSize: Math.round(px * 0.5) };
27
+ }
28
+ /** Overlap for stacked avatars — 30% keeps ~70% of each face visible. */
29
+ function overlap(px) {
30
+ return -Math.round(px * 0.3);
31
+ }
32
+ /**
33
+ * Derive 1–2 initials from a display name.
34
+ *
35
+ * First + last word (middle names are skipped, as in most avatar stacks), one
36
+ * initial for a single-word name, and `""` when there is nothing to derive —
37
+ * callers treat that as "fall through to the icon".
38
+ *
39
+ * Grapheme-aware by way of `Array.from`, so an accented character written as
40
+ * base + combining mark keeps its accent and an astral code point (emoji) is
41
+ * not sliced into a lone surrogate. `toLocaleUpperCase` is deliberate: a
42
+ * locale-sensitive uppercase is right for names.
43
+ */
44
+ export function getAvatarInitials(name) {
45
+ if (!name)
46
+ return "";
47
+ const words = name.trim().split(/\s+/).filter(Boolean);
48
+ if (words.length === 0)
49
+ return "";
50
+ const picked = words.length === 1 ? [words[0]] : [words[0], words[words.length - 1]];
51
+ return picked
52
+ .map((word) => {
53
+ const [first] = Array.from(word.normalize("NFC"));
54
+ return first ?? "";
55
+ })
56
+ .join("")
57
+ .toLocaleUpperCase();
58
+ }
59
+ /**
60
+ * Identity of an image source by content, not object identity.
61
+ *
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.
65
+ */
66
+ function getSourceKey(source) {
67
+ if (source == null)
68
+ return null;
69
+ if (typeof source === "number")
70
+ return `asset:${source}`;
71
+ if (Array.isArray(source)) {
72
+ const key = source.map((entry) => entry?.uri ?? "").filter(Boolean).join("|");
73
+ return key || null;
74
+ }
75
+ return source.uri || null;
76
+ }
77
+ const AvatarGroupContext = React.createContext(null);
78
+ /**
79
+ * Avatar Component
80
+ *
81
+ * 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.
84
+ *
85
+ * Usage:
86
+ * ```tsx
87
+ * <Avatar source={{ uri: user.avatarUrl }} name={user.fullName} />
88
+ * <Avatar name="Ada Lovelace" size="lg" />
89
+ * <Avatar icon="camera" shape="square" />
90
+ * ```
91
+ */
92
+ function Avatar({ source, name, icon, size, shape, accessibilityLabel, style: styleOverride, }) {
93
+ const { theme } = useTheme();
94
+ const styles = themedStyles(theme);
95
+ const group = use(AvatarGroupContext);
96
+ const px = resolveSize(size ?? group?.size ?? "md");
97
+ const resolvedShape = shape ?? group?.shape ?? "circle";
98
+ 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.
101
+ const [failedKey, setFailedKey] = useState(null);
102
+ const showImage = sourceKey !== null && failedKey !== sourceKey;
103
+ const initials = getAvatarInitials(name);
104
+ 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 })) }));
106
+ }
107
+ /**
108
+ * Shared chrome for an avatar-shaped tile — used by `Avatar` and by
109
+ * `AvatarGroup`'s `+N` overflow tile so both stay the same size, shape, and
110
+ * ring without duplicating the geometry rules.
111
+ */
112
+ function AvatarSurface({ px, shape, ring, accessibilityLabel, style, children, }) {
113
+ const { theme } = useTheme();
114
+ const styles = themedStyles(theme);
115
+ const token = SIZE_TOKEN_FOR_PX[px];
116
+ return (_jsx(View
117
+ // Only join the accessibility tree when there is something to announce;
118
+ // an unlabeled avatar is decorative next to the name it sits beside.
119
+ , {
120
+ // Only join the accessibility tree when there is something to announce;
121
+ // an unlabeled avatar is decorative next to the name it sits beside.
122
+ accessible: accessibilityLabel ? true : undefined,
123
+ // RN's role name is "image"; RNW maps it to aria-role "img" on the web.
124
+ accessibilityRole: accessibilityLabel ? "image" : undefined, accessibilityLabel: accessibilityLabel, style: [
125
+ geometry.base,
126
+ // Token sizes hit a pre-registered rule; a custom pixel size falls back
127
+ // to an inline style (which always ships in the SSR HTML).
128
+ token
129
+ ? geometry[shape === "circle" ? `${token}Circle` : `${token}Square`]
130
+ : { width: px, height: px, borderRadius: shape === "circle" ? spacing.radiusFull : squareRadius(px) },
131
+ ring && geometry.ring,
132
+ ring && styles.ring,
133
+ style,
134
+ ], children: children }));
135
+ }
136
+ function AvatarLabel({ px, style, children, }) {
137
+ return (_jsx(StyledText, { accessible: false, selectable: false, fontWeight: "semibold", style: [geometry.labelText, { fontSize: contentSize(px).fontSize }, style], children: children }));
138
+ }
139
+ /**
140
+ * AvatarGroup Component
141
+ *
142
+ * Overlapping stack of avatars, each ringed in the theme `background` color so
143
+ * the stack reads on any surface. Earlier avatars sit on top; anything past
144
+ * `max` collapses into a `+N` tile.
145
+ *
146
+ * Usage:
147
+ * ```tsx
148
+ * <AvatarGroup max={3}>
149
+ * {members.map((m) => <Avatar key={m.id} source={{ uri: m.avatarUrl }} name={m.name} />)}
150
+ * </AvatarGroup>
151
+ * ```
152
+ */
153
+ function AvatarGroup({ children, max, size = "md", shape = "circle", accessibilityLabel, style: styleOverride, }) {
154
+ const { theme } = useTheme();
155
+ const styles = themedStyles(theme);
156
+ // toArray drops null/undefined/false children so a conditionally rendered
157
+ // avatar doesn't inflate the count or the overflow tally.
158
+ const items = React.Children.toArray(children);
159
+ const visible = max != null && max >= 0 ? items.slice(0, max) : items;
160
+ const overflowCount = items.length - visible.length;
161
+ const px = resolveSize(size);
162
+ const token = SIZE_TOKEN_FOR_PX[px];
163
+ const stackStyle = token ? geometry[`${token}Stack`] : { marginLeft: overlap(px) };
164
+ const totalTiles = visible.length + (overflowCount > 0 ? 1 : 0);
165
+ 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] }) }));
173
+ }
174
+ /** px → token, so a size passed as a raw number still hits the registered rules. */
175
+ const SIZE_TOKEN_FOR_PX = {
176
+ [SIZE_TOKENS.sm]: "sm",
177
+ [SIZE_TOKENS.md]: "md",
178
+ [SIZE_TOKENS.lg]: "lg",
179
+ };
180
+ // 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.
183
+ const geometry = StyleSheet.create({
184
+ base: {
185
+ alignItems: "center",
186
+ justifyContent: "center",
187
+ overflow: "hidden",
188
+ },
189
+ image: {
190
+ width: "100%",
191
+ height: "100%",
192
+ },
193
+ labelText: {
194
+ // The tile is the touch/visual target; text must not add its own metrics.
195
+ includeFontPadding: false,
196
+ textAlign: "center",
197
+ userSelect: "none",
198
+ },
199
+ ring: {
200
+ borderWidth: spacing.xxs,
201
+ },
202
+ group: {
203
+ flexDirection: "row",
204
+ alignItems: "center",
205
+ },
206
+ smCircle: { width: SIZE_TOKENS.sm, height: SIZE_TOKENS.sm, borderRadius: spacing.radiusFull },
207
+ mdCircle: { width: SIZE_TOKENS.md, height: SIZE_TOKENS.md, borderRadius: spacing.radiusFull },
208
+ lgCircle: { width: SIZE_TOKENS.lg, height: SIZE_TOKENS.lg, borderRadius: spacing.radiusFull },
209
+ smSquare: { width: SIZE_TOKENS.sm, height: SIZE_TOKENS.sm, borderRadius: squareRadius(SIZE_TOKENS.sm) },
210
+ mdSquare: { width: SIZE_TOKENS.md, height: SIZE_TOKENS.md, borderRadius: squareRadius(SIZE_TOKENS.md) },
211
+ lgSquare: { width: SIZE_TOKENS.lg, height: SIZE_TOKENS.lg, borderRadius: squareRadius(SIZE_TOKENS.lg) },
212
+ smStack: { marginLeft: overlap(SIZE_TOKENS.sm) },
213
+ mdStack: { marginLeft: overlap(SIZE_TOKENS.md) },
214
+ lgStack: { marginLeft: overlap(SIZE_TOKENS.lg) },
215
+ });
216
+ const createStyles = (theme) => StyleSheet.create({
217
+ fallback: {
218
+ backgroundColor: theme.colors.muted,
219
+ },
220
+ fallbackText: {
221
+ color: theme.colors.mutedForeground,
222
+ },
223
+ ring: {
224
+ borderColor: theme.colors.background,
225
+ },
226
+ overflow: {
227
+ backgroundColor: theme.colors.accent,
228
+ },
229
+ overflowText: {
230
+ color: theme.colors.accentForeground,
231
+ },
232
+ });
233
+ const themedStyles = createThemedStyles(createStyles);
234
+ export { Avatar, AvatarGroup };
@@ -117,6 +117,14 @@ interface BottomSheetContentProps extends ViewProps {
117
117
  avoidKeyboard?: boolean;
118
118
  /** Accepted for call-site ergonomics; ignored (platform owns keyboard). */
119
119
  dismissKeyboardOnDrag?: boolean;
120
+ /**
121
+ * Style for the native sheet surface behind the RN content — the web (vaul)
122
+ * panel, Android `containerColor`, iOS `presentationBackground`. Merged over
123
+ * the themed card default, so `{ backgroundColor: "transparent" }` lets a
124
+ * custom chrome (e.g. a glass backdrop) show through. The RN content column
125
+ * paints its own card fill too; clear that via `style`.
126
+ */
127
+ backgroundStyle?: StyleProp<ViewStyle>;
120
128
  style?: StyleProp<ViewStyle>;
121
129
  children: React.ReactNode;
122
130
  }
@@ -140,7 +148,7 @@ interface BottomSheetCloseProps {
140
148
  declare function useBottomSheetContext(): BottomSheetContextValue;
141
149
  declare function BottomSheetRoot({ open: controlledOpen, onOpenChange: controlledOnOpenChange, defaultOpen, snapPoints, closeOnBackdropPress, children, }: BottomSheetProps): React.JSX.Element;
142
150
  declare function BottomSheetTrigger({ asChild, children, style: styleOverride }: BottomSheetTriggerProps): React.JSX.Element;
143
- declare function BottomSheetContent({ swipeEnabled: _swipeEnabled, avoidKeyboard: _avoidKeyboard, dismissKeyboardOnDrag: _dismissKeyboardOnDrag, style: styleOverride, children, }: BottomSheetContentProps): React.JSX.Element;
151
+ declare function BottomSheetContent({ swipeEnabled: _swipeEnabled, avoidKeyboard: _avoidKeyboard, dismissKeyboardOnDrag: _dismissKeyboardOnDrag, backgroundStyle: backgroundStyleOverride, style: styleOverride, children, }: BottomSheetContentProps): React.JSX.Element;
144
152
  declare function BottomSheetHandle({ style }: BottomSheetHandleProps): React.JSX.Element;
145
153
  declare function BottomSheetHeader({ children, style, ...props }: BottomSheetHeaderProps): React.JSX.Element;
146
154
  declare function BottomSheetBody({ children, style, contentContainerStyle, onContentSizeChange, onLayout, ...props }: BottomSheetBodyProps): React.JSX.Element;
@@ -296,7 +296,7 @@ function BottomSheetTrigger({ asChild, children, style: styleOverride }) {
296
296
  // ============================================================================
297
297
  function BottomSheetContent({
298
298
  // Accepted-but-ignored ergonomics props (platform owns these behaviors):
299
- swipeEnabled: _swipeEnabled, avoidKeyboard: _avoidKeyboard, dismissKeyboardOnDrag: _dismissKeyboardOnDrag, style: styleOverride, children, }) {
299
+ swipeEnabled: _swipeEnabled, avoidKeyboard: _avoidKeyboard, dismissKeyboardOnDrag: _dismissKeyboardOnDrag, backgroundStyle: backgroundStyleOverride, style: styleOverride, children, }) {
300
300
  const { open, onOpenChange, snapPoints, snapIndex, setSnapIndex, hasHeader } = useBottomSheetContext();
301
301
  const { theme } = useTheme();
302
302
  const dismissDisabled = useDismissDisabled();
@@ -337,8 +337,13 @@ swipeEnabled: _swipeEnabled, avoidKeyboard: _avoidKeyboard, dismissKeyboardOnDra
337
337
  if (open)
338
338
  onOpenChange(false);
339
339
  },
340
- // Themes the scrim/background on web (vaul) and Android (containerColor).
341
- backgroundStyle: { backgroundColor: theme.colors.card }, children: _jsx(TextColorContext.Provider, { value: theme.colors.foreground, children: _jsx(TextClassContext.Provider, { value: "", children: _jsxs(View, { style: [
340
+ // Themes the scrim/background on web (vaul), Android (containerColor),
341
+ // and iOS (presentationBackground). Flattened so native readers that
342
+ // expect a plain object (not a style array) keep working.
343
+ backgroundStyle: StyleSheet.flatten([
344
+ { backgroundColor: theme.colors.card },
345
+ backgroundStyleOverride,
346
+ ]), children: _jsx(TextColorContext.Provider, { value: theme.colors.foreground, children: _jsx(TextClassContext.Provider, { value: "", children: _jsxs(View, { style: [
342
347
  {
343
348
  flex: 1,
344
349
  maxHeight: detentHeight,