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