@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/LLM_USAGE.md
CHANGED
|
@@ -74,6 +74,25 @@ avoidance is `react-native-keyboard-controller`, so mount its `KeyboardProvider`
|
|
|
74
74
|
above `UIProvider`; use `KeyboardAvoidingView` directly only for a subtree with
|
|
75
75
|
custom behavior.
|
|
76
76
|
|
|
77
|
+
Web setup, two more steps (every web theme color is a `var(--c-*)`, and the
|
|
78
|
+
theme store boots at `"system"`/light so the first client render matches the
|
|
79
|
+
server or exported HTML):
|
|
80
|
+
|
|
81
|
+
1. `app/+html.tsx`: put `getThemeCssVariables()` (from `constants`) in the
|
|
82
|
+
global `<style>`, with the same overrides passed to `setColors` if the app
|
|
83
|
+
re-brands; without it every theme color is unset. Optionally add a blocking
|
|
84
|
+
inline script that sets `document.documentElement.dataset.theme` from
|
|
85
|
+
`localStorage[THEME_STORAGE_KEY]` (from `state`) or `prefers-color-scheme`,
|
|
86
|
+
so the first frame paints in the visitor's scheme.
|
|
87
|
+
2. Root layout: `useEffect(() => syncThemeFromEnvironment(), []);`
|
|
88
|
+
(`syncThemeFromEnvironment` from `state`). It reads the persisted preference
|
|
89
|
+
and follows the OS color scheme, and returns its cleanup. Never call it
|
|
90
|
+
during render or at module scope. Safe to call more than once: calls share
|
|
91
|
+
one OS listener and each cleanup releases only its own call. `UIProvider`
|
|
92
|
+
does not call it; without it a web app stays on the boot default until the
|
|
93
|
+
user picks a theme. Optional on native, which loads the preference at
|
|
94
|
+
startup.
|
|
95
|
+
|
|
77
96
|
`DismissKeyboard` owns tap dismissal; its ScrollView uses
|
|
78
97
|
`keyboardShouldPersistTaps="always"` so RN cannot independently blur a hosted
|
|
79
98
|
field after a non-scrolling drag. Use `always` for your own scroll view inside
|
|
@@ -87,17 +106,68 @@ platform owns gestures and keyboard avoidance: `swipeEnabled`, `avoidKeyboard`,
|
|
|
87
106
|
and `dismissKeyboardOnDrag` are accepted for call-site ergonomics but have no
|
|
88
107
|
effect. On iOS the sheet presentation lifts or shrinks the hosted content so
|
|
89
108
|
`Footer` and the tail of `Body` end at the keyboard's top (device-verified);
|
|
90
|
-
Android
|
|
109
|
+
on Android Material3 shrinks the sheet and `@expo/ui`'s `RNHostView` re-reports
|
|
110
|
+
its size to the shadow tree, which needs the app built against
|
|
111
|
+
`expo-modules-core` >= 57.0.4 (expo/expo#47778) — below that the column keeps
|
|
112
|
+
its detent height and `Footer` sits under the keyboard; the sheet warns once in
|
|
113
|
+
dev on Android, and the fix is `npx expo install --fix` plus a rebuild, never
|
|
114
|
+
padding in app code. Do not nest a `KeyboardAvoidingView` inside a
|
|
91
115
|
sheet. `BottomSheet.Content` mounts the tap-away keyboard-dismiss boundary on
|
|
92
116
|
its column, and `BottomSheet.Body` sets `keyboardShouldPersistTaps="always"` on
|
|
93
|
-
its ScrollView so that boundary owns dismissal; do not pass `never`.
|
|
94
|
-
|
|
117
|
+
its ScrollView so that boundary owns dismissal; do not pass `never`. Sheet
|
|
118
|
+
content stays in the screen's React tree although it is drawn in another
|
|
119
|
+
window, so a ScrollView *around* a `BottomSheet` on the default
|
|
120
|
+
`keyboardShouldPersistTaps="never"` claims the first tap on any sheet control
|
|
121
|
+
(`Footer` included) while a sheet field is focused and blurs the field instead:
|
|
122
|
+
give scroll views that contain a sheet `keyboardShouldPersistTaps="always"` (or
|
|
123
|
+
`"handled"`), or use `DismissKeyboard`; the sheet warns once in dev on Android
|
|
124
|
+
when it sees such a tap. `Slider` and `SegmentedControl` are also
|
|
125
|
+
`@expo/ui`-backed.
|
|
126
|
+
|
|
127
|
+
`BottomSheet` `onDismissed` fires once per close after the sheet is fully gone
|
|
128
|
+
on every platform (iOS: `@expo/ui`'s native SwiftUI `onDismiss` event, after
|
|
129
|
+
the transition; Android: `@expo/ui`'s post-animation close callback; web: the
|
|
130
|
+
HTML `<dialog>` `close` event at the end of the exit animation). Open the next
|
|
131
|
+
modal — a `Dialog`, a native stack modal — from `onDismissed`, never from
|
|
132
|
+
`onOpenChange(false)` or a timer: on iOS the sheet is still dismissing when
|
|
133
|
+
`onOpenChange` fires and a `Modal` presented then is rejected.
|
|
95
134
|
|
|
96
135
|
`BottomSheet.Content` themes the native sheet surface with the card color. Pass
|
|
97
136
|
`backgroundStyle={{ backgroundColor: "transparent" }}`, plus a `style` clearing
|
|
98
137
|
the content column's card fill, when custom chrome such as a glass backdrop must
|
|
99
138
|
show through.
|
|
100
139
|
|
|
140
|
+
`Dialog` and `AlertDialog` present their content through React Native's `Modal`
|
|
141
|
+
on iOS, rendered inline where the dialog sits (transparent, `overFullScreen`,
|
|
142
|
+
unanimated; the package fade/scale run inside), so it stacks above whatever
|
|
143
|
+
screen, native stack modal or sheet contains it; `portalHost` is honored on
|
|
144
|
+
Android and web only. `@expo/ui`-hosted controls (`TextInput`, `Slider`,
|
|
145
|
+
`SegmentedControl`) need a view controller above them and render as empty,
|
|
146
|
+
unfocusable boxes inside react-native-screens' `FullWindowOverlay`, which
|
|
147
|
+
`Drawer`, `Popover`, `Select`, `DropdownMenu` and `Tooltip` still use on iOS —
|
|
148
|
+
keep hosted controls out of those, and do not place a `Dialog` inside their
|
|
149
|
+
content (no view controller to present from; render it at screen level and open
|
|
150
|
+
it from the item's `onPress`). The dialog owns keyboard avoidance on iOS and
|
|
151
|
+
Android (its content sits outside `UIProvider`'s root avoidance on both: the iOS
|
|
152
|
+
Modal and the Android portal host): the centered container is a package
|
|
153
|
+
`KeyboardAvoidingView` (`behavior="padding"`), the card recenters above the
|
|
154
|
+
keyboard with fields and footer visible, and `useKeyboardAvoidance()` is `true`
|
|
155
|
+
in dialog content; do not wrap dialog content in another `KeyboardAvoidingView`.
|
|
156
|
+
Web has no software keyboard and no avoidance owner. Dialog content owns tap-away
|
|
157
|
+
keyboard dismissal on iOS and Android (the same boundary as `DismissKeyboard`,
|
|
158
|
+
on the `Dialog` card itself and on the `AlertDialog` centered container:
|
|
159
|
+
dead-space taps hide the keyboard on release, a `Dialog` backdrop tap closes the
|
|
160
|
+
dialog and the keyboard with it, controls and fields fire on the first tap); do
|
|
161
|
+
not wrap dialog content in `DismissKeyboard` for that. Android and web render dialog content inline into
|
|
162
|
+
the portal host.
|
|
163
|
+
|
|
164
|
+
`PopoverContent` treats `side` as a preference: on iOS and Android it opens on
|
|
165
|
+
the other side when the content does not fit and there is more room there,
|
|
166
|
+
caps its height to the room and scrolls the children (pass `scrollable={false}`
|
|
167
|
+
for content with its own `FlatList`; it then stays on `side`). `insets` default
|
|
168
|
+
to the safe area, and a `style` merges over the themed surface rather than
|
|
169
|
+
replacing it. A `PopoverTrigger` ref (`PopoverTriggerRef`) has `open()` / `close()`.
|
|
170
|
+
|
|
101
171
|
i18n is optional. Do not add app-level i18n setup just to use this package;
|
|
102
172
|
plain children and `text` props work without `i18next` or `react-i18next`. `tx`
|
|
103
173
|
props render their fallback text when provided, otherwise the key. Package-owned
|
|
@@ -115,9 +185,15 @@ configureExpoUiI18n((key, options) => i18n.t(key, options));
|
|
|
115
185
|
- Use `useTheme()` and semantic tokens instead of hardcoded colors.
|
|
116
186
|
- Use `StyledText` or its semantic aliases instead of raw `Text` for app UI.
|
|
117
187
|
- Use `Button.preset`, not `variant`.
|
|
188
|
+
- `disabled` blocks the press (no `onPress`, no scale/haptic, out of the web tab order). To keep a button focusable and pressable while announced and dimmed as disabled — so the press can explain why — use `aria-disabled` (or `accessibilityState={{ disabled: true }}`), never a hand-rolled opacity style.
|
|
118
189
|
- Button visible heights: `sm` 28, `md` 32, `lg` 40. `TextInput`/`Select`: 32/36/40. `Toggle` sizes are `sm`/`default`/`lg` (32/36/40). `Tabs`: `sm`/`md` (32/36).
|
|
119
190
|
- Use `Button size="sm"` for compact popover, tooltip, and toolbar triggers; nested `StyledText` inherits the Button size.
|
|
120
|
-
-
|
|
191
|
+
- Web SSR paints the visitor's scheme: wrap the root layout in `<InitialSchemeProvider scheme>` (`@mrmeg/expo-ui/state`) with the `"light"`/`"dark"` value read from a cookie on both server and client (template: `shared/ssrColorScheme.ts`, written by `+html.tsx` and `client/features/app/colorSchemeCookie.ts`); `useTheme()` uses it until `hasLoadedTheme`. Never read `localStorage` for the first render.
|
|
192
|
+
- A Radix-backed component (`Tabs`, `Accordion`, `Collapsible`, `Select`, `DropdownMenu`, `Popover`) on a server-rendered web route logs a `useId` hydration mismatch (Expo Router hydrates `#root`, the server rendered `+html.tsx`). Wrap that subtree — not the page — in `<Hydrated fallback={…}>` (`@mrmeg/expo-ui/components`) with a fallback that holds its height; never branch on `typeof window`. Content that must be in the server HTML stays outside the gate.
|
|
193
|
+
- Web console hygiene: animate with `useNativeDriver: shouldUseNativeDriver` (`@mrmeg/expo-ui/lib`), never `true`; set `pointerEvents` in `style`, never as a prop (react-native-web deprecates the prop, and its lazy warning module has overflowed the first SSR render of deep routes). `AnimatedView` folds a `pointerEvents` prop into style for you.
|
|
194
|
+
- Haptics are one setting: `<UIProvider haptics="off" | "selection" | "all">` (default `"selection"`: Switch/Checkbox/Toggle/ToggleGroup/SegmentedControl tap on change; `"all"` adds a light tap on press for Button, pressable Card and Item). Do not call `expo-haptics` per control; force one Button with `haptic`. Pressed/disabled looks come from `interaction.pressedOpacity` / `interaction.disabledOpacity` in `@mrmeg/expo-ui/constants`.
|
|
195
|
+
- Bottom toasts must clear the tab bar: the layout that owns a bar calls `useNotificationOffset({ bottom })` (`@mrmeg/expo-ui/hooks`) with the bar's distance from the window edge, inset included — `useBottomTabBarHeight()` for a JS `<Tabs>` layout, platform bar height + `useWindowInsets().bottom` for `NativeTabs` — and `null` while the bar is hidden. Never position a toast yourself.
|
|
196
|
+
- Use `notify` plus a root `UIProvider` for transient global feedback. (`globalUIStore` stays available for reactive subscriptions and tests. In a component, read it with zustand's `useStore(globalUIStore, selector)`, never `globalUIStore()`: the React Compiler only treats `use*` calls as hooks, caches the bare call, and the next render crashes with React error #311.)
|
|
121
197
|
- Keep app monitoring, auth, API, and domain behavior outside this package.
|
|
122
198
|
|
|
123
199
|
Semantic color tokens on `theme.colors`: `surfaceSunken`, `background`,
|
|
@@ -134,11 +210,19 @@ Token intent:
|
|
|
134
210
|
- `surfaceSunken`: app-chrome surface one tier below `background`
|
|
135
211
|
- `borderStrong`: hairline for elements on filled surfaces, where `border` blends in
|
|
136
212
|
|
|
137
|
-
Elevation is a surface-tier ladder,
|
|
138
|
-
`background` (content) < `card`/`popover` (raised) <
|
|
213
|
+
Elevation is a surface-tier ladder plus, in dark, an edge highlight:
|
|
214
|
+
`surfaceSunken` (chrome) < `background` (content) < `card`/`popover` (raised) <
|
|
215
|
+
`muted` (chips, insets). `getShadowStyle` is scheme-aware: in dark the raised
|
|
216
|
+
presets (`subtle`, `soft`, `elevated`, `card`, `cardSubtle`, `cardHover`,
|
|
217
|
+
`glass`) start with a 1px inset white top highlight and use black shadows at
|
|
218
|
+
real alpha; in light it is the shadow alone, at about twice the old alpha. A
|
|
219
|
+
fork that overrides `background`, `card`, or `popover` should re-check against
|
|
220
|
+
the template's `/surface-lab`.
|
|
139
221
|
|
|
140
222
|
On web every `theme.colors.*` value is a CSS custom property (`var(--c-*)`), so
|
|
141
|
-
themes swap in CSS when `html[data-theme]` changes; native keeps literals.
|
|
223
|
+
themes swap in CSS when `html[data-theme]` changes; native keeps literals. Both
|
|
224
|
+
web schemes share one `colors` object (`colors.light.colors ===
|
|
225
|
+
colors.dark.colors`); re-brand with `setColors`, never by mutating it. Hex
|
|
142
226
|
alpha concatenation (`theme.colors.x + "15"`) does **not** work — use
|
|
143
227
|
`withAlpha(theme.colors.x, 0.08)`, exported standalone from `hooks` and from
|
|
144
228
|
`useTheme()`. For sinks that cannot take `var()` (e.g. `<meta name="theme-color">`)
|
|
@@ -158,19 +242,24 @@ Use `useStyles()` for memoized theme-aware local styles. Its factory receives
|
|
|
158
242
|
|
|
159
243
|
```tsx
|
|
160
244
|
const { styles } = useStyles(({ theme, spacing, withAlpha }) => ({
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
245
|
+
content: {
|
|
246
|
+
paddingVertical: spacing.md,
|
|
247
|
+
gap: spacing.sectionSpacing,
|
|
248
|
+
},
|
|
249
|
+
selectedIcon: {
|
|
250
|
+
backgroundColor: withAlpha(theme.colors.primary, 0.12),
|
|
164
251
|
},
|
|
165
252
|
}));
|
|
166
253
|
```
|
|
167
254
|
|
|
168
255
|
Layout spacing uses semantic density tokens, not raw scale steps:
|
|
169
|
-
`spacing.screenPadding` (16) for screen
|
|
170
|
-
|
|
256
|
+
`spacing.screenPadding` (16) for the one horizontal inset of a screen or block,
|
|
257
|
+
`spacing.sectionSpacing` (24) between sections and `ItemGroup`s,
|
|
171
258
|
`spacing.dialogPadding` (20) for dialogs, and `spacing.rowPaddingY`/`rowPaddingX`
|
|
172
259
|
(10/16) with `spacing.rowGap` (12) for list rows. `Item` already applies the row
|
|
173
260
|
tokens and keeps a 44px hit area on native while rendering 40px on web.
|
|
261
|
+
`spacing.cardPadding` (16) is the inner padding of `Card` and `StatCard` tiles,
|
|
262
|
+
not a layout inset. See Screen Layout below.
|
|
174
263
|
|
|
175
264
|
When the saved theme preference is `system`, the package theme store owns the OS
|
|
176
265
|
color-scheme subscription, including web `prefers-color-scheme`. Do not add
|
|
@@ -193,10 +282,26 @@ sansSerif?, serif?, mono? }, webWeightStrategy? })` replaces the bundled faces
|
|
|
193
282
|
(Inter / Georgia / system-mono) everywhere text renders; groups and weights are
|
|
194
283
|
partial, missing weights fall back to that group's `regular`, and an overridden
|
|
195
284
|
`sansSerif` makes `useResources` skip downloading Inter (call `setFonts` before
|
|
196
|
-
mount for the skip).
|
|
285
|
+
mount for the skip). Each group also takes `italic: { weight: face }`; `<StyledText
|
|
286
|
+
italic>` (or `useFontStyle(weight, variant, { italic: true })`) uses that face,
|
|
287
|
+
else the group's italic `regular`, else `fontStyle: "italic"` on the upright face.
|
|
288
|
+
`useResources({ serif: "newsreader", serifFonts })` swaps Georgia for the
|
|
289
|
+
Newsreader preset (four weights + a 400 italic): the app depends on
|
|
290
|
+
`@expo-google-fonts/newsreader` and passes the five per-weight faces on native;
|
|
291
|
+
web gets one stylesheet (`id="mrmeg-expo-ui-newsreader"`). A `setFonts` serif
|
|
292
|
+
override still wins. Use `webWeightStrategy: "family"` when loading per-weight
|
|
197
293
|
faces through `expo-font` / `@expo-google-fonts`, `"numeric"` (default) for one
|
|
198
|
-
multi-weight CSS family. `setShape({ button
|
|
199
|
-
re-shapes
|
|
294
|
+
multi-weight CSS family. `setShape({ button?, input?, card?, sheet?, badge?,
|
|
295
|
+
dialog? })` re-shapes a slot globally (`{ borderRadius }`; `button` also takes
|
|
296
|
+
`withShadow`): `input` reaches TextInput, Select and InputOTP; `card` reaches
|
|
297
|
+
Card, StatCard, bordered EmptyState and SkeletonCard; `sheet` the BottomSheet's
|
|
298
|
+
top corners; `dialog` Dialog and AlertDialog. Per-instance props and caller
|
|
299
|
+
`style` always win. Extra color tokens are declared once by augmenting
|
|
300
|
+
`ThemeColorExtensions` (`declare module "@mrmeg/expo-ui/constants" { interface
|
|
301
|
+
ThemeColorExtensions { brandGold: string } }`); they then type through
|
|
302
|
+
`setColors`, `ThemeColorScope`, `theme.colors`, and `getThemeCssVariables`
|
|
303
|
+
emits `--c-brand-gold` when the overrides name it. Provide every extension key
|
|
304
|
+
in both schemes.
|
|
200
305
|
|
|
201
306
|
Enforce these rules mechanically with `@mrmeg/eslint-plugin-expo-ui`
|
|
202
307
|
(`bun add -d @mrmeg/eslint-plugin-expo-ui`), which reports raw colors, off-scale
|
|
@@ -210,6 +315,60 @@ added ship one: the installed release has it when
|
|
|
210
315
|
settings:
|
|
211
316
|
https://raw.githubusercontent.com/mrmeg/expo-template/main/packages/lint/README.md
|
|
212
317
|
|
|
318
|
+
## Screen Layout
|
|
319
|
+
|
|
320
|
+
Build screens flat. On a 390 pt phone every nested inset comes out of the
|
|
321
|
+
content: a settings group boxed inside a padded screen puts its rows 33 pt from
|
|
322
|
+
each edge (16 screen + 1 border + 16 row) and leaves 324 pt for them; the same
|
|
323
|
+
rows in an `ItemGroup` sit 16 pt in and get 358 pt.
|
|
324
|
+
|
|
325
|
+
- **Every screen root is `Screen`, with explicit `edges`.** `<Screen
|
|
326
|
+
edges={["bottom"]} scroll>` under a Stack header; `edges={["top"]}` above a
|
|
327
|
+
tab bar; `["top", "bottom"]` for a headerless screen or a modal; `[]` when the
|
|
328
|
+
navigator insets both. Never pad `insets.top` under a header or
|
|
329
|
+
`insets.bottom` above a tab bar (double padding), and never leave a headerless
|
|
330
|
+
screen without `"top"` (content under the Dynamic Island). `scroll` puts the
|
|
331
|
+
insets on the content container; `padded={false}` for full-bleed `ItemGroup`
|
|
332
|
+
rows; `contentContainerStyle` padding adds to the insets. Read insets with
|
|
333
|
+
`useWindowInsets()` (works inside modals and native sheets too), not
|
|
334
|
+
`useSafeAreaInsets()`.
|
|
335
|
+
- **One horizontal inset per screen: `spacing.screenPadding` (16).** Either a
|
|
336
|
+
container pads or its children do, never both. `Item` rows, and `ItemGroup`
|
|
337
|
+
titles and footers, carry the 16 themselves, so a scroll view of
|
|
338
|
+
`ItemGroup`s gets no horizontal padding; text, forms, and buttons between the
|
|
339
|
+
groups pad their own wrapper.
|
|
340
|
+
- **No boxes as layout.** Never wrap a section, a form, or a group of rows in a
|
|
341
|
+
bordered, shadowed, or tinted rounded `View`, or in a `Card`. Separate
|
|
342
|
+
sections with a header (an `ItemGroup` title, `SectionHeader`),
|
|
343
|
+
`spacing.sectionSpacing`, or a hairline (`Separator`).
|
|
344
|
+
- **Lists and settings: `ItemGroup` + `Item`.** Full-width rows and touch
|
|
345
|
+
targets, hairlines inset under the title, no card per row, no border around
|
|
346
|
+
the group. Pass the rows as direct children (a mapped array works; a
|
|
347
|
+
Fragment counts as one row) and leave `separator` off: the group draws the
|
|
348
|
+
line under every row but the last. A `Switch`, `Checkbox` or `Toggle` in
|
|
349
|
+
`ItemActions` is named by the row's `ItemTitle` automatically (its text as
|
|
350
|
+
`accessibilityLabel` / `aria-label`; a composed title links by id after
|
|
351
|
+
hydration); pass `accessibilityLabel` only when the title is not the right
|
|
352
|
+
name.
|
|
353
|
+
- **Forms:** fields span the column, grouped under section headers, not cards.
|
|
354
|
+
- **Primary content fills the width.** QR codes, photos, maps, media, and
|
|
355
|
+
charts size to the column (`width: "100%"` with `aspectRatio`, or a size
|
|
356
|
+
computed from the column width), never a fixed size floating in whitespace.
|
|
357
|
+
A photo library is a grid of width-sized tiles, not rows of 56 pt thumbnails.
|
|
358
|
+
- **Wide screens:** cap and centre the column instead of boxing it: `width:
|
|
359
|
+
"100%", maxWidth: 640, alignSelf: "center"` on the scroll view's
|
|
360
|
+
`contentContainerStyle` (960 for dense grids and dashboards).
|
|
361
|
+
- **When a `Card` is right:** one item in a collection (a feed entry, a grid
|
|
362
|
+
tile, a carousel slide) or a single tappable object. Its parts pad
|
|
363
|
+
`spacing.cardPadding`, a tile's inner padding. Never nest Cards or put one in
|
|
364
|
+
a padded panel. `StatCard` follows the same rule (tiles in a scrolling
|
|
365
|
+
metrics rail, or one tappable metric); a summary of numbers is flat text on
|
|
366
|
+
the gutter.
|
|
367
|
+
- Dialog, sheet, and popover chrome keep their own surfaces; these rules are
|
|
368
|
+
for screen content.
|
|
369
|
+
|
|
370
|
+
The first Minimal Example below is a complete flat screen.
|
|
371
|
+
|
|
213
372
|
## Component Use-Case Index
|
|
214
373
|
|
|
215
374
|
Check this before creating a new app-local primitive. All components come from
|
|
@@ -224,7 +383,7 @@ Check this before creating a new app-local primitive. All components come from
|
|
|
224
383
|
| `Badge` | Short status labels | Custom pill `View` + `Text` |
|
|
225
384
|
| `BottomSheet` | Mobile-first modal sheets | Custom absolute-position sheets |
|
|
226
385
|
| `Button` | Commands and CTAs | Pressable plus custom text styling |
|
|
227
|
-
| `Card`, `CardHeader`, `CardTitle`, `CardDescription`, `CardContent`, `CardFooter` |
|
|
386
|
+
| `Card`, `CardHeader`, `CardTitle`, `CardDescription`, `CardContent`, `CardFooter` | One item in a collection (feed entry, grid tile, carousel slide) or a single tappable object | Hand-rolled bordered tiles; never a box around a section, form, or row group |
|
|
228
387
|
| `Carousel` | Horizontally snapping slide row with pressable dots | Snap `ScrollView` plus manual offset math |
|
|
229
388
|
| `Checkbox` | Boolean selection | Custom checkmark controls |
|
|
230
389
|
| `Collapsible`, `CollapsibleTrigger`, `CollapsibleContent` | One-off disclosure | Local animated height wrappers |
|
|
@@ -237,10 +396,12 @@ Check this before creating a new app-local primitive. All components come from
|
|
|
237
396
|
| `Icon` | Lucide or custom icons with theme tokens | Raw vector icons with hardcoded colors |
|
|
238
397
|
| `InputOTP` | Verification code entry | Several manually managed text inputs |
|
|
239
398
|
| `Item`, `ItemMedia`, `ItemContent`, `ItemTitle`, `ItemDescription`, `ItemActions` | List / settings rows with density tokens | Hand-rolled row `View`s |
|
|
399
|
+
| `ItemGroup` | A titled group of full-width `Item` rows with inset hairlines and an optional footer | Bordered or shadowed boxes around rows, a `Card` per row, hand-drawn dividers |
|
|
240
400
|
| `KeyboardAvoidingView` | Native keyboard-aware layout root | Repeated app-local keyboard wrappers |
|
|
241
401
|
| `Label` | Accessible form labels | Plain styled text labels |
|
|
242
402
|
| `MaxWidthContainer` | Centered responsive width | Per-screen max-width wrappers |
|
|
243
403
|
| `Notification` | Global toast surface | Screen-local toast state |
|
|
404
|
+
| `Screen` | Page container with required safe-area `edges`, one horizontal inset, optional scroll | Hand-rolled `useSafeAreaInsets` padding, `SafeAreaView` per screen |
|
|
244
405
|
| `Popover` | Anchored contextual content | Custom anchored views |
|
|
245
406
|
| `Progress` | Determinate or indeterminate progress | Layout-shifting spinners |
|
|
246
407
|
| `RadioGroup`, `RadioGroupItem` | Mutually exclusive choices | Custom radio rows |
|
|
@@ -250,13 +411,13 @@ Check this before creating a new app-local primitive. All components come from
|
|
|
250
411
|
| `Separator` | Horizontal or vertical dividers | Border-only spacer views |
|
|
251
412
|
| `Skeleton`, `SkeletonText`, `SkeletonAvatar`, `SkeletonCard` | Loading placeholders | Blank space or generic spinners |
|
|
252
413
|
| `Slider` | Numeric value selection (`@expo/ui`) | Custom pan gesture track |
|
|
253
|
-
| `StatCard` | Metric tile
|
|
414
|
+
| `StatCard` | Metric tile in a collection (a scrolling KPI rail) or one tappable metric | Hand-rolled dashboard tiles |
|
|
254
415
|
| `StatusBar` | Theme-aware native status bar | Per-screen status-bar duplication |
|
|
255
416
|
| `StyledText` and text aliases | Theme-aware typography | Raw `Text` with hardcoded styles |
|
|
256
417
|
| `Switch` | Binary settings | Custom toggle switches |
|
|
257
418
|
| `Tabs`, `TabsList`, `TabsTrigger`, `TabsContent` | In-page tabbed views | Custom segmented/tab controls |
|
|
258
419
|
| `TextInput` | Text entry with label, helper/error text, clear, password reveal | Raw `TextInput` plus repeated label/error code |
|
|
259
|
-
| `Toggle`, `ToggleIcon` | Pressed/unpressed control | Button with local selected styling |
|
|
420
|
+
| `Toggle`, `ToggleIcon` | Pressed/unpressed control; an `iconOnly` toggle needs `accessibilityLabel` (dev warns once) | Button with local selected styling |
|
|
260
421
|
| `ToggleGroup`, `ToggleGroupItem`, `ToggleGroupIcon` | Single or multi toggle groups | Custom segmented controls |
|
|
261
422
|
| `Tooltip` | Short hover/focus help | Persistent helper text or custom hover cards |
|
|
262
423
|
|
|
@@ -278,29 +439,96 @@ already scope label selectability; ordinary iOS/web text stays selectable.
|
|
|
278
439
|
|
|
279
440
|
- `Button` commands · `Toggle` one pressed state · `ToggleGroup` a related set · `Switch` binary settings · `RadioGroup` few exclusive choices · `Select` longer option sets.
|
|
280
441
|
- `Dialog` blocking decisions · `Popover` contextual controls · `Tooltip` short explanations · `DropdownMenu` action lists.
|
|
281
|
-
- `Card`
|
|
442
|
+
- `ItemGroup` + `Item` lists and settings · `Card` one item in a collection or one tappable object, never a wrapper around a section, form, or row group, never nested · `EmptyState` no-data or recoverable errors · `Skeleton` loading content with stable layout · `Progress` real or indeterminate progress.
|
|
282
443
|
- `Carousel` for a horizontal snap row of a known, small set of slides. It renders every child (no virtualization), so slides survive into the exported HTML shell and the first client frame; use `FlatList` for large or unbounded data. Dots are pressable and jump to their slide. A fractional `itemWidth` (default `0.85`) measures the viewport until the first layout, so pass absolute pixels (`> 1`) when the parent is narrower than the window and the first frame matters.
|
|
283
444
|
- `Avatar` with both `source` and `name` whenever both exist: `name` supplies the initials shown when the image is absent, still loading, or failed, plus the default accessibility label. Inside `AvatarGroup`, set `size`/`shape` on the group — children inherit them and gain the ring; the group's count is a hidden summary node, so each member stays individually announceable and the group needs no `accessible` wrapper.
|
|
284
445
|
- Pair a standalone `Label` with its control using two DISTINCT ids: `nativeID` is the label's own id, `htmlFor` is the input's id (`<Label nativeID="email-label" htmlFor="email-input">` + `<TextInput nativeID="email-input" />`). One id on both renders duplicate ids on web and associates nothing. Prefer `TextInput`'s own `label` prop when no separate label element is needed.
|
|
446
|
+
- A plain `View` whose `opacity`/`pointerEvents` follows state must be `collapsable={false}` on Android (Fabric re-parents its children on the flip; a flip racing a navigation pop crashes). Package surfaces are pinned; spread `stateSurfaceProps()` from `@mrmeg/expo-ui/lib` on app-owned ones. `Pressable` already pins itself.
|
|
285
447
|
- `TextInput` `onBlur` fires only after a real focus on Android (the native field's first-composition blur is dropped in the package); validate on blur directly and do not add a touched-fields guard in app code.
|
|
286
448
|
- `Drawer.Header` takes `icon`, `title`, and `action` slots for a compact app-brand row; put `Drawer.ToggleCollapse` in `action` for a trailing rail control. `Drawer.Content` owns safe-area top/bottom padding — do not duplicate it in children.
|
|
449
|
+
- `Icon` and `Button.Icon` take `name` (a kebab-case name from the package registry, typed `IconName`) or `component` (any Lucide import such as `import House from "lucide-react-native/icons/house"`, or another SVG component taking `size` and `color`). Both are sized, colored with theme tokens, and hidden from assistive tech unless `accessibilityLabel` names a standalone icon (`decorative` only silences a labelled one); `Button.Icon` inherits the button's label color and is decorative by default. Use `component` for icons outside the registry instead of rendering raw Lucide components.
|
|
287
450
|
|
|
288
451
|
## Minimal Examples
|
|
289
452
|
|
|
453
|
+
A flat settings screen: groups of full-width rows, one 16 pt inset, a capped
|
|
454
|
+
column on wide screens, no boxes.
|
|
455
|
+
|
|
290
456
|
```tsx
|
|
291
|
-
import {
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
457
|
+
import { useState } from "react";
|
|
458
|
+
import { ScrollView, StyleSheet, View } from "react-native";
|
|
459
|
+
import {
|
|
460
|
+
Button,
|
|
461
|
+
Icon,
|
|
462
|
+
Item,
|
|
463
|
+
ItemActions,
|
|
464
|
+
ItemContent,
|
|
465
|
+
ItemDescription,
|
|
466
|
+
ItemGroup,
|
|
467
|
+
ItemMedia,
|
|
468
|
+
ItemTitle,
|
|
469
|
+
Switch,
|
|
470
|
+
} from "@mrmeg/expo-ui/components";
|
|
471
|
+
import { spacing } from "@mrmeg/expo-ui/constants";
|
|
472
|
+
|
|
473
|
+
export function SettingsScreen({ onEditProfile, onSignOut }: { onEditProfile: () => void; onSignOut: () => void }) {
|
|
474
|
+
const [push, setPush] = useState(true);
|
|
475
|
+
|
|
476
|
+
return (
|
|
477
|
+
<ScrollView contentContainerStyle={styles.content}>
|
|
478
|
+
<ItemGroup title="Account">
|
|
479
|
+
<Item onPress={onEditProfile}>
|
|
480
|
+
<ItemMedia icon="user" />
|
|
481
|
+
<ItemContent>
|
|
482
|
+
<ItemTitle>Edit profile</ItemTitle>
|
|
483
|
+
</ItemContent>
|
|
484
|
+
<ItemActions>
|
|
485
|
+
<Icon name="chevron-right" size={18} color="mutedForeground" />
|
|
486
|
+
</ItemActions>
|
|
487
|
+
</Item>
|
|
488
|
+
<Item>
|
|
489
|
+
<ItemMedia icon="bell" />
|
|
490
|
+
<ItemContent>
|
|
491
|
+
<ItemTitle>Push notifications</ItemTitle>
|
|
492
|
+
<ItemDescription>Replies and mentions</ItemDescription>
|
|
493
|
+
</ItemContent>
|
|
494
|
+
<ItemActions>
|
|
495
|
+
<Switch checked={push} onCheckedChange={setPush} />
|
|
496
|
+
</ItemActions>
|
|
497
|
+
</Item>
|
|
498
|
+
</ItemGroup>
|
|
499
|
+
|
|
500
|
+
<ItemGroup title="About" footer="Version 1.4.0">
|
|
501
|
+
<Item>
|
|
502
|
+
<ItemContent>
|
|
503
|
+
<ItemTitle>Build</ItemTitle>
|
|
504
|
+
</ItemContent>
|
|
505
|
+
<ItemActions>
|
|
506
|
+
<ItemDescription>2026.09.25</ItemDescription>
|
|
507
|
+
</ItemActions>
|
|
508
|
+
</Item>
|
|
509
|
+
</ItemGroup>
|
|
510
|
+
|
|
511
|
+
{/* Not rows, so it pads its own wrapper to the same 16 pt gutter. */}
|
|
512
|
+
<View style={styles.gutter}>
|
|
513
|
+
<Button preset="destructive" fullWidth text="Sign out" onPress={onSignOut} />
|
|
514
|
+
</View>
|
|
515
|
+
</ScrollView>
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const styles = StyleSheet.create({
|
|
520
|
+
// No horizontal padding: the rows carry the screen's inset.
|
|
521
|
+
content: {
|
|
522
|
+
width: "100%",
|
|
523
|
+
maxWidth: 640,
|
|
524
|
+
alignSelf: "center",
|
|
525
|
+
paddingVertical: spacing.md,
|
|
526
|
+
gap: spacing.sectionSpacing,
|
|
527
|
+
},
|
|
528
|
+
gutter: {
|
|
529
|
+
paddingHorizontal: spacing.screenPadding,
|
|
530
|
+
},
|
|
531
|
+
});
|
|
304
532
|
```
|
|
305
533
|
|
|
306
534
|
```tsx
|