@mrmeg/expo-ui 0.15.0 → 0.16.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 +58 -0
- package/README.md +44 -0
- package/dist/components/Badge.js +4 -2
- package/dist/components/Button.js +8 -2
- package/dist/components/Card.js +5 -3
- package/dist/components/EmptyState.js +3 -2
- package/dist/components/InputOTP.js +4 -2
- package/dist/components/Label.js +3 -2
- package/dist/components/Notification.js +4 -2
- package/dist/components/Skeleton.js +3 -1
- package/dist/components/StyledText.js +28 -11
- package/dist/components/TextInput.js +9 -2
- package/dist/constants/fonts.d.ts +44 -8
- package/dist/constants/fonts.js +7 -0
- package/dist/lib/index.d.ts +1 -0
- package/dist/lib/index.js +1 -0
- package/dist/lib/themedStyles.d.ts +21 -0
- package/dist/lib/themedStyles.js +33 -0
- package/dist/state/themeStore.d.ts +17 -0
- package/dist/state/themeStore.js +6 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,64 @@
|
|
|
3
3
|
All notable changes to `@mrmeg/expo-ui` are documented here. This project
|
|
4
4
|
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
5
5
|
|
|
6
|
+
## [0.16.0]
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- **`setFonts` — font injection, mirroring `setColors`.** A host app can now
|
|
11
|
+
forward its own bundled faces to the package:
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
useThemeStore.getState().setFonts({
|
|
15
|
+
families: { sansSerif: { light: "…", regular: "…", medium: "…",
|
|
16
|
+
semibold: "…", bold: "…" } },
|
|
17
|
+
webWeightStrategy: "family",
|
|
18
|
+
});
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Overrides are partial and per-group — pass `sansSerif` alone and `serif`
|
|
22
|
+
keeps the package default. `setFonts({})` clears back to the defaults.
|
|
23
|
+
`StyledText` subscribes to the store, so a call after mount re-renders
|
|
24
|
+
rather than leaving stale families on screen.
|
|
25
|
+
|
|
26
|
+
- **`webWeightStrategy` — `"numeric" | "family"`.** Declares how a family map
|
|
27
|
+
expresses weight. `"numeric"` (the package default on web) assumes one
|
|
28
|
+
multi-weight CSS family where a numeric `fontWeight` selects the
|
|
29
|
+
`@font-face` variant. `"family"` means each weight is its own
|
|
30
|
+
separately-registered single-face family, so the family name alone carries
|
|
31
|
+
the weight and `StyledText` suppresses the numeric `fontWeight`. Native is
|
|
32
|
+
always `"family"`, unchanged.
|
|
33
|
+
|
|
34
|
+
Apps loading per-weight faces through `expo-font` need `"family"` on web:
|
|
35
|
+
`expo-font` registers each file as its own family there too, so the previous
|
|
36
|
+
always-numeric behaviour synthesised a second layer of bold on top of an
|
|
37
|
+
already-bold face.
|
|
38
|
+
|
|
39
|
+
- Exported `FontFamilyMap`, `FontFamilyWeight`, `FontOverrides`,
|
|
40
|
+
`FontWeightStrategy`, and `defaultWebWeightStrategy` from `constants/fonts`.
|
|
41
|
+
|
|
42
|
+
- **`createThemedStyles` — SSR-safe themed style registration**, exported from
|
|
43
|
+
`lib`. Wraps a `(theme: Theme) => styles` factory so both base themes are
|
|
44
|
+
registered with react-native-web at module scope — before expo-router
|
|
45
|
+
snapshots the server-side stylesheet — instead of lazily during render via
|
|
46
|
+
`useMemo`, which left SSR HTML referencing class names with no rules
|
|
47
|
+
(unstyled first paint until hydration). Override themes are cached per theme
|
|
48
|
+
object identity. The package's own components (Badge, Button, Card,
|
|
49
|
+
EmptyState, InputOTP, Label, Notification, Skeleton, TextInput) now use it.
|
|
50
|
+
|
|
51
|
+
### Why
|
|
52
|
+
|
|
53
|
+
`fontFamilies` was a module-level `const` with no injection point, so the only
|
|
54
|
+
way for a consumer to use its own type was to patch `dist/` inside
|
|
55
|
+
`node_modules`. Bun keys `patchedDependencies` on an exact `name@version`, so
|
|
56
|
+
those patches stop applying — silently, with no warning — the moment the
|
|
57
|
+
package version is bumped. Two consumer apps hit exactly that and shipped
|
|
58
|
+
fallback fonts without noticing. Colors already had `setColors` for this
|
|
59
|
+
reason; fonts now match.
|
|
60
|
+
|
|
61
|
+
Fully backward compatible: with no `setFonts` call, font resolution is
|
|
62
|
+
identical to 0.15.0.
|
|
63
|
+
|
|
6
64
|
## [0.15.0]
|
|
7
65
|
|
|
8
66
|
### Added
|
package/README.md
CHANGED
|
@@ -163,6 +163,50 @@ import { ThemeColorScope } from "@mrmeg/expo-ui/state";
|
|
|
163
163
|
</ThemeColorScope>
|
|
164
164
|
```
|
|
165
165
|
|
|
166
|
+
### Font overrides
|
|
167
|
+
|
|
168
|
+
The package bundles Inter (sans) and Georgia (serif). Most apps ship their own
|
|
169
|
+
faces — forward them with `setFonts`, the font counterpart to `setColors`.
|
|
170
|
+
Call it once at startup, after your fonts are registered:
|
|
171
|
+
|
|
172
|
+
```tsx
|
|
173
|
+
import { useThemeStore } from "@mrmeg/expo-ui/state";
|
|
174
|
+
|
|
175
|
+
useThemeStore.getState().setFonts({
|
|
176
|
+
families: {
|
|
177
|
+
sansSerif: {
|
|
178
|
+
light: "HankenGrotesk_300Light",
|
|
179
|
+
regular: "HankenGrotesk_400Regular",
|
|
180
|
+
medium: "HankenGrotesk_500Medium",
|
|
181
|
+
semibold: "HankenGrotesk_600SemiBold",
|
|
182
|
+
bold: "HankenGrotesk_700Bold",
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
webWeightStrategy: "family",
|
|
186
|
+
});
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Overrides are partial and per-group: pass `sansSerif` alone and `serif` keeps
|
|
190
|
+
the package default. `setFonts({})` clears back to the defaults.
|
|
191
|
+
|
|
192
|
+
**Pick the right `webWeightStrategy`** — this is the one non-obvious part:
|
|
193
|
+
|
|
194
|
+
| Strategy | Use when | Effect |
|
|
195
|
+
|---|---|---|
|
|
196
|
+
| `"numeric"` (default) | You ship **one multi-weight CSS family** (a variable font, or Google Fonts' single `"Inter"` family) | A numeric `fontWeight` selects the `@font-face` variant |
|
|
197
|
+
| `"family"` | You load **per-weight faces via `expo-font`** | The family name alone carries the weight; the numeric `fontWeight` is suppressed |
|
|
198
|
+
|
|
199
|
+
If you load fonts through `expo-font` / `@expo-google-fonts`, choose
|
|
200
|
+
`"family"`. `expo-font` registers each weight as its own single-face family on
|
|
201
|
+
web as well as native, so emitting a numeric weight on top makes the browser
|
|
202
|
+
synthesise a *second* layer of bold over an already-bold face.
|
|
203
|
+
|
|
204
|
+
Native always behaves as `"family"` regardless of this setting.
|
|
205
|
+
|
|
206
|
+
> Prefer `setFonts` over patching `node_modules`. Bun keys
|
|
207
|
+
> `patchedDependencies` to an exact `name@version`, so a font patch silently
|
|
208
|
+
> stops applying the next time you bump the package — with no warning.
|
|
209
|
+
|
|
166
210
|
Use `StyledText` for theme-aware text:
|
|
167
211
|
|
|
168
212
|
```tsx
|
package/dist/components/Badge.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
import React
|
|
2
|
+
import React from "react";
|
|
3
3
|
import { View, StyleSheet } from "react-native";
|
|
4
4
|
import { useTheme } from "../hooks/useTheme.js";
|
|
5
5
|
import { spacing } from "../constants/spacing.js";
|
|
6
6
|
import { StyledText } from "./StyledText.js";
|
|
7
|
+
import { createThemedStyles } from "../lib/themedStyles.js";
|
|
7
8
|
/**
|
|
8
9
|
* Badge Component
|
|
9
10
|
*
|
|
@@ -19,7 +20,7 @@ import { StyledText } from "./StyledText.js";
|
|
|
19
20
|
*/
|
|
20
21
|
function Badge({ children, text, variant = "default", style: styleOverride }) {
|
|
21
22
|
const { theme } = useTheme();
|
|
22
|
-
const styles =
|
|
23
|
+
const styles = themedStyles(theme);
|
|
23
24
|
const badgeContent = text ?? children;
|
|
24
25
|
const textStyle = [
|
|
25
26
|
styles.text,
|
|
@@ -74,4 +75,5 @@ const createStyles = (theme) => StyleSheet.create({
|
|
|
74
75
|
userSelect: "none",
|
|
75
76
|
},
|
|
76
77
|
});
|
|
78
|
+
const themedStyles = createThemedStyles(createStyles);
|
|
77
79
|
export { Badge };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { use, useCallback,
|
|
2
|
+
import { use, useCallback, useState } from "react";
|
|
3
3
|
import { Pressable, StyleSheet, View, Platform, ActivityIndicator, Animated, } from "react-native";
|
|
4
4
|
import { spacing } from "../constants/spacing.js";
|
|
5
5
|
import { StyledText } from "./StyledText.js";
|
|
@@ -9,6 +9,7 @@ import { fontFamilies } from "../constants/fonts.js";
|
|
|
9
9
|
import { palette } from "../constants/colors.js";
|
|
10
10
|
import { useTheme } from "../hooks/useTheme.js";
|
|
11
11
|
import { useScalePress } from "../hooks/useScalePress.js";
|
|
12
|
+
import { createThemedStyles } from "../lib/themedStyles.js";
|
|
12
13
|
const SIZE_CONFIGS = {
|
|
13
14
|
sm: {
|
|
14
15
|
paddingVertical: spacing.xxs,
|
|
@@ -72,7 +73,7 @@ const getNativeHitSlop = (sizeConfig) => Math.ceil(Math.max(0, spacing.touchTarg
|
|
|
72
73
|
function ButtonRoot(props) {
|
|
73
74
|
const { tx, text, txOptions, style: styleOverride, pressedStyle: pressedStyleOverride, textStyle: textStyleOverride, pressedTextStyle: pressedTextStyleOverride, disabledTextStyle: disabledTextStyleOverride, children, RightAccessory, LeftAccessory, disabled, disabledStyle: disabledStyleOverride, withShadow: withShadowProp, preset = "default", size = "md", loading = false, fullWidth = false, onFocus, onBlur, onPressIn, onPressOut, ...rest } = props;
|
|
74
75
|
const { theme, getContrastingColor, getFocusRingStyle, getShadowStyle } = useTheme();
|
|
75
|
-
const styles =
|
|
76
|
+
const styles = themedStyles(theme)[size];
|
|
76
77
|
const shadowStyle = getShadowStyle("subtle");
|
|
77
78
|
const sizeConfig = SIZE_CONFIGS[size];
|
|
78
79
|
// Filled (`default`) buttons float by default; ghost/outline/link stay flat
|
|
@@ -321,6 +322,11 @@ const createStyles = (theme, size) => {
|
|
|
321
322
|
},
|
|
322
323
|
});
|
|
323
324
|
};
|
|
325
|
+
const themedStyles = createThemedStyles((theme) => ({
|
|
326
|
+
sm: createStyles(theme, "sm"),
|
|
327
|
+
md: createStyles(theme, "md"),
|
|
328
|
+
lg: createStyles(theme, "lg"),
|
|
329
|
+
}));
|
|
324
330
|
/**
|
|
325
331
|
* Button with explicit subcomponents.
|
|
326
332
|
* - `Button.Text` for label text (inherits control typography)
|
package/dist/components/Card.js
CHANGED
|
@@ -5,6 +5,7 @@ import { StyledText } from "./StyledText.js";
|
|
|
5
5
|
import { useTheme } from "../hooks/useTheme.js";
|
|
6
6
|
import { useScalePress } from "../hooks/useScalePress.js";
|
|
7
7
|
import { spacing } from "../constants/spacing.js";
|
|
8
|
+
import { createThemedStyles } from "../lib/themedStyles.js";
|
|
8
9
|
/**
|
|
9
10
|
* Card Component
|
|
10
11
|
*
|
|
@@ -30,15 +31,15 @@ import { spacing } from "../constants/spacing.js";
|
|
|
30
31
|
const CardContext = createContext(null);
|
|
31
32
|
function useCardContext() {
|
|
32
33
|
const ctx = use(CardContext);
|
|
33
|
-
// useTheme must run unconditionally (Rules of Hooks);
|
|
34
|
+
// useTheme must run unconditionally (Rules of Hooks); themedStyles is a
|
|
34
35
|
// plain function, so the `??` keeps the fallback styles lazy.
|
|
35
36
|
const { theme } = useTheme();
|
|
36
37
|
// Fallback for standalone usage without a Card parent.
|
|
37
|
-
return ctx ?? { theme, styles:
|
|
38
|
+
return ctx ?? { theme, styles: themedStyles(theme) };
|
|
38
39
|
}
|
|
39
40
|
function Card({ children, style: styleOverride, variant = "default", onPress, disabled }) {
|
|
40
41
|
const { theme, getShadowStyle } = useTheme();
|
|
41
|
-
const styles =
|
|
42
|
+
const styles = themedStyles(theme);
|
|
42
43
|
const shadowStyle = getShadowStyle("subtle");
|
|
43
44
|
const ctx = { theme, styles };
|
|
44
45
|
const { animatedStyle: scaleStyle, pressHandlers } = useScalePress({
|
|
@@ -122,4 +123,5 @@ const createCardStyles = (theme) => StyleSheet.create({
|
|
|
122
123
|
lineHeight: 20,
|
|
123
124
|
},
|
|
124
125
|
});
|
|
126
|
+
const themedStyles = createThemedStyles(createCardStyles);
|
|
125
127
|
export { Card, CardHeader, CardContent, CardFooter, CardTitle, CardDescription };
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useMemo } from "react";
|
|
3
2
|
import { View, StyleSheet } from "react-native";
|
|
4
3
|
import { SansSerifBoldText, SansSerifText } from "./StyledText.js";
|
|
5
4
|
import { Button } from "./Button.js";
|
|
6
5
|
import { Icon } from "./Icon.js";
|
|
7
6
|
import { useTheme } from "../hooks/useTheme.js";
|
|
8
7
|
import { spacing } from "../constants/spacing.js";
|
|
8
|
+
import { createThemedStyles } from "../lib/themedStyles.js";
|
|
9
9
|
/**
|
|
10
10
|
* EmptyState Component
|
|
11
11
|
*
|
|
@@ -25,7 +25,7 @@ import { spacing } from "../constants/spacing.js";
|
|
|
25
25
|
*/
|
|
26
26
|
export function EmptyState({ icon, iconSize = spacing.iconMd, title, description, actionLabel, onAction, actionPreset = "default", style, children, bordered = false, }) {
|
|
27
27
|
const { theme } = useTheme();
|
|
28
|
-
const styles =
|
|
28
|
+
const styles = themedStyles(theme);
|
|
29
29
|
return (_jsxs(View, { style: [styles.container, bordered && styles.bordered, style], children: [!!icon && (_jsx(View, { style: styles.iconWrapper, children: _jsx(Icon, { name: icon, size: iconSize, color: theme.colors.mutedForeground }) })), _jsx(SansSerifBoldText, { selectable: false, size: "lg", style: styles.title, children: title }), !!description && (_jsx(SansSerifText, { selectable: false, size: "base", style: styles.description, children: description })), children, !!actionLabel && onAction && (_jsx(Button, { preset: actionPreset, onPress: onAction, text: actionLabel, style: styles.action }))] }));
|
|
30
30
|
}
|
|
31
31
|
const MEDIA_SIZE = 56;
|
|
@@ -74,3 +74,4 @@ const createStyles = (theme) => StyleSheet.create({
|
|
|
74
74
|
alignSelf: "center",
|
|
75
75
|
},
|
|
76
76
|
});
|
|
77
|
+
const themedStyles = createThemedStyles(createStyles);
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import {
|
|
2
|
+
import { useRef, useState, useCallback } from "react";
|
|
3
3
|
import { View, TextInput as RNTextInput, Pressable, StyleSheet, Platform, } from "react-native";
|
|
4
4
|
import { useTheme } from "../hooks/useTheme.js";
|
|
5
5
|
import { spacing } from "../constants/spacing.js";
|
|
6
6
|
import { fontFamilies } from "../constants/fonts.js";
|
|
7
7
|
import { StyledText } from "./StyledText.js";
|
|
8
|
+
import { createThemedStyles } from "../lib/themedStyles.js";
|
|
8
9
|
const CELL_WIDTH = 36;
|
|
9
10
|
const CELL_HEIGHT = 40;
|
|
10
11
|
const CELL_FONT_SIZE = 20;
|
|
@@ -30,7 +31,7 @@ function InputOTP({ length = 6, value = "", onChangeText, onComplete, error = fa
|
|
|
30
31
|
const { theme } = useTheme();
|
|
31
32
|
const inputRef = useRef(null);
|
|
32
33
|
const [focused, setFocused] = useState(false);
|
|
33
|
-
const styles =
|
|
34
|
+
const styles = themedStyles(theme);
|
|
34
35
|
const hasError = error || !!errorText;
|
|
35
36
|
// Active cell index: next empty cell, or last cell when full
|
|
36
37
|
const activeIndex = Math.min(value.length, length - 1);
|
|
@@ -132,4 +133,5 @@ const createStyles = (theme) => StyleSheet.create({
|
|
|
132
133
|
marginTop: spacing.xs,
|
|
133
134
|
},
|
|
134
135
|
});
|
|
136
|
+
const themedStyles = createThemedStyles(createStyles);
|
|
135
137
|
export { InputOTP };
|
package/dist/components/Label.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useMemo } from "react";
|
|
3
2
|
import { StyleSheet, Platform } from "react-native";
|
|
4
3
|
import * as LabelPrimitive from "@rn-primitives/label";
|
|
5
4
|
import { useTheme } from "../hooks/useTheme.js";
|
|
6
5
|
import { spacing } from "../constants/spacing.js";
|
|
7
6
|
import { fontFamilies } from "../constants/fonts.js";
|
|
8
7
|
import { StyledText } from "./StyledText.js";
|
|
8
|
+
import { createThemedStyles } from "../lib/themedStyles.js";
|
|
9
9
|
const SIZE_CONFIGS = {
|
|
10
10
|
sm: { fontSize: 12 },
|
|
11
11
|
md: { fontSize: 14 },
|
|
@@ -37,7 +37,7 @@ const SIZE_CONFIGS = {
|
|
|
37
37
|
*/
|
|
38
38
|
export function Label({ children, nativeID, required, size = "md", error, disabled, style, onPress, }) {
|
|
39
39
|
const { theme } = useTheme();
|
|
40
|
-
const styles =
|
|
40
|
+
const styles = themedStyles(theme);
|
|
41
41
|
const sizeConfig = SIZE_CONFIGS[size];
|
|
42
42
|
const textStyle = StyleSheet.flatten([
|
|
43
43
|
styles.label,
|
|
@@ -78,3 +78,4 @@ const createStyles = (theme) => StyleSheet.create({
|
|
|
78
78
|
opacity: 0.6,
|
|
79
79
|
},
|
|
80
80
|
});
|
|
81
|
+
const themedStyles = createThemedStyles(createStyles);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import {
|
|
2
|
+
import { useCallback, use, useEffect, useEffectEvent, useRef } from "react";
|
|
3
3
|
import { Animated, Easing, StyleSheet, View, ActivityIndicator, Pressable, Platform } from "react-native";
|
|
4
4
|
import { SafeAreaInsetsContext } from "react-native-safe-area-context";
|
|
5
5
|
import { Icon } from "./Icon.js";
|
|
@@ -10,6 +10,7 @@ import { durations } from "../constants/motion.js";
|
|
|
10
10
|
import { StyledText } from "./StyledText.js";
|
|
11
11
|
import { palette } from "../constants/colors.js";
|
|
12
12
|
import { translateText } from "../lib/i18n.js";
|
|
13
|
+
import { createThemedStyles } from "../lib/themedStyles.js";
|
|
13
14
|
import { globalUIStore } from "../state/globalUIStore.js";
|
|
14
15
|
const timingIn = { duration: durations.fast, easing: Easing.out(Easing.quad), useNativeDriver: true };
|
|
15
16
|
const timingOut = { duration: durations.instant, easing: Easing.in(Easing.quad), useNativeDriver: true };
|
|
@@ -36,7 +37,7 @@ export const Notification = () => {
|
|
|
36
37
|
const reduceMotion = useReducedMotion();
|
|
37
38
|
const insets = use(SafeAreaInsetsContext);
|
|
38
39
|
const { alert, hide } = globalUIStore();
|
|
39
|
-
const styles =
|
|
40
|
+
const styles = themedStyles(theme);
|
|
40
41
|
const position = alert?.position ?? "top";
|
|
41
42
|
const isBottom = position === "bottom";
|
|
42
43
|
// Just opacity + translateY — no scale (scale = bouncy feel)
|
|
@@ -301,3 +302,4 @@ const createStyles = (theme) => StyleSheet.create({
|
|
|
301
302
|
...(Platform.OS === "web" && { cursor: "pointer" }),
|
|
302
303
|
},
|
|
303
304
|
});
|
|
305
|
+
const themedStyles = createThemedStyles(createStyles);
|
|
@@ -4,6 +4,7 @@ import { View, StyleSheet, Animated } from "react-native";
|
|
|
4
4
|
import { useTheme } from "../hooks/useTheme.js";
|
|
5
5
|
import { useReducedMotion } from "../hooks/useReduceMotion.js";
|
|
6
6
|
import { spacing } from "../constants/spacing.js";
|
|
7
|
+
import { createThemedStyles } from "../lib/themedStyles.js";
|
|
7
8
|
/**
|
|
8
9
|
* Skeleton Component
|
|
9
10
|
*
|
|
@@ -72,7 +73,7 @@ export function SkeletonAvatar({ size = 40, style }) {
|
|
|
72
73
|
*/
|
|
73
74
|
export function SkeletonCard({ showImage = true, imageHeight = 140, showAvatar = true, textLines = 3, style, }) {
|
|
74
75
|
const { theme, getShadowStyle } = useTheme();
|
|
75
|
-
const styles =
|
|
76
|
+
const styles = themedStyles(theme);
|
|
76
77
|
return (_jsxs(View, { style: [styles.card, getShadowStyle("subtle"), style], children: [showImage && (_jsx(Skeleton, { width: "100%", height: imageHeight, borderRadius: 0 })), _jsxs(View, { style: styles.body, children: [showAvatar && (_jsxs(View, { style: styles.avatarRow, children: [_jsx(SkeletonAvatar, { size: 36 }), _jsxs(View, { style: styles.avatarText, children: [_jsx(Skeleton, { width: "50%", height: 14 }), _jsx(Skeleton, { width: "30%", height: 12 })] })] })), _jsx(SkeletonText, { lines: textLines })] })] }));
|
|
77
78
|
}
|
|
78
79
|
const createCardStyles = (theme) => StyleSheet.create({
|
|
@@ -97,3 +98,4 @@ const createCardStyles = (theme) => StyleSheet.create({
|
|
|
97
98
|
gap: spacing.xs,
|
|
98
99
|
},
|
|
99
100
|
});
|
|
101
|
+
const themedStyles = createThemedStyles(createCardStyles);
|
|
@@ -2,7 +2,8 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
2
2
|
import { use } from "react";
|
|
3
3
|
import { Platform, Text as RNText, StyleSheet } from "react-native";
|
|
4
4
|
import { useTheme } from "../hooks/useTheme.js";
|
|
5
|
-
import { fontFamilies } from "../constants/fonts.js";
|
|
5
|
+
import { fontFamilies, defaultWebWeightStrategy } from "../constants/fonts.js";
|
|
6
|
+
import { useThemeStore } from "../state/themeStore.js";
|
|
6
7
|
import { translateText } from "../lib/i18n.js";
|
|
7
8
|
import { TextColorContext, TextSelectabilityContext, TextStyleContext } from "./StyledText.context";
|
|
8
9
|
const FONT_SIZES = {
|
|
@@ -89,6 +90,9 @@ const WEB_FONT_WEIGHTS = {
|
|
|
89
90
|
export function StyledText(props) {
|
|
90
91
|
const { tx, text, txOptions, style, variant = "sansSerif", fontWeight, size, semantic, align, selectable, children, ref, ...otherProps } = props;
|
|
91
92
|
const { theme } = useTheme();
|
|
93
|
+
// Subscribed, not read via getState(), so a `setFonts` call after mount
|
|
94
|
+
// re-renders text instead of leaving stale families on screen.
|
|
95
|
+
const fontOverrides = useThemeStore((s) => s.fontOverrides);
|
|
92
96
|
// Check if there's a color override from parent context (e.g., Button)
|
|
93
97
|
const contextColor = use(TextColorContext);
|
|
94
98
|
const contextTextStyle = use(TextStyleContext);
|
|
@@ -100,18 +104,31 @@ export function StyledText(props) {
|
|
|
100
104
|
const semanticConfig = semantic ? SEMANTIC_CONFIGS[semantic] : undefined;
|
|
101
105
|
const finalSize = semanticConfig?.size ?? size ?? "body";
|
|
102
106
|
const finalFontWeight = semanticConfig?.weight ?? fontWeight ?? "regular";
|
|
103
|
-
// Get font family based on variant and weight
|
|
107
|
+
// Get font family based on variant and weight. Host apps can replace the
|
|
108
|
+
// package's bundled faces via `setFonts` (see constants/fonts.ts); merging
|
|
109
|
+
// per-group means an app overriding only `sansSerif` keeps the default serif.
|
|
104
110
|
const fontFamilyWeight = getFontFamilyWeight(finalFontWeight);
|
|
105
|
-
|
|
111
|
+
const overriddenFamilies = fontOverrides.families;
|
|
112
|
+
const serifFamilies = overriddenFamilies?.serif ?? fontFamilies.serif;
|
|
113
|
+
const sansSerifFamilies = overriddenFamilies?.sansSerif ?? fontFamilies.sansSerif;
|
|
114
|
+
// The serif slot has a single usable weight — use it regardless of the
|
|
115
|
+
// requested weight.
|
|
106
116
|
const fontFamily = variant === "serif"
|
|
107
|
-
?
|
|
108
|
-
:
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
|
|
117
|
+
? serifFamilies.regular
|
|
118
|
+
: sansSerifFamilies[fontFamilyWeight] ?? sansSerifFamilies.regular;
|
|
119
|
+
// Weight is carried either by a numeric `fontWeight` (one multi-weight CSS
|
|
120
|
+
// family) or by the family name itself (discrete per-weight faces). Native
|
|
121
|
+
// is always the latter: adding a numeric weight on top of an already-bold
|
|
122
|
+
// file faux-bolds it. Web defaults to numeric for the package's own single
|
|
123
|
+
// "Inter" family, but an app loading per-weight faces through `expo-font`
|
|
124
|
+
// gets its own family per weight on web too, and must declare
|
|
125
|
+
// `webWeightStrategy: "family"` to avoid a second synthesised layer of bold.
|
|
126
|
+
const weightStrategy = Platform.OS === "web"
|
|
127
|
+
? fontOverrides.webWeightStrategy ?? defaultWebWeightStrategy
|
|
128
|
+
: "family";
|
|
129
|
+
const resolvedFontWeight = weightStrategy === "numeric"
|
|
130
|
+
? WEB_FONT_WEIGHTS[finalFontWeight]
|
|
131
|
+
: undefined;
|
|
115
132
|
// Get fontSize and lineHeight from size variant
|
|
116
133
|
const fontSize = FONT_SIZES[finalSize];
|
|
117
134
|
const lineHeight = LINE_HEIGHTS[finalSize];
|
|
@@ -8,6 +8,7 @@ import { fontFamilies } from "../constants/fonts.js";
|
|
|
8
8
|
import { StyledText } from "./StyledText.js";
|
|
9
9
|
import { Icon } from "./Icon.js";
|
|
10
10
|
import { hapticLight } from "../lib/haptics.js";
|
|
11
|
+
import { createThemedStyles } from "../lib/themedStyles.js";
|
|
11
12
|
import { palette } from "../constants/colors.js";
|
|
12
13
|
import { clearKeyboardFocusedInputLayout, setKeyboardFocusedInputLayout, } from "./keyboardFocusRegistry.js";
|
|
13
14
|
const NUMERIC_REGEX = /^[0-9]*$/;
|
|
@@ -91,7 +92,7 @@ export function TextInput(props) {
|
|
|
91
92
|
*/
|
|
92
93
|
function WebTextInput({ variant = "outline", size = "md", label, helperText, errorText, error, required, rows, showSecureEntryToggle, leftElement, rightElement, clearable = false, wrapperStyle, focusedStyle, forceLight, secureTextEntry, inputMode, style, onChangeText, onFocus, onBlur, value, multiline, editable = true, ref, ...rest }) {
|
|
93
94
|
const { theme, getContrastingColor, getFocusRingStyle } = useTheme();
|
|
94
|
-
const styles =
|
|
95
|
+
const styles = themedStyles(theme)[variant][size];
|
|
95
96
|
const [focused, setFocused] = useState(false);
|
|
96
97
|
const [contentHeight, setContentHeight] = useState(0);
|
|
97
98
|
const [passwordVisible, setPasswordVisible] = useState(false);
|
|
@@ -197,7 +198,7 @@ function NativeTextInput({ variant = "outline", size = "md", label, helperText,
|
|
|
197
198
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
198
199
|
leftElement, rightElement, clearable, focusedStyle, style, ...rest }) {
|
|
199
200
|
const { theme, getContrastingColor } = useTheme();
|
|
200
|
-
const styles =
|
|
201
|
+
const styles = themedStyles(theme)[variant][size];
|
|
201
202
|
const [passwordVisible, setPasswordVisible] = useState(false);
|
|
202
203
|
const hasError = error || !!errorText;
|
|
203
204
|
const sizeConfig = SIZE_CONFIGS[size];
|
|
@@ -443,3 +444,9 @@ const createStyles = (theme, variant, size) => StyleSheet.create({
|
|
|
443
444
|
zIndex: 1,
|
|
444
445
|
},
|
|
445
446
|
});
|
|
447
|
+
const VARIANT_KEYS = ["outline", "filled", "underlined"];
|
|
448
|
+
const SIZE_KEYS = ["sm", "md", "lg"];
|
|
449
|
+
const themedStyles = createThemedStyles((theme) => Object.fromEntries(VARIANT_KEYS.map((variant) => [
|
|
450
|
+
variant,
|
|
451
|
+
Object.fromEntries(SIZE_KEYS.map((size) => [size, createStyles(theme, variant, size)])),
|
|
452
|
+
])));
|
|
@@ -9,19 +9,55 @@ interface NavigationFonts {
|
|
|
9
9
|
bold: FontStyle;
|
|
10
10
|
heavy: FontStyle;
|
|
11
11
|
}
|
|
12
|
-
|
|
12
|
+
/** Weight slots every sans-serif family map must provide. */
|
|
13
|
+
export type FontFamilyWeight = "light" | "regular" | "medium" | "semibold" | "bold";
|
|
14
|
+
/**
|
|
15
|
+
* How a platform expresses weight for a given family map.
|
|
16
|
+
*
|
|
17
|
+
* - `"numeric"` — one multi-weight CSS family (the package's own web default:
|
|
18
|
+
* a single `"Inter"` family where a numeric `fontWeight` selects the
|
|
19
|
+
* `@font-face` variant).
|
|
20
|
+
* - `"family"` — each weight is its own separately-registered single-face
|
|
21
|
+
* family, so the family name alone carries the weight. Emitting a numeric
|
|
22
|
+
* `fontWeight` on top of one makes the renderer synthesise a second layer of
|
|
23
|
+
* bold, so `StyledText` suppresses it.
|
|
24
|
+
*
|
|
25
|
+
* Native is always `"family"` (discrete static font files). Web is `"numeric"`
|
|
26
|
+
* by default, but apps that load per-weight faces through `expo-font` — which
|
|
27
|
+
* registers each file as its own family on web too — must declare `"family"`.
|
|
28
|
+
*/
|
|
29
|
+
export type FontWeightStrategy = "numeric" | "family";
|
|
30
|
+
export type FontFamilyMap = {
|
|
13
31
|
serif: {
|
|
14
32
|
regular: string;
|
|
15
33
|
bold: string;
|
|
16
34
|
};
|
|
17
|
-
sansSerif:
|
|
18
|
-
light: string;
|
|
19
|
-
regular: string;
|
|
20
|
-
medium: string;
|
|
21
|
-
semibold: string;
|
|
22
|
-
bold: string;
|
|
23
|
-
};
|
|
35
|
+
sansSerif: Record<FontFamilyWeight, string>;
|
|
24
36
|
};
|
|
37
|
+
/**
|
|
38
|
+
* Font overrides a host app can inject to brand the package.
|
|
39
|
+
*
|
|
40
|
+
* Mirrors `setColors`: the package ships neutral defaults, but a consuming app
|
|
41
|
+
* almost always bundles its own faces. Without an injection point, package
|
|
42
|
+
* components render the package's fonts while app-authored siblings render the
|
|
43
|
+
* app's — and the only remaining lever is patching `node_modules`, which is
|
|
44
|
+
* keyed to an exact version and silently stops applying on the next bump.
|
|
45
|
+
*
|
|
46
|
+
* Partial by design: override `sansSerif` alone and `serif` keeps its default.
|
|
47
|
+
*/
|
|
48
|
+
export type FontOverrides = {
|
|
49
|
+
families?: Partial<FontFamilyMap>;
|
|
50
|
+
/** Defaults to the package's own per-platform behaviour when omitted. */
|
|
51
|
+
webWeightStrategy?: FontWeightStrategy;
|
|
52
|
+
};
|
|
53
|
+
/** The package's built-in families. Override per-app via `setFonts`. */
|
|
54
|
+
export declare const fontFamilies: FontFamilyMap;
|
|
55
|
+
/**
|
|
56
|
+
* The package's own web weight strategy: one multi-weight "Inter" family, so
|
|
57
|
+
* weight comes from a numeric `fontWeight`. Native always behaves as
|
|
58
|
+
* `"family"` regardless of this value.
|
|
59
|
+
*/
|
|
60
|
+
export declare const defaultWebWeightStrategy: FontWeightStrategy;
|
|
25
61
|
/**
|
|
26
62
|
* Typography scale — Tailwind-matching sizes for consistent text hierarchy.
|
|
27
63
|
* Components can progressively adopt these tokens.
|
package/dist/constants/fonts.js
CHANGED
|
@@ -42,10 +42,17 @@ const sansSerifFamilies = isWebRuntime
|
|
|
42
42
|
semibold: "sans-serif",
|
|
43
43
|
bold: "sans-serif",
|
|
44
44
|
};
|
|
45
|
+
/** The package's built-in families. Override per-app via `setFonts`. */
|
|
45
46
|
export const fontFamilies = {
|
|
46
47
|
serif: serifFamilies,
|
|
47
48
|
sansSerif: sansSerifFamilies,
|
|
48
49
|
};
|
|
50
|
+
/**
|
|
51
|
+
* The package's own web weight strategy: one multi-weight "Inter" family, so
|
|
52
|
+
* weight comes from a numeric `fontWeight`. Native always behaves as
|
|
53
|
+
* `"family"` regardless of this value.
|
|
54
|
+
*/
|
|
55
|
+
export const defaultWebWeightStrategy = "numeric";
|
|
49
56
|
export const typography = {
|
|
50
57
|
xs: { fontSize: 12, lineHeight: 16 },
|
|
51
58
|
sm: { fontSize: 14, lineHeight: 20 },
|
package/dist/lib/index.d.ts
CHANGED
package/dist/lib/index.js
CHANGED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type Theme } from "../constants/colors";
|
|
2
|
+
/**
|
|
3
|
+
* Registers theme-dependent styles at module scope so react-native-web
|
|
4
|
+
* inserts their CSS rules into the server-side sheet before the document
|
|
5
|
+
* <head> is serialized.
|
|
6
|
+
*
|
|
7
|
+
* Styles created lazily during render — the
|
|
8
|
+
* `useMemo(() => createStyles(theme), [theme])` idiom — are inserted into
|
|
9
|
+
* RNW's sheet *after* expo-router has already captured the
|
|
10
|
+
* `<style id="react-native-stylesheet">` snapshot for the head. The SSR
|
|
11
|
+
* HTML then references class names that have no rules, which paints
|
|
12
|
+
* completely unstyled until hydration re-inserts them client-side (worst
|
|
13
|
+
* on the first request after a server cold start, and on every dev
|
|
14
|
+
* rebundle). See docs/ssr-hydration.md.
|
|
15
|
+
*
|
|
16
|
+
* Both base themes are computed eagerly at module load. Themes carrying
|
|
17
|
+
* color overrides (global brand or scoped) are computed once per theme
|
|
18
|
+
* object identity — `useTheme` keeps that identity stable, so the WeakMap
|
|
19
|
+
* behaves like the old `useMemo` without the render-time registration.
|
|
20
|
+
*/
|
|
21
|
+
export declare function createThemedStyles<T extends object>(factory: (theme: Theme) => T): (theme: Theme) => T;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { colors } from "../constants/colors.js";
|
|
2
|
+
/**
|
|
3
|
+
* Registers theme-dependent styles at module scope so react-native-web
|
|
4
|
+
* inserts their CSS rules into the server-side sheet before the document
|
|
5
|
+
* <head> is serialized.
|
|
6
|
+
*
|
|
7
|
+
* Styles created lazily during render — the
|
|
8
|
+
* `useMemo(() => createStyles(theme), [theme])` idiom — are inserted into
|
|
9
|
+
* RNW's sheet *after* expo-router has already captured the
|
|
10
|
+
* `<style id="react-native-stylesheet">` snapshot for the head. The SSR
|
|
11
|
+
* HTML then references class names that have no rules, which paints
|
|
12
|
+
* completely unstyled until hydration re-inserts them client-side (worst
|
|
13
|
+
* on the first request after a server cold start, and on every dev
|
|
14
|
+
* rebundle). See docs/ssr-hydration.md.
|
|
15
|
+
*
|
|
16
|
+
* Both base themes are computed eagerly at module load. Themes carrying
|
|
17
|
+
* color overrides (global brand or scoped) are computed once per theme
|
|
18
|
+
* object identity — `useTheme` keeps that identity stable, so the WeakMap
|
|
19
|
+
* behaves like the old `useMemo` without the render-time registration.
|
|
20
|
+
*/
|
|
21
|
+
export function createThemedStyles(factory) {
|
|
22
|
+
const cache = new WeakMap();
|
|
23
|
+
cache.set(colors.light, factory(colors.light));
|
|
24
|
+
cache.set(colors.dark, factory(colors.dark));
|
|
25
|
+
return (theme) => {
|
|
26
|
+
const cached = cache.get(theme);
|
|
27
|
+
if (cached)
|
|
28
|
+
return cached;
|
|
29
|
+
const created = factory(theme);
|
|
30
|
+
cache.set(theme, created);
|
|
31
|
+
return created;
|
|
32
|
+
};
|
|
33
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ThemeColors } from "../constants/colors";
|
|
2
|
+
import type { FontOverrides } from "../constants/fonts";
|
|
2
3
|
export type ThemePreference = "system" | "light" | "dark";
|
|
3
4
|
export type ResolvedTheme = "light" | "dark";
|
|
4
5
|
/**
|
|
@@ -28,6 +29,12 @@ export type ThemeStore = {
|
|
|
28
29
|
* behaves exactly as before this field existed (fully backward compatible).
|
|
29
30
|
*/
|
|
30
31
|
colorOverrides: ColorOverrides;
|
|
32
|
+
/**
|
|
33
|
+
* App-injected font overrides, applied by `StyledText` on top of the package
|
|
34
|
+
* defaults. Empty by default — zero override means the package behaves
|
|
35
|
+
* exactly as before this field existed (fully backward compatible).
|
|
36
|
+
*/
|
|
37
|
+
fontOverrides: FontOverrides;
|
|
31
38
|
setTheme: (theme: ThemePreference) => void;
|
|
32
39
|
setSystemTheme: (theme: ResolvedTheme) => void;
|
|
33
40
|
/**
|
|
@@ -35,6 +42,16 @@ export type ThemeStore = {
|
|
|
35
42
|
* clear overrides and fall back to the package defaults.
|
|
36
43
|
*/
|
|
37
44
|
setColors: (overrides: ColorOverrides) => void;
|
|
45
|
+
/**
|
|
46
|
+
* Replace the active font overrides. Pass `{}` to clear them and fall back
|
|
47
|
+
* to the package's bundled faces.
|
|
48
|
+
*
|
|
49
|
+
* Call this once at startup, after the app's fonts have been registered
|
|
50
|
+
* (e.g. alongside `setColors` in a theme-sync component). Apps loading
|
|
51
|
+
* per-weight faces via `expo-font` should also pass
|
|
52
|
+
* `webWeightStrategy: "family"` — see `FontWeightStrategy`.
|
|
53
|
+
*/
|
|
54
|
+
setFonts: (overrides: FontOverrides) => void;
|
|
38
55
|
loadTheme: () => void;
|
|
39
56
|
};
|
|
40
57
|
export declare function resolveThemePreference(userTheme: ThemePreference, systemTheme: ResolvedTheme): ResolvedTheme;
|
package/dist/state/themeStore.js
CHANGED
|
@@ -19,9 +19,15 @@ export const useThemeStore = create((set) => ({
|
|
|
19
19
|
// No overrides by default: the package renders with its built-in palette
|
|
20
20
|
// until a host app calls `setColors`.
|
|
21
21
|
colorOverrides: {},
|
|
22
|
+
// Same contract as `colorOverrides`: the package renders with its bundled
|
|
23
|
+
// faces until a host app calls `setFonts`.
|
|
24
|
+
fontOverrides: {},
|
|
22
25
|
setColors: (overrides) => {
|
|
23
26
|
set({ colorOverrides: overrides ?? {} });
|
|
24
27
|
},
|
|
28
|
+
setFonts: (overrides) => {
|
|
29
|
+
set({ fontOverrides: overrides ?? {} });
|
|
30
|
+
},
|
|
25
31
|
setTheme: (theme) => {
|
|
26
32
|
set({
|
|
27
33
|
userTheme: theme,
|