@mrmeg/expo-ui 0.27.1 → 0.29.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 +666 -0
- package/LLM_USAGE.md +241 -37
- package/README.md +591 -136
- package/bin/expo-ui-icons.mjs +509 -0
- package/dist/components/Accordion.js +12 -6
- package/dist/components/AnimatedView.d.ts +1 -1
- package/dist/components/AnimatedView.js +5 -2
- package/dist/components/Avatar.d.ts +1 -1
- package/dist/components/Avatar.js +2 -2
- package/dist/components/Badge.js +5 -1
- package/dist/components/BottomSheet.d.ts +29 -1
- package/dist/components/BottomSheet.js +107 -30
- package/dist/components/Button.d.ts +20 -4
- package/dist/components/Button.js +60 -43
- package/dist/components/Card.d.ts +1 -1
- package/dist/components/Card.js +24 -4
- package/dist/components/Carousel.js +2 -2
- package/dist/components/Checkbox.js +51 -44
- package/dist/components/Collapsible.js +7 -3
- package/dist/components/Dialog.js +87 -16
- package/dist/components/Drawer.d.ts +1 -1
- package/dist/components/Drawer.js +65 -55
- package/dist/components/DropdownMenu.js +11 -7
- package/dist/components/EmptyState.d.ts +2 -2
- package/dist/components/EmptyState.js +5 -2
- package/dist/components/Hydrated.d.ts +31 -0
- package/dist/components/Hydrated.js +30 -0
- package/dist/components/Icon.d.ts +71 -28
- package/dist/components/Icon.js +70 -28
- package/dist/components/IconGlyph.d.ts +15 -0
- package/dist/components/IconGlyph.js +32 -0
- package/dist/components/IconGlyph.native.d.ts +10 -0
- package/dist/components/IconGlyph.native.js +30 -0
- package/dist/components/InputOTP.js +5 -2
- package/dist/components/Item.d.ts +103 -4
- package/dist/components/Item.js +168 -15
- package/dist/components/KeyboardAvoidingView.d.ts +1 -1
- package/dist/components/KeyboardAvoidingView.js +4 -1
- package/dist/components/Label.js +3 -2
- package/dist/components/MaxWidthContainer.js +1 -1
- package/dist/components/Notification.js +45 -27
- package/dist/components/Popover.d.ts +18 -5
- package/dist/components/Popover.js +72 -8
- package/dist/components/Progress.js +8 -6
- package/dist/components/RadioGroup.js +52 -49
- package/dist/components/Screen.d.ts +61 -0
- package/dist/components/Screen.js +62 -0
- package/dist/components/SectionHeader.d.ts +1 -1
- package/dist/components/SegmentedControl.d.ts +12 -4
- package/dist/components/SegmentedControl.js +94 -4
- package/dist/components/Select.js +21 -26
- package/dist/components/Skeleton.js +24 -10
- package/dist/components/Slider.d.ts +11 -3
- package/dist/components/Slider.js +73 -6
- package/dist/components/StatCard.d.ts +4 -2
- package/dist/components/StatCard.js +3 -1
- package/dist/components/StyledText.d.ts +7 -1
- package/dist/components/StyledText.js +5 -3
- package/dist/components/Switch.d.ts +1 -1
- package/dist/components/Switch.js +20 -30
- package/dist/components/Tabs.d.ts +2 -2
- package/dist/components/Tabs.js +13 -24
- package/dist/components/TextInput.d.ts +1 -1
- package/dist/components/TextInput.js +71 -26
- package/dist/components/ThemeSelector.d.ts +52 -0
- package/dist/components/ThemeSelector.js +90 -0
- package/dist/components/Toggle.d.ts +5 -49
- package/dist/components/Toggle.js +53 -29
- package/dist/components/ToggleGroup.d.ts +4 -8
- package/dist/components/ToggleGroup.js +52 -34
- package/dist/components/Tooltip.d.ts +1 -1
- package/dist/components/Tooltip.js +9 -3
- package/dist/components/UIProvider.d.ts +22 -2
- package/dist/components/UIProvider.js +15 -3
- package/dist/components/bottomSheetDismiss.d.ts +19 -0
- package/dist/components/bottomSheetDismiss.js +44 -0
- package/dist/components/iconBuiltins.generated.d.ts +143 -0
- package/dist/components/iconBuiltins.generated.js +25 -0
- package/dist/components/iconTypes.d.ts +37 -0
- package/dist/components/iconTypes.js +1 -0
- package/dist/components/index.d.ts +47 -44
- package/dist/components/index.js +3 -0
- package/dist/components/keyboardDismiss.js +12 -4
- package/dist/components/nativeTextField.android.js +13 -9
- package/dist/constants/colors.d.ts +40 -5
- package/dist/constants/colors.js +26 -8
- package/dist/constants/fonts.d.ts +41 -2
- package/dist/constants/fonts.js +46 -7
- package/dist/constants/index.d.ts +5 -4
- package/dist/constants/index.js +1 -0
- package/dist/constants/interaction.d.ts +18 -0
- package/dist/constants/interaction.js +18 -0
- package/dist/constants/spacing.d.ts +2 -1
- package/dist/constants/spacing.js +2 -1
- package/dist/design-system.json +429 -132
- package/dist/hooks/index.d.ts +12 -7
- package/dist/hooks/index.js +5 -0
- package/dist/hooks/useDimensions.d.ts +13 -8
- package/dist/hooks/useDimensions.js +123 -48
- package/dist/hooks/useFocusVisible.d.ts +30 -0
- package/dist/hooks/useFocusVisible.js +45 -0
- package/dist/hooks/useFontStyle.d.ts +2 -2
- package/dist/hooks/useFontStyle.js +3 -2
- package/dist/hooks/useHydrated.d.ts +13 -0
- package/dist/hooks/useHydrated.js +19 -0
- package/dist/hooks/useNotificationOffset.d.ts +20 -0
- package/dist/hooks/useNotificationOffset.js +34 -0
- package/dist/hooks/useResources.d.ts +21 -20
- package/dist/hooks/useResources.js +72 -68
- package/dist/hooks/useScalePress.d.ts +8 -5
- package/dist/hooks/useScalePress.js +16 -9
- package/dist/hooks/useShape.d.ts +24 -0
- package/dist/hooks/useShape.js +24 -0
- package/dist/hooks/useStaggeredEntrance.js +9 -7
- package/dist/hooks/useTheme.d.ts +3 -3
- package/dist/hooks/useTheme.js +177 -66
- package/dist/hooks/useWindowInsets.d.ts +13 -0
- package/dist/hooks/useWindowInsets.js +23 -0
- package/dist/icons/LICENSE.lucide +43 -0
- package/dist/icons/lucide.json +2144 -0
- package/dist/index.d.ts +5 -5
- package/dist/lib/composeRefs.d.ts +8 -0
- package/dist/lib/composeRefs.js +20 -0
- package/dist/lib/haptics.d.ts +12 -0
- package/dist/lib/haptics.js +21 -0
- package/dist/lib/index.d.ts +6 -5
- package/dist/lib/index.js +1 -0
- package/dist/lib/interFonts.d.ts +15 -0
- package/dist/lib/interFonts.js +1 -0
- package/dist/lib/interFonts.native.d.ts +3 -0
- package/dist/lib/interFonts.native.js +20 -0
- package/dist/lib/popoverPlacement.d.ts +36 -0
- package/dist/lib/popoverPlacement.js +29 -0
- package/dist/lib/themedStyles.d.ts +1 -1
- package/dist/lib/useAnimatedValue.d.ts +15 -0
- package/dist/lib/useAnimatedValue.js +19 -0
- package/dist/lib/webHitTarget.d.ts +40 -0
- package/dist/lib/webHitTarget.js +37 -0
- package/dist/state/feedbackStore.d.ts +24 -0
- package/dist/state/feedbackStore.js +16 -0
- package/dist/state/globalUIStore.d.ts +27 -0
- package/dist/state/globalUIStore.js +23 -0
- package/dist/state/index.d.ts +7 -5
- package/dist/state/index.js +2 -0
- package/dist/state/initialScheme.d.ts +27 -0
- package/dist/state/initialScheme.js +29 -0
- package/dist/state/notify.d.ts +1 -1
- package/dist/state/themeColorScope.d.ts +1 -1
- package/dist/state/themeStore.d.ts +69 -16
- package/dist/state/themeStore.js +65 -38
- package/llms-full.md +59 -10
- package/llms.txt +10 -1
- package/package.json +79 -4
- package/dist/components/iconRegistry.generated.d.ts +0 -148
- package/dist/components/iconRegistry.generated.js +0 -296
package/README.md
CHANGED
|
@@ -32,9 +32,10 @@ Web 0.21. Install these peers at the versions your Expo SDK recommends: `expo`,
|
|
|
32
32
|
`react-native-web`, `react-native-gesture-handler`,
|
|
33
33
|
`react-native-keyboard-controller`, `react-native-safe-area-context`,
|
|
34
34
|
`react-native-screens`, `@react-native-async-storage/async-storage`, `zustand`.
|
|
35
|
-
`Icon`
|
|
36
|
-
|
|
37
|
-
|
|
35
|
+
`Icon` draws SVG, so also install `react-native-svg` (a native module, pinned
|
|
36
|
+
by your SDK): `npx expo install react-native-svg`. The icons themselves are
|
|
37
|
+
[Lucide](https://lucide.dev/icons) glyph data the package ships and renders;
|
|
38
|
+
no icon library is a peer (see [Icons](#icons)).
|
|
38
39
|
|
|
39
40
|
`@rn-primitives/*` and `@expo-google-fonts/inter` are package dependencies, not
|
|
40
41
|
peers. Animations use React Native `Animated`. `BottomSheet`, `Slider`, and
|
|
@@ -59,6 +60,14 @@ Importable paths: root, `components`, `components/*`, `constants`,
|
|
|
59
60
|
`constants/*`, `hooks`, `hooks/*`, `state`, `state/*`, `lib`. Never import from
|
|
60
61
|
`dist/*` or a source checkout path.
|
|
61
62
|
|
|
63
|
+
The package declares `sideEffects`: only `state/themeStore` does work at import
|
|
64
|
+
time (on native it loads the saved theme and starts the OS color-scheme
|
|
65
|
+
listener). A bundler that tree-shakes (Expo with `EXPO_UNSTABLE_TREE_SHAKING=1`,
|
|
66
|
+
esbuild, Rollup, webpack) keeps only the modules a barrel import actually uses,
|
|
67
|
+
so `import { Button } from "@mrmeg/expo-ui"` costs about what the
|
|
68
|
+
`components/Button` deep import does. Metro without tree shaking bundles
|
|
69
|
+
everything a file imports, so the deep imports stay the smaller choice there.
|
|
70
|
+
|
|
62
71
|
## App Startup
|
|
63
72
|
|
|
64
73
|
Call `useResources()` once near the Expo app root before hiding the splash
|
|
@@ -97,6 +106,21 @@ export default function RootLayout() {
|
|
|
97
106
|
}
|
|
98
107
|
```
|
|
99
108
|
|
|
109
|
+
On web with server rendering, tell the kit the visitor's scheme for the first
|
|
110
|
+
render: `<InitialSchemeProvider scheme={cookieScheme}>` around the root layout
|
|
111
|
+
(or `<UIProvider initialScheme>` when nothing above it reads the theme), where
|
|
112
|
+
`cookieScheme` is `"light"` / `"dark"` read from the same cookie on the server
|
|
113
|
+
and the client — see the template's `shared/ssrColorScheme.ts`. `useTheme()`
|
|
114
|
+
paints it until the persisted preference has loaded.
|
|
115
|
+
|
|
116
|
+
A Radix-backed component (`Tabs`, `Accordion`, `Collapsible`, `Select`,
|
|
117
|
+
`DropdownMenu`, `Popover`) inside a server-rendered route logs a `useId`
|
|
118
|
+
hydration mismatch in development, because Expo Router's server renders the app
|
|
119
|
+
inside `+html.tsx` while the client hydrates `#root`. Wrap that subtree in
|
|
120
|
+
`<Hydrated fallback={…}>` (from `@mrmeg/expo-ui/components`): the fallback holds
|
|
121
|
+
the layout in the server HTML and the children mount in the first client render.
|
|
122
|
+
Keep content that must be server-rendered outside the gate.
|
|
123
|
+
|
|
100
124
|
`UIProvider` is required before `Dialog`, `AlertDialog`, `BottomSheet`,
|
|
101
125
|
`Drawer`, `DropdownMenu`, `Popover`, `SelectContent`, `Tooltip`, and package
|
|
102
126
|
notifications.
|
|
@@ -113,6 +137,45 @@ For a subtree with custom keyboard behavior, use `KeyboardAvoidingView`
|
|
|
113
137
|
directly (`behavior`, `automaticOffset`, `contentContainerStyle`,
|
|
114
138
|
`keyboardVerticalOffset`).
|
|
115
139
|
|
|
140
|
+
### Web setup
|
|
141
|
+
|
|
142
|
+
Web needs two more pieces, because every web theme color is a CSS variable
|
|
143
|
+
(see [Web theming is CSS variables](#web-theming-is-css-variables)) and the
|
|
144
|
+
theme store boots at `"system"`/light so the first client render matches the
|
|
145
|
+
server-rendered or exported HTML:
|
|
146
|
+
|
|
147
|
+
1. **`app/+html.tsx`: define the variables.** Put `getThemeCssVariables()` (from
|
|
148
|
+
`constants`) in the document's global `<style>`, passing the same overrides
|
|
149
|
+
you give `setColors` if you re-brand. Without it every `var(--c-*)` color is
|
|
150
|
+
unset. A blocking inline script that stamps `data-theme` on `<html>` from
|
|
151
|
+
the persisted preference (`THEME_STORAGE_KEY`, from `state`) or
|
|
152
|
+
`prefers-color-scheme` makes the first frame paint in the visitor's scheme
|
|
153
|
+
before any JS runs.
|
|
154
|
+
2. **Root layout: sync the store after the first commit.** Call
|
|
155
|
+
`syncThemeFromEnvironment()` (from `state`) in a top-level `useEffect` and
|
|
156
|
+
return its result as the cleanup. It reads the persisted preference and
|
|
157
|
+
starts following the OS color scheme. Never call it during render or at
|
|
158
|
+
module scope: the server has no `window`, and reading it while hydrating
|
|
159
|
+
would disagree with the markup. It is safe to call more than once
|
|
160
|
+
(StrictMode's double effects, several roots): the calls share one OS
|
|
161
|
+
listener, and each cleanup releases only its own call. `UIProvider` does not
|
|
162
|
+
call it. A web app that skips it stays on the boot default until the user
|
|
163
|
+
picks a theme. Native loads the preference and follows the OS at startup, so
|
|
164
|
+
the call is optional there.
|
|
165
|
+
|
|
166
|
+
```tsx
|
|
167
|
+
// app/+html.tsx (head)
|
|
168
|
+
import { getThemeCssVariables } from "@mrmeg/expo-ui/constants";
|
|
169
|
+
|
|
170
|
+
<style>{`${getThemeCssVariables()} body { background-color: var(--c-background); }`}</style>
|
|
171
|
+
|
|
172
|
+
// Root layout
|
|
173
|
+
import { useEffect } from "react";
|
|
174
|
+
import { syncThemeFromEnvironment } from "@mrmeg/expo-ui/state";
|
|
175
|
+
|
|
176
|
+
useEffect(() => syncThemeFromEnvironment(), []);
|
|
177
|
+
```
|
|
178
|
+
|
|
116
179
|
### Keyboard dismissal
|
|
117
180
|
|
|
118
181
|
`DismissKeyboard` owns tap-away dismissal without claiming touches. Its scroll
|
|
@@ -135,34 +198,35 @@ import { StyledText } from "@mrmeg/expo-ui/components";
|
|
|
135
198
|
import { spacing } from "@mrmeg/expo-ui/constants";
|
|
136
199
|
import { useTheme } from "@mrmeg/expo-ui/hooks";
|
|
137
200
|
|
|
138
|
-
export function
|
|
139
|
-
const { theme
|
|
201
|
+
export function Intro() {
|
|
202
|
+
const { theme } = useTheme();
|
|
140
203
|
|
|
141
204
|
return (
|
|
142
|
-
<View
|
|
143
|
-
|
|
144
|
-
{ backgroundColor: theme.colors.card, borderColor: theme.colors.border },
|
|
145
|
-
{ borderWidth: StyleSheet.hairlineWidth, borderRadius: spacing.radiusLg },
|
|
146
|
-
{ padding: spacing.cardPadding, gap: spacing.sm },
|
|
147
|
-
getShadowStyle("subtle"),
|
|
148
|
-
]}
|
|
149
|
-
>
|
|
150
|
-
<StyledText semantic="heading">Theme-aware panel</StyledText>
|
|
205
|
+
<View style={{ paddingHorizontal: spacing.screenPadding, gap: spacing.sm }}>
|
|
206
|
+
<StyledText semantic="heading">Theme-aware section</StyledText>
|
|
151
207
|
<StyledText semantic="body" style={{ color: theme.colors.mutedForeground }}>
|
|
152
208
|
Uses package tokens instead of hardcoded colors.
|
|
153
209
|
</StyledText>
|
|
210
|
+
<View style={{ height: StyleSheet.hairlineWidth, backgroundColor: theme.colors.border }} />
|
|
154
211
|
</View>
|
|
155
212
|
);
|
|
156
213
|
}
|
|
157
214
|
```
|
|
158
215
|
|
|
216
|
+
Sections sit on the screen's 16 pt gutter and end in a hairline, not in a
|
|
217
|
+
bordered, shadowed panel; see [Screen layout](#screen-layout).
|
|
218
|
+
|
|
159
219
|
`useTheme()` returns the active `theme`, resolved `scheme`, persisted
|
|
160
220
|
`currentTheme`, `setTheme`, `toggleTheme`, `getShadowStyle`,
|
|
161
221
|
`getFocusRingStyle(offset = 2)`, `getContrastingColor`, `getContrastRatio`,
|
|
162
222
|
`getTextColorForBackground`, and `withAlpha`. `getShadowStyle(type)` takes
|
|
163
223
|
`base`, `soft`, `sharp`, `subtle`, `elevated`, `glow`, `glass`, `card`,
|
|
164
224
|
`cardHover`, or `cardSubtle` and returns a cross-platform `boxShadow` (RN 0.85
|
|
165
|
-
and react-native-web 0.21 deprecate the legacy `shadow*` props).
|
|
225
|
+
and react-native-web 0.21 deprecate the legacy `shadow*` props). The layers
|
|
226
|
+
follow the scheme: in dark the raised presets add a 1px inset white top
|
|
227
|
+
highlight to black shadows at real alpha (a faint drop shadow is invisible on a
|
|
228
|
+
near-black base); in light it is the shadow alone, at about twice the original
|
|
229
|
+
alpha.
|
|
166
230
|
|
|
167
231
|
Semantic tokens on `theme.colors`: `surfaceSunken`, `background`, `foreground`,
|
|
168
232
|
`card`, `cardForeground`, `popover`, `popoverForeground`, `text`, `textDim`,
|
|
@@ -172,12 +236,13 @@ Semantic tokens on `theme.colors`: `surfaceSunken`, `background`, `foreground`,
|
|
|
172
236
|
`secondary` a neutral secondary surface, `accent` the teal highlight, `input`
|
|
173
237
|
the default form-control border, `ring` the focus outline.
|
|
174
238
|
|
|
175
|
-
Surfaces are layered tiers,
|
|
176
|
-
as the Drawer rail) < `background` (content) < `card`/`popover`
|
|
177
|
-
< `muted` (chips, insets). `border` is the hairline on `background`/`card`; use
|
|
239
|
+
Surfaces are layered tiers, with an edge highlight in dark: `surfaceSunken` (app
|
|
240
|
+
chrome such as the Drawer rail) < `background` (content) < `card`/`popover`
|
|
241
|
+
(raised panels) < `muted` (chips, insets). `border` is the hairline on `background`/`card`; use
|
|
178
242
|
`borderStrong` on filled surfaces. `textDim`/`mutedForeground` hold at least
|
|
179
243
|
7:1 against `background`/`card` and 6:1 against `muted` in both schemes,
|
|
180
|
-
enforced by a package test.
|
|
244
|
+
enforced by a package test. If you override `background`, `card`, or `popover`,
|
|
245
|
+
re-check them against the template's Surface Lab (`/surface-lab`).
|
|
181
246
|
|
|
182
247
|
When `currentTheme` is `"system"` the package tracks the OS color scheme and
|
|
183
248
|
updates every `useTheme()`/`useStyles()` consumer through the theme store. Do
|
|
@@ -193,10 +258,13 @@ helpers.
|
|
|
193
258
|
import { useStyles } from "@mrmeg/expo-ui/hooks";
|
|
194
259
|
|
|
195
260
|
const { styles } = useStyles(({ theme, spacing, withAlpha }) => ({
|
|
196
|
-
|
|
197
|
-
|
|
261
|
+
content: {
|
|
262
|
+
paddingVertical: spacing.md,
|
|
263
|
+
gap: spacing.sectionSpacing,
|
|
264
|
+
},
|
|
265
|
+
selectedIcon: {
|
|
266
|
+
backgroundColor: withAlpha(theme.colors.primary, 0.12),
|
|
198
267
|
borderRadius: spacing.radiusMd,
|
|
199
|
-
padding: spacing.md,
|
|
200
268
|
},
|
|
201
269
|
}));
|
|
202
270
|
```
|
|
@@ -206,7 +274,13 @@ const { styles } = useStyles(({ theme, spacing, withAlpha }) => ({
|
|
|
206
274
|
On web every `theme.colors.*` value resolves to a CSS custom property
|
|
207
275
|
(`var(--c-<kebab-token>)`), so styles built from the theme — including HTML
|
|
208
276
|
shells baked at export time — re-theme purely in CSS when `html[data-theme]`
|
|
209
|
-
changes. Native keeps literal values.
|
|
277
|
+
changes. Native keeps literal values. Because the references are the same in
|
|
278
|
+
both schemes, web shares one object between them (`colors.light.colors ===
|
|
279
|
+
colors.dark.colors`), so a scheme switch keeps `theme.colors` identical and
|
|
280
|
+
anything memoized on it keeps its result. Re-brand through `setColors`, not by
|
|
281
|
+
mutating that object. The package keeps `html[data-theme]` on the resolved
|
|
282
|
+
scheme with one store subscription, started by the first `useTheme()` consumer
|
|
283
|
+
to mount.
|
|
210
284
|
|
|
211
285
|
Consequence: hex-suffix alpha (`theme.colors.x + "15"`) does not work. Use
|
|
212
286
|
`withAlpha(theme.colors.x, 0.08)`, exported standalone from `hooks` as well as
|
|
@@ -235,21 +309,63 @@ everywhere at once:
|
|
|
235
309
|
|
|
236
310
|
| Token | Value | Use |
|
|
237
311
|
|-------|-------|-----|
|
|
238
|
-
| `screenPadding` | 16 |
|
|
239
|
-
| `sectionSpacing` | 24 | Gap between
|
|
240
|
-
| `cardPadding` | 16 | `Card
|
|
312
|
+
| `screenPadding` | 16 | The one horizontal inset per screen: scroll content, landing blocks, text and forms between row groups |
|
|
313
|
+
| `sectionSpacing` | 24 | Gap between screen sections and `ItemGroup`s |
|
|
314
|
+
| `cardPadding` | 16 | Inside `Card` and `StatCard` tiles; not a layout inset |
|
|
241
315
|
| `dialogPadding` | 20 | `Dialog` and `AlertDialog` content |
|
|
242
|
-
| `rowPaddingY` / `rowPaddingX` | 10 / 16 | `Item` rows
|
|
316
|
+
| `rowPaddingY` / `rowPaddingX` | 10 / 16 | `Item` rows; `rowPaddingX` is the row's copy of the screen inset |
|
|
243
317
|
| `rowGap` | 12 | Gap between row media, content, actions |
|
|
244
318
|
| `rowMinHeight` | 40 | Visual row height on web; native rows keep `touchTarget` |
|
|
245
319
|
| `formRowMinHeight` | 32 | Checkbox and radio rows on web; native keeps `touchTarget` |
|
|
246
|
-
| `touchTarget` | 44 | Minimum
|
|
320
|
+
| `touchTarget` | 44 | Minimum hit area for a control. `Button`, `Toggle` and `ToggleGroup` items reach it on every platform: native through computed `hitSlop`, web through a transparent hit extender inside the drawn box (react-native-web ignores `hitSlop`) |
|
|
321
|
+
| `minTarget` | 24 | Smallest pointer target the kit draws on web (WCAG 2.5.8); `Checkbox`/`RadioGroup` grow their hit box to it around a smaller drawn box |
|
|
247
322
|
|
|
248
323
|
Controls size themselves from their own `size` prop and ignore these tokens:
|
|
249
324
|
`Button` 28/32/40 (`sm`/`md`/`lg`), `TextInput` and `Select` 32/36/40
|
|
250
325
|
(`sm`/`md`/`lg`), `Toggle` 32/36/40 (`sm`/`default`/`lg`), `Tabs` 32/36
|
|
251
326
|
(`sm`/`md`).
|
|
252
327
|
|
|
328
|
+
### Screen layout
|
|
329
|
+
|
|
330
|
+
Phones are narrow, so every nested inset comes out of the content. Screens
|
|
331
|
+
built from this package are flat:
|
|
332
|
+
|
|
333
|
+
- **Start from `Screen`.** `<Screen edges={["bottom"]} scroll>` is the page
|
|
334
|
+
container: safe-area insets on the edges the screen owns (`edges` is
|
|
335
|
+
required — `["bottom"]` under a Stack header, `["top"]` above a tab bar,
|
|
336
|
+
`["top", "bottom"]` headerless, `[]` when the navigator owns both), the one
|
|
337
|
+
horizontal inset, the theme background under all of it. `scroll` puts the
|
|
338
|
+
insets on the `ScrollView`'s content so it scrolls under the island and home
|
|
339
|
+
indicator; `padded={false}` for full-bleed `ItemGroup` rows. Its
|
|
340
|
+
`contentContainerStyle` padding is added to the insets, never replaced.
|
|
341
|
+
Insets come from `useWindowInsets()` (`@mrmeg/expo-ui/hooks`), which also
|
|
342
|
+
covers content the system presents outside the app's `SafeAreaProvider`
|
|
343
|
+
(modals, native sheets), as `Notification`, `Dialog` and `BottomSheet` do.
|
|
344
|
+
- **One horizontal inset: `spacing.screenPadding` (16).** Either a container
|
|
345
|
+
pads or its children do, never both. `Item` rows carry the inset themselves,
|
|
346
|
+
so a screen of `ItemGroup`s pads nothing horizontally; text, forms, and
|
|
347
|
+
buttons between the groups pad their own wrapper.
|
|
348
|
+
- **No boxes as layout.** Don't wrap a section, a form, or a group of rows in
|
|
349
|
+
a bordered, shadowed, or tinted rounded panel. Separate sections with a
|
|
350
|
+
header, `spacing.sectionSpacing`, or a hairline (`Separator`).
|
|
351
|
+
- **Lists and settings are `ItemGroup` + `Item`:** plain full-width rows,
|
|
352
|
+
inset hairlines, no card per row, no border around the group. A `Switch`,
|
|
353
|
+
`Checkbox` or `Toggle` in `ItemActions` takes its accessible name from the
|
|
354
|
+
row's `ItemTitle` (`useItemControlLabel` does the same for a custom control).
|
|
355
|
+
- **Forms:** fields span the column, grouped under section headers.
|
|
356
|
+
- **Primary content fills the width.** Photos, video, maps, QR codes, and
|
|
357
|
+
charts size to the column (`width: "100%"` plus `aspectRatio`), never a
|
|
358
|
+
fixed size in whitespace.
|
|
359
|
+
- **Wide screens cap the column** instead of boxing it:
|
|
360
|
+
`contentContainerStyle={{ width: "100%", maxWidth: 640, alignSelf: "center" }}`.
|
|
361
|
+
- **`Card` is for** one item in a collection (a feed entry, a grid tile, a
|
|
362
|
+
carousel slide) or a single tappable object. Never nest Cards.
|
|
363
|
+
|
|
364
|
+
On a 390 pt phone a boxed settings group puts row content 33 pt from each edge
|
|
365
|
+
(16 screen + 1 border + 16 row) and leaves 324 pt for it; the flat group puts
|
|
366
|
+
it 16 pt in and leaves 358 pt. [`LLM_USAGE.md`](LLM_USAGE.md#screen-layout)
|
|
367
|
+
has a complete screen.
|
|
368
|
+
|
|
253
369
|
### Color overrides
|
|
254
370
|
|
|
255
371
|
Colors resolve in three layers, last wins: package defaults (zinc/teal) →
|
|
@@ -278,23 +394,113 @@ useThemeStore.getState().setColors({
|
|
|
278
394
|
unwinds when that subtree unmounts. Nested scopes compose (inner keys win,
|
|
279
395
|
outer fill in), and a scoped key beats the global brand inside it.
|
|
280
396
|
|
|
397
|
+
#### Extending the palette
|
|
398
|
+
|
|
399
|
+
An app with tokens the package does not have (a brand gold, a chart series)
|
|
400
|
+
declares them once on `ThemeColorExtensions`, and they become part of
|
|
401
|
+
`ThemeColors` everywhere: `setColors` and `ThemeColorScope` accept them,
|
|
402
|
+
`useTheme().theme.colors.brandGold` is typed, and `getThemeCssVariables`
|
|
403
|
+
emits a `--c-brand-gold` variable when the overrides you pass it name the
|
|
404
|
+
key. No side palette, no cast.
|
|
405
|
+
|
|
406
|
+
```ts
|
|
407
|
+
// theme.d.ts (any file in the app's TypeScript program)
|
|
408
|
+
declare module "@mrmeg/expo-ui/constants" {
|
|
409
|
+
interface ThemeColorExtensions {
|
|
410
|
+
brandGold: string;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// startup
|
|
415
|
+
useThemeStore.getState().setColors({
|
|
416
|
+
light: { primary: "#7c3aed", brandGold: "#c9a227" },
|
|
417
|
+
dark: { primary: "#a78bfa", brandGold: "#ffe066" },
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
// anywhere
|
|
421
|
+
const { theme } = useTheme();
|
|
422
|
+
theme.colors.brandGold; // string
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
Two things to know. The package ships no value for an extension key, so
|
|
426
|
+
provide it in both schemes or it is `undefined` at runtime in the scheme that
|
|
427
|
+
lacks it. And on web the built-in tokens are `var(--c-*)` references while
|
|
428
|
+
extension values are literals per scheme, so with any override `theme.colors`
|
|
429
|
+
is a new object when the scheme changes (the identity caveat every override
|
|
430
|
+
already has); the CSS variable from `getThemeCssVariables` is the way to use
|
|
431
|
+
an extension token in an HTML shell.
|
|
432
|
+
|
|
281
433
|
### Shape overrides
|
|
282
434
|
|
|
283
|
-
`setShape` is the geometry counterpart, grouped per
|
|
284
|
-
|
|
435
|
+
`setShape` is the geometry counterpart, grouped per slot. Each slot takes a
|
|
436
|
+
`borderRadius`; `button` also takes `withShadow`:
|
|
285
437
|
|
|
286
438
|
```tsx
|
|
287
439
|
useThemeStore.getState().setShape({
|
|
288
|
-
button: {
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
},
|
|
440
|
+
button: { borderRadius: 9999, withShadow: false }, // pills, flat `default` preset
|
|
441
|
+
input: { borderRadius: spacing.radiusXl }, // TextInput, Select, InputOTP
|
|
442
|
+
card: { borderRadius: spacing.radius2xl }, // Card, StatCard, EmptyState, SkeletonCard
|
|
443
|
+
sheet: { borderRadius: spacing.radius2xl }, // BottomSheet top corners
|
|
444
|
+
badge: { borderRadius: spacing.radiusSm }, // squarer badges
|
|
445
|
+
dialog: { borderRadius: spacing.radius2xl }, // Dialog and AlertDialog
|
|
292
446
|
});
|
|
293
447
|
```
|
|
294
448
|
|
|
449
|
+
| Slot | Reaches | Package default |
|
|
450
|
+
|---|---|---|
|
|
451
|
+
| `button` | Button, every preset (`withShadow` only affects `default`) | `spacing.radiusMd` (10), shadow on |
|
|
452
|
+
| `input` | TextInput (`outline` and `filled`; `underlined` stays square), Select trigger, InputOTP cells | `spacing.radiusMd` (10) |
|
|
453
|
+
| `card` | Card (surface and pressable focus ring), StatCard, EmptyState with `bordered`, SkeletonCard | `spacing.radiusLg` (14) |
|
|
454
|
+
| `sheet` | BottomSheet top corners, where the platform lets the sheet draw them (web, Android); iOS system sheets keep the system radius | platform |
|
|
455
|
+
| `badge` | Badge | `spacing.radiusFull` (pill) |
|
|
456
|
+
| `dialog` | Dialog and AlertDialog content | `spacing.radiusLg` (14) |
|
|
457
|
+
|
|
295
458
|
Precedence is caller-wins: a per-instance `style={{ borderRadius }}` or
|
|
296
459
|
`withShadow` prop beats the global override, which beats the package default.
|
|
297
|
-
`setShape({})` clears back to the defaults.
|
|
460
|
+
`setShape({})` clears back to the defaults. A component reads its slot with
|
|
461
|
+
`useShape(slot)` from `@mrmeg/expo-ui/hooks` and layers the radius after its
|
|
462
|
+
static one; `shapeRadius(override)` turns the slot into a `{ borderRadius }`
|
|
463
|
+
style or `undefined` for that layering.
|
|
464
|
+
|
|
465
|
+
### Press feedback and haptics
|
|
466
|
+
|
|
467
|
+
Every pressable shares one pressed look: a 0.97 scale (0.92 on small controls)
|
|
468
|
+
plus `interaction.pressedOpacity` (0.85) on filled surfaces, and
|
|
469
|
+
`interaction.disabledOpacity` (0.5) when disabled — both from
|
|
470
|
+
`@mrmeg/expo-ui/constants`. Under the OS reduce-motion setting the scale stays
|
|
471
|
+
at 1 and only the opacity signals the press.
|
|
472
|
+
|
|
473
|
+
Haptics are one provider-level setting, read at event time:
|
|
474
|
+
|
|
475
|
+
```tsx
|
|
476
|
+
<UIProvider haptics="all">…</UIProvider>
|
|
477
|
+
// or, from startup code:
|
|
478
|
+
import { setHaptics } from "@mrmeg/expo-ui/state";
|
|
479
|
+
setHaptics("all");
|
|
480
|
+
```
|
|
481
|
+
|
|
482
|
+
| `haptics` | Fires on |
|
|
483
|
+
|---|---|
|
|
484
|
+
| `"off"` | nothing |
|
|
485
|
+
| `"selection"` (default) | state changes: Switch, Checkbox, Toggle, ToggleGroup, SegmentedControl, ThemeSelector |
|
|
486
|
+
| `"all"` | selection plus a light tap on press-in for Button, pressable Card and Item, and `useScalePress` consumers |
|
|
487
|
+
|
|
488
|
+
Two ways to disable a `Button`. `disabled` swallows presses, skips the scale
|
|
489
|
+
and haptic, dims, and leaves the web tab order — for actions that cannot run.
|
|
490
|
+
`aria-disabled` (or `accessibilityState={{ disabled: true }}`) is announced
|
|
491
|
+
and dimmed the same way but stays focusable and pressable, so the press can say
|
|
492
|
+
why: a required question's "Next", a "Publish" without a title. Prefer it
|
|
493
|
+
whenever the user could otherwise wonder why nothing happens.
|
|
494
|
+
|
|
495
|
+
```tsx
|
|
496
|
+
<Button text="Publish" aria-disabled={!title} onPress={() => (title ? publish() : notify.info("Add a title first"))} />
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
Web never vibrates. A single `Button` can force either way with `haptic`
|
|
500
|
+
(`<Button haptic />`, `<Button haptic={false} />`); `useScalePress({ haptic })`
|
|
501
|
+
takes `true` (always), `false` (never) or `"setting"` (the default, follows the
|
|
502
|
+
provider). `hapticSuccess()` from `@mrmeg/expo-ui/lib` is there for completion
|
|
503
|
+
moments the app owns.
|
|
298
504
|
|
|
299
505
|
## Typography
|
|
300
506
|
|
|
@@ -312,6 +518,11 @@ import { BodyText, CaptionText, HeadingText, StyledText } from "@mrmeg/expo-ui/c
|
|
|
312
518
|
- `semantic`: `title`, `heading`, `subheading`, `body`, `caption`, `label`, `eyebrow`
|
|
313
519
|
- `size`: `xs`, `sm`, `base`, `body`, `lg`, `xl`, `xxl`, `display`
|
|
314
520
|
- `fontWeight`: `light`, `regular`, `medium`, `semibold`, `bold`
|
|
521
|
+
- `italic`: a real italic face where the family has one (an app's `setFonts`
|
|
522
|
+
`italic` map, or the Newsreader serif preset below), otherwise
|
|
523
|
+
`fontStyle: "italic"` and the platform synthesizes the slant. Inter ships no
|
|
524
|
+
italic file on native (four upright weights, by design); on web the injected
|
|
525
|
+
Inter stylesheet carries the 400 italic, so body-weight italic is real there
|
|
315
526
|
- `variant`: `sansSerif`, `serif`, `mono`
|
|
316
527
|
- `align`, `text`, `tx`, `txOptions`
|
|
317
528
|
- `selectable`: defaults to `true` on iOS and web and to `false` on Android
|
|
@@ -348,12 +559,16 @@ configureExpoUiI18n((key, options) => i18n.t(key, options));
|
|
|
348
559
|
|
|
349
560
|
Inter is the sans face on every platform, in four static weights
|
|
350
561
|
(`Inter_400Regular`, `Inter_500Medium`, `Inter_600SemiBold`, `Inter_700Bold`)
|
|
351
|
-
from the bundled `@expo-google-fonts/inter`. Serif is Georgia
|
|
352
|
-
platform system
|
|
562
|
+
from the bundled `@expo-google-fonts/inter`. Serif is Georgia by default, or
|
|
563
|
+
Newsreader through the serif preset below; mono is the platform system
|
|
564
|
+
monospace.
|
|
353
565
|
|
|
354
566
|
`useResources()` loads those four weights on native, so `StyledText`'s
|
|
355
|
-
`light`–`bold` range resolves to real files instead of a faked OS bold.
|
|
356
|
-
|
|
567
|
+
`light`–`bold` range resolves to real files instead of a faked OS bold. Each
|
|
568
|
+
weight is imported from its own `@expo-google-fonts/inter/<weight>` subpath, so
|
|
569
|
+
a native bundle carries those four files and no other Inter face, and a web
|
|
570
|
+
bundle carries no Inter file at all. Icons
|
|
571
|
+
are SVG drawn from glyph data, so there is no icon font to load on any
|
|
357
572
|
platform. On web
|
|
358
573
|
it injects one Google Fonts Inter stylesheet (all four weights) after hydration
|
|
359
574
|
unless the app already added it; web weight differentiation then comes from a
|
|
@@ -366,10 +581,82 @@ Expo Router web apps, add the links to app-owned `app/+html.tsx`:
|
|
|
366
581
|
<link
|
|
367
582
|
id="mrmeg-expo-ui-inter"
|
|
368
583
|
rel="stylesheet"
|
|
369
|
-
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
|
|
584
|
+
href="https://fonts.googleapis.com/css2?family=Inter:ital,wght@0,400;0,500;0,600;0,700;1,400&display=swap"
|
|
370
585
|
/>
|
|
371
586
|
```
|
|
372
587
|
|
|
588
|
+
### Serif preset: Newsreader
|
|
589
|
+
|
|
590
|
+
Georgia is one face per platform, so `SerifText fontWeight="bold"` is a faux
|
|
591
|
+
bold and `italic` a synthesized slant. Newsreader is a real serif family —
|
|
592
|
+
four weights and a 400 italic — offered as an opt-in preset that costs an app
|
|
593
|
+
nothing unless it opts in: the package does not depend on the font package, so
|
|
594
|
+
the app adds it and hands the native files over.
|
|
595
|
+
|
|
596
|
+
```bash
|
|
597
|
+
bun add @expo-google-fonts/newsreader
|
|
598
|
+
```
|
|
599
|
+
|
|
600
|
+
```ts
|
|
601
|
+
// lib/newsreaderFonts.native.ts — per-weight subpaths, so the native bundle
|
|
602
|
+
// carries exactly these five files. The web twin exports `null`.
|
|
603
|
+
import { Newsreader_400Regular } from "@expo-google-fonts/newsreader/400Regular";
|
|
604
|
+
import { Newsreader_400Regular_Italic } from "@expo-google-fonts/newsreader/400Regular_Italic";
|
|
605
|
+
import { Newsreader_500Medium } from "@expo-google-fonts/newsreader/500Medium";
|
|
606
|
+
import { Newsreader_600SemiBold } from "@expo-google-fonts/newsreader/600SemiBold";
|
|
607
|
+
import { Newsreader_700Bold } from "@expo-google-fonts/newsreader/700Bold";
|
|
608
|
+
|
|
609
|
+
export const newsreaderFontMap = {
|
|
610
|
+
Newsreader_400Regular,
|
|
611
|
+
Newsreader_500Medium,
|
|
612
|
+
Newsreader_600SemiBold,
|
|
613
|
+
Newsreader_700Bold,
|
|
614
|
+
Newsreader_400Regular_Italic,
|
|
615
|
+
};
|
|
616
|
+
```
|
|
617
|
+
|
|
618
|
+
```tsx
|
|
619
|
+
// where the app already calls useResources
|
|
620
|
+
const { loaded } = useResources({ serif: "newsreader", serifFonts: newsreaderFontMap });
|
|
621
|
+
```
|
|
622
|
+
|
|
623
|
+
On native `useResources` registers the files and then sets the theme store's
|
|
624
|
+
`serifPreset` to `"newsreader"`, so serif text resolves to
|
|
625
|
+
`Newsreader_400Regular` … `Newsreader_700Bold` (`light` shares the 400 file)
|
|
626
|
+
and `italic` to `Newsreader_400Regular_Italic` at every weight. On web it
|
|
627
|
+
injects one Google Fonts stylesheet (`Newsreader:ital,wght@0,400;0,500;0,600;0,700;1,400`)
|
|
628
|
+
under the id `mrmeg-expo-ui-newsreader` — put an element with that id in
|
|
629
|
+
`+html.tsx` (the Google Fonts `<link>`, or self-hosted `@font-face` rules as
|
|
630
|
+
this template does from `@fontsource-variable/newsreader`) and the hook skips
|
|
631
|
+
its own — and switches the preset at once
|
|
632
|
+
(the stack falls back to Georgia until the faces arrive); weight is numeric on
|
|
633
|
+
the shared `"Newsreader"` family. A `setFonts` serif override wins over the
|
|
634
|
+
preset and skips its load. Without `serifFonts` on native the hook warns once
|
|
635
|
+
and keeps Georgia. The family names are exported as `newsreaderFamilies` from
|
|
636
|
+
`@mrmeg/expo-ui/constants`; `SerifPreset` and `useThemeStore().serifPreset` /
|
|
637
|
+
`setSerifPreset` are the store side.
|
|
638
|
+
|
|
639
|
+
### Italic faces through `setFonts`
|
|
640
|
+
|
|
641
|
+
Each family group in `setFonts` takes an `italic` map beside the weights:
|
|
642
|
+
|
|
643
|
+
```ts
|
|
644
|
+
useThemeStore.getState().setFonts({
|
|
645
|
+
families: {
|
|
646
|
+
sansSerif: {
|
|
647
|
+
regular: "Brand_Regular",
|
|
648
|
+
bold: "Brand_Bold",
|
|
649
|
+
italic: { regular: "Brand_Italic", bold: "Brand_BoldItalic" },
|
|
650
|
+
},
|
|
651
|
+
},
|
|
652
|
+
});
|
|
653
|
+
```
|
|
654
|
+
|
|
655
|
+
`italic` on `StyledText` (or `useFontStyle(weight, variant, { italic: true })`)
|
|
656
|
+
then uses the italic face for its weight, falls back to the group's italic
|
|
657
|
+
`regular` when that weight has none, and emits no `fontStyle`; a group with no
|
|
658
|
+
`italic` map synthesizes on its upright face.
|
|
659
|
+
|
|
373
660
|
### Font overrides
|
|
374
661
|
|
|
375
662
|
Forward your own faces with `setFonts`, once at startup after they are
|
|
@@ -431,7 +718,7 @@ building a new primitive.
|
|
|
431
718
|
| `Badge` | Short status labels | Draft/active state, counts, plan and role tags |
|
|
432
719
|
| `BottomSheet` | Mobile-first modal sheets | Action pickers, mobile filters, quick edit forms |
|
|
433
720
|
| `Button` | Commands and CTAs | Submit, save, delete, navigation CTAs; loading state keeps the resting width |
|
|
434
|
-
| `Card` |
|
|
721
|
+
| `Card` | One item in a collection, or a single tappable object; not a layout box | Feed entries, grid tiles, carousel slides, a tappable summary |
|
|
435
722
|
| `Carousel` | Horizontally snapping slides with pressable dots | Testimonials, onboarding pages, image galleries |
|
|
436
723
|
| `Checkbox` | Boolean selection | Terms consent, checklists, multi-select filters |
|
|
437
724
|
| `Collapsible` | One-off disclosure | Advanced settings, hidden helper text |
|
|
@@ -441,68 +728,137 @@ building a new primitive.
|
|
|
441
728
|
| `DropdownMenu` | Menus and command lists | Row actions, account menu, sort menu, checkbox/radio groups |
|
|
442
729
|
| `EmptyState` | No-data or recoverable error regions | Empty inbox, no results, failed list load |
|
|
443
730
|
| `ErrorBoundary` | React render error fallback | Route-level and feature-level fallbacks |
|
|
444
|
-
| `Icon` | Lucide or custom
|
|
731
|
+
| `Icon` | Registered Lucide glyphs or custom art on theme tokens | Button accessories, menu icons, status glyphs |
|
|
445
732
|
| `InputOTP` | Verification code entry | Email/SMS codes, MFA, invite codes |
|
|
446
733
|
| `Item` | List and settings rows on the density tokens | Settings lists, inbox rows, pickers, detail rows |
|
|
734
|
+
| `ItemGroup` | Flat grouped list: title, full-width `Item` rows with inset hairlines, footer | Settings sections, profile details, activity feeds |
|
|
447
735
|
| `KeyboardAvoidingView` | Native keyboard-aware layout | Screen roots, composer footers, form-heavy subtrees |
|
|
448
736
|
| `Label` | Accessible form labels | Required, disabled, and group labels |
|
|
449
|
-
| `MaxWidthContainer` | Centered responsive width | Web pages, tablet layouts, auth
|
|
737
|
+
| `MaxWidthContainer` | Centered responsive width | Web pages, tablet layouts, auth forms |
|
|
450
738
|
| `Notification` | Global toast surface | Saved/error/sync toasts, action toasts, loading toast |
|
|
739
|
+
| `Screen` | Page container: safe-area `edges`, one horizontal inset, theme background, optional scroll | Every screen root; under a header pass `edges={["bottom"]}` |
|
|
451
740
|
| `Popover` | Anchored contextual content | Inline help, quick previews, small forms |
|
|
452
741
|
| `Progress` | Determinate or indeterminate progress | Upload progress, onboarding completion |
|
|
453
742
|
| `RadioGroup` | Mutually exclusive choices | Plan interval, visibility choice, survey answer |
|
|
454
|
-
| `SectionHeader` | Eyebrow / title / description section intro | Landing sections,
|
|
743
|
+
| `SectionHeader` | Eyebrow / title / description section intro | Landing sections, report headers, empty-screen intros |
|
|
455
744
|
| `SegmentedControl` | Native segmented picker (`@expo/ui`) | Platform-native view switchers, iOS-style filters |
|
|
456
745
|
| `Select` | Option menus | Country, category, status pickers; `label` drives default item text |
|
|
457
|
-
| `Separator` | Horizontal or vertical dividers |
|
|
746
|
+
| `Separator` | Horizontal or vertical dividers | Section, menu, and toolbar dividers |
|
|
458
747
|
| `Skeleton` | Loading placeholders | List, profile card, dashboard loading |
|
|
459
748
|
| `Slider` | Numeric value selection (`@expo/ui`) | Volume, percentage, rating, threshold |
|
|
460
|
-
| `StatCard` | Metric tile: `label`, `value`, `unit`, `change`, `icon`, `onPress` | KPI
|
|
749
|
+
| `StatCard` | Metric tile: `label`, `value`, `unit`, `change`, `icon`, `onPress` | A scrolling KPI rail, one tappable metric |
|
|
461
750
|
| `StatusBar` | Theme-aware native status bar | Root layout status styling |
|
|
462
751
|
| `StyledText` and aliases | Theme-aware typography | Titles, labels, body copy, captions, translated text |
|
|
463
752
|
| `Switch` | Binary settings | Notification, privacy, and feature toggles |
|
|
464
753
|
| `Tabs` | In-page tabbed views | Profile sections, report views, settings categories |
|
|
465
754
|
| `TextInput` | Text entry with label, helper/error text, clear button, password reveal, numeric filtering, left/right elements | Email/password, search, multiline notes |
|
|
755
|
+
| `ThemeSelector` | Compact System / Light / Dark switch bound to the theme store (icon-only, or `showLabels`) | The appearance row of a settings screen |
|
|
466
756
|
| `Toggle` | Pressed/unpressed control | Favorite, mute, bold/italic, view mode |
|
|
467
757
|
| `ToggleGroup` | Single or multi toggle groups | Alignment, formatting toolbar, filter chips |
|
|
468
758
|
| `Tooltip` | Short hover/focus help | Icon button labels, field hints, disabled-action reasons |
|
|
469
759
|
|
|
470
760
|
### Icons
|
|
471
761
|
|
|
472
|
-
`Icon`
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
(`
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
762
|
+
`Icon` draws Lucide glyphs from plain data: each icon is a list of
|
|
763
|
+
`[tag, attrs]` SVG nodes on a 24×24 grid, stroked 2 px with round caps and
|
|
764
|
+
joins. The package owns both renderers: an inline `<svg>` on web and in
|
|
765
|
+
server-rendered HTML (stroked `currentColor` with the color as a style, so a
|
|
766
|
+
theme change recolors it through CSS and server and client markup match), and
|
|
767
|
+
`react-native-svg` on iOS and Android, drawn the way Lucide's own native
|
|
768
|
+
component draws it. No icon runtime or font ships, and each registered glyph
|
|
769
|
+
adds about 40 bytes (brotli) to the bundle.
|
|
770
|
+
|
|
771
|
+
`name` is typed by `IconName`: the 19 names the package's own components draw
|
|
772
|
+
(`src/components/icon-names.json`: the chevrons, `check`, `x`, the status and
|
|
773
|
+
theme-mode glyphs) plus every name the app registers. An app lists the Lucide
|
|
774
|
+
names it uses in `client/icons.json` and compiles them with the package's
|
|
775
|
+
`expo-ui-icons` CLI:
|
|
776
|
+
|
|
777
|
+
```json
|
|
778
|
+
{ "icons": ["archive", "bell", "compass"], "tabIcons": ["compass"] }
|
|
779
|
+
```
|
|
780
|
+
|
|
781
|
+
```sh
|
|
782
|
+
bunx expo-ui-icons init # seed client/icons.json from name="…" / icon="…" literals in app/, client/, shared/
|
|
783
|
+
bunx expo-ui-icons add calendar # add names; an alias is replaced by its canonical name
|
|
784
|
+
bunx expo-ui-icons # write client/icons.generated.ts (--check: exit 1 when stale)
|
|
785
|
+
bunx expo-ui-icons tabs # rasterize tabIcons to 1x/2x/3x template PNGs (--check never rasterizes)
|
|
786
|
+
```
|
|
787
|
+
|
|
788
|
+
The generated module exports the glyphs as `appIcons` and augments the
|
|
789
|
+
`IconRegistry` interface, so the new names type-check everywhere. Register them
|
|
790
|
+
once at module scope in the root layout, so they are in place before any route
|
|
791
|
+
renders, on the server as well as the client:
|
|
479
792
|
|
|
480
793
|
```tsx
|
|
481
|
-
import {
|
|
482
|
-
import
|
|
794
|
+
import { registerIcons } from "@mrmeg/expo-ui/components/Icon";
|
|
795
|
+
import { appIcons } from "@/client/icons.generated";
|
|
796
|
+
|
|
797
|
+
registerIcons(appIcons);
|
|
798
|
+
```
|
|
799
|
+
|
|
800
|
+
An unregistered literal is a type error. A name that arrives at runtime (a
|
|
801
|
+
database field, a server payload) renders nothing and warns once in
|
|
802
|
+
development; check it with `isIconName(value)`, which narrows to `IconName`.
|
|
803
|
+
The CLI rejects a name that is not in the snapshot, and an alias with its
|
|
804
|
+
canonical name (`home → house`); the alias map also ships in
|
|
805
|
+
`design-system.json` as `icons.aliases`. Brand glyphs (`github`, `chrome`,
|
|
806
|
+
`facebook`, `instagram`, `linkedin`, `twitter`) have no Lucide equivalent.
|
|
807
|
+
|
|
808
|
+
`tabs` writes `assets/icons/tabs/<name>.png` (plus `@2x` and `@3x`) and
|
|
809
|
+
`client/tabIcons.generated.ts` (`TAB_ICONS`, `TabIconName`) for a native tab
|
|
810
|
+
bar: `<NativeTabs.Trigger.Icon src={TAB_ICONS.compass} renderingMode="template" />`.
|
|
811
|
+
The PNGs are black on transparent and the platform tints them. Freshness is a
|
|
812
|
+
hash of each glyph's SVG in `assets/icons/tabs/manifest.json`, so `tabs --check`
|
|
813
|
+
and an up-to-date `tabs` need no rasterizer; only a changed tab icon needs
|
|
814
|
+
`rsvg-convert` (`brew install librsvg`, or point `RSVG_CONVERT` at it).
|
|
815
|
+
|
|
816
|
+
`color` takes a theme color name or a literal. `accessibilityLabel` names an
|
|
817
|
+
icon that stands alone (`role="img"` + `aria-label` on web, `accessible` +
|
|
818
|
+
`accessibilityLabel` on native); an icon without one is hidden from assistive
|
|
819
|
+
tech — the glyph beside text that already says it would otherwise be announced
|
|
820
|
+
as an unnamed image — and `decorative` only remains to silence a labelled icon.
|
|
821
|
+
On web the SVG carries ARIA only (`aria-hidden` or `role="img"`), never the
|
|
822
|
+
RN-only accessibility props. A named icon's SVG root takes `testID`, which
|
|
823
|
+
defaults to `icon-<name>` in development. Instead of `name`, `Icon` and
|
|
824
|
+
`Button.Icon` take `component` (an `IconComponent`) for art that is not a
|
|
825
|
+
Lucide icon, such as a brand mark: any component that accepts `size` and
|
|
826
|
+
`color`, sized, colored, themed, and hidden from assistive tech exactly like a
|
|
827
|
+
named icon. `Button.Icon`, `ToggleIcon`, and `ToggleGroupIcon` take the same
|
|
828
|
+
props, default their color to the control's label color, and are decorative.
|
|
829
|
+
|
|
830
|
+
```tsx
|
|
831
|
+
import { Button, Icon } from "@mrmeg/expo-ui/components";
|
|
832
|
+
import { GitHubMark } from "@/client/components/GitHubMark";
|
|
483
833
|
|
|
484
834
|
<Icon name="circle-check-big" color="success" size={16} />
|
|
485
|
-
<Icon component={
|
|
835
|
+
<Icon component={GitHubMark} color="accent" />
|
|
836
|
+
<Button onPress={goHome}>
|
|
837
|
+
<Button.Icon name="house" />
|
|
838
|
+
<Button.Text>Home</Button.Text>
|
|
839
|
+
</Button>
|
|
486
840
|
```
|
|
487
841
|
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
`
|
|
504
|
-
|
|
505
|
-
|
|
842
|
+
The Lucide snapshot ships as `dist/icons/lucide.json` (Lucide 1.55.0: every
|
|
843
|
+
icon plus the alias map) and the CLI compiles against it, so adding a name needs
|
|
844
|
+
no package release. Lucide renames about one name in six minor releases and
|
|
845
|
+
sometimes redraws a glyph under a stable name, so the snapshot is pinned
|
|
846
|
+
exactly and moves only in a package release: after upgrading, run
|
|
847
|
+
`bunx expo-ui-icons` and `bunx expo-ui-icons tabs`, review the glyph diff, and
|
|
848
|
+
replace any name the CLI now rejects. Lucide is ISC-licensed (its
|
|
849
|
+
Feather-derived icons MIT); the license ships as `dist/icons/LICENSE.lucide`.
|
|
850
|
+
|
|
851
|
+
Tests need no icon mock and no Lucide module mapping: glyphs are plain objects
|
|
852
|
+
drawn with `react-native-svg`. Register `appIcons` in the Jest setup (lazily, in
|
|
853
|
+
a `beforeAll`, so test-file mocks still apply to `Icon`) and query a named icon
|
|
854
|
+
by its `icon-<name>` test id.
|
|
855
|
+
|
|
856
|
+
In this repository, `bun run ui:icons` regenerates `iconBuiltins.generated.ts`,
|
|
857
|
+
`src/icons/lucide.json`, and `src/icons/LICENSE.lucide` from the exactly pinned
|
|
858
|
+
`lucide-static` devDependency and `icon-names.json`. A Lucide bump changes that
|
|
859
|
+
pin, then `bun install`, `bun run ui:icons`, `bun run icons`, and
|
|
860
|
+
`bun run icons:tabs`. The template app's own list is `client/icons.json`
|
|
861
|
+
(`bun run icons`, `bun run icons:tabs`).
|
|
506
862
|
|
|
507
863
|
### Compound Parts
|
|
508
864
|
|
|
@@ -518,7 +874,7 @@ Lucide equivalent; use `component` with your own SVG.
|
|
|
518
874
|
| `Dialog` | `DialogTrigger`, `DialogContent`, `DialogHeader`, `DialogFooter`, `DialogTitle`, `DialogDescription`, `DialogClose` |
|
|
519
875
|
| `Drawer` | `DrawerTrigger`, `DrawerContent`, `DrawerHeader`, `DrawerBody`, `DrawerFooter`, `DrawerClose`, `DrawerToggleCollapse` |
|
|
520
876
|
| `DropdownMenu` | `DropdownMenuTrigger`, `DropdownMenuContent`, `DropdownMenuGroup`, `DropdownMenuItem`, `DropdownMenuCheckboxItem`, `DropdownMenuRadioGroup`, `DropdownMenuRadioItem`, `DropdownMenuLabel`, `DropdownMenuSeparator`, `DropdownMenuShortcut`, `DropdownMenuPortal`, `DropdownMenuSub`, `DropdownMenuSubTrigger`, `DropdownMenuSubContent` |
|
|
521
|
-
| `Item` | `ItemMedia`, `ItemContent`, `ItemTitle`, `ItemDescription`, `ItemActions` |
|
|
877
|
+
| `Item` | `ItemGroup`, `ItemMedia`, `ItemContent`, `ItemTitle`, `ItemDescription`, `ItemActions` |
|
|
522
878
|
| `Popover` | `PopoverTrigger`, `PopoverContent`, `PopoverHeader`, `PopoverBody`, `PopoverFooter` |
|
|
523
879
|
| `RadioGroup` | `RadioGroupItem` |
|
|
524
880
|
| `Select` | `SelectTrigger`, `SelectValue`, `SelectContent`, `SelectItem`, `SelectGroup`, `SelectLabel`, `SelectSeparator` |
|
|
@@ -538,25 +894,34 @@ are named exports only.
|
|
|
538
894
|
|
|
539
895
|
| Hook | Returns |
|
|
540
896
|
|------|---------|
|
|
541
|
-
| `useResources()` | `{ loaded, error }` — loads the Inter faces (icons are SVG, nothing to load) |
|
|
897
|
+
| `useResources()` | `{ loaded, error }` — loads the Inter faces (icons are SVG drawn from glyph data, nothing to load) |
|
|
542
898
|
| `useTheme()` | Active theme, scheme helpers, shadow/contrast/alpha helpers |
|
|
543
899
|
| `useStyles(factory)` | Memoized theme-aware styles plus the `useTheme()` helpers |
|
|
544
900
|
| `useDimensions()` | `{ width, height, orientation, isSmallScreen, isMediumScreen, isLargeScreen }` against `SCREEN_SIZES` (768 / 1000 / 1200) |
|
|
545
901
|
| `useFontStyle(weight?, variant?)` | `{ fontFamily, fontWeight? }` resolved through `setFonts` overrides |
|
|
546
902
|
| `useReducedMotion()` | `true` when the OS asks for reduced motion |
|
|
903
|
+
| `useFocusVisible(options?)` | `{ focused, onFocus, onBlur }` for a `Pressable`; on web `focused` follows `:focus-visible` (keyboard only), so layer `getFocusRingStyle()` while it is true — the gate every kit control uses |
|
|
547
904
|
| `useScalePress(options?)` | Animated style plus `onPressIn`/`onPressOut` for a `Pressable` (`scaleTo`, `haptic`, `disabled`) |
|
|
548
905
|
| `useStaggeredEntrance(options?)` | Entrance animated style for list rows (`delay`; `STAGGER_DELAY` is 30) |
|
|
549
906
|
|
|
550
907
|
`SsrViewportContext` (from `state`) supplies the first-render viewport width on
|
|
551
|
-
web, where the window cannot be read during export or hydration.
|
|
908
|
+
web, where the window cannot be read during export or hydration. Right after
|
|
909
|
+
hydration `useDimensions()` switches to the real window. On web every consumer
|
|
910
|
+
shares one `resize` listener and one snapshot, re-renders only when the width or
|
|
911
|
+
height changes, and a component that mounts later reads the current viewport
|
|
912
|
+
straight away. The `mrmeg-vw` cookie (the width a server can seed the next
|
|
913
|
+
render from) is written once per page view and then once resizing settles.
|
|
552
914
|
|
|
553
915
|
### Patterns And Gotchas
|
|
554
916
|
|
|
555
917
|
- Use `Button.preset`, not `variant`: `default` is the neutral primary action,
|
|
556
918
|
`secondary` a neutral secondary surface, `outline` lower emphasis, `ghost`
|
|
557
919
|
compact toolbars, `link` text-like commands, `destructive` dangerous actions.
|
|
558
|
-
|
|
559
|
-
|
|
920
|
+
Every platform gives a Button a 44pt vertical target: native through computed
|
|
921
|
+
`hitSlop`, web through a transparent hit extender inside the drawn 28/32/40pt
|
|
922
|
+
box, so the visual size, layout and popover anchoring do not change; a caller
|
|
923
|
+
`hitSlop` replaces both (on web it is honoured too). Nested `StyledText`
|
|
924
|
+
inherits the Button size, so use `size="sm"` for popover, tooltip, and toolbar
|
|
560
925
|
triggers.
|
|
561
926
|
- A plain `View` whose `opacity` or `pointerEvents` follows state (`disabled`,
|
|
562
927
|
`loading`, `checked`, `editable`) must be `collapsable={false}` on Android.
|
|
@@ -601,6 +966,18 @@ web, where the window cannot be read during export or hydration.
|
|
|
601
966
|
rebuild; the sheet warns once in development on Android when the native core
|
|
602
967
|
is older. Web has none. Do not wrap sheet content in another
|
|
603
968
|
`KeyboardAvoidingView`: it would lift twice.
|
|
969
|
+
`onDismissed` (root prop) fires once per close after the sheet is fully gone;
|
|
970
|
+
present the next modal — a `Dialog` (an RN `Modal` on iOS), a native stack
|
|
971
|
+
modal — from it, not from `onOpenChange(false)` or a timer: on iOS the sheet
|
|
972
|
+
is still dismissing when `onOpenChange` fires and UIKit rejects a `Modal`
|
|
973
|
+
presented then ("already presenting") with no retry. Detection per platform:
|
|
974
|
+
iOS uses `@expo/ui`'s native `onDismiss` event, raised by SwiftUI
|
|
975
|
+
`.sheet(isPresented:onDismiss:)` after the dismissal transition; Android uses
|
|
976
|
+
`@expo/ui`'s close callback, raised after Material's hide animation for a
|
|
977
|
+
swipe / back / scrim dismissal and with the removal of the Compose sheet for a
|
|
978
|
+
prop-driven close (which has no exit animation); web derives it from the HTML
|
|
979
|
+
`<dialog>`'s `close` event, which `@expo/ui` raises when its exit animation
|
|
980
|
+
ends (immediately under reduced motion).
|
|
604
981
|
The sheet hosts its content in a separate
|
|
605
982
|
native window outside the app's `DismissKeyboard`, so `BottomSheet.Content`
|
|
606
983
|
mounts its own tap-away keyboard-dismiss boundary on the content column: it
|
|
@@ -647,16 +1024,41 @@ web, where the window cannot be read during export or hydration.
|
|
|
647
1024
|
not put `@expo/ui`-hosted controls inside them yet, and do not place a
|
|
648
1025
|
`Dialog` inside their content either — it has no view controller to present
|
|
649
1026
|
from there; render it at screen level and open it from the item's
|
|
650
|
-
`onPress`.
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
`
|
|
655
|
-
|
|
1027
|
+
`onPress`. Dialog content sits outside `UIProvider`'s root keyboard
|
|
1028
|
+
avoidance on both native platforms (the iOS Modal is presented outside it;
|
|
1029
|
+
the Android portal host is a sibling of it), so the dialog owns keyboard
|
|
1030
|
+
avoidance on iOS and Android: the centered container is a package
|
|
1031
|
+
`KeyboardAvoidingView` (`behavior="padding"`), the card recenters above the
|
|
1032
|
+
keyboard with its fields and footer visible, and `useKeyboardAvoidance()` is
|
|
1033
|
+
`true` inside dialog content. Do not wrap dialog content in another
|
|
1034
|
+
`KeyboardAvoidingView`. Web has no software keyboard and no avoidance owner
|
|
1035
|
+
(`useKeyboardAvoidance()` is `false` there). Dialog content also owns tap-away
|
|
1036
|
+
keyboard dismissal on iOS and Android with the same boundary as
|
|
1037
|
+
`DismissKeyboard` and `BottomSheet.Content`: `Dialog` carries it on the card
|
|
1038
|
+
itself (the primitive's native content claims every touch inside the card so
|
|
1039
|
+
the backdrop's close-on-press never fires there, and that claim would stop a
|
|
1040
|
+
boundary on any ancestor from ever being asked), `AlertDialog` on its
|
|
1041
|
+
centered container. A tap on the card's dead space (padding, labels, the gap
|
|
1042
|
+
between fields and footer) hides the keyboard on release, a `Dialog` backdrop
|
|
1043
|
+
tap closes the dialog and the keyboard with it, and `Close`, `Action`,
|
|
1044
|
+
`Cancel`, buttons and fields still fire on the first tap; no
|
|
1045
|
+
`DismissKeyboard` is needed inside a dialog (inert on web, which has no
|
|
1046
|
+
software keyboard). The platform close request
|
|
656
1047
|
(hardware back, TV menu) routes to the root's `onOpenChange(false)`. Android
|
|
657
|
-
and web render dialog content inline into the portal host
|
|
658
|
-
|
|
659
|
-
|
|
1048
|
+
and web render dialog content inline into the portal host.
|
|
1049
|
+
- `PopoverContent` treats `side` as a preference. On iOS and Android it opens
|
|
1050
|
+
on the other side when the content does not fit and there is more room
|
|
1051
|
+
there, caps its height to the room it gets, and scrolls the children inside
|
|
1052
|
+
that cap; `insets` default to the safe area, so the card stays clear of the
|
|
1053
|
+
status bar and home indicator. Pass `scrollable={false}` when the content
|
|
1054
|
+
brings its own `FlatList` (a VirtualizedList inside a vertical ScrollView
|
|
1055
|
+
warns): the cap still applies, but the popover can no longer measure its full
|
|
1056
|
+
height, so it stays on `side`. A `style` merges over the themed surface
|
|
1057
|
+
(background, border, radius, shadow) instead of replacing it. On web, Radix
|
|
1058
|
+
flips it and the card is capped to the space Radix measures; the primitive
|
|
1059
|
+
cannot pass Radix a collision padding, so a card wider than the room beside
|
|
1060
|
+
its trigger can sit flush against the viewport edge. A `PopoverTrigger` ref
|
|
1061
|
+
(`PopoverTriggerRef`) exposes `open()` and `close()`.
|
|
660
1062
|
- `Carousel` renders every child (no virtualization), so slides survive into
|
|
661
1063
|
the exported HTML shell and the first client frame; use `FlatList` for large
|
|
662
1064
|
or unbounded data. An `itemWidth` below 1 (default `0.85`) is a fraction of
|
|
@@ -684,7 +1086,7 @@ import { Drawer, Icon } from "@mrmeg/expo-ui/components";
|
|
|
684
1086
|
title="Acme"
|
|
685
1087
|
action={
|
|
686
1088
|
<Drawer.ToggleCollapse>
|
|
687
|
-
<Icon name="
|
|
1089
|
+
<Icon name="panel-left" decorative />
|
|
688
1090
|
</Drawer.ToggleCollapse>
|
|
689
1091
|
}
|
|
690
1092
|
/>
|
|
@@ -693,19 +1095,39 @@ import { Drawer, Icon } from "@mrmeg/expo-ui/components";
|
|
|
693
1095
|
### Quick Examples
|
|
694
1096
|
|
|
695
1097
|
```tsx
|
|
696
|
-
import {
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
1098
|
+
import {
|
|
1099
|
+
Badge,
|
|
1100
|
+
Icon,
|
|
1101
|
+
Item,
|
|
1102
|
+
ItemActions,
|
|
1103
|
+
ItemContent,
|
|
1104
|
+
ItemDescription,
|
|
1105
|
+
ItemGroup,
|
|
1106
|
+
ItemMedia,
|
|
1107
|
+
ItemTitle,
|
|
1108
|
+
} from "@mrmeg/expo-ui/components";
|
|
1109
|
+
|
|
1110
|
+
<ItemGroup title="Subscription" footer="Renews on the 1st of each month.">
|
|
1111
|
+
<Item>
|
|
1112
|
+
<ItemMedia icon="award" />
|
|
1113
|
+
<ItemContent>
|
|
1114
|
+
<ItemTitle>Pro plan</ItemTitle>
|
|
1115
|
+
<ItemDescription>$12 / month</ItemDescription>
|
|
1116
|
+
</ItemContent>
|
|
1117
|
+
<ItemActions>
|
|
1118
|
+
<Badge variant="secondary">Active</Badge>
|
|
1119
|
+
</ItemActions>
|
|
1120
|
+
</Item>
|
|
1121
|
+
<Item onPress={openBillingPortal}>
|
|
1122
|
+
<ItemMedia icon="credit-card" />
|
|
1123
|
+
<ItemContent>
|
|
1124
|
+
<ItemTitle>Manage billing</ItemTitle>
|
|
1125
|
+
</ItemContent>
|
|
1126
|
+
<ItemActions>
|
|
1127
|
+
<Icon name="chevron-right" size={18} color="mutedForeground" />
|
|
1128
|
+
</ItemActions>
|
|
1129
|
+
</Item>
|
|
1130
|
+
</ItemGroup>
|
|
709
1131
|
```
|
|
710
1132
|
|
|
711
1133
|
```tsx
|
|
@@ -747,23 +1169,45 @@ after 4s (`DEFAULT_NOTIFICATION_DURATION`) unless a `duration` is given;
|
|
|
747
1169
|
auto-dismisses. `position` is `"top"` (default) or `"bottom"`. `globalUIStore`
|
|
748
1170
|
stays available for reactive selectors and tests.
|
|
749
1171
|
|
|
1172
|
+
A bottom toast renders in the root layout, above every navigator, so a layout
|
|
1173
|
+
that owns a bar registers where it ends with `useNotificationOffset` from
|
|
1174
|
+
`@mrmeg/expo-ui/hooks`; the toast then sits 8 pt past the larger of the
|
|
1175
|
+
safe-area inset and the offset. The offset is measured from the window edge and
|
|
1176
|
+
includes the inset, so a JS tab layout passes `useBottomTabBarHeight()` as-is
|
|
1177
|
+
and a `NativeTabs` layout passes the platform bar height plus
|
|
1178
|
+
`useWindowInsets().bottom`. Pass `null` while the bar is hidden (keyboard up).
|
|
1179
|
+
|
|
1180
|
+
```tsx
|
|
1181
|
+
import { Tabs } from "expo-router";
|
|
1182
|
+
import { useBottomTabBarHeight } from "@react-navigation/bottom-tabs";
|
|
1183
|
+
import { useNotificationOffset } from "@mrmeg/expo-ui/hooks";
|
|
1184
|
+
|
|
1185
|
+
function ToastOffset() {
|
|
1186
|
+
useNotificationOffset({ bottom: useBottomTabBarHeight() });
|
|
1187
|
+
return null;
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
// Render <ToastOffset /> inside a tab screen (it must be a descendant of <Tabs>).
|
|
1191
|
+
```
|
|
1192
|
+
|
|
750
1193
|
## Package Release
|
|
751
1194
|
|
|
752
1195
|
```sh
|
|
753
|
-
bun run ui
|
|
1196
|
+
bun run pkg ui release -- --patch --publish
|
|
754
1197
|
```
|
|
755
1198
|
|
|
756
|
-
Use `--patch`, `--minor`, `--major`, or an exact version such as `0.2.0
|
|
757
|
-
|
|
758
|
-
`bun run packages:peer-check`
|
|
759
|
-
`ui
|
|
760
|
-
`
|
|
761
|
-
|
|
762
|
-
|
|
1199
|
+
Use `--patch`, `--minor`, `--major`, or an exact version such as `0.2.0` (the
|
|
1200
|
+
committed version itself releases without a bump). The command updates
|
|
1201
|
+
`packages/ui/package.json` and `bun.lock`, runs `bun run packages:peer-check`
|
|
1202
|
+
then `bun run pkg ui typecheck`, `test`, and `build`, packs one tarball, runs
|
|
1203
|
+
`bun run pkg ui consumer-smoke -- --tarball <that tarball>`, and publishes that
|
|
1204
|
+
same tarball with `npm publish <tarball> --access public` only when
|
|
1205
|
+
`--publish` is present (which also requires a working `npm whoami`). Without
|
|
1206
|
+
`--publish` it is the same bump and gate run as a dry run.
|
|
763
1207
|
|
|
764
1208
|
A clean working tree is required; commit first or pass `--allow-dirty`.
|
|
765
1209
|
|
|
766
|
-
CI also installs and exports packed consumers against Expo 56 and
|
|
1210
|
+
CI also installs and exports packed consumers against Expo 56, 57, and 58
|
|
767
1211
|
(`.github/workflows/package-compatibility.yml`).
|
|
768
1212
|
|
|
769
1213
|
### GitHub Publishing
|
|
@@ -773,18 +1217,20 @@ publishing:
|
|
|
773
1217
|
|
|
774
1218
|
1. In npm package settings for `@mrmeg/expo-ui`, add a trusted publisher:
|
|
775
1219
|
GitHub Actions, owner `mrmeg`, repository `expo-template`, workflow filename
|
|
776
|
-
`publish-
|
|
1220
|
+
`publish-packages.yml`.
|
|
777
1221
|
2. Bump `packages/ui/package.json` in a commit and push it to `main`.
|
|
778
1222
|
|
|
779
|
-
`publish-
|
|
780
|
-
and on `workflow_dispatch`, using
|
|
781
|
-
local npm login. On push it
|
|
782
|
-
version
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
`
|
|
1223
|
+
`publish-packages.yml` publishes every workspace package. It runs on a push to
|
|
1224
|
+
`main` that changes a `packages/*/package.json` and on `workflow_dispatch`, using
|
|
1225
|
+
npm OIDC rather than a checked-in token or local npm login. On push it releases
|
|
1226
|
+
the committed version when that version changed in the push and npm does not
|
|
1227
|
+
have it yet, through the same release script as the local command, then
|
|
1228
|
+
publishes the smoked tarball with provenance and tags `expo-ui-v<version>`.
|
|
1229
|
+
Manual runs take `package=ui`, `version` (`patch`, `minor`, `major`, or exact
|
|
1230
|
+
`x.y.z`), and `ref` (default `main`); they bump the version and commit it to
|
|
1231
|
+
`ref`, run the gates, publish, and tag. If a manual run fails after the bump
|
|
1232
|
+
landed, rerun it with the exact current version (e.g. `version=0.1.3`) — the
|
|
1233
|
+
committed version does not bump again.
|
|
788
1234
|
|
|
789
1235
|
Keep `repository.url` in `package.json` as
|
|
790
1236
|
`git+https://github.com/mrmeg/expo-template.git`: npm trusted publishing checks
|
|
@@ -799,31 +1245,40 @@ CI configuration, never in the repository.
|
|
|
799
1245
|
|
|
800
1246
|
```sh
|
|
801
1247
|
bun run packages:peer-check
|
|
802
|
-
bun run ui
|
|
803
|
-
bun run ui
|
|
804
|
-
bun run ui
|
|
805
|
-
bun run ui
|
|
806
|
-
bun run ui
|
|
1248
|
+
bun run pkg ui typecheck
|
|
1249
|
+
bun run pkg ui test
|
|
1250
|
+
bun run pkg ui build
|
|
1251
|
+
bun run pkg ui pack
|
|
1252
|
+
bun run pkg ui consumer-smoke
|
|
807
1253
|
```
|
|
808
1254
|
|
|
809
|
-
`ui
|
|
810
|
-
inspected before release. `ui
|
|
811
|
-
a clean fixture, checks every documented export-map target resolves,
|
|
812
|
-
type-checks all public entrypoints,
|
|
813
|
-
|
|
1255
|
+
`pkg ui pack` is a dry pack, so the published file list and package size can be
|
|
1256
|
+
inspected before release. `pkg ui consumer-smoke` installs the packed tarball
|
|
1257
|
+
into a clean fixture, checks every documented export-map target resolves,
|
|
1258
|
+
type-checks all public entrypoints, imports the Node-safe subpaths
|
|
1259
|
+
(`constants/spacing`, `constants/motion`, `components/keyboardFocusRegistry`,
|
|
1260
|
+
`state/notify` with `state/globalUIStore`, `state/SsrViewportContext`) in plain
|
|
1261
|
+
Node, and runs iOS, Android, and web `expo export`s against the packed package
|
|
1262
|
+
at the workspace's Expo version, without a custom Metro config. The web export's
|
|
1263
|
+
source maps must show one copy of React, react-dom, react-native-web, and every
|
|
1264
|
+
`@radix-ui/*` / `@rn-primitives/*` module: a consumer install that resolves two
|
|
1265
|
+
copies of `@radix-ui/react-slot` crashes the web `AlertDialog` with
|
|
1266
|
+
`React.Children.only expected to receive a single React element child`. Pass
|
|
1267
|
+
`-- --tarball <file>` to smoke an existing tarball instead of building one.
|
|
814
1268
|
|
|
815
1269
|
### Design-system manifest
|
|
816
1270
|
|
|
817
|
-
`ui
|
|
1271
|
+
`pkg ui build` also writes `dist/design-system.json`
|
|
818
1272
|
([`scripts/build-design-system-manifest.mjs`](../../scripts/build-design-system-manifest.mjs)):
|
|
819
1273
|
the spacing, radius and icon scales, the palette, the light and dark themes, the
|
|
820
|
-
font variants, every component's variant and size values, and `icons
|
|
821
|
-
`IconName`
|
|
822
|
-
|
|
1274
|
+
font variants, every component's variant and size values, and `icons` (the
|
|
1275
|
+
builtin `IconName` list as `names`, Lucide's renamed-name `aliases`, and the
|
|
1276
|
+
snapshot's `lucide` version), serialized out of `src` by the same extractor the
|
|
1277
|
+
lint rules use. It is exported as
|
|
823
1278
|
`@mrmeg/expo-ui/design-system.json`, and `@mrmeg/eslint-plugin-expo-ui` reads it
|
|
824
1279
|
in projects that install this package instead of checking out its sources — which
|
|
825
1280
|
is what lets the design-system rules quote this release's presets and tokens.
|
|
826
|
-
`ui
|
|
1281
|
+
`pkg ui consumer-smoke` asserts the packed tarball carries it and that it parses at
|
|
827
1282
|
`schemaVersion: 1` with a non-empty component list. The build fails rather than
|
|
828
1283
|
writing an empty manifest, so a broken extractor cannot ship as "no rules to
|
|
829
1284
|
enforce".
|