@mrmeg/expo-ui 0.27.0 → 0.28.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 +712 -0
- package/LLM_USAGE.md +260 -32
- package/README.md +558 -101
- 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 +66 -9
- package/dist/components/BottomSheet.js +143 -39
- package/dist/components/Button.d.ts +20 -4
- package/dist/components/Button.js +64 -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 +178 -29
- 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 +17 -5
- package/dist/components/Icon.js +31 -10
- package/dist/components/InputOTP.js +7 -3
- 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 +74 -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 +23 -32
- 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 +73 -27
- package/dist/components/Toggle.d.ts +2 -42
- package/dist/components/Toggle.js +38 -27
- package/dist/components/ToggleGroup.d.ts +1 -1
- package/dist/components/ToggleGroup.js +37 -32
- 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/bottomSheetHostSupport.d.ts +46 -0
- package/dist/components/bottomSheetHostSupport.js +67 -0
- package/dist/components/index.d.ts +46 -44
- package/dist/components/index.js +2 -0
- package/dist/components/keyboardDismiss.d.ts +25 -0
- package/dist/components/keyboardDismiss.js +60 -3
- 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 +154 -2
- 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/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 -4
- package/dist/lib/index.js +2 -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/stateSurface.d.ts +28 -0
- package/dist/lib/stateSurface.js +30 -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 +56 -8
- package/llms.txt +10 -1
- package/package.json +69 -2
package/README.md
CHANGED
|
@@ -59,6 +59,14 @@ Importable paths: root, `components`, `components/*`, `constants`,
|
|
|
59
59
|
`constants/*`, `hooks`, `hooks/*`, `state`, `state/*`, `lib`. Never import from
|
|
60
60
|
`dist/*` or a source checkout path.
|
|
61
61
|
|
|
62
|
+
The package declares `sideEffects`: only `state/themeStore` does work at import
|
|
63
|
+
time (on native it loads the saved theme and starts the OS color-scheme
|
|
64
|
+
listener). A bundler that tree-shakes (Expo with `EXPO_UNSTABLE_TREE_SHAKING=1`,
|
|
65
|
+
esbuild, Rollup, webpack) keeps only the modules a barrel import actually uses,
|
|
66
|
+
so `import { Button } from "@mrmeg/expo-ui"` costs about what the
|
|
67
|
+
`components/Button` deep import does. Metro without tree shaking bundles
|
|
68
|
+
everything a file imports, so the deep imports stay the smaller choice there.
|
|
69
|
+
|
|
62
70
|
## App Startup
|
|
63
71
|
|
|
64
72
|
Call `useResources()` once near the Expo app root before hiding the splash
|
|
@@ -97,6 +105,21 @@ export default function RootLayout() {
|
|
|
97
105
|
}
|
|
98
106
|
```
|
|
99
107
|
|
|
108
|
+
On web with server rendering, tell the kit the visitor's scheme for the first
|
|
109
|
+
render: `<InitialSchemeProvider scheme={cookieScheme}>` around the root layout
|
|
110
|
+
(or `<UIProvider initialScheme>` when nothing above it reads the theme), where
|
|
111
|
+
`cookieScheme` is `"light"` / `"dark"` read from the same cookie on the server
|
|
112
|
+
and the client — see the template's `shared/ssrColorScheme.ts`. `useTheme()`
|
|
113
|
+
paints it until the persisted preference has loaded.
|
|
114
|
+
|
|
115
|
+
A Radix-backed component (`Tabs`, `Accordion`, `Collapsible`, `Select`,
|
|
116
|
+
`DropdownMenu`, `Popover`) inside a server-rendered route logs a `useId`
|
|
117
|
+
hydration mismatch in development, because Expo Router's server renders the app
|
|
118
|
+
inside `+html.tsx` while the client hydrates `#root`. Wrap that subtree in
|
|
119
|
+
`<Hydrated fallback={…}>` (from `@mrmeg/expo-ui/components`): the fallback holds
|
|
120
|
+
the layout in the server HTML and the children mount in the first client render.
|
|
121
|
+
Keep content that must be server-rendered outside the gate.
|
|
122
|
+
|
|
100
123
|
`UIProvider` is required before `Dialog`, `AlertDialog`, `BottomSheet`,
|
|
101
124
|
`Drawer`, `DropdownMenu`, `Popover`, `SelectContent`, `Tooltip`, and package
|
|
102
125
|
notifications.
|
|
@@ -113,6 +136,45 @@ For a subtree with custom keyboard behavior, use `KeyboardAvoidingView`
|
|
|
113
136
|
directly (`behavior`, `automaticOffset`, `contentContainerStyle`,
|
|
114
137
|
`keyboardVerticalOffset`).
|
|
115
138
|
|
|
139
|
+
### Web setup
|
|
140
|
+
|
|
141
|
+
Web needs two more pieces, because every web theme color is a CSS variable
|
|
142
|
+
(see [Web theming is CSS variables](#web-theming-is-css-variables)) and the
|
|
143
|
+
theme store boots at `"system"`/light so the first client render matches the
|
|
144
|
+
server-rendered or exported HTML:
|
|
145
|
+
|
|
146
|
+
1. **`app/+html.tsx`: define the variables.** Put `getThemeCssVariables()` (from
|
|
147
|
+
`constants`) in the document's global `<style>`, passing the same overrides
|
|
148
|
+
you give `setColors` if you re-brand. Without it every `var(--c-*)` color is
|
|
149
|
+
unset. A blocking inline script that stamps `data-theme` on `<html>` from
|
|
150
|
+
the persisted preference (`THEME_STORAGE_KEY`, from `state`) or
|
|
151
|
+
`prefers-color-scheme` makes the first frame paint in the visitor's scheme
|
|
152
|
+
before any JS runs.
|
|
153
|
+
2. **Root layout: sync the store after the first commit.** Call
|
|
154
|
+
`syncThemeFromEnvironment()` (from `state`) in a top-level `useEffect` and
|
|
155
|
+
return its result as the cleanup. It reads the persisted preference and
|
|
156
|
+
starts following the OS color scheme. Never call it during render or at
|
|
157
|
+
module scope: the server has no `window`, and reading it while hydrating
|
|
158
|
+
would disagree with the markup. It is safe to call more than once
|
|
159
|
+
(StrictMode's double effects, several roots): the calls share one OS
|
|
160
|
+
listener, and each cleanup releases only its own call. `UIProvider` does not
|
|
161
|
+
call it. A web app that skips it stays on the boot default until the user
|
|
162
|
+
picks a theme. Native loads the preference and follows the OS at startup, so
|
|
163
|
+
the call is optional there.
|
|
164
|
+
|
|
165
|
+
```tsx
|
|
166
|
+
// app/+html.tsx (head)
|
|
167
|
+
import { getThemeCssVariables } from "@mrmeg/expo-ui/constants";
|
|
168
|
+
|
|
169
|
+
<style>{`${getThemeCssVariables()} body { background-color: var(--c-background); }`}</style>
|
|
170
|
+
|
|
171
|
+
// Root layout
|
|
172
|
+
import { useEffect } from "react";
|
|
173
|
+
import { syncThemeFromEnvironment } from "@mrmeg/expo-ui/state";
|
|
174
|
+
|
|
175
|
+
useEffect(() => syncThemeFromEnvironment(), []);
|
|
176
|
+
```
|
|
177
|
+
|
|
116
178
|
### Keyboard dismissal
|
|
117
179
|
|
|
118
180
|
`DismissKeyboard` owns tap-away dismissal without claiming touches. Its scroll
|
|
@@ -135,34 +197,35 @@ import { StyledText } from "@mrmeg/expo-ui/components";
|
|
|
135
197
|
import { spacing } from "@mrmeg/expo-ui/constants";
|
|
136
198
|
import { useTheme } from "@mrmeg/expo-ui/hooks";
|
|
137
199
|
|
|
138
|
-
export function
|
|
139
|
-
const { theme
|
|
200
|
+
export function Intro() {
|
|
201
|
+
const { theme } = useTheme();
|
|
140
202
|
|
|
141
203
|
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>
|
|
204
|
+
<View style={{ paddingHorizontal: spacing.screenPadding, gap: spacing.sm }}>
|
|
205
|
+
<StyledText semantic="heading">Theme-aware section</StyledText>
|
|
151
206
|
<StyledText semantic="body" style={{ color: theme.colors.mutedForeground }}>
|
|
152
207
|
Uses package tokens instead of hardcoded colors.
|
|
153
208
|
</StyledText>
|
|
209
|
+
<View style={{ height: StyleSheet.hairlineWidth, backgroundColor: theme.colors.border }} />
|
|
154
210
|
</View>
|
|
155
211
|
);
|
|
156
212
|
}
|
|
157
213
|
```
|
|
158
214
|
|
|
215
|
+
Sections sit on the screen's 16 pt gutter and end in a hairline, not in a
|
|
216
|
+
bordered, shadowed panel; see [Screen layout](#screen-layout).
|
|
217
|
+
|
|
159
218
|
`useTheme()` returns the active `theme`, resolved `scheme`, persisted
|
|
160
219
|
`currentTheme`, `setTheme`, `toggleTheme`, `getShadowStyle`,
|
|
161
220
|
`getFocusRingStyle(offset = 2)`, `getContrastingColor`, `getContrastRatio`,
|
|
162
221
|
`getTextColorForBackground`, and `withAlpha`. `getShadowStyle(type)` takes
|
|
163
222
|
`base`, `soft`, `sharp`, `subtle`, `elevated`, `glow`, `glass`, `card`,
|
|
164
223
|
`cardHover`, or `cardSubtle` and returns a cross-platform `boxShadow` (RN 0.85
|
|
165
|
-
and react-native-web 0.21 deprecate the legacy `shadow*` props).
|
|
224
|
+
and react-native-web 0.21 deprecate the legacy `shadow*` props). The layers
|
|
225
|
+
follow the scheme: in dark the raised presets add a 1px inset white top
|
|
226
|
+
highlight to black shadows at real alpha (a faint drop shadow is invisible on a
|
|
227
|
+
near-black base); in light it is the shadow alone, at about twice the original
|
|
228
|
+
alpha.
|
|
166
229
|
|
|
167
230
|
Semantic tokens on `theme.colors`: `surfaceSunken`, `background`, `foreground`,
|
|
168
231
|
`card`, `cardForeground`, `popover`, `popoverForeground`, `text`, `textDim`,
|
|
@@ -172,12 +235,13 @@ Semantic tokens on `theme.colors`: `surfaceSunken`, `background`, `foreground`,
|
|
|
172
235
|
`secondary` a neutral secondary surface, `accent` the teal highlight, `input`
|
|
173
236
|
the default form-control border, `ring` the focus outline.
|
|
174
237
|
|
|
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
|
|
238
|
+
Surfaces are layered tiers, with an edge highlight in dark: `surfaceSunken` (app
|
|
239
|
+
chrome such as the Drawer rail) < `background` (content) < `card`/`popover`
|
|
240
|
+
(raised panels) < `muted` (chips, insets). `border` is the hairline on `background`/`card`; use
|
|
178
241
|
`borderStrong` on filled surfaces. `textDim`/`mutedForeground` hold at least
|
|
179
242
|
7:1 against `background`/`card` and 6:1 against `muted` in both schemes,
|
|
180
|
-
enforced by a package test.
|
|
243
|
+
enforced by a package test. If you override `background`, `card`, or `popover`,
|
|
244
|
+
re-check them against the template's Surface Lab (`/surface-lab`).
|
|
181
245
|
|
|
182
246
|
When `currentTheme` is `"system"` the package tracks the OS color scheme and
|
|
183
247
|
updates every `useTheme()`/`useStyles()` consumer through the theme store. Do
|
|
@@ -193,10 +257,13 @@ helpers.
|
|
|
193
257
|
import { useStyles } from "@mrmeg/expo-ui/hooks";
|
|
194
258
|
|
|
195
259
|
const { styles } = useStyles(({ theme, spacing, withAlpha }) => ({
|
|
196
|
-
|
|
197
|
-
|
|
260
|
+
content: {
|
|
261
|
+
paddingVertical: spacing.md,
|
|
262
|
+
gap: spacing.sectionSpacing,
|
|
263
|
+
},
|
|
264
|
+
selectedIcon: {
|
|
265
|
+
backgroundColor: withAlpha(theme.colors.primary, 0.12),
|
|
198
266
|
borderRadius: spacing.radiusMd,
|
|
199
|
-
padding: spacing.md,
|
|
200
267
|
},
|
|
201
268
|
}));
|
|
202
269
|
```
|
|
@@ -206,7 +273,13 @@ const { styles } = useStyles(({ theme, spacing, withAlpha }) => ({
|
|
|
206
273
|
On web every `theme.colors.*` value resolves to a CSS custom property
|
|
207
274
|
(`var(--c-<kebab-token>)`), so styles built from the theme — including HTML
|
|
208
275
|
shells baked at export time — re-theme purely in CSS when `html[data-theme]`
|
|
209
|
-
changes. Native keeps literal values.
|
|
276
|
+
changes. Native keeps literal values. Because the references are the same in
|
|
277
|
+
both schemes, web shares one object between them (`colors.light.colors ===
|
|
278
|
+
colors.dark.colors`), so a scheme switch keeps `theme.colors` identical and
|
|
279
|
+
anything memoized on it keeps its result. Re-brand through `setColors`, not by
|
|
280
|
+
mutating that object. The package keeps `html[data-theme]` on the resolved
|
|
281
|
+
scheme with one store subscription, started by the first `useTheme()` consumer
|
|
282
|
+
to mount.
|
|
210
283
|
|
|
211
284
|
Consequence: hex-suffix alpha (`theme.colors.x + "15"`) does not work. Use
|
|
212
285
|
`withAlpha(theme.colors.x, 0.08)`, exported standalone from `hooks` as well as
|
|
@@ -235,21 +308,63 @@ everywhere at once:
|
|
|
235
308
|
|
|
236
309
|
| Token | Value | Use |
|
|
237
310
|
|-------|-------|-----|
|
|
238
|
-
| `screenPadding` | 16 |
|
|
239
|
-
| `sectionSpacing` | 24 | Gap between
|
|
240
|
-
| `cardPadding` | 16 | `Card
|
|
311
|
+
| `screenPadding` | 16 | The one horizontal inset per screen: scroll content, landing blocks, text and forms between row groups |
|
|
312
|
+
| `sectionSpacing` | 24 | Gap between screen sections and `ItemGroup`s |
|
|
313
|
+
| `cardPadding` | 16 | Inside `Card` and `StatCard` tiles; not a layout inset |
|
|
241
314
|
| `dialogPadding` | 20 | `Dialog` and `AlertDialog` content |
|
|
242
|
-
| `rowPaddingY` / `rowPaddingX` | 10 / 16 | `Item` rows
|
|
315
|
+
| `rowPaddingY` / `rowPaddingX` | 10 / 16 | `Item` rows; `rowPaddingX` is the row's copy of the screen inset |
|
|
243
316
|
| `rowGap` | 12 | Gap between row media, content, actions |
|
|
244
317
|
| `rowMinHeight` | 40 | Visual row height on web; native rows keep `touchTarget` |
|
|
245
318
|
| `formRowMinHeight` | 32 | Checkbox and radio rows on web; native keeps `touchTarget` |
|
|
246
|
-
| `touchTarget` | 44 | Minimum
|
|
319
|
+
| `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`) |
|
|
320
|
+
| `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
321
|
|
|
248
322
|
Controls size themselves from their own `size` prop and ignore these tokens:
|
|
249
323
|
`Button` 28/32/40 (`sm`/`md`/`lg`), `TextInput` and `Select` 32/36/40
|
|
250
324
|
(`sm`/`md`/`lg`), `Toggle` 32/36/40 (`sm`/`default`/`lg`), `Tabs` 32/36
|
|
251
325
|
(`sm`/`md`).
|
|
252
326
|
|
|
327
|
+
### Screen layout
|
|
328
|
+
|
|
329
|
+
Phones are narrow, so every nested inset comes out of the content. Screens
|
|
330
|
+
built from this package are flat:
|
|
331
|
+
|
|
332
|
+
- **Start from `Screen`.** `<Screen edges={["bottom"]} scroll>` is the page
|
|
333
|
+
container: safe-area insets on the edges the screen owns (`edges` is
|
|
334
|
+
required — `["bottom"]` under a Stack header, `["top"]` above a tab bar,
|
|
335
|
+
`["top", "bottom"]` headerless, `[]` when the navigator owns both), the one
|
|
336
|
+
horizontal inset, the theme background under all of it. `scroll` puts the
|
|
337
|
+
insets on the `ScrollView`'s content so it scrolls under the island and home
|
|
338
|
+
indicator; `padded={false}` for full-bleed `ItemGroup` rows. Its
|
|
339
|
+
`contentContainerStyle` padding is added to the insets, never replaced.
|
|
340
|
+
Insets come from `useWindowInsets()` (`@mrmeg/expo-ui/hooks`), which also
|
|
341
|
+
covers content the system presents outside the app's `SafeAreaProvider`
|
|
342
|
+
(modals, native sheets), as `Notification`, `Dialog` and `BottomSheet` do.
|
|
343
|
+
- **One horizontal inset: `spacing.screenPadding` (16).** Either a container
|
|
344
|
+
pads or its children do, never both. `Item` rows carry the inset themselves,
|
|
345
|
+
so a screen of `ItemGroup`s pads nothing horizontally; text, forms, and
|
|
346
|
+
buttons between the groups pad their own wrapper.
|
|
347
|
+
- **No boxes as layout.** Don't wrap a section, a form, or a group of rows in
|
|
348
|
+
a bordered, shadowed, or tinted rounded panel. Separate sections with a
|
|
349
|
+
header, `spacing.sectionSpacing`, or a hairline (`Separator`).
|
|
350
|
+
- **Lists and settings are `ItemGroup` + `Item`:** plain full-width rows,
|
|
351
|
+
inset hairlines, no card per row, no border around the group. A `Switch`,
|
|
352
|
+
`Checkbox` or `Toggle` in `ItemActions` takes its accessible name from the
|
|
353
|
+
row's `ItemTitle` (`useItemControlLabel` does the same for a custom control).
|
|
354
|
+
- **Forms:** fields span the column, grouped under section headers.
|
|
355
|
+
- **Primary content fills the width.** Photos, video, maps, QR codes, and
|
|
356
|
+
charts size to the column (`width: "100%"` plus `aspectRatio`), never a
|
|
357
|
+
fixed size in whitespace.
|
|
358
|
+
- **Wide screens cap the column** instead of boxing it:
|
|
359
|
+
`contentContainerStyle={{ width: "100%", maxWidth: 640, alignSelf: "center" }}`.
|
|
360
|
+
- **`Card` is for** one item in a collection (a feed entry, a grid tile, a
|
|
361
|
+
carousel slide) or a single tappable object. Never nest Cards.
|
|
362
|
+
|
|
363
|
+
On a 390 pt phone a boxed settings group puts row content 33 pt from each edge
|
|
364
|
+
(16 screen + 1 border + 16 row) and leaves 324 pt for it; the flat group puts
|
|
365
|
+
it 16 pt in and leaves 358 pt. [`LLM_USAGE.md`](LLM_USAGE.md#screen-layout)
|
|
366
|
+
has a complete screen.
|
|
367
|
+
|
|
253
368
|
### Color overrides
|
|
254
369
|
|
|
255
370
|
Colors resolve in three layers, last wins: package defaults (zinc/teal) →
|
|
@@ -278,23 +393,113 @@ useThemeStore.getState().setColors({
|
|
|
278
393
|
unwinds when that subtree unmounts. Nested scopes compose (inner keys win,
|
|
279
394
|
outer fill in), and a scoped key beats the global brand inside it.
|
|
280
395
|
|
|
396
|
+
#### Extending the palette
|
|
397
|
+
|
|
398
|
+
An app with tokens the package does not have (a brand gold, a chart series)
|
|
399
|
+
declares them once on `ThemeColorExtensions`, and they become part of
|
|
400
|
+
`ThemeColors` everywhere: `setColors` and `ThemeColorScope` accept them,
|
|
401
|
+
`useTheme().theme.colors.brandGold` is typed, and `getThemeCssVariables`
|
|
402
|
+
emits a `--c-brand-gold` variable when the overrides you pass it name the
|
|
403
|
+
key. No side palette, no cast.
|
|
404
|
+
|
|
405
|
+
```ts
|
|
406
|
+
// theme.d.ts (any file in the app's TypeScript program)
|
|
407
|
+
declare module "@mrmeg/expo-ui/constants" {
|
|
408
|
+
interface ThemeColorExtensions {
|
|
409
|
+
brandGold: string;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// startup
|
|
414
|
+
useThemeStore.getState().setColors({
|
|
415
|
+
light: { primary: "#7c3aed", brandGold: "#c9a227" },
|
|
416
|
+
dark: { primary: "#a78bfa", brandGold: "#ffe066" },
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
// anywhere
|
|
420
|
+
const { theme } = useTheme();
|
|
421
|
+
theme.colors.brandGold; // string
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
Two things to know. The package ships no value for an extension key, so
|
|
425
|
+
provide it in both schemes or it is `undefined` at runtime in the scheme that
|
|
426
|
+
lacks it. And on web the built-in tokens are `var(--c-*)` references while
|
|
427
|
+
extension values are literals per scheme, so with any override `theme.colors`
|
|
428
|
+
is a new object when the scheme changes (the identity caveat every override
|
|
429
|
+
already has); the CSS variable from `getThemeCssVariables` is the way to use
|
|
430
|
+
an extension token in an HTML shell.
|
|
431
|
+
|
|
281
432
|
### Shape overrides
|
|
282
433
|
|
|
283
|
-
`setShape` is the geometry counterpart, grouped per
|
|
284
|
-
|
|
434
|
+
`setShape` is the geometry counterpart, grouped per slot. Each slot takes a
|
|
435
|
+
`borderRadius`; `button` also takes `withShadow`:
|
|
285
436
|
|
|
286
437
|
```tsx
|
|
287
438
|
useThemeStore.getState().setShape({
|
|
288
|
-
button: {
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
},
|
|
439
|
+
button: { borderRadius: 9999, withShadow: false }, // pills, flat `default` preset
|
|
440
|
+
input: { borderRadius: spacing.radiusXl }, // TextInput, Select, InputOTP
|
|
441
|
+
card: { borderRadius: spacing.radius2xl }, // Card, StatCard, EmptyState, SkeletonCard
|
|
442
|
+
sheet: { borderRadius: spacing.radius2xl }, // BottomSheet top corners
|
|
443
|
+
badge: { borderRadius: spacing.radiusSm }, // squarer badges
|
|
444
|
+
dialog: { borderRadius: spacing.radius2xl }, // Dialog and AlertDialog
|
|
292
445
|
});
|
|
293
446
|
```
|
|
294
447
|
|
|
448
|
+
| Slot | Reaches | Package default |
|
|
449
|
+
|---|---|---|
|
|
450
|
+
| `button` | Button, every preset (`withShadow` only affects `default`) | `spacing.radiusMd` (10), shadow on |
|
|
451
|
+
| `input` | TextInput (`outline` and `filled`; `underlined` stays square), Select trigger, InputOTP cells | `spacing.radiusMd` (10) |
|
|
452
|
+
| `card` | Card (surface and pressable focus ring), StatCard, EmptyState with `bordered`, SkeletonCard | `spacing.radiusLg` (14) |
|
|
453
|
+
| `sheet` | BottomSheet top corners, where the platform lets the sheet draw them (web, Android); iOS system sheets keep the system radius | platform |
|
|
454
|
+
| `badge` | Badge | `spacing.radiusFull` (pill) |
|
|
455
|
+
| `dialog` | Dialog and AlertDialog content | `spacing.radiusLg` (14) |
|
|
456
|
+
|
|
295
457
|
Precedence is caller-wins: a per-instance `style={{ borderRadius }}` or
|
|
296
458
|
`withShadow` prop beats the global override, which beats the package default.
|
|
297
|
-
`setShape({})` clears back to the defaults.
|
|
459
|
+
`setShape({})` clears back to the defaults. A component reads its slot with
|
|
460
|
+
`useShape(slot)` from `@mrmeg/expo-ui/hooks` and layers the radius after its
|
|
461
|
+
static one; `shapeRadius(override)` turns the slot into a `{ borderRadius }`
|
|
462
|
+
style or `undefined` for that layering.
|
|
463
|
+
|
|
464
|
+
### Press feedback and haptics
|
|
465
|
+
|
|
466
|
+
Every pressable shares one pressed look: a 0.97 scale (0.92 on small controls)
|
|
467
|
+
plus `interaction.pressedOpacity` (0.85) on filled surfaces, and
|
|
468
|
+
`interaction.disabledOpacity` (0.5) when disabled — both from
|
|
469
|
+
`@mrmeg/expo-ui/constants`. Under the OS reduce-motion setting the scale stays
|
|
470
|
+
at 1 and only the opacity signals the press.
|
|
471
|
+
|
|
472
|
+
Haptics are one provider-level setting, read at event time:
|
|
473
|
+
|
|
474
|
+
```tsx
|
|
475
|
+
<UIProvider haptics="all">…</UIProvider>
|
|
476
|
+
// or, from startup code:
|
|
477
|
+
import { setHaptics } from "@mrmeg/expo-ui/state";
|
|
478
|
+
setHaptics("all");
|
|
479
|
+
```
|
|
480
|
+
|
|
481
|
+
| `haptics` | Fires on |
|
|
482
|
+
|---|---|
|
|
483
|
+
| `"off"` | nothing |
|
|
484
|
+
| `"selection"` (default) | state changes: Switch, Checkbox, Toggle, ToggleGroup, SegmentedControl |
|
|
485
|
+
| `"all"` | selection plus a light tap on press-in for Button, pressable Card and Item, and `useScalePress` consumers |
|
|
486
|
+
|
|
487
|
+
Two ways to disable a `Button`. `disabled` swallows presses, skips the scale
|
|
488
|
+
and haptic, dims, and leaves the web tab order — for actions that cannot run.
|
|
489
|
+
`aria-disabled` (or `accessibilityState={{ disabled: true }}`) is announced
|
|
490
|
+
and dimmed the same way but stays focusable and pressable, so the press can say
|
|
491
|
+
why: a required question's "Next", a "Publish" without a title. Prefer it
|
|
492
|
+
whenever the user could otherwise wonder why nothing happens.
|
|
493
|
+
|
|
494
|
+
```tsx
|
|
495
|
+
<Button text="Publish" aria-disabled={!title} onPress={() => (title ? publish() : notify.info("Add a title first"))} />
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
Web never vibrates. A single `Button` can force either way with `haptic`
|
|
499
|
+
(`<Button haptic />`, `<Button haptic={false} />`); `useScalePress({ haptic })`
|
|
500
|
+
takes `true` (always), `false` (never) or `"setting"` (the default, follows the
|
|
501
|
+
provider). `hapticSuccess()` from `@mrmeg/expo-ui/lib` is there for completion
|
|
502
|
+
moments the app owns.
|
|
298
503
|
|
|
299
504
|
## Typography
|
|
300
505
|
|
|
@@ -312,6 +517,11 @@ import { BodyText, CaptionText, HeadingText, StyledText } from "@mrmeg/expo-ui/c
|
|
|
312
517
|
- `semantic`: `title`, `heading`, `subheading`, `body`, `caption`, `label`, `eyebrow`
|
|
313
518
|
- `size`: `xs`, `sm`, `base`, `body`, `lg`, `xl`, `xxl`, `display`
|
|
314
519
|
- `fontWeight`: `light`, `regular`, `medium`, `semibold`, `bold`
|
|
520
|
+
- `italic`: a real italic face where the family has one (an app's `setFonts`
|
|
521
|
+
`italic` map, or the Newsreader serif preset below), otherwise
|
|
522
|
+
`fontStyle: "italic"` and the platform synthesizes the slant. Inter ships no
|
|
523
|
+
italic file on native (four upright weights, by design); on web the injected
|
|
524
|
+
Inter stylesheet carries the 400 italic, so body-weight italic is real there
|
|
315
525
|
- `variant`: `sansSerif`, `serif`, `mono`
|
|
316
526
|
- `align`, `text`, `tx`, `txOptions`
|
|
317
527
|
- `selectable`: defaults to `true` on iOS and web and to `false` on Android
|
|
@@ -348,11 +558,15 @@ configureExpoUiI18n((key, options) => i18n.t(key, options));
|
|
|
348
558
|
|
|
349
559
|
Inter is the sans face on every platform, in four static weights
|
|
350
560
|
(`Inter_400Regular`, `Inter_500Medium`, `Inter_600SemiBold`, `Inter_700Bold`)
|
|
351
|
-
from the bundled `@expo-google-fonts/inter`. Serif is Georgia
|
|
352
|
-
platform system
|
|
561
|
+
from the bundled `@expo-google-fonts/inter`. Serif is Georgia by default, or
|
|
562
|
+
Newsreader through the serif preset below; mono is the platform system
|
|
563
|
+
monospace.
|
|
353
564
|
|
|
354
565
|
`useResources()` loads those four weights on native, so `StyledText`'s
|
|
355
|
-
`light`–`bold` range resolves to real files instead of a faked OS bold.
|
|
566
|
+
`light`–`bold` range resolves to real files instead of a faked OS bold. Each
|
|
567
|
+
weight is imported from its own `@expo-google-fonts/inter/<weight>` subpath, so
|
|
568
|
+
a native bundle carries those four files and no other Inter face, and a web
|
|
569
|
+
bundle carries no Inter file at all. Icons
|
|
356
570
|
are SVG (`lucide-react-native`), so there is no icon font to load on any
|
|
357
571
|
platform. On web
|
|
358
572
|
it injects one Google Fonts Inter stylesheet (all four weights) after hydration
|
|
@@ -366,10 +580,82 @@ Expo Router web apps, add the links to app-owned `app/+html.tsx`:
|
|
|
366
580
|
<link
|
|
367
581
|
id="mrmeg-expo-ui-inter"
|
|
368
582
|
rel="stylesheet"
|
|
369
|
-
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
|
|
583
|
+
href="https://fonts.googleapis.com/css2?family=Inter:ital,wght@0,400;0,500;0,600;0,700;1,400&display=swap"
|
|
370
584
|
/>
|
|
371
585
|
```
|
|
372
586
|
|
|
587
|
+
### Serif preset: Newsreader
|
|
588
|
+
|
|
589
|
+
Georgia is one face per platform, so `SerifText fontWeight="bold"` is a faux
|
|
590
|
+
bold and `italic` a synthesized slant. Newsreader is a real serif family —
|
|
591
|
+
four weights and a 400 italic — offered as an opt-in preset that costs an app
|
|
592
|
+
nothing unless it opts in: the package does not depend on the font package, so
|
|
593
|
+
the app adds it and hands the native files over.
|
|
594
|
+
|
|
595
|
+
```bash
|
|
596
|
+
bun add @expo-google-fonts/newsreader
|
|
597
|
+
```
|
|
598
|
+
|
|
599
|
+
```ts
|
|
600
|
+
// lib/newsreaderFonts.native.ts — per-weight subpaths, so the native bundle
|
|
601
|
+
// carries exactly these five files. The web twin exports `null`.
|
|
602
|
+
import { Newsreader_400Regular } from "@expo-google-fonts/newsreader/400Regular";
|
|
603
|
+
import { Newsreader_400Regular_Italic } from "@expo-google-fonts/newsreader/400Regular_Italic";
|
|
604
|
+
import { Newsreader_500Medium } from "@expo-google-fonts/newsreader/500Medium";
|
|
605
|
+
import { Newsreader_600SemiBold } from "@expo-google-fonts/newsreader/600SemiBold";
|
|
606
|
+
import { Newsreader_700Bold } from "@expo-google-fonts/newsreader/700Bold";
|
|
607
|
+
|
|
608
|
+
export const newsreaderFontMap = {
|
|
609
|
+
Newsreader_400Regular,
|
|
610
|
+
Newsreader_500Medium,
|
|
611
|
+
Newsreader_600SemiBold,
|
|
612
|
+
Newsreader_700Bold,
|
|
613
|
+
Newsreader_400Regular_Italic,
|
|
614
|
+
};
|
|
615
|
+
```
|
|
616
|
+
|
|
617
|
+
```tsx
|
|
618
|
+
// where the app already calls useResources
|
|
619
|
+
const { loaded } = useResources({ serif: "newsreader", serifFonts: newsreaderFontMap });
|
|
620
|
+
```
|
|
621
|
+
|
|
622
|
+
On native `useResources` registers the files and then sets the theme store's
|
|
623
|
+
`serifPreset` to `"newsreader"`, so serif text resolves to
|
|
624
|
+
`Newsreader_400Regular` … `Newsreader_700Bold` (`light` shares the 400 file)
|
|
625
|
+
and `italic` to `Newsreader_400Regular_Italic` at every weight. On web it
|
|
626
|
+
injects one Google Fonts stylesheet (`Newsreader:ital,wght@0,400;0,500;0,600;0,700;1,400`)
|
|
627
|
+
under the id `mrmeg-expo-ui-newsreader` — put an element with that id in
|
|
628
|
+
`+html.tsx` (the Google Fonts `<link>`, or self-hosted `@font-face` rules as
|
|
629
|
+
this template does from `@fontsource-variable/newsreader`) and the hook skips
|
|
630
|
+
its own — and switches the preset at once
|
|
631
|
+
(the stack falls back to Georgia until the faces arrive); weight is numeric on
|
|
632
|
+
the shared `"Newsreader"` family. A `setFonts` serif override wins over the
|
|
633
|
+
preset and skips its load. Without `serifFonts` on native the hook warns once
|
|
634
|
+
and keeps Georgia. The family names are exported as `newsreaderFamilies` from
|
|
635
|
+
`@mrmeg/expo-ui/constants`; `SerifPreset` and `useThemeStore().serifPreset` /
|
|
636
|
+
`setSerifPreset` are the store side.
|
|
637
|
+
|
|
638
|
+
### Italic faces through `setFonts`
|
|
639
|
+
|
|
640
|
+
Each family group in `setFonts` takes an `italic` map beside the weights:
|
|
641
|
+
|
|
642
|
+
```ts
|
|
643
|
+
useThemeStore.getState().setFonts({
|
|
644
|
+
families: {
|
|
645
|
+
sansSerif: {
|
|
646
|
+
regular: "Brand_Regular",
|
|
647
|
+
bold: "Brand_Bold",
|
|
648
|
+
italic: { regular: "Brand_Italic", bold: "Brand_BoldItalic" },
|
|
649
|
+
},
|
|
650
|
+
},
|
|
651
|
+
});
|
|
652
|
+
```
|
|
653
|
+
|
|
654
|
+
`italic` on `StyledText` (or `useFontStyle(weight, variant, { italic: true })`)
|
|
655
|
+
then uses the italic face for its weight, falls back to the group's italic
|
|
656
|
+
`regular` when that weight has none, and emits no `fontStyle`; a group with no
|
|
657
|
+
`italic` map synthesizes on its upright face.
|
|
658
|
+
|
|
373
659
|
### Font overrides
|
|
374
660
|
|
|
375
661
|
Forward your own faces with `setFonts`, once at startup after they are
|
|
@@ -431,7 +717,7 @@ building a new primitive.
|
|
|
431
717
|
| `Badge` | Short status labels | Draft/active state, counts, plan and role tags |
|
|
432
718
|
| `BottomSheet` | Mobile-first modal sheets | Action pickers, mobile filters, quick edit forms |
|
|
433
719
|
| `Button` | Commands and CTAs | Submit, save, delete, navigation CTAs; loading state keeps the resting width |
|
|
434
|
-
| `Card` |
|
|
720
|
+
| `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
721
|
| `Carousel` | Horizontally snapping slides with pressable dots | Testimonials, onboarding pages, image galleries |
|
|
436
722
|
| `Checkbox` | Boolean selection | Terms consent, checklists, multi-select filters |
|
|
437
723
|
| `Collapsible` | One-off disclosure | Advanced settings, hidden helper text |
|
|
@@ -444,20 +730,22 @@ building a new primitive.
|
|
|
444
730
|
| `Icon` | Lucide or custom icons on theme tokens | Button accessories, menu icons, status glyphs |
|
|
445
731
|
| `InputOTP` | Verification code entry | Email/SMS codes, MFA, invite codes |
|
|
446
732
|
| `Item` | List and settings rows on the density tokens | Settings lists, inbox rows, pickers, detail rows |
|
|
733
|
+
| `ItemGroup` | Flat grouped list: title, full-width `Item` rows with inset hairlines, footer | Settings sections, profile details, activity feeds |
|
|
447
734
|
| `KeyboardAvoidingView` | Native keyboard-aware layout | Screen roots, composer footers, form-heavy subtrees |
|
|
448
735
|
| `Label` | Accessible form labels | Required, disabled, and group labels |
|
|
449
|
-
| `MaxWidthContainer` | Centered responsive width | Web pages, tablet layouts, auth
|
|
736
|
+
| `MaxWidthContainer` | Centered responsive width | Web pages, tablet layouts, auth forms |
|
|
450
737
|
| `Notification` | Global toast surface | Saved/error/sync toasts, action toasts, loading toast |
|
|
738
|
+
| `Screen` | Page container: safe-area `edges`, one horizontal inset, theme background, optional scroll | Every screen root; under a header pass `edges={["bottom"]}` |
|
|
451
739
|
| `Popover` | Anchored contextual content | Inline help, quick previews, small forms |
|
|
452
740
|
| `Progress` | Determinate or indeterminate progress | Upload progress, onboarding completion |
|
|
453
741
|
| `RadioGroup` | Mutually exclusive choices | Plan interval, visibility choice, survey answer |
|
|
454
|
-
| `SectionHeader` | Eyebrow / title / description section intro | Landing sections,
|
|
742
|
+
| `SectionHeader` | Eyebrow / title / description section intro | Landing sections, report headers, empty-screen intros |
|
|
455
743
|
| `SegmentedControl` | Native segmented picker (`@expo/ui`) | Platform-native view switchers, iOS-style filters |
|
|
456
744
|
| `Select` | Option menus | Country, category, status pickers; `label` drives default item text |
|
|
457
|
-
| `Separator` | Horizontal or vertical dividers |
|
|
745
|
+
| `Separator` | Horizontal or vertical dividers | Section, menu, and toolbar dividers |
|
|
458
746
|
| `Skeleton` | Loading placeholders | List, profile card, dashboard loading |
|
|
459
747
|
| `Slider` | Numeric value selection (`@expo/ui`) | Volume, percentage, rating, threshold |
|
|
460
|
-
| `StatCard` | Metric tile: `label`, `value`, `unit`, `change`, `icon`, `onPress` | KPI
|
|
748
|
+
| `StatCard` | Metric tile: `label`, `value`, `unit`, `change`, `icon`, `onPress` | A scrolling KPI rail, one tappable metric |
|
|
461
749
|
| `StatusBar` | Theme-aware native status bar | Root layout status styling |
|
|
462
750
|
| `StyledText` and aliases | Theme-aware typography | Titles, labels, body copy, captions, translated text |
|
|
463
751
|
| `Switch` | Binary settings | Notification, privacy, and feature toggles |
|
|
@@ -472,17 +760,32 @@ building a new primitive.
|
|
|
472
760
|
`Icon` renders `lucide-react-native` — Lucide is a maintained superset of
|
|
473
761
|
Feather in the same 24px, 2px round-stroke style. `name` is typed by
|
|
474
762
|
`IconName`, the union of kebab-case Lucide names in the package registry
|
|
475
|
-
(`src/components/icon-names.json`, about 150 names)
|
|
476
|
-
|
|
477
|
-
entry (1,800+ icons) is never
|
|
478
|
-
|
|
763
|
+
(`src/components/icon-names.json`, about 150 names). Importing `Icon` includes
|
|
764
|
+
that named registry because `name` is looked up at runtime, even when a caller
|
|
765
|
+
uses `component`; the root `lucide-react-native` entry (1,800+ icons) is never
|
|
766
|
+
imported. `color` takes a theme color name or a
|
|
767
|
+
literal. `accessibilityLabel` names an icon that stands alone (`role="img"` +
|
|
768
|
+
`aria-label` on web, `accessible` + `accessibilityLabel` on native); an icon
|
|
769
|
+
without one is hidden from assistive tech — the glyph beside text that already
|
|
770
|
+
says it would otherwise be announced as an unnamed image — and `decorative`
|
|
771
|
+
only remains to silence a labelled icon. On web the SVG carries ARIA only
|
|
772
|
+
(`aria-hidden` or `role="img"`), never the RN-only accessibility props. Instead of `name`,
|
|
773
|
+
`Icon` and `Button.Icon` take `component`: any Lucide import (or another SVG
|
|
774
|
+
component that accepts `size` and `color`), sized, colored, themed, and hidden
|
|
775
|
+
from assistive tech exactly like a named icon. `Button.Icon` defaults its
|
|
776
|
+
color to the button's label color either way.
|
|
479
777
|
|
|
480
778
|
```tsx
|
|
481
|
-
import { Icon } from "@mrmeg/expo-ui/components";
|
|
779
|
+
import { Button, Icon } from "@mrmeg/expo-ui/components";
|
|
780
|
+
import House from "lucide-react-native/icons/house";
|
|
482
781
|
import Rocket from "lucide-react-native/icons/rocket";
|
|
483
782
|
|
|
484
783
|
<Icon name="circle-check-big" color="success" size={16} />
|
|
485
784
|
<Icon component={Rocket} color="accent" />
|
|
785
|
+
<Button onPress={goHome}>
|
|
786
|
+
<Button.Icon component={House} />
|
|
787
|
+
<Button.Text>Home</Button.Text>
|
|
788
|
+
</Button>
|
|
486
789
|
```
|
|
487
790
|
|
|
488
791
|
Adding an icon: in this repo, add the kebab-case Lucide name to
|
|
@@ -518,7 +821,7 @@ Lucide equivalent; use `component` with your own SVG.
|
|
|
518
821
|
| `Dialog` | `DialogTrigger`, `DialogContent`, `DialogHeader`, `DialogFooter`, `DialogTitle`, `DialogDescription`, `DialogClose` |
|
|
519
822
|
| `Drawer` | `DrawerTrigger`, `DrawerContent`, `DrawerHeader`, `DrawerBody`, `DrawerFooter`, `DrawerClose`, `DrawerToggleCollapse` |
|
|
520
823
|
| `DropdownMenu` | `DropdownMenuTrigger`, `DropdownMenuContent`, `DropdownMenuGroup`, `DropdownMenuItem`, `DropdownMenuCheckboxItem`, `DropdownMenuRadioGroup`, `DropdownMenuRadioItem`, `DropdownMenuLabel`, `DropdownMenuSeparator`, `DropdownMenuShortcut`, `DropdownMenuPortal`, `DropdownMenuSub`, `DropdownMenuSubTrigger`, `DropdownMenuSubContent` |
|
|
521
|
-
| `Item` | `ItemMedia`, `ItemContent`, `ItemTitle`, `ItemDescription`, `ItemActions` |
|
|
824
|
+
| `Item` | `ItemGroup`, `ItemMedia`, `ItemContent`, `ItemTitle`, `ItemDescription`, `ItemActions` |
|
|
522
825
|
| `Popover` | `PopoverTrigger`, `PopoverContent`, `PopoverHeader`, `PopoverBody`, `PopoverFooter` |
|
|
523
826
|
| `RadioGroup` | `RadioGroupItem` |
|
|
524
827
|
| `Select` | `SelectTrigger`, `SelectValue`, `SelectContent`, `SelectItem`, `SelectGroup`, `SelectLabel`, `SelectSeparator` |
|
|
@@ -544,20 +847,39 @@ are named exports only.
|
|
|
544
847
|
| `useDimensions()` | `{ width, height, orientation, isSmallScreen, isMediumScreen, isLargeScreen }` against `SCREEN_SIZES` (768 / 1000 / 1200) |
|
|
545
848
|
| `useFontStyle(weight?, variant?)` | `{ fontFamily, fontWeight? }` resolved through `setFonts` overrides |
|
|
546
849
|
| `useReducedMotion()` | `true` when the OS asks for reduced motion |
|
|
850
|
+
| `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
851
|
| `useScalePress(options?)` | Animated style plus `onPressIn`/`onPressOut` for a `Pressable` (`scaleTo`, `haptic`, `disabled`) |
|
|
548
852
|
| `useStaggeredEntrance(options?)` | Entrance animated style for list rows (`delay`; `STAGGER_DELAY` is 30) |
|
|
549
853
|
|
|
550
854
|
`SsrViewportContext` (from `state`) supplies the first-render viewport width on
|
|
551
|
-
web, where the window cannot be read during export or hydration.
|
|
855
|
+
web, where the window cannot be read during export or hydration. Right after
|
|
856
|
+
hydration `useDimensions()` switches to the real window. On web every consumer
|
|
857
|
+
shares one `resize` listener and one snapshot, re-renders only when the width or
|
|
858
|
+
height changes, and a component that mounts later reads the current viewport
|
|
859
|
+
straight away. The `mrmeg-vw` cookie (the width a server can seed the next
|
|
860
|
+
render from) is written once per page view and then once resizing settles.
|
|
552
861
|
|
|
553
862
|
### Patterns And Gotchas
|
|
554
863
|
|
|
555
864
|
- Use `Button.preset`, not `variant`: `default` is the neutral primary action,
|
|
556
865
|
`secondary` a neutral secondary surface, `outline` lower emphasis, `ghost`
|
|
557
866
|
compact toolbars, `link` text-like commands, `destructive` dangerous actions.
|
|
558
|
-
|
|
559
|
-
|
|
867
|
+
Every platform gives a Button a 44pt vertical target: native through computed
|
|
868
|
+
`hitSlop`, web through a transparent hit extender inside the drawn 28/32/40pt
|
|
869
|
+
box, so the visual size, layout and popover anchoring do not change; a caller
|
|
870
|
+
`hitSlop` replaces both (on web it is honoured too). Nested `StyledText`
|
|
871
|
+
inherits the Button size, so use `size="sm"` for popover, tooltip, and toolbar
|
|
560
872
|
triggers.
|
|
873
|
+
- A plain `View` whose `opacity` or `pointerEvents` follows state (`disabled`,
|
|
874
|
+
`loading`, `checked`, `editable`) must be `collapsable={false}` on Android.
|
|
875
|
+
Fabric hoists the children of a filled or bordered View into its parent while
|
|
876
|
+
the opacity is 1 and pulls them back when it changes, and a flip racing a
|
|
877
|
+
navigation pop crashes with `addViewAt: … View already has a parent`. The
|
|
878
|
+
package pins its own surfaces (`Button`, `TextInput`, `Slider`, `InputOTP`,
|
|
879
|
+
`Switch` labels); spread `stateSurfaceProps()` from `@mrmeg/expo-ui/lib` on
|
|
880
|
+
app-owned surfaces (`<View {...stateSurfaceProps()} style={{ opacity:
|
|
881
|
+
disabled ? 0.5 : 1 }} />`). `Pressable` already pins itself. iOS and web get
|
|
882
|
+
no prop.
|
|
561
883
|
- Pair a standalone `Label` with its control using two DISTINCT ids: `nativeID`
|
|
562
884
|
is the label's own id, `htmlFor` is the input's id (`<Label
|
|
563
885
|
nativeID="email-label" htmlFor="email-input">` + `<TextInput
|
|
@@ -581,9 +903,28 @@ web, where the window cannot be read during export or hydration.
|
|
|
581
903
|
hosted content above the keyboard — a short sheet is lifted whole, a tall one
|
|
582
904
|
is shrunk — so `Footer` and the tail of `Body` end at the keyboard's top with
|
|
583
905
|
only their home-indicator padding as clearance (device-verified on iOS 27 with
|
|
584
|
-
`@expo/ui` 58); on Android Material3's `ModalBottomSheet`
|
|
585
|
-
|
|
586
|
-
|
|
906
|
+
`@expo/ui` 58); on Android Material3's `ModalBottomSheet` shrinks the sheet
|
|
907
|
+
for the keyboard and `@expo/ui`'s `RNHostView` re-reports its size to the
|
|
908
|
+
React Native shadow tree, so the hosted column follows and `Footer` stays
|
|
909
|
+
above the keyboard — this needs the app built against `expo-modules-core`
|
|
910
|
+
>= 57.0.4 (expo/expo#47778; older cores dropped that size update until the
|
|
911
|
+
activity redrew, leaving `Footer` under the keyboard; no 56.x release has the
|
|
912
|
+
fix). Run `npx expo install --fix` (or `bun update expo-modules-core`) and
|
|
913
|
+
rebuild; the sheet warns once in development on Android when the native core
|
|
914
|
+
is older. Web has none. Do not wrap sheet content in another
|
|
915
|
+
`KeyboardAvoidingView`: it would lift twice.
|
|
916
|
+
`onDismissed` (root prop) fires once per close after the sheet is fully gone;
|
|
917
|
+
present the next modal — a `Dialog` (an RN `Modal` on iOS), a native stack
|
|
918
|
+
modal — from it, not from `onOpenChange(false)` or a timer: on iOS the sheet
|
|
919
|
+
is still dismissing when `onOpenChange` fires and UIKit rejects a `Modal`
|
|
920
|
+
presented then ("already presenting") with no retry. Detection per platform:
|
|
921
|
+
iOS uses `@expo/ui`'s native `onDismiss` event, raised by SwiftUI
|
|
922
|
+
`.sheet(isPresented:onDismiss:)` after the dismissal transition; Android uses
|
|
923
|
+
`@expo/ui`'s close callback, raised after Material's hide animation for a
|
|
924
|
+
swipe / back / scrim dismissal and with the removal of the Compose sheet for a
|
|
925
|
+
prop-driven close (which has no exit animation); web derives it from the HTML
|
|
926
|
+
`<dialog>`'s `close` event, which `@expo/ui` raises when its exit animation
|
|
927
|
+
ends (immediately under reduced motion).
|
|
587
928
|
The sheet hosts its content in a separate
|
|
588
929
|
native window outside the app's `DismissKeyboard`, so `BottomSheet.Content`
|
|
589
930
|
mounts its own tap-away keyboard-dismiss boundary on the content column: it
|
|
@@ -592,15 +933,79 @@ web, where the window cannot be read during export or hydration.
|
|
|
592
933
|
matching `DismissKeyboard`. `BottomSheet.Body` sets
|
|
593
934
|
`keyboardShouldPersistTaps="always"` on its ScrollView so that column boundary
|
|
594
935
|
owns dismissal; do not pass `never`, which lets RN claim the first tap on a
|
|
595
|
-
body control and blur the field instead.
|
|
936
|
+
body control and blur the field instead. That covers ScrollViews inside the
|
|
937
|
+
sheet only: the sheet's content stays in the screen's React tree even though
|
|
938
|
+
it is drawn in another window, and RN's responder negotiation walks that tree,
|
|
939
|
+
so a ScrollView *around* the `BottomSheet` left on the default
|
|
940
|
+
`keyboardShouldPersistTaps="never"` claims the first tap on any sheet control
|
|
941
|
+
(`Footer` included) while a sheet field is focused and blurs the field instead
|
|
942
|
+
of firing the control. Set `keyboardShouldPersistTaps="always"` (or
|
|
943
|
+
`"handled"`) on scroll views that contain a sheet, or use `DismissKeyboard`,
|
|
944
|
+
which already does; the sheet warns once in development on Android when it
|
|
945
|
+
observes such a swallowed tap. Android has only two snap states
|
|
596
946
|
(partial / expanded) and maps extra snap points to the nearest; because
|
|
597
947
|
Material ignores percentage snap points, the Android body fills the rendered
|
|
598
948
|
sheet height rather than a window-percentage cap. `BottomSheet.Content` also
|
|
599
949
|
takes `backgroundStyle`, merged over the themed card default on the native
|
|
600
950
|
sheet surface (web panel, Android `containerColor`, iOS
|
|
601
|
-
`presentationBackground`)
|
|
602
|
-
|
|
603
|
-
a
|
|
951
|
+
`presentationBackground`). That surface is the sheet's only background: the
|
|
952
|
+
content column paints no fill of its own, so a translucent card reads as one
|
|
953
|
+
layer from the grabber down (a second, column-level fill made the 16 pt
|
|
954
|
+
strip behind the iOS grabber a different shade), and
|
|
955
|
+
`{ backgroundColor: "transparent" }` alone lets custom chrome such as a glass
|
|
956
|
+
backdrop show through.
|
|
957
|
+
- `Dialog` and `AlertDialog` present their content through React Native's
|
|
958
|
+
`Modal` on iOS, rendered inline where the dialog sits in the tree
|
|
959
|
+
(transparent, `overFullScreen`, no animation of its own; the package's fade
|
|
960
|
+
and scale still run inside). The Modal presents a real view controller, which
|
|
961
|
+
`@expo/ui`-hosted controls need: `expo-modules-core`'s SwiftUI hosting view
|
|
962
|
+
drops its content when no `UIViewController` sits above it, so inside
|
|
963
|
+
react-native-screens' `FullWindowOverlay` — which adds its container straight
|
|
964
|
+
to the window — a package `TextInput`, `Slider` or `SegmentedControl`
|
|
965
|
+
rendered as an empty box that could not take focus (device-verified on
|
|
966
|
+
iOS 27). RN presents a `Modal` from the view controller nearest its host
|
|
967
|
+
view, so the dialog stacks above whatever screen, native stack modal or sheet
|
|
968
|
+
contains it (device-verified over a `presentation: "modal"` route);
|
|
969
|
+
`portalHost` is honored on Android and web only. `Drawer`, `Popover`,
|
|
970
|
+
`Select`, `DropdownMenu` and `Tooltip` still use that overlay on iOS, so do
|
|
971
|
+
not put `@expo/ui`-hosted controls inside them yet, and do not place a
|
|
972
|
+
`Dialog` inside their content either — it has no view controller to present
|
|
973
|
+
from there; render it at screen level and open it from the item's
|
|
974
|
+
`onPress`. Dialog content sits outside `UIProvider`'s root keyboard
|
|
975
|
+
avoidance on both native platforms (the iOS Modal is presented outside it;
|
|
976
|
+
the Android portal host is a sibling of it), so the dialog owns keyboard
|
|
977
|
+
avoidance on iOS and Android: the centered container is a package
|
|
978
|
+
`KeyboardAvoidingView` (`behavior="padding"`), the card recenters above the
|
|
979
|
+
keyboard with its fields and footer visible, and `useKeyboardAvoidance()` is
|
|
980
|
+
`true` inside dialog content. Do not wrap dialog content in another
|
|
981
|
+
`KeyboardAvoidingView`. Web has no software keyboard and no avoidance owner
|
|
982
|
+
(`useKeyboardAvoidance()` is `false` there). Dialog content also owns tap-away
|
|
983
|
+
keyboard dismissal on iOS and Android with the same boundary as
|
|
984
|
+
`DismissKeyboard` and `BottomSheet.Content`: `Dialog` carries it on the card
|
|
985
|
+
itself (the primitive's native content claims every touch inside the card so
|
|
986
|
+
the backdrop's close-on-press never fires there, and that claim would stop a
|
|
987
|
+
boundary on any ancestor from ever being asked), `AlertDialog` on its
|
|
988
|
+
centered container. A tap on the card's dead space (padding, labels, the gap
|
|
989
|
+
between fields and footer) hides the keyboard on release, a `Dialog` backdrop
|
|
990
|
+
tap closes the dialog and the keyboard with it, and `Close`, `Action`,
|
|
991
|
+
`Cancel`, buttons and fields still fire on the first tap; no
|
|
992
|
+
`DismissKeyboard` is needed inside a dialog (inert on web, which has no
|
|
993
|
+
software keyboard). The platform close request
|
|
994
|
+
(hardware back, TV menu) routes to the root's `onOpenChange(false)`. Android
|
|
995
|
+
and web render dialog content inline into the portal host.
|
|
996
|
+
- `PopoverContent` treats `side` as a preference. On iOS and Android it opens
|
|
997
|
+
on the other side when the content does not fit and there is more room
|
|
998
|
+
there, caps its height to the room it gets, and scrolls the children inside
|
|
999
|
+
that cap; `insets` default to the safe area, so the card stays clear of the
|
|
1000
|
+
status bar and home indicator. Pass `scrollable={false}` when the content
|
|
1001
|
+
brings its own `FlatList` (a VirtualizedList inside a vertical ScrollView
|
|
1002
|
+
warns): the cap still applies, but the popover can no longer measure its full
|
|
1003
|
+
height, so it stays on `side`. A `style` merges over the themed surface
|
|
1004
|
+
(background, border, radius, shadow) instead of replacing it. On web, Radix
|
|
1005
|
+
flips it and the card is capped to the space Radix measures; the primitive
|
|
1006
|
+
cannot pass Radix a collision padding, so a card wider than the room beside
|
|
1007
|
+
its trigger can sit flush against the viewport edge. A `PopoverTrigger` ref
|
|
1008
|
+
(`PopoverTriggerRef`) exposes `open()` and `close()`.
|
|
604
1009
|
- `Carousel` renders every child (no virtualization), so slides survive into
|
|
605
1010
|
the exported HTML shell and the first client frame; use `FlatList` for large
|
|
606
1011
|
or unbounded data. An `itemWidth` below 1 (default `0.85`) is a fraction of
|
|
@@ -637,19 +1042,39 @@ import { Drawer, Icon } from "@mrmeg/expo-ui/components";
|
|
|
637
1042
|
### Quick Examples
|
|
638
1043
|
|
|
639
1044
|
```tsx
|
|
640
|
-
import {
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
1045
|
+
import {
|
|
1046
|
+
Badge,
|
|
1047
|
+
Icon,
|
|
1048
|
+
Item,
|
|
1049
|
+
ItemActions,
|
|
1050
|
+
ItemContent,
|
|
1051
|
+
ItemDescription,
|
|
1052
|
+
ItemGroup,
|
|
1053
|
+
ItemMedia,
|
|
1054
|
+
ItemTitle,
|
|
1055
|
+
} from "@mrmeg/expo-ui/components";
|
|
1056
|
+
|
|
1057
|
+
<ItemGroup title="Subscription" footer="Renews on the 1st of each month.">
|
|
1058
|
+
<Item>
|
|
1059
|
+
<ItemMedia icon="award" />
|
|
1060
|
+
<ItemContent>
|
|
1061
|
+
<ItemTitle>Pro plan</ItemTitle>
|
|
1062
|
+
<ItemDescription>$12 / month</ItemDescription>
|
|
1063
|
+
</ItemContent>
|
|
1064
|
+
<ItemActions>
|
|
1065
|
+
<Badge variant="secondary">Active</Badge>
|
|
1066
|
+
</ItemActions>
|
|
1067
|
+
</Item>
|
|
1068
|
+
<Item onPress={openBillingPortal}>
|
|
1069
|
+
<ItemMedia icon="credit-card" />
|
|
1070
|
+
<ItemContent>
|
|
1071
|
+
<ItemTitle>Manage billing</ItemTitle>
|
|
1072
|
+
</ItemContent>
|
|
1073
|
+
<ItemActions>
|
|
1074
|
+
<Icon name="chevron-right" size={18} color="mutedForeground" />
|
|
1075
|
+
</ItemActions>
|
|
1076
|
+
</Item>
|
|
1077
|
+
</ItemGroup>
|
|
653
1078
|
```
|
|
654
1079
|
|
|
655
1080
|
```tsx
|
|
@@ -691,23 +1116,45 @@ after 4s (`DEFAULT_NOTIFICATION_DURATION`) unless a `duration` is given;
|
|
|
691
1116
|
auto-dismisses. `position` is `"top"` (default) or `"bottom"`. `globalUIStore`
|
|
692
1117
|
stays available for reactive selectors and tests.
|
|
693
1118
|
|
|
1119
|
+
A bottom toast renders in the root layout, above every navigator, so a layout
|
|
1120
|
+
that owns a bar registers where it ends with `useNotificationOffset` from
|
|
1121
|
+
`@mrmeg/expo-ui/hooks`; the toast then sits 8 pt past the larger of the
|
|
1122
|
+
safe-area inset and the offset. The offset is measured from the window edge and
|
|
1123
|
+
includes the inset, so a JS tab layout passes `useBottomTabBarHeight()` as-is
|
|
1124
|
+
and a `NativeTabs` layout passes the platform bar height plus
|
|
1125
|
+
`useWindowInsets().bottom`. Pass `null` while the bar is hidden (keyboard up).
|
|
1126
|
+
|
|
1127
|
+
```tsx
|
|
1128
|
+
import { Tabs } from "expo-router";
|
|
1129
|
+
import { useBottomTabBarHeight } from "@react-navigation/bottom-tabs";
|
|
1130
|
+
import { useNotificationOffset } from "@mrmeg/expo-ui/hooks";
|
|
1131
|
+
|
|
1132
|
+
function ToastOffset() {
|
|
1133
|
+
useNotificationOffset({ bottom: useBottomTabBarHeight() });
|
|
1134
|
+
return null;
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
// Render <ToastOffset /> inside a tab screen (it must be a descendant of <Tabs>).
|
|
1138
|
+
```
|
|
1139
|
+
|
|
694
1140
|
## Package Release
|
|
695
1141
|
|
|
696
1142
|
```sh
|
|
697
|
-
bun run ui
|
|
1143
|
+
bun run pkg ui release -- --patch --publish
|
|
698
1144
|
```
|
|
699
1145
|
|
|
700
|
-
Use `--patch`, `--minor`, `--major`, or an exact version such as `0.2.0
|
|
701
|
-
|
|
702
|
-
`bun run packages:peer-check`
|
|
703
|
-
`ui
|
|
704
|
-
`
|
|
705
|
-
|
|
706
|
-
|
|
1146
|
+
Use `--patch`, `--minor`, `--major`, or an exact version such as `0.2.0` (the
|
|
1147
|
+
committed version itself releases without a bump). The command updates
|
|
1148
|
+
`packages/ui/package.json` and `bun.lock`, runs `bun run packages:peer-check`
|
|
1149
|
+
then `bun run pkg ui typecheck`, `test`, and `build`, packs one tarball, runs
|
|
1150
|
+
`bun run pkg ui consumer-smoke -- --tarball <that tarball>`, and publishes that
|
|
1151
|
+
same tarball with `npm publish <tarball> --access public` only when
|
|
1152
|
+
`--publish` is present (which also requires a working `npm whoami`). Without
|
|
1153
|
+
`--publish` it is the same bump and gate run as a dry run.
|
|
707
1154
|
|
|
708
1155
|
A clean working tree is required; commit first or pass `--allow-dirty`.
|
|
709
1156
|
|
|
710
|
-
CI also installs and exports packed consumers against Expo 56 and
|
|
1157
|
+
CI also installs and exports packed consumers against Expo 56, 57, and 58
|
|
711
1158
|
(`.github/workflows/package-compatibility.yml`).
|
|
712
1159
|
|
|
713
1160
|
### GitHub Publishing
|
|
@@ -717,18 +1164,20 @@ publishing:
|
|
|
717
1164
|
|
|
718
1165
|
1. In npm package settings for `@mrmeg/expo-ui`, add a trusted publisher:
|
|
719
1166
|
GitHub Actions, owner `mrmeg`, repository `expo-template`, workflow filename
|
|
720
|
-
`publish-
|
|
1167
|
+
`publish-packages.yml`.
|
|
721
1168
|
2. Bump `packages/ui/package.json` in a commit and push it to `main`.
|
|
722
1169
|
|
|
723
|
-
`publish-
|
|
724
|
-
and on `workflow_dispatch`, using
|
|
725
|
-
local npm login. On push it
|
|
726
|
-
version
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
`
|
|
1170
|
+
`publish-packages.yml` publishes every workspace package. It runs on a push to
|
|
1171
|
+
`main` that changes a `packages/*/package.json` and on `workflow_dispatch`, using
|
|
1172
|
+
npm OIDC rather than a checked-in token or local npm login. On push it releases
|
|
1173
|
+
the committed version when that version changed in the push and npm does not
|
|
1174
|
+
have it yet, through the same release script as the local command, then
|
|
1175
|
+
publishes the smoked tarball with provenance and tags `expo-ui-v<version>`.
|
|
1176
|
+
Manual runs take `package=ui`, `version` (`patch`, `minor`, `major`, or exact
|
|
1177
|
+
`x.y.z`), and `ref` (default `main`); they bump the version and commit it to
|
|
1178
|
+
`ref`, run the gates, publish, and tag. If a manual run fails after the bump
|
|
1179
|
+
landed, rerun it with the exact current version (e.g. `version=0.1.3`) — the
|
|
1180
|
+
committed version does not bump again.
|
|
732
1181
|
|
|
733
1182
|
Keep `repository.url` in `package.json` as
|
|
734
1183
|
`git+https://github.com/mrmeg/expo-template.git`: npm trusted publishing checks
|
|
@@ -743,22 +1192,30 @@ CI configuration, never in the repository.
|
|
|
743
1192
|
|
|
744
1193
|
```sh
|
|
745
1194
|
bun run packages:peer-check
|
|
746
|
-
bun run ui
|
|
747
|
-
bun run ui
|
|
748
|
-
bun run ui
|
|
749
|
-
bun run ui
|
|
750
|
-
bun run ui
|
|
1195
|
+
bun run pkg ui typecheck
|
|
1196
|
+
bun run pkg ui test
|
|
1197
|
+
bun run pkg ui build
|
|
1198
|
+
bun run pkg ui pack
|
|
1199
|
+
bun run pkg ui consumer-smoke
|
|
751
1200
|
```
|
|
752
1201
|
|
|
753
|
-
`ui
|
|
754
|
-
inspected before release. `ui
|
|
755
|
-
a clean fixture, checks every documented export-map target resolves,
|
|
756
|
-
type-checks all public entrypoints,
|
|
757
|
-
|
|
1202
|
+
`pkg ui pack` is a dry pack, so the published file list and package size can be
|
|
1203
|
+
inspected before release. `pkg ui consumer-smoke` installs the packed tarball
|
|
1204
|
+
into a clean fixture, checks every documented export-map target resolves,
|
|
1205
|
+
type-checks all public entrypoints, imports the Node-safe subpaths
|
|
1206
|
+
(`constants/spacing`, `constants/motion`, `components/keyboardFocusRegistry`,
|
|
1207
|
+
`state/notify` with `state/globalUIStore`, `state/SsrViewportContext`) in plain
|
|
1208
|
+
Node, and runs iOS, Android, and web `expo export`s against the packed package
|
|
1209
|
+
at the workspace's Expo version, without a custom Metro config. The web export's
|
|
1210
|
+
source maps must show one copy of React, react-dom, react-native-web, and every
|
|
1211
|
+
`@radix-ui/*` / `@rn-primitives/*` module: a consumer install that resolves two
|
|
1212
|
+
copies of `@radix-ui/react-slot` crashes the web `AlertDialog` with
|
|
1213
|
+
`React.Children.only expected to receive a single React element child`. Pass
|
|
1214
|
+
`-- --tarball <file>` to smoke an existing tarball instead of building one.
|
|
758
1215
|
|
|
759
1216
|
### Design-system manifest
|
|
760
1217
|
|
|
761
|
-
`ui
|
|
1218
|
+
`pkg ui build` also writes `dist/design-system.json`
|
|
762
1219
|
([`scripts/build-design-system-manifest.mjs`](../../scripts/build-design-system-manifest.mjs)):
|
|
763
1220
|
the spacing, radius and icon scales, the palette, the light and dark themes, the
|
|
764
1221
|
font variants, every component's variant and size values, and `icons.names` (the
|
|
@@ -767,7 +1224,7 @@ rules use. It is exported as
|
|
|
767
1224
|
`@mrmeg/expo-ui/design-system.json`, and `@mrmeg/eslint-plugin-expo-ui` reads it
|
|
768
1225
|
in projects that install this package instead of checking out its sources — which
|
|
769
1226
|
is what lets the design-system rules quote this release's presets and tokens.
|
|
770
|
-
`ui
|
|
1227
|
+
`pkg ui consumer-smoke` asserts the packed tarball carries it and that it parses at
|
|
771
1228
|
`schemaVersion: 1` with a non-empty component list. The build fails rather than
|
|
772
1229
|
writing an empty manifest, so a broken extractor cannot ship as "no rules to
|
|
773
1230
|
enforce".
|