@mrmeg/expo-ui 0.14.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.
Files changed (61) hide show
  1. package/CHANGELOG.md +81 -0
  2. package/README.md +50 -6
  3. package/dist/components/Accordion.d.ts +1 -1
  4. package/dist/components/Accordion.js +25 -19
  5. package/dist/components/Badge.js +10 -7
  6. package/dist/components/BottomSheet.js +21 -19
  7. package/dist/components/Button.d.ts +2 -1
  8. package/dist/components/Button.js +22 -6
  9. package/dist/components/Card.js +5 -3
  10. package/dist/components/Checkbox.js +49 -19
  11. package/dist/components/Collapsible.d.ts +1 -1
  12. package/dist/components/Collapsible.js +22 -16
  13. package/dist/components/Drawer.js +8 -7
  14. package/dist/components/DropdownMenu.d.ts +1 -1
  15. package/dist/components/DropdownMenu.js +80 -59
  16. package/dist/components/EmptyState.d.ts +7 -1
  17. package/dist/components/EmptyState.js +26 -9
  18. package/dist/components/InputOTP.js +10 -4
  19. package/dist/components/Item.d.ts +93 -0
  20. package/dist/components/Item.js +125 -0
  21. package/dist/components/Label.js +10 -4
  22. package/dist/components/Notification.js +14 -24
  23. package/dist/components/Popover.d.ts +3 -18
  24. package/dist/components/Popover.js +10 -6
  25. package/dist/components/RadioGroup.js +49 -22
  26. package/dist/components/SectionHeader.d.ts +42 -0
  27. package/dist/components/SectionHeader.js +41 -0
  28. package/dist/components/Select.js +73 -41
  29. package/dist/components/Skeleton.js +5 -3
  30. package/dist/components/StatCard.d.ts +45 -0
  31. package/dist/components/StatCard.js +41 -0
  32. package/dist/components/StyledText.d.ts +20 -10
  33. package/dist/components/StyledText.js +85 -29
  34. package/dist/components/Switch.js +84 -57
  35. package/dist/components/Tabs.js +43 -12
  36. package/dist/components/TextInput.js +17 -11
  37. package/dist/components/Toggle.js +69 -42
  38. package/dist/components/ToggleGroup.js +82 -55
  39. package/dist/components/index.d.ts +3 -0
  40. package/dist/components/index.js +3 -0
  41. package/dist/constants/fonts.d.ts +44 -5
  42. package/dist/constants/fonts.js +42 -12
  43. package/dist/constants/index.d.ts +1 -0
  44. package/dist/constants/index.js +1 -0
  45. package/dist/constants/motion.d.ts +24 -0
  46. package/dist/constants/motion.js +24 -0
  47. package/dist/constants/spacing.d.ts +7 -7
  48. package/dist/constants/spacing.js +7 -7
  49. package/dist/hooks/useResources.d.ts +4 -2
  50. package/dist/hooks/useResources.js +30 -8
  51. package/dist/hooks/useStaggeredEntrance.d.ts +1 -1
  52. package/dist/hooks/useStaggeredEntrance.js +2 -1
  53. package/dist/hooks/useTheme.d.ts +1 -1
  54. package/dist/hooks/useTheme.js +53 -16
  55. package/dist/lib/index.d.ts +1 -0
  56. package/dist/lib/index.js +1 -0
  57. package/dist/lib/themedStyles.d.ts +21 -0
  58. package/dist/lib/themedStyles.js +33 -0
  59. package/dist/state/themeStore.d.ts +17 -0
  60. package/dist/state/themeStore.js +6 -0
  61. package/package.json +20 -19
@@ -1,6 +1,7 @@
1
1
  import { useEffect, useState } from "react";
2
2
  import * as Font from "expo-font";
3
3
  import Feather from "@expo/vector-icons/Feather";
4
+ import { Inter_400Regular, Inter_500Medium, Inter_600SemiBold, Inter_700Bold, } from "@expo-google-fonts/inter";
4
5
  import { Platform } from "react-native";
5
6
  // Eager, module-scope load so expo-font registers Feather in its SSR
6
7
  // serverContext. When the server renders the HTML document, expo-font emits
@@ -11,30 +12,50 @@ import { Platform } from "react-native";
11
12
  // the effect; the effect's loadAsync becomes a no-op for the already-loaded
12
13
  // font.
13
14
  void Font.loadAsync(Feather.font);
14
- const LATO_STYLESHEET_ID = "mrmeg-expo-ui-lato";
15
- const LATO_STYLESHEET_URL = "https://fonts.googleapis.com/css2?family=Lato:wght@400;700&display=swap";
15
+ // The four static Inter weights StyledText's native family keys point at
16
+ // (see constants/fonts.ts). Native-only: web never renders these family names
17
+ // ("Inter_400Regular" etc.) — fontFamilies.sansSerif resolves every weight to
18
+ // the single "Inter" CSS family on web (loaded via ensureWebFontStylesheet
19
+ // below), so fetching these .ttf assets there would just be ~1.3MB of dead
20
+ // weight with nothing pointing at them.
21
+ const interFontMap = {
22
+ Inter_400Regular,
23
+ Inter_500Medium,
24
+ Inter_600SemiBold,
25
+ Inter_700Bold,
26
+ };
27
+ function loadNativeInterFonts() {
28
+ if (Platform.OS === "web") {
29
+ return Promise.resolve();
30
+ }
31
+ return Font.loadAsync(interFontMap);
32
+ }
33
+ const INTER_STYLESHEET_ID = "mrmeg-expo-ui-inter";
34
+ const INTER_STYLESHEET_URL = "https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap";
16
35
  function ensureWebFontStylesheet() {
17
36
  if (Platform.OS !== "web" || typeof document === "undefined") {
18
37
  return Promise.resolve();
19
38
  }
20
- if (document.getElementById(LATO_STYLESHEET_ID)) {
39
+ if (document.getElementById(INTER_STYLESHEET_ID)) {
21
40
  return Promise.resolve();
22
41
  }
23
42
  return new Promise((resolve, reject) => {
24
43
  const link = document.createElement("link");
25
- link.id = LATO_STYLESHEET_ID;
44
+ link.id = INTER_STYLESHEET_ID;
26
45
  link.rel = "stylesheet";
27
- link.href = LATO_STYLESHEET_URL;
46
+ link.href = INTER_STYLESHEET_URL;
28
47
  link.onload = () => resolve();
29
- link.onerror = () => reject(new Error("Lato stylesheet failed to load"));
48
+ link.onerror = () => reject(new Error("Inter stylesheet failed to load"));
30
49
  document.head.appendChild(link);
31
50
  });
32
51
  }
33
52
  /**
34
53
  * Loads essential app resources on startup.
35
54
  *
36
- * The UI package does not bundle font files. Web loads Lato from Google Fonts;
37
- * native platforms use their system sans-serif fallback.
55
+ * Native platforms load four static Inter weights (via
56
+ * @expo-google-fonts/inter) so StyledText's weight range resolves to real
57
+ * font files. Web loads Inter from Google Fonts as a single CSS family;
58
+ * weight differentiation there comes from a numeric fontWeight instead.
38
59
  */
39
60
  export const useResources = () => {
40
61
  const [loaded, setLoaded] = useState(false);
@@ -45,6 +66,7 @@ export const useResources = () => {
45
66
  try {
46
67
  const fontPromise = Promise.all([
47
68
  Font.loadAsync(Feather.font),
69
+ loadNativeInterFonts(),
48
70
  ensureWebFontStylesheet(),
49
71
  ]);
50
72
  // Timeout after 5 seconds — proceed with system fallback fonts
@@ -14,7 +14,7 @@ interface StaggeredEntranceOptions {
14
14
  delay?: number;
15
15
  /**
16
16
  * Duration of the entrance animation (ms)
17
- * @default 200
17
+ * @default durations.normal (200)
18
18
  */
19
19
  duration?: number;
20
20
  /**
@@ -1,6 +1,7 @@
1
1
  import { useEffect, useMemo, useRef } from "react";
2
2
  import { Animated, Easing } from "react-native";
3
3
  import { useReducedMotion } from "./useReduceMotion.js";
4
+ import { durations } from "../constants/motion.js";
4
5
  /**
5
6
  * Hook for entrance animations with stagger support using React Native Animated.
6
7
  *
@@ -21,7 +22,7 @@ import { useReducedMotion } from "./useReduceMotion.js";
21
22
  * ```
22
23
  */
23
24
  export function useStaggeredEntrance(options = {}) {
24
- const { type = "fadeSlideUp", delay = 0, duration = 200, slideDistance = 8, initialScale = 0.95, } = options;
25
+ const { type = "fadeSlideUp", delay = 0, duration = durations.normal, slideDistance = 8, initialScale = 0.95, } = options;
25
26
  const reduceMotion = useReducedMotion();
26
27
  const opacity = useRef(new Animated.Value(reduceMotion ? 1 : 0)).current;
27
28
  const translateY = useRef(new Animated.Value(reduceMotion
@@ -32,7 +32,7 @@ interface ExtendedColorScheme {
32
32
  * - getTextColorForBackground("#000") → "light"
33
33
  * - getContrastingColor("#f4f4f4", "#222", "#fff") → "#222"
34
34
  * - withAlpha("#336699", 0.6) → "rgba(51,102,153,0.6)"
35
- * - getShadowStyle('base') → { boxShadow: "0px 1px 3px rgba(0, 0, 0, 0.1)" }
35
+ * - getShadowStyle('base') → { boxShadow: "0px 2px 6px rgba(0, 0, 0, 0.05), 0px 4px 12px rgba(0, 0, 0, 0.03)" }
36
36
  */
37
37
  export declare function useTheme(): ExtendedColorScheme & {
38
38
  toggleTheme: () => void;
@@ -42,7 +42,7 @@ function getCachedOrCompute(key, compute) {
42
42
  * - getTextColorForBackground("#000") → "light"
43
43
  * - getContrastingColor("#f4f4f4", "#222", "#fff") → "#222"
44
44
  * - withAlpha("#336699", 0.6) → "rgba(51,102,153,0.6)"
45
- * - getShadowStyle('base') → { boxShadow: "0px 1px 3px rgba(0, 0, 0, 0.1)" }
45
+ * - getShadowStyle('base') → { boxShadow: "0px 2px 6px rgba(0, 0, 0, 0.05), 0px 4px 12px rgba(0, 0, 0, 0.03)" }
46
46
  */
47
47
  export function useTheme() {
48
48
  const userTheme = useThemeStore((s) => s.userTheme);
@@ -100,29 +100,66 @@ export function useTheme() {
100
100
  * `boxShadow` has no separate opacity field, each preset's opacity is folded
101
101
  * into the color's alpha via `withAlpha`. `elevation` is dropped — `boxShadow`
102
102
  * renders shadows on Android in 0.85+.
103
+ *
104
+ * Every preset (except `sharp` and `glow`, which are deliberately
105
+ * single-layer) is a dual-layer shadow: a tight "contact" layer plus a
106
+ * wider, softer "ambient" layer, pitsi-ui-style. Layers are comma-joined
107
+ * into one `boxShadow` value.
103
108
  */
104
109
  const getShadowStyle = useCallback((type) => {
105
- // Each preset: [offsetX, offsetY, blurRadius, color, opacity].
110
+ // Each preset is one or more layers of [offsetX, offsetY, blurRadius, color, opacity].
106
111
  // Darker themes get a stronger alpha so shadows stay visible.
107
112
  const boost = theme.dark ? 3 : 1;
108
113
  const overlay = theme.colors.overlay;
109
114
  const shadowConfigs = {
110
- base: { x: 0, y: 1, blur: 3, color: overlay, opacity: 0.1 },
111
- soft: { x: 0, y: 4, blur: 6, color: overlay, opacity: 0.1 },
112
- sharp: { x: 0, y: 1, blur: 1, color: overlay, opacity: 0.15 },
113
- subtle: { x: 0, y: 1, blur: 2, color: overlay, opacity: 0.05 },
114
- elevated: { x: 0, y: 20, blur: 40, color: overlay, opacity: 0.15 },
115
- glow: { x: 0, y: 4, blur: 20, color: theme.colors.primary, opacity: 0.4 },
116
- glass: { x: 0, y: 4, blur: 30, color: overlay, opacity: 0.05 },
117
- card: { x: 0, y: 2, blur: 8, color: overlay, opacity: 0.08 },
118
- cardHover: { x: 0, y: 8, blur: 24, color: overlay, opacity: 0.12 },
119
- cardSubtle: { x: 0, y: 1, blur: 3, color: overlay, opacity: 0.08 },
115
+ subtle: [
116
+ { x: 0, y: 1, blur: 3, color: overlay, opacity: 0.04 },
117
+ { x: 0, y: 2, blur: 8, color: overlay, opacity: 0.03 },
118
+ ],
119
+ base: [
120
+ { x: 0, y: 2, blur: 6, color: overlay, opacity: 0.05 },
121
+ { x: 0, y: 4, blur: 12, color: overlay, opacity: 0.03 },
122
+ ],
123
+ soft: [
124
+ { x: 0, y: 4, blur: 10, color: overlay, opacity: 0.05 },
125
+ { x: 0, y: 8, blur: 20, color: overlay, opacity: 0.03 },
126
+ ],
127
+ card: [
128
+ { x: 0, y: 4, blur: 12, color: overlay, opacity: 0.05 },
129
+ { x: 0, y: 8, blur: 24, color: overlay, opacity: 0.03 },
130
+ ],
131
+ cardSubtle: [
132
+ { x: 0, y: 1, blur: 3, color: overlay, opacity: 0.05 },
133
+ { x: 0, y: 3, blur: 9, color: overlay, opacity: 0.03 },
134
+ ],
135
+ cardHover: [
136
+ { x: 0, y: 8, blur: 24, color: overlay, opacity: 0.06 },
137
+ { x: 0, y: 16, blur: 48, color: overlay, opacity: 0.04 },
138
+ ],
139
+ elevated: [
140
+ { x: 0, y: 16, blur: 48, color: overlay, opacity: 0.08 },
141
+ { x: 0, y: 32, blur: 96, color: overlay, opacity: 0.05 },
142
+ ],
143
+ glass: [
144
+ { x: 0, y: 4, blur: 30, color: overlay, opacity: 0.05 },
145
+ { x: 0, y: 8, blur: 60, color: overlay, opacity: 0.03 },
146
+ ],
147
+ // Single layer — intentional crispness, not a soft dual-layer shadow.
148
+ sharp: [
149
+ { x: 0, y: 1, blur: 1, color: overlay, opacity: 0.15 },
150
+ ],
151
+ // Single layer — already a deliberate, vivid accent glow.
152
+ glow: [
153
+ { x: 0, y: 4, blur: 20, color: theme.colors.primary, opacity: 0.4 },
154
+ ],
120
155
  };
121
- const { x, y, blur, color, opacity } = shadowConfigs[type];
122
- // Don't boost the glow accent — it's already a deliberate, vivid alpha.
123
- const alpha = color === theme.colors.primary ? opacity : Math.min(opacity * boost, 1);
156
+ const layers = shadowConfigs[type].map(({ x, y, blur, color, opacity }) => {
157
+ // Don't boost the glow accent — it's already a deliberate, vivid alpha.
158
+ const alpha = color === theme.colors.primary ? opacity : Math.min(opacity * boost, 1);
159
+ return `${x}px ${y}px ${blur}px ${withAlpha(color, alpha)}`;
160
+ });
124
161
  return {
125
- boxShadow: `${x}px ${y}px ${blur}px ${withAlpha(color, alpha)}`,
162
+ boxShadow: layers.join(", "),
126
163
  };
127
164
  }, [theme]);
128
165
  const getFocusRingStyle = useCallback((offset = 2) => {
@@ -1,3 +1,4 @@
1
1
  export * from "./animations";
2
2
  export * from "./haptics";
3
3
  export * from "./i18n";
4
+ export * from "./themedStyles";
package/dist/lib/index.js CHANGED
@@ -1,3 +1,4 @@
1
1
  export * from "./animations.js";
2
2
  export * from "./haptics.js";
3
3
  export * from "./i18n.js";
4
+ export * from "./themedStyles.js";
@@ -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;
@@ -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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrmeg/expo-ui",
3
- "version": "0.14.0",
3
+ "version": "0.16.0",
4
4
  "private": false,
5
5
  "description": "Reusable Expo and React Native UI primitives for MrMeg projects.",
6
6
  "keywords": [
@@ -88,25 +88,26 @@
88
88
  "publish:dry-run": "bun pm pack --dry-run"
89
89
  },
90
90
  "dependencies": {
91
+ "@expo-google-fonts/inter": "^0.4.2",
91
92
  "@expo/vector-icons": ">=15.0.0 <16.0.0",
92
- "@rn-primitives/accordion": "~1.4.0",
93
- "@rn-primitives/alert-dialog": "~1.4.0",
94
- "@rn-primitives/checkbox": "~1.4.0",
95
- "@rn-primitives/collapsible": "~1.4.0",
96
- "@rn-primitives/dialog": "~1.4.0",
97
- "@rn-primitives/dropdown-menu": "~1.4.0",
98
- "@rn-primitives/label": "~1.4.0",
99
- "@rn-primitives/popover": "~1.4.0",
100
- "@rn-primitives/portal": "~1.4.0",
101
- "@rn-primitives/radio-group": "~1.4.0",
102
- "@rn-primitives/select": "~1.4.0",
103
- "@rn-primitives/separator": "~1.4.0",
104
- "@rn-primitives/slot": "~1.4.0",
105
- "@rn-primitives/switch": "~1.4.0",
106
- "@rn-primitives/tabs": "~1.4.0",
107
- "@rn-primitives/toggle": "~1.4.0",
108
- "@rn-primitives/toggle-group": "~1.4.0",
109
- "@rn-primitives/tooltip": "~1.4.0"
93
+ "@rn-primitives/accordion": "~1.5.2",
94
+ "@rn-primitives/alert-dialog": "~1.5.2",
95
+ "@rn-primitives/checkbox": "~1.5.2",
96
+ "@rn-primitives/collapsible": "~1.5.2",
97
+ "@rn-primitives/dialog": "~1.5.2",
98
+ "@rn-primitives/dropdown-menu": "~1.5.2",
99
+ "@rn-primitives/label": "~1.5.2",
100
+ "@rn-primitives/popover": "~1.5.2",
101
+ "@rn-primitives/portal": "~1.5.2",
102
+ "@rn-primitives/radio-group": "~1.5.2",
103
+ "@rn-primitives/select": "~1.5.2",
104
+ "@rn-primitives/separator": "~1.5.2",
105
+ "@rn-primitives/slot": "~1.5.2",
106
+ "@rn-primitives/switch": "~1.5.2",
107
+ "@rn-primitives/tabs": "~1.5.2",
108
+ "@rn-primitives/toggle": "~1.5.2",
109
+ "@rn-primitives/toggle-group": "~1.5.2",
110
+ "@rn-primitives/tooltip": "~1.5.2"
110
111
  },
111
112
  "peerDependencies": {
112
113
  "@expo/ui": ">=56.0.0 <58.0.0",