@mrmeg/expo-ui 0.18.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 +133 -0
- package/LLM_USAGE.md +13 -0
- package/README.md +7 -1
- package/dist/components/Avatar.d.ts +80 -0
- package/dist/components/Avatar.js +234 -0
- package/dist/components/Carousel.d.ts +73 -0
- package/dist/components/Carousel.js +184 -0
- package/dist/components/Drawer.js +5 -2
- package/dist/components/index.d.ts +2 -0
- package/dist/components/index.js +2 -0
- package/dist/constants/colors.d.ts +4 -0
- package/dist/constants/colors.js +12 -4
- package/dist/hooks/useTheme.js +16 -2
- package/dist/state/index.d.ts +1 -0
- package/dist/state/index.js +1 -0
- package/dist/state/ssrTheme.d.ts +41 -0
- package/dist/state/ssrTheme.js +33 -0
- package/dist/state/themeStore.d.ts +19 -0
- package/dist/state/themeStore.js +55 -6
- package/llms-full.md +8 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,139 @@
|
|
|
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
|
+
|
|
6
139
|
## [0.18.0]
|
|
7
140
|
|
|
8
141
|
### 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 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,10 @@ 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 server rendering; reach for `FlatList` when the data set is large or unbounded.
|
|
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 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.
|
|
232
245
|
- Use `Progress` for real progress or indeterminate long-running work.
|
|
233
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.
|
|
234
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 |
|
|
@@ -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 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 };
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { type StyleProp, type ViewStyle } from "react-native";
|
|
3
|
+
export interface CarouselProps {
|
|
4
|
+
/** Slides to page through. Each child is wrapped in a fixed-width slide. */
|
|
5
|
+
children?: React.ReactNode;
|
|
6
|
+
/**
|
|
7
|
+
* Slide width. Values `<= 1` are a fraction of the carousel's own width —
|
|
8
|
+
* the default `0.85` leaves the next slide peeking. Values `> 1` are
|
|
9
|
+
* absolute pixels.
|
|
10
|
+
* @default 0.85
|
|
11
|
+
*/
|
|
12
|
+
itemWidth?: number;
|
|
13
|
+
/** Space between slides. @default spacing.md */
|
|
14
|
+
gap?: number;
|
|
15
|
+
/** Horizontal inset before the first and after the last slide. @default spacing.lg */
|
|
16
|
+
contentPadding?: number;
|
|
17
|
+
/** Render the dot indicators. Auto-hidden for a single slide. @default true */
|
|
18
|
+
showDots?: boolean;
|
|
19
|
+
/** Slide shown on mount. @default 0 */
|
|
20
|
+
initialIndex?: number;
|
|
21
|
+
/** Called with the new active index whenever it changes. */
|
|
22
|
+
onIndexChange?: (index: number) => void;
|
|
23
|
+
/** Snap to slide boundaries. @default true */
|
|
24
|
+
snap?: boolean;
|
|
25
|
+
/** Custom style override for the outer container. */
|
|
26
|
+
style?: StyleProp<ViewStyle>;
|
|
27
|
+
/** Custom style override for the scroll content row. */
|
|
28
|
+
contentContainerStyle?: StyleProp<ViewStyle>;
|
|
29
|
+
/**
|
|
30
|
+
* testID prefix. Emits `<testID>`, `-scroll`, `-item-<i>`, `-dots`,
|
|
31
|
+
* `-dot-<i>`, and `-status`.
|
|
32
|
+
* @default "carousel"
|
|
33
|
+
*/
|
|
34
|
+
testID?: string;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Active slide index for a horizontal scroll offset.
|
|
38
|
+
*
|
|
39
|
+
* `interval` is the slide pitch (item width + gap), so page `i` sits at
|
|
40
|
+
* `i * interval` for both the native snap offsets and web's CSS scroll-snap
|
|
41
|
+
* (see `scrollPaddingLeft` below). Clamped to `[0, count - 1]` so rubber-band
|
|
42
|
+
* overscroll at either end can't report an out-of-range page. Returns `0` for
|
|
43
|
+
* a degenerate interval — which is also the pre-measurement state on the
|
|
44
|
+
* server.
|
|
45
|
+
*/
|
|
46
|
+
export declare function getCarouselIndex(offsetX: number, interval: number, count: number): number;
|
|
47
|
+
/** Resolves the `itemWidth` prop against a container width (fraction vs px). */
|
|
48
|
+
export declare function resolveCarouselItemWidth(itemWidth: number, containerWidth: number): number;
|
|
49
|
+
/**
|
|
50
|
+
* Carousel Component
|
|
51
|
+
*
|
|
52
|
+
* Horizontally snapping row of slides with dot indicators and an optional
|
|
53
|
+
* peek of the next slide. Children-based: every child becomes one slide at the
|
|
54
|
+
* resolved item width.
|
|
55
|
+
*
|
|
56
|
+
* 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
|
|
60
|
+
* animation library is involved.
|
|
61
|
+
*
|
|
62
|
+
* @example
|
|
63
|
+
* ```tsx
|
|
64
|
+
* <Carousel itemWidth={0.8} onIndexChange={setPage}>
|
|
65
|
+
* {testimonials.map((t) => (
|
|
66
|
+
* <Card key={t.name}>
|
|
67
|
+
* <BodyText>{t.quote}</BodyText>
|
|
68
|
+
* </Card>
|
|
69
|
+
* ))}
|
|
70
|
+
* </Carousel>
|
|
71
|
+
* ```
|
|
72
|
+
*/
|
|
73
|
+
export declare function Carousel({ children, itemWidth, gap, contentPadding, showDots, initialIndex, onIndexChange, snap, style: styleOverride, contentContainerStyle, testID, }: CarouselProps): React.JSX.Element;
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import React, { useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
+
import { Platform, ScrollView, StyleSheet, Text, View, } from "react-native";
|
|
4
|
+
import { useTheme } from "../hooks/useTheme.js";
|
|
5
|
+
import { useDimensions } from "../hooks/useDimensions.js";
|
|
6
|
+
import { spacing } from "../constants/spacing.js";
|
|
7
|
+
import { createThemedStyles } from "../lib/themedStyles.js";
|
|
8
|
+
// ============================================================================
|
|
9
|
+
// Index math
|
|
10
|
+
// ============================================================================
|
|
11
|
+
/**
|
|
12
|
+
* Active slide index for a horizontal scroll offset.
|
|
13
|
+
*
|
|
14
|
+
* `interval` is the slide pitch (item width + gap), so page `i` sits at
|
|
15
|
+
* `i * interval` for both the native snap offsets and web's CSS scroll-snap
|
|
16
|
+
* (see `scrollPaddingLeft` below). Clamped to `[0, count - 1]` so rubber-band
|
|
17
|
+
* overscroll at either end can't report an out-of-range page. Returns `0` for
|
|
18
|
+
* a degenerate interval — which is also the pre-measurement state on the
|
|
19
|
+
* server.
|
|
20
|
+
*/
|
|
21
|
+
export function getCarouselIndex(offsetX, interval, count) {
|
|
22
|
+
if (!Number.isFinite(offsetX) || !Number.isFinite(interval))
|
|
23
|
+
return 0;
|
|
24
|
+
if (interval <= 0 || count <= 0)
|
|
25
|
+
return 0;
|
|
26
|
+
const raw = Math.round(offsetX / interval);
|
|
27
|
+
if (raw < 0)
|
|
28
|
+
return 0;
|
|
29
|
+
if (raw > count - 1)
|
|
30
|
+
return count - 1;
|
|
31
|
+
return raw;
|
|
32
|
+
}
|
|
33
|
+
/** Resolves the `itemWidth` prop against a container width (fraction vs px). */
|
|
34
|
+
export function resolveCarouselItemWidth(itemWidth, containerWidth) {
|
|
35
|
+
if (!Number.isFinite(itemWidth) || itemWidth <= 0)
|
|
36
|
+
return 0;
|
|
37
|
+
if (itemWidth > 1)
|
|
38
|
+
return Math.round(itemWidth);
|
|
39
|
+
if (!Number.isFinite(containerWidth) || containerWidth <= 0)
|
|
40
|
+
return 0;
|
|
41
|
+
return Math.round(containerWidth * itemWidth);
|
|
42
|
+
}
|
|
43
|
+
function clampIndex(index, count) {
|
|
44
|
+
if (count <= 0)
|
|
45
|
+
return 0;
|
|
46
|
+
return Math.min(count - 1, Math.max(0, Math.trunc(index)));
|
|
47
|
+
}
|
|
48
|
+
// ============================================================================
|
|
49
|
+
// Carousel
|
|
50
|
+
// ============================================================================
|
|
51
|
+
/**
|
|
52
|
+
* Carousel Component
|
|
53
|
+
*
|
|
54
|
+
* Horizontally snapping row of slides with dot indicators and an optional
|
|
55
|
+
* peek of the next slide. Children-based: every child becomes one slide at the
|
|
56
|
+
* resolved item width.
|
|
57
|
+
*
|
|
58
|
+
* Built on `ScrollView` — not a virtualized list — on purpose: every slide is
|
|
59
|
+
* in the server-rendered tree, so web SSR ships the real content instead of an
|
|
60
|
+
* empty scroller that only fills in after measurement. Snapping uses the
|
|
61
|
+
* platform scroller (`snapToInterval` on native, CSS scroll-snap on web); no
|
|
62
|
+
* animation library is involved.
|
|
63
|
+
*
|
|
64
|
+
* @example
|
|
65
|
+
* ```tsx
|
|
66
|
+
* <Carousel itemWidth={0.8} onIndexChange={setPage}>
|
|
67
|
+
* {testimonials.map((t) => (
|
|
68
|
+
* <Card key={t.name}>
|
|
69
|
+
* <BodyText>{t.quote}</BodyText>
|
|
70
|
+
* </Card>
|
|
71
|
+
* ))}
|
|
72
|
+
* </Carousel>
|
|
73
|
+
* ```
|
|
74
|
+
*/
|
|
75
|
+
export function Carousel({ children, itemWidth = 0.85, gap = spacing.md, contentPadding = spacing.lg, showDots = true, initialIndex = 0, onIndexChange, snap = true, style: styleOverride, contentContainerStyle, testID = "carousel", }) {
|
|
76
|
+
const { theme } = useTheme();
|
|
77
|
+
const styles = themedStyles(theme);
|
|
78
|
+
const items = React.Children.toArray(children);
|
|
79
|
+
const count = items.length;
|
|
80
|
+
// Width source of truth: the measured container after mount, seeded from the
|
|
81
|
+
// viewport so the server and the client's first render agree. `useDimensions`
|
|
82
|
+
// reads the SSR viewport context rather than `window`, which is what keeps
|
|
83
|
+
// that seed hydration-safe (docs/ssr-hydration.md §4).
|
|
84
|
+
const { width: viewportWidth } = useDimensions();
|
|
85
|
+
const [measuredWidth, setMeasuredWidth] = useState(null);
|
|
86
|
+
const containerWidth = measuredWidth ?? viewportWidth;
|
|
87
|
+
const slideWidth = resolveCarouselItemWidth(itemWidth, containerWidth);
|
|
88
|
+
const interval = slideWidth + gap;
|
|
89
|
+
const [activeIndex, setActiveIndex] = useState(() => clampIndex(initialIndex, count));
|
|
90
|
+
// Mirrors activeIndex so the scroll handler can dedupe without re-reading
|
|
91
|
+
// state: one settle emits many scroll events, onIndexChange must fire once.
|
|
92
|
+
const activeIndexRef = useRef(activeIndex);
|
|
93
|
+
const scrollRef = useRef(null);
|
|
94
|
+
const hasScrolledToInitial = useRef(false);
|
|
95
|
+
const commitIndex = useCallback((next) => {
|
|
96
|
+
if (next === activeIndexRef.current)
|
|
97
|
+
return;
|
|
98
|
+
activeIndexRef.current = next;
|
|
99
|
+
setActiveIndex(next);
|
|
100
|
+
onIndexChange?.(next);
|
|
101
|
+
}, [onIndexChange]);
|
|
102
|
+
const handleScroll = useCallback((event) => {
|
|
103
|
+
commitIndex(getCarouselIndex(event.nativeEvent.contentOffset.x, interval, count));
|
|
104
|
+
}, [commitIndex, count, interval]);
|
|
105
|
+
const handleLayout = useCallback((event) => {
|
|
106
|
+
const next = event.nativeEvent.layout.width;
|
|
107
|
+
if (!(next > 0))
|
|
108
|
+
return;
|
|
109
|
+
setMeasuredWidth((current) => current !== null && Math.abs(current - next) < 1 ? current : next);
|
|
110
|
+
}, []);
|
|
111
|
+
// Jump to initialIndex once, after the first real layout — the seeded
|
|
112
|
+
// viewport width would put the offset in the wrong place. `contentOffset` is
|
|
113
|
+
// iOS-only, so drive it imperatively for Android/web parity.
|
|
114
|
+
const targetIndex = clampIndex(initialIndex, count);
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
if (hasScrolledToInitial.current || measuredWidth === null || interval <= 0)
|
|
117
|
+
return;
|
|
118
|
+
hasScrolledToInitial.current = true;
|
|
119
|
+
if (targetIndex === 0)
|
|
120
|
+
return;
|
|
121
|
+
scrollRef.current?.scrollTo({ x: targetIndex * interval, animated: false });
|
|
122
|
+
}, [interval, measuredWidth, targetIndex]);
|
|
123
|
+
const isWeb = Platform.OS === "web";
|
|
124
|
+
const useCssSnap = isWeb && snap;
|
|
125
|
+
const dotsVisible = showDots && count > 1;
|
|
126
|
+
return (_jsxs(View, { style: [styles.container, styleOverride], onLayout: handleLayout, testID: testID, children: [_jsx(ScrollView, { ref: scrollRef, testID: `${testID}-scroll`, horizontal: true, showsHorizontalScrollIndicator: false,
|
|
127
|
+
// Native snapping. react-native-web drops these (they aren't in its
|
|
128
|
+
// forwarded-prop list), so web uses the CSS scroll-snap styles instead.
|
|
129
|
+
snapToInterval: snap && interval > 0 ? interval : undefined, snapToAlignment: "start", decelerationRate: snap ? "fast" : "normal",
|
|
130
|
+
// react-native-web only emits scroll ticks when the throttle is > 0 and
|
|
131
|
+
// never emits momentum events — this is what keeps the dots live for
|
|
132
|
+
// wheel/trackpad scrolling.
|
|
133
|
+
scrollEventThrottle: 16, onScroll: handleScroll, onMomentumScrollEnd: handleScroll, style: useCssSnap
|
|
134
|
+
? [webStyles.scroller, { scrollPaddingLeft: contentPadding }]
|
|
135
|
+
: undefined, contentContainerStyle: [{ paddingHorizontal: contentPadding, gap }, contentContainerStyle], children: items.map((child, index) => (_jsx(View, { testID: `${testID}-item-${index}`, style: [slideWidth > 0 && { width: slideWidth }, useCssSnap && webStyles.item], children: child }, React.isValidElement(child) && child.key != null ? child.key : index))) }), count > 1 && (_jsx(View, { testID: `${testID}-status`, style: styles.status, "aria-live": "polite", children: _jsx(Text, { style: styles.statusText, children: `${activeIndex + 1} of ${count}` }) })), dotsVisible && (_jsx(View, { testID: `${testID}-dots`, style: styles.dots, accessibilityRole: "tablist", children: items.map((_, index) => (_jsx(View, { testID: `${testID}-dot-${index}`, accessibilityRole: "tab", accessibilityState: { selected: index === activeIndex }, accessibilityLabel: `Slide ${index + 1} of ${count}`, style: [styles.dot, index === activeIndex ? styles.dotActive : styles.dotInactive] }, index))) }))] }));
|
|
136
|
+
}
|
|
137
|
+
// ============================================================================
|
|
138
|
+
// Styles
|
|
139
|
+
// ============================================================================
|
|
140
|
+
const DOT_SIZE = 8;
|
|
141
|
+
// Web-only CSS scroll-snap. `scrollPaddingLeft` is applied inline at the call
|
|
142
|
+
// site (it tracks the contentPadding prop) so the leading inset survives the
|
|
143
|
+
// browser snapping slide 0 to the scrollport start — which also makes page `i`
|
|
144
|
+
// land on `i * interval`, matching the native snap offsets.
|
|
145
|
+
const webStyles = StyleSheet.create({
|
|
146
|
+
scroller: { scrollSnapType: "x mandatory" },
|
|
147
|
+
item: { scrollSnapAlign: "start" },
|
|
148
|
+
});
|
|
149
|
+
const createStyles = (theme) => StyleSheet.create({
|
|
150
|
+
container: {
|
|
151
|
+
width: "100%",
|
|
152
|
+
},
|
|
153
|
+
// Clipped to 1x1 rather than removed: screen readers skip
|
|
154
|
+
// display:none/opacity:0 nodes, and there is no visual affordance to show.
|
|
155
|
+
status: {
|
|
156
|
+
position: "absolute",
|
|
157
|
+
width: 1,
|
|
158
|
+
height: 1,
|
|
159
|
+
overflow: "hidden",
|
|
160
|
+
},
|
|
161
|
+
statusText: {
|
|
162
|
+
fontSize: 1,
|
|
163
|
+
color: "transparent",
|
|
164
|
+
},
|
|
165
|
+
dots: {
|
|
166
|
+
flexDirection: "row",
|
|
167
|
+
alignItems: "center",
|
|
168
|
+
justifyContent: "center",
|
|
169
|
+
gap: spacing.xs,
|
|
170
|
+
marginTop: spacing.md,
|
|
171
|
+
},
|
|
172
|
+
dot: {
|
|
173
|
+
width: DOT_SIZE,
|
|
174
|
+
height: DOT_SIZE,
|
|
175
|
+
borderRadius: spacing.radiusFull,
|
|
176
|
+
},
|
|
177
|
+
dotActive: {
|
|
178
|
+
backgroundColor: theme.colors.accent,
|
|
179
|
+
},
|
|
180
|
+
dotInactive: {
|
|
181
|
+
backgroundColor: theme.colors.border,
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
const themedStyles = createThemedStyles(createStyles);
|
|
@@ -395,7 +395,9 @@ function DrawerOverlayContent({ swipeEnabled = true, swipeThreshold = 0.3, veloc
|
|
|
395
395
|
bottom: 0,
|
|
396
396
|
width,
|
|
397
397
|
[side]: 0,
|
|
398
|
-
|
|
398
|
+
// App chrome sits on the sunken tier so it reads as a layer beneath the
|
|
399
|
+
// content pane rather than the same surface.
|
|
400
|
+
backgroundColor: theme.colors.surfaceSunken,
|
|
399
401
|
borderColor: theme.colors.border,
|
|
400
402
|
...(side === "left"
|
|
401
403
|
? { borderRightWidth: 1 }
|
|
@@ -502,7 +504,8 @@ swipeEnabled: _swipeEnabled, swipeThreshold: _swipeThreshold, velocityThreshold:
|
|
|
502
504
|
const panelStyle = {
|
|
503
505
|
width: Platform.OS === "web" ? targetWidth : widthAnim,
|
|
504
506
|
overflow: "hidden",
|
|
505
|
-
|
|
507
|
+
// Rail chrome sits on the sunken tier, one step below the content pane.
|
|
508
|
+
backgroundColor: theme.colors.surfaceSunken,
|
|
506
509
|
borderColor: theme.colors.border,
|
|
507
510
|
...(side === "left" ? { borderRightWidth: 1 } : { borderLeftWidth: 1 }),
|
|
508
511
|
paddingTop: insets.top,
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
export * from "./Accordion";
|
|
2
2
|
export * from "./Alert";
|
|
3
3
|
export * from "./AnimatedView";
|
|
4
|
+
export * from "./Avatar";
|
|
4
5
|
export * from "./Badge";
|
|
5
6
|
export * from "./BottomSheet";
|
|
6
7
|
export * from "./Button";
|
|
7
8
|
export * from "./Card";
|
|
9
|
+
export * from "./Carousel";
|
|
8
10
|
export * from "./Checkbox";
|
|
9
11
|
export * from "./Collapsible";
|
|
10
12
|
export * from "./Dialog";
|
package/dist/components/index.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
export * from "./Accordion.js";
|
|
2
2
|
export * from "./Alert.js";
|
|
3
3
|
export * from "./AnimatedView.js";
|
|
4
|
+
export * from "./Avatar.js";
|
|
4
5
|
export * from "./Badge.js";
|
|
5
6
|
export * from "./BottomSheet.js";
|
|
6
7
|
export * from "./Button.js";
|
|
7
8
|
export * from "./Card.js";
|
|
9
|
+
export * from "./Carousel.js";
|
|
8
10
|
export * from "./Checkbox.js";
|
|
9
11
|
export * from "./Collapsible.js";
|
|
10
12
|
export * from "./Dialog.js";
|
|
@@ -22,11 +22,13 @@ declare const palette: {
|
|
|
22
22
|
readonly gray900: "#18181B";
|
|
23
23
|
readonly gray950: "#09090B";
|
|
24
24
|
readonly black: "#000000";
|
|
25
|
+
readonly dark950: "#050506";
|
|
25
26
|
readonly dark900: "#09090B";
|
|
26
27
|
readonly dark800: "#18181B";
|
|
27
28
|
readonly dark700: "#27272A";
|
|
28
29
|
readonly dark600: "#3F3F46";
|
|
29
30
|
readonly dark400: "#A1A1AA";
|
|
31
|
+
readonly dark350: "#B0B0B8";
|
|
30
32
|
readonly dark300: "#D4D4D8";
|
|
31
33
|
readonly dark100: "#F4F4F5";
|
|
32
34
|
readonly green500: "#22C55E";
|
|
@@ -40,6 +42,7 @@ declare const palette: {
|
|
|
40
42
|
* Semantic color interface - what colors mean in the UI
|
|
41
43
|
*/
|
|
42
44
|
export interface ThemeColors {
|
|
45
|
+
surfaceSunken: string;
|
|
43
46
|
background: string;
|
|
44
47
|
foreground: string;
|
|
45
48
|
card: string;
|
|
@@ -61,6 +64,7 @@ export interface ThemeColors {
|
|
|
61
64
|
success: string;
|
|
62
65
|
warning: string;
|
|
63
66
|
border: string;
|
|
67
|
+
borderStrong: string;
|
|
64
68
|
input: string;
|
|
65
69
|
ring: string;
|
|
66
70
|
overlay: string;
|
package/dist/constants/colors.js
CHANGED
|
@@ -25,11 +25,15 @@ const palette = {
|
|
|
25
25
|
gray950: "#09090B",
|
|
26
26
|
black: "#000000",
|
|
27
27
|
// Dark mode surfaces — zinc-based
|
|
28
|
+
// `dark950` sits below zinc-950 as the app-chrome tier; `dark350` is a
|
|
29
|
+
// zinc-350 midpoint used for dim text that stays comfortably readable.
|
|
30
|
+
dark950: "#050506",
|
|
28
31
|
dark900: "#09090B",
|
|
29
32
|
dark800: "#18181B",
|
|
30
33
|
dark700: "#27272A",
|
|
31
34
|
dark600: "#3F3F46",
|
|
32
35
|
dark400: "#A1A1AA",
|
|
36
|
+
dark350: "#B0B0B8",
|
|
33
37
|
dark300: "#D4D4D8",
|
|
34
38
|
dark100: "#F4F4F5",
|
|
35
39
|
// Status
|
|
@@ -44,6 +48,7 @@ const lightTheme = {
|
|
|
44
48
|
dark: false,
|
|
45
49
|
fonts: navigationFonts,
|
|
46
50
|
colors: {
|
|
51
|
+
surfaceSunken: palette.gray50,
|
|
47
52
|
background: palette.white,
|
|
48
53
|
foreground: palette.gray950,
|
|
49
54
|
card: palette.white,
|
|
@@ -51,13 +56,13 @@ const lightTheme = {
|
|
|
51
56
|
popover: palette.white,
|
|
52
57
|
popoverForeground: palette.gray950,
|
|
53
58
|
text: palette.gray950,
|
|
54
|
-
textDim: palette.
|
|
59
|
+
textDim: palette.gray600,
|
|
55
60
|
primary: palette.gray900,
|
|
56
61
|
primaryForeground: palette.gray50,
|
|
57
62
|
secondary: palette.gray100,
|
|
58
63
|
secondaryForeground: palette.gray900,
|
|
59
64
|
muted: palette.gray100,
|
|
60
|
-
mutedForeground: palette.
|
|
65
|
+
mutedForeground: palette.gray600,
|
|
61
66
|
accent: palette.teal500,
|
|
62
67
|
accentForeground: palette.white,
|
|
63
68
|
destructive: palette.red500,
|
|
@@ -65,6 +70,7 @@ const lightTheme = {
|
|
|
65
70
|
success: palette.green500,
|
|
66
71
|
warning: palette.amber500,
|
|
67
72
|
border: palette.gray200,
|
|
73
|
+
borderStrong: palette.gray300,
|
|
68
74
|
input: palette.gray200,
|
|
69
75
|
ring: palette.gray400,
|
|
70
76
|
overlay: "rgba(0, 0, 0, 0.5)",
|
|
@@ -82,6 +88,7 @@ const darkTheme = {
|
|
|
82
88
|
dark: true,
|
|
83
89
|
fonts: navigationFonts,
|
|
84
90
|
colors: {
|
|
91
|
+
surfaceSunken: palette.dark950,
|
|
85
92
|
background: palette.dark900,
|
|
86
93
|
foreground: palette.dark100,
|
|
87
94
|
card: palette.dark800,
|
|
@@ -89,13 +96,13 @@ const darkTheme = {
|
|
|
89
96
|
popover: palette.dark800,
|
|
90
97
|
popoverForeground: palette.dark100,
|
|
91
98
|
text: palette.dark100,
|
|
92
|
-
textDim: palette.
|
|
99
|
+
textDim: palette.dark350,
|
|
93
100
|
primary: palette.gray50,
|
|
94
101
|
primaryForeground: palette.gray900,
|
|
95
102
|
secondary: palette.gray800,
|
|
96
103
|
secondaryForeground: palette.gray50,
|
|
97
104
|
muted: palette.dark700,
|
|
98
|
-
mutedForeground: palette.
|
|
105
|
+
mutedForeground: palette.dark350,
|
|
99
106
|
accent: palette.teal400,
|
|
100
107
|
accentForeground: palette.gray900,
|
|
101
108
|
destructive: palette.red400,
|
|
@@ -103,6 +110,7 @@ const darkTheme = {
|
|
|
103
110
|
success: palette.green400,
|
|
104
111
|
warning: palette.amber400,
|
|
105
112
|
border: palette.dark700,
|
|
113
|
+
borderStrong: palette.dark600,
|
|
106
114
|
input: palette.dark700,
|
|
107
115
|
ring: palette.dark400,
|
|
108
116
|
overlay: "rgba(0, 0, 0, 0.7)",
|
package/dist/hooks/useTheme.js
CHANGED
|
@@ -3,6 +3,7 @@ import { colors } from "../constants/colors.js";
|
|
|
3
3
|
import { Platform, StyleSheet } from "react-native";
|
|
4
4
|
import { resolveThemePreference, useThemeStore } from "../state/themeStore.js";
|
|
5
5
|
import { useThemeColorScope } from "../state/themeColorScope.js";
|
|
6
|
+
import { useSsrThemeSeed } from "../state/ssrTheme.js";
|
|
6
7
|
import { spacing as spacingConstants } from "../constants/spacing.js";
|
|
7
8
|
// Module-level cache for contrast calculations to avoid memory leak
|
|
8
9
|
// and share across components
|
|
@@ -45,11 +46,24 @@ function getCachedOrCompute(key, compute) {
|
|
|
45
46
|
* - getShadowStyle('base') → { boxShadow: "0px 2px 6px rgba(0, 0, 0, 0.05), 0px 4px 12px rgba(0, 0, 0, 0.03)" }
|
|
46
47
|
*/
|
|
47
48
|
export function useTheme() {
|
|
48
|
-
const
|
|
49
|
-
const
|
|
49
|
+
const storeUserTheme = useThemeStore((s) => s.userTheme);
|
|
50
|
+
const storeSystemTheme = useThemeStore((s) => s.systemTheme);
|
|
51
|
+
const hasLoadedTheme = useThemeStore((s) => s.hasLoadedTheme);
|
|
50
52
|
const setTheme = useThemeStore((s) => s.setTheme);
|
|
51
53
|
const colorOverrides = useThemeStore((s) => s.colorOverrides);
|
|
52
54
|
const scoped = useThemeColorScope();
|
|
55
|
+
const seed = useSsrThemeSeed();
|
|
56
|
+
// Before persistence has been read, a web render must resolve from the
|
|
57
|
+
// per-request SSR seed instead of store state. The store is a module
|
|
58
|
+
// singleton shared across concurrent SSR requests, so it can only ever hold
|
|
59
|
+
// the SSR-safe boot defaults ("system"/light); the seed is cookie-derived,
|
|
60
|
+
// which is the one signal the server and the browser read identically —
|
|
61
|
+
// that's what keeps the hydrated tree matching the server HTML. After mount
|
|
62
|
+
// `syncThemeFromEnvironment()` flips `hasLoadedTheme` and store state (real
|
|
63
|
+
// localStorage/AsyncStorage + live OS listener) takes over for good.
|
|
64
|
+
const useSeed = Platform.OS === "web" && !hasLoadedTheme;
|
|
65
|
+
const userTheme = useSeed ? seed.userTheme : storeUserTheme;
|
|
66
|
+
const systemTheme = useSeed ? seed.systemTheme : storeSystemTheme;
|
|
53
67
|
// Determine which theme to use (user preference or system)
|
|
54
68
|
const effectiveScheme = resolveThemePreference(userTheme, systemTheme);
|
|
55
69
|
const base = colors[effectiveScheme];
|
package/dist/state/index.d.ts
CHANGED
package/dist/state/index.js
CHANGED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { ResolvedTheme, ThemePreference } from "./themeStore";
|
|
2
|
+
/**
|
|
3
|
+
* The theme values the FIRST render must use on web — server and client alike.
|
|
4
|
+
*
|
|
5
|
+
* `useThemeStore` is a module singleton shared by every concurrent SSR request,
|
|
6
|
+
* so a per-request theme can never be written into it. This context is the
|
|
7
|
+
* per-request channel instead: the host app resolves it from the request (a
|
|
8
|
+
* cookie the client mirrors) and provides it above the tree, and `useTheme`
|
|
9
|
+
* reads it until the store has loaded real persistence after mount.
|
|
10
|
+
*/
|
|
11
|
+
export type SsrThemeSeed = {
|
|
12
|
+
userTheme: ThemePreference;
|
|
13
|
+
systemTheme: ResolvedTheme;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* What a first-time visitor gets: no persisted preference, light scheme. Same
|
|
17
|
+
* values the store boots with, so a host app that never provides the context
|
|
18
|
+
* behaves exactly as it did before the seed existed.
|
|
19
|
+
*/
|
|
20
|
+
export declare const SSR_THEME_SEED_DEFAULT: SsrThemeSeed;
|
|
21
|
+
/**
|
|
22
|
+
* Per-request theme seed consumed by `useTheme`/`useStyles` while
|
|
23
|
+
* `hasLoadedTheme` is false (i.e. on the server render and the client's first
|
|
24
|
+
* render, before `syncThemeFromEnvironment()` reads real persistence).
|
|
25
|
+
*
|
|
26
|
+
* Provide it near the top of the tree from a value BOTH the server and the
|
|
27
|
+
* browser can derive from the same bytes — a cookie. Reading `localStorage`
|
|
28
|
+
* for this would reintroduce the hydration mismatch it exists to remove,
|
|
29
|
+
* because the server cannot see `localStorage`.
|
|
30
|
+
*
|
|
31
|
+
* ```tsx
|
|
32
|
+
* import { SsrThemeSeedContext } from "@mrmeg/expo-ui/state";
|
|
33
|
+
*
|
|
34
|
+
* <SsrThemeSeedContext.Provider value={seedFromCookie}>
|
|
35
|
+
* <App />
|
|
36
|
+
* </SsrThemeSeedContext.Provider>
|
|
37
|
+
* ```
|
|
38
|
+
*/
|
|
39
|
+
export declare const SsrThemeSeedContext: import("react").Context<SsrThemeSeed>;
|
|
40
|
+
/** Read the active SSR theme seed (the default when no provider is mounted). */
|
|
41
|
+
export declare function useSsrThemeSeed(): SsrThemeSeed;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { createContext, use } from "react";
|
|
2
|
+
/**
|
|
3
|
+
* What a first-time visitor gets: no persisted preference, light scheme. Same
|
|
4
|
+
* values the store boots with, so a host app that never provides the context
|
|
5
|
+
* behaves exactly as it did before the seed existed.
|
|
6
|
+
*/
|
|
7
|
+
export const SSR_THEME_SEED_DEFAULT = {
|
|
8
|
+
userTheme: "system",
|
|
9
|
+
systemTheme: "light",
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Per-request theme seed consumed by `useTheme`/`useStyles` while
|
|
13
|
+
* `hasLoadedTheme` is false (i.e. on the server render and the client's first
|
|
14
|
+
* render, before `syncThemeFromEnvironment()` reads real persistence).
|
|
15
|
+
*
|
|
16
|
+
* Provide it near the top of the tree from a value BOTH the server and the
|
|
17
|
+
* browser can derive from the same bytes — a cookie. Reading `localStorage`
|
|
18
|
+
* for this would reintroduce the hydration mismatch it exists to remove,
|
|
19
|
+
* because the server cannot see `localStorage`.
|
|
20
|
+
*
|
|
21
|
+
* ```tsx
|
|
22
|
+
* import { SsrThemeSeedContext } from "@mrmeg/expo-ui/state";
|
|
23
|
+
*
|
|
24
|
+
* <SsrThemeSeedContext.Provider value={seedFromCookie}>
|
|
25
|
+
* <App />
|
|
26
|
+
* </SsrThemeSeedContext.Provider>
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
export const SsrThemeSeedContext = createContext(SSR_THEME_SEED_DEFAULT);
|
|
30
|
+
/** Read the active SSR theme seed (the default when no provider is mounted). */
|
|
31
|
+
export function useSsrThemeSeed() {
|
|
32
|
+
return use(SsrThemeSeedContext);
|
|
33
|
+
}
|
|
@@ -2,6 +2,16 @@ import type { ThemeColors } from "../constants/colors";
|
|
|
2
2
|
import type { FontOverrides } from "../constants/fonts";
|
|
3
3
|
export type ThemePreference = "system" | "light" | "dark";
|
|
4
4
|
export type ResolvedTheme = "light" | "dark";
|
|
5
|
+
/**
|
|
6
|
+
* Cookie name the web build mirrors the persisted preference into, so a
|
|
7
|
+
* server-rendered host can seed its first render with the visitor's real
|
|
8
|
+
* theme instead of shipping a light tree that recolors after hydration.
|
|
9
|
+
*
|
|
10
|
+
* Same name as the `localStorage` key on purpose: one string to grep, and the
|
|
11
|
+
* blocking color-scheme script in a host's `+html.tsx` already reads it.
|
|
12
|
+
* `localStorage` stays the source of truth; the cookie is a render hint only.
|
|
13
|
+
*/
|
|
14
|
+
export declare const THEME_COOKIE_NAME = "user-theme-preference";
|
|
5
15
|
/**
|
|
6
16
|
* Per-scheme color overrides a host app can inject to brand the package.
|
|
7
17
|
*
|
|
@@ -44,6 +54,15 @@ export type ShapeOverrides = {
|
|
|
44
54
|
export type ThemeStore = {
|
|
45
55
|
userTheme: ThemePreference;
|
|
46
56
|
systemTheme: ResolvedTheme;
|
|
57
|
+
/**
|
|
58
|
+
* True once the persisted preference has actually been read (or explicitly
|
|
59
|
+
* set by the user). Until then, a web render must not trust `userTheme` /
|
|
60
|
+
* `systemTheme` — they still hold the SSR-safe boot defaults, and `useTheme`
|
|
61
|
+
* uses `SsrThemeSeedContext` (cookie-derived, identical on server and
|
|
62
|
+
* client) instead. Mirrors `hasLoadedOnboarding` in the host app's
|
|
63
|
+
* onboarding store.
|
|
64
|
+
*/
|
|
65
|
+
hasLoadedTheme: boolean;
|
|
47
66
|
/**
|
|
48
67
|
* App-injected palette overrides, applied by `useTheme` on top of the
|
|
49
68
|
* package defaults. Empty by default — zero override means the package
|
package/dist/state/themeStore.js
CHANGED
|
@@ -2,6 +2,26 @@ import { Appearance, Platform } from "react-native";
|
|
|
2
2
|
import { create } from "zustand";
|
|
3
3
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
|
4
4
|
const THEME_KEY = "user-theme-preference";
|
|
5
|
+
/**
|
|
6
|
+
* Cookie name the web build mirrors the persisted preference into, so a
|
|
7
|
+
* server-rendered host can seed its first render with the visitor's real
|
|
8
|
+
* theme instead of shipping a light tree that recolors after hydration.
|
|
9
|
+
*
|
|
10
|
+
* Same name as the `localStorage` key on purpose: one string to grep, and the
|
|
11
|
+
* blocking color-scheme script in a host's `+html.tsx` already reads it.
|
|
12
|
+
* `localStorage` stays the source of truth; the cookie is a render hint only.
|
|
13
|
+
*/
|
|
14
|
+
export const THEME_COOKIE_NAME = THEME_KEY;
|
|
15
|
+
const THEME_COOKIE_MAX_AGE = 60 * 60 * 24 * 365; // 1 year
|
|
16
|
+
function isThemePreference(value) {
|
|
17
|
+
return value === "system" || value === "light" || value === "dark";
|
|
18
|
+
}
|
|
19
|
+
function writeThemeCookie(theme) {
|
|
20
|
+
if (Platform.OS !== "web" || typeof document === "undefined")
|
|
21
|
+
return;
|
|
22
|
+
document.cookie =
|
|
23
|
+
`${THEME_COOKIE_NAME}=${theme}; path=/; max-age=${THEME_COOKIE_MAX_AGE}; SameSite=Lax`;
|
|
24
|
+
}
|
|
5
25
|
export function resolveThemePreference(userTheme, systemTheme) {
|
|
6
26
|
return userTheme === "system" ? systemTheme : userTheme;
|
|
7
27
|
}
|
|
@@ -14,8 +34,13 @@ function getSystemTheme() {
|
|
|
14
34
|
export const useThemeStore = create((set) => ({
|
|
15
35
|
userTheme: "system",
|
|
16
36
|
// Always start with "light" so SSR and the first client render agree.
|
|
17
|
-
// Real values are populated by `syncThemeFromEnvironment()` after mount
|
|
37
|
+
// Real values are populated by `syncThemeFromEnvironment()` after mount;
|
|
38
|
+
// until then a web render reads `SsrThemeSeedContext` (cookie-derived) so
|
|
39
|
+
// the server and the browser paint the visitor's actual theme.
|
|
18
40
|
systemTheme: "light",
|
|
41
|
+
// False until persistence has been read. `useTheme` uses this to decide
|
|
42
|
+
// whether to trust store state or the per-request SSR seed.
|
|
43
|
+
hasLoadedTheme: false,
|
|
19
44
|
// No overrides by default: the package renders with its built-in palette
|
|
20
45
|
// until a host app calls `setColors`.
|
|
21
46
|
colorOverrides: {},
|
|
@@ -36,6 +61,7 @@ export const useThemeStore = create((set) => ({
|
|
|
36
61
|
setTheme: (theme) => {
|
|
37
62
|
set({
|
|
38
63
|
userTheme: theme,
|
|
64
|
+
hasLoadedTheme: true,
|
|
39
65
|
...(theme === "system" ? { systemTheme: getSystemTheme() } : {}),
|
|
40
66
|
});
|
|
41
67
|
// Save directly when setting theme
|
|
@@ -43,10 +69,14 @@ export const useThemeStore = create((set) => ({
|
|
|
43
69
|
AsyncStorage.setItem(THEME_KEY, theme).catch(() => {
|
|
44
70
|
// Silently fail if storage is not available
|
|
45
71
|
});
|
|
72
|
+
return;
|
|
46
73
|
}
|
|
47
|
-
|
|
74
|
+
if (typeof window !== "undefined" && window.localStorage) {
|
|
48
75
|
localStorage.setItem(THEME_KEY, theme);
|
|
49
76
|
}
|
|
77
|
+
// Dual-write: localStorage for the client, cookie for the next SSR render
|
|
78
|
+
// (so the server paints the right theme on the visitor's next request).
|
|
79
|
+
writeThemeCookie(theme);
|
|
50
80
|
},
|
|
51
81
|
setSystemTheme: (theme) => {
|
|
52
82
|
set({ systemTheme: theme });
|
|
@@ -54,19 +84,32 @@ export const useThemeStore = create((set) => ({
|
|
|
54
84
|
loadTheme: () => {
|
|
55
85
|
if (Platform.OS !== "web") {
|
|
56
86
|
AsyncStorage.getItem(THEME_KEY).then((saved) => {
|
|
57
|
-
if (saved && (saved
|
|
87
|
+
if (saved && isThemePreference(saved)) {
|
|
58
88
|
set({ userTheme: saved });
|
|
59
89
|
}
|
|
60
90
|
}).catch(() => {
|
|
61
91
|
// Use default if loading fails
|
|
92
|
+
}).finally(() => {
|
|
93
|
+
set({ hasLoadedTheme: true });
|
|
62
94
|
});
|
|
95
|
+
return;
|
|
63
96
|
}
|
|
64
|
-
|
|
97
|
+
if (typeof window !== "undefined" && window.localStorage) {
|
|
65
98
|
const saved = localStorage.getItem(THEME_KEY);
|
|
66
|
-
if (saved && (saved
|
|
99
|
+
if (saved && isThemePreference(saved)) {
|
|
67
100
|
set({ userTheme: saved });
|
|
101
|
+
// Backfill / repair the cookie so a visitor who set their preference
|
|
102
|
+
// before this mechanism existed (or who cleared cookies) gets a
|
|
103
|
+
// correctly themed server render on their very next request.
|
|
104
|
+
writeThemeCookie(saved);
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
// Nothing persisted: mirror the implicit "system" default so the
|
|
108
|
+
// server stops guessing from a stale cookie.
|
|
109
|
+
writeThemeCookie("system");
|
|
68
110
|
}
|
|
69
111
|
}
|
|
112
|
+
set({ hasLoadedTheme: true });
|
|
70
113
|
}
|
|
71
114
|
}));
|
|
72
115
|
let stopSystemThemeListener = null;
|
|
@@ -112,9 +155,15 @@ export function startSystemThemeListener() {
|
|
|
112
155
|
// environment (persisted preference + OS color scheme listener). Safe to
|
|
113
156
|
// call multiple times — `startSystemThemeListener` is idempotent — and
|
|
114
157
|
// returns the unsubscribe so it can be used directly inside `useEffect`.
|
|
158
|
+
//
|
|
159
|
+
// The listener starts BEFORE `loadTheme()` on purpose: `loadTheme` is what
|
|
160
|
+
// flips `hasLoadedTheme`, which is the moment web renders stop trusting the
|
|
161
|
+
// SSR seed and start trusting store state. Reading the real OS scheme first
|
|
162
|
+
// means a `system` user never sees a frame of the boot-default "light".
|
|
115
163
|
export function syncThemeFromEnvironment() {
|
|
164
|
+
const stop = startSystemThemeListener();
|
|
116
165
|
useThemeStore.getState().loadTheme();
|
|
117
|
-
return
|
|
166
|
+
return stop;
|
|
118
167
|
}
|
|
119
168
|
// Native has no SSR mismatch concern, so keep the historical auto-init
|
|
120
169
|
// behavior there. On web the host app must call `syncThemeFromEnvironment()`
|
package/llms-full.md
CHANGED
|
@@ -51,9 +51,14 @@ package `tx` props translated by its app-owned i18n instance.
|
|
|
51
51
|
## Theme Rules
|
|
52
52
|
|
|
53
53
|
Use `useTheme()` and `useStyles()` from `@mrmeg/expo-ui/hooks`. Use semantic
|
|
54
|
-
tokens such as `background`, `foreground`, `card`, `popover`,
|
|
55
|
-
`input`, `ring`, `primary`, `secondary`, `accent`,
|
|
56
|
-
`destructive`, `success`, and `warning`.
|
|
54
|
+
tokens such as `surfaceSunken`, `background`, `foreground`, `card`, `popover`,
|
|
55
|
+
`border`, `borderStrong`, `input`, `ring`, `primary`, `secondary`, `accent`,
|
|
56
|
+
`mutedForeground`, `destructive`, `success`, and `warning`.
|
|
57
|
+
|
|
58
|
+
Surfaces form a tier ladder rather than a shadow scale: `surfaceSunken` (app
|
|
59
|
+
chrome such as the Drawer rail) < `background` (content) < `card`/`popover`
|
|
60
|
+
(raised) < `muted` (chips, insets). Use `borderStrong` for hairlines on filled
|
|
61
|
+
or raised elements, where `border` would blend into the fill.
|
|
57
62
|
|
|
58
63
|
Use `StyledText` and semantic text aliases instead of raw `Text` for app UI.
|
|
59
64
|
Use package controls instead of hardcoded Pressable/View/Text combinations.
|