@mrmeg/expo-ui 0.1.5 → 0.1.6

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.
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Icon } from "./Icon.js";
3
- import { TextClassContext, TextColorContext } from "./StyledText.js";
3
+ import { TextClassContext, TextColorContext, TextSelectabilityContext } from "./StyledText.js";
4
4
  import { spacing } from "../constants/spacing.js";
5
5
  import { useTheme } from "../hooks/useTheme.js";
6
6
  import * as ToggleGroupPrimitive from "@rn-primitives/toggle-group";
@@ -175,12 +175,13 @@ function ToggleGroupItem({ isFirst = false, isLast = false, children, ...props }
175
175
  ...(Platform.OS === "web" && {
176
176
  cursor: props.disabled ? "not-allowed" : "pointer",
177
177
  transition: "all 150ms",
178
+ userSelect: "none",
178
179
  // Ensure proper z-index on focus
179
180
  ...(isSelected && {
180
181
  zIndex: 10,
181
182
  }),
182
183
  }),
183
- }, hitSlop: DEFAULT_HIT_SLOP, children: children }) }) }));
184
+ }, hitSlop: DEFAULT_HIT_SLOP, children: typeof children === "function" ? ((state) => (_jsx(TextSelectabilityContext.Provider, { value: false, children: children(state) }))) : (_jsx(TextSelectabilityContext.Provider, { value: false, children: children })) }) }) }));
184
185
  }
185
186
  function ToggleGroupIcon({ name, size, color }) {
186
187
  const contextColor = React.useContext(TextColorContext);
@@ -2,7 +2,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import * as React from "react";
3
3
  import { Platform, StyleSheet, View } from "react-native";
4
4
  import { AnimatedView } from "./AnimatedView.js";
5
- import { TextClassContext, TextColorContext } from "./StyledText.js";
5
+ import { TextClassContext, TextColorContext, TextSelectabilityContext } from "./StyledText.js";
6
6
  import { useTheme } from "../hooks/useTheme.js";
7
7
  import { spacing } from "../constants/spacing.js";
8
8
  import * as TooltipPrimitive from "@rn-primitives/tooltip";
@@ -50,9 +50,9 @@ function TooltipContent({ side = "top", align = "center", sideOffset = 4, portal
50
50
  case "default":
51
51
  default:
52
52
  return {
53
- background: theme.colors.card,
53
+ background: theme.colors.popover,
54
54
  border: theme.colors.border,
55
- text: getContrastingColor(theme.colors.card, palette.white, palette.black),
55
+ text: getContrastingColor(theme.colors.popover, palette.white, palette.black),
56
56
  };
57
57
  }
58
58
  };
@@ -69,7 +69,7 @@ function TooltipContent({ side = "top", align = "center", sideOffset = 4, portal
69
69
  ...getShadowStyle("soft"),
70
70
  },
71
71
  ]);
72
- return (_jsx(TooltipPrimitive.Portal, { hostName: portalHost, children: _jsx(FullWindowOverlay, { children: _jsx(TooltipPrimitive.Overlay, { style: Platform.select({ native: StyleSheet.absoluteFill }), children: _jsx(AnimatedView, { type: "fade", enterDuration: 150, children: _jsx(TextColorContext.Provider, { value: colors.text, children: _jsx(TextClassContext.Provider, { value: "", children: _jsx(TooltipPrimitive.Content, { side: side, align: align, sideOffset: sideOffset, style: contentStyle, ...props }) }) }) }) }) }) }));
72
+ return (_jsx(TooltipPrimitive.Portal, { hostName: portalHost, children: _jsx(FullWindowOverlay, { children: _jsx(TooltipPrimitive.Overlay, { style: Platform.select({ native: StyleSheet.absoluteFill }), children: _jsx(AnimatedView, { type: "fade", enterDuration: 150, children: _jsx(TextColorContext.Provider, { value: colors.text, children: _jsx(TextClassContext.Provider, { value: "", children: _jsx(TextSelectabilityContext.Provider, { value: false, children: _jsx(TooltipPrimitive.Content, { side: side, align: align, sideOffset: sideOffset, style: contentStyle, ...props }) }) }) }) }) }) }) }));
73
73
  }
74
74
  function TooltipBody({ children, style, ...props }) {
75
75
  return (_jsx(View, { style: [
@@ -44,6 +44,8 @@ export interface ThemeColors {
44
44
  foreground: string;
45
45
  card: string;
46
46
  cardForeground: string;
47
+ popover: string;
48
+ popoverForeground: string;
47
49
  text: string;
48
50
  textDim: string;
49
51
  primary: string;
@@ -59,6 +61,8 @@ export interface ThemeColors {
59
61
  success: string;
60
62
  warning: string;
61
63
  border: string;
64
+ input: string;
65
+ ring: string;
62
66
  overlay: string;
63
67
  }
64
68
  export interface Theme {
@@ -46,8 +46,10 @@ const lightTheme = {
46
46
  colors: {
47
47
  background: palette.white,
48
48
  foreground: palette.gray950,
49
- card: palette.gray50,
49
+ card: palette.white,
50
50
  cardForeground: palette.gray950,
51
+ popover: palette.white,
52
+ popoverForeground: palette.gray950,
51
53
  text: palette.gray950,
52
54
  textDim: palette.gray500,
53
55
  primary: palette.gray900,
@@ -63,6 +65,8 @@ const lightTheme = {
63
65
  success: palette.green500,
64
66
  warning: palette.amber500,
65
67
  border: palette.gray200,
68
+ input: palette.gray200,
69
+ ring: palette.gray400,
66
70
  overlay: "rgba(0, 0, 0, 0.5)",
67
71
  },
68
72
  navigation: {
@@ -82,6 +86,8 @@ const darkTheme = {
82
86
  foreground: palette.dark100,
83
87
  card: palette.dark800,
84
88
  cardForeground: palette.dark100,
89
+ popover: palette.dark800,
90
+ popoverForeground: palette.dark100,
85
91
  text: palette.dark100,
86
92
  textDim: palette.dark400,
87
93
  primary: palette.gray50,
@@ -97,6 +103,8 @@ const darkTheme = {
97
103
  success: palette.green400,
98
104
  warning: palette.amber400,
99
105
  border: palette.dark700,
106
+ input: palette.dark700,
107
+ ring: palette.dark400,
100
108
  overlay: "rgba(0, 0, 0, 0.7)",
101
109
  },
102
110
  navigation: {
@@ -22,6 +22,7 @@ export declare const spacing: {
22
22
  readonly cardPadding: 16;
23
23
  readonly sectionSpacing: 32;
24
24
  readonly listItemSpacing: 8;
25
+ readonly touchTarget: 44;
25
26
  readonly radiusNone: 0;
26
27
  readonly radiusXs: 2;
27
28
  readonly radiusSm: 4;
@@ -36,5 +37,5 @@ export declare const spacing: {
36
37
  readonly iconLg: 32;
37
38
  readonly iconXl: 48;
38
39
  };
39
- export declare const base: 8, xxs: 2, xs: 4, sm: 8, md: 16, lg: 24, xl: 32, xxl: 48, xxxl: 64, gutter: 16, gutterVertical: 24, screenPadding: 16, buttonPadding: 10, inputPadding: 10, cardPadding: 16, sectionSpacing: 32, listItemSpacing: 8, radiusNone: 0, radiusXs: 2, radiusSm: 4, radiusMd: 6, radiusLg: 8, radiusXl: 12, radius2xl: 16, radiusFull: 9999, iconXs: 12, iconSm: 16, iconMd: 24, iconLg: 32, iconXl: 48;
40
+ export declare const base: 8, xxs: 2, xs: 4, sm: 8, md: 16, lg: 24, xl: 32, xxl: 48, xxxl: 64, gutter: 16, gutterVertical: 24, screenPadding: 16, buttonPadding: 10, inputPadding: 10, cardPadding: 16, sectionSpacing: 32, listItemSpacing: 8, touchTarget: 44, radiusNone: 0, radiusXs: 2, radiusSm: 4, radiusMd: 6, radiusLg: 8, radiusXl: 12, radius2xl: 16, radiusFull: 9999, iconXs: 12, iconSm: 16, iconMd: 24, iconLg: 32, iconXl: 48;
40
41
  export declare const space: (multiplier: number) => number;
@@ -26,6 +26,7 @@ export const spacing = {
26
26
  cardPadding: 16, // Default card padding
27
27
  sectionSpacing: 32, // Space between major sections
28
28
  listItemSpacing: 8, // Space between list items
29
+ touchTarget: 44, // Minimum comfortable native touch target
29
30
  // Border radius — shadcn-inspired scale (radiusMd = 6px default)
30
31
  radiusNone: 0,
31
32
  radiusXs: 2,
@@ -43,6 +44,6 @@ export const spacing = {
43
44
  iconXl: 48,
44
45
  };
45
46
  // Export individual constants for convenience
46
- export const { base, xxs, xs, sm, md, lg, xl, xxl, xxxl, gutter, gutterVertical, screenPadding, buttonPadding, inputPadding, cardPadding, sectionSpacing, listItemSpacing, radiusNone, radiusXs, radiusSm, radiusMd, radiusLg, radiusXl, radius2xl, radiusFull, iconXs, iconSm, iconMd, iconLg, iconXl, } = spacing;
47
+ export const { base, xxs, xs, sm, md, lg, xl, xxl, xxxl, gutter, gutterVertical, screenPadding, buttonPadding, inputPadding, cardPadding, sectionSpacing, listItemSpacing, touchTarget, radiusNone, radiusXs, radiusSm, radiusMd, radiusLg, radiusXl, radius2xl, radiusFull, iconXs, iconSm, iconMd, iconLg, iconXl, } = spacing;
47
48
  // Helper function to multiply base spacing
48
49
  export const space = (multiplier) => spacing.base * multiplier;
@@ -6,6 +6,7 @@ interface ExtendedColorScheme {
6
6
  theme: Colors["light" | "dark"];
7
7
  scheme: "light" | "dark";
8
8
  getShadowStyle: (type: ShadowType) => ViewStyle;
9
+ getFocusRingStyle: (offset?: number) => ViewStyle;
9
10
  getContrastingColor: (backgroundColor: string, color1?: string, color2?: string) => string;
10
11
  getTextColorForBackground: (backgroundColor: string) => "light" | "dark";
11
12
  withAlpha: (color: string, alpha: number) => string;
@@ -21,6 +22,7 @@ interface ExtendedColorScheme {
21
22
  * - theme: active theme colors (light or dark)
22
23
  * - scheme: "light" | "dark"
23
24
  * - getShadowStyle(type): returns cross-platform shadow style object
25
+ * - getFocusRingStyle(offset): returns web focus ring style object
24
26
  * - getContrastingColor(bg, color1?, color2?): pick best contrast of two options
25
27
  * - getTextColorForBackground(bg): returns "light" or "dark"
26
28
  * - withAlpha(color, alpha): adds transparency
@@ -71,7 +73,7 @@ type UseStylesReturn<T extends StyleSheet.NamedStyles<T>> = {
71
73
  * borderRadius: spacing.radiusMd,
72
74
  * },
73
75
  * text: {
74
- * color: theme.colors.textPrimary,
76
+ * color: theme.colors.text,
75
77
  * fontSize: 16,
76
78
  * },
77
79
  * }));
@@ -1,4 +1,4 @@
1
- import { useEffect } from "react";
1
+ import { useCallback, useEffect, useMemo } from "react";
2
2
  import { colors } from "../constants/colors.js";
3
3
  import { useColorScheme as useColorSchemeDefault, Platform, StyleSheet } from "react-native";
4
4
  import { useThemeStore } from "../state/themeStore.js";
@@ -31,6 +31,7 @@ function getCachedOrCompute(key, compute) {
31
31
  * - theme: active theme colors (light or dark)
32
32
  * - scheme: "light" | "dark"
33
33
  * - getShadowStyle(type): returns cross-platform shadow style object
34
+ * - getFocusRingStyle(offset): returns web focus ring style object
34
35
  * - getContrastingColor(bg, color1?, color2?): pick best contrast of two options
35
36
  * - getTextColorForBackground(bg): returns "light" or "dark"
36
37
  * - withAlpha(color, alpha): adds transparency
@@ -61,7 +62,7 @@ export function useTheme() {
61
62
  }
62
63
  }, [effectiveScheme]);
63
64
  // Toggle between light, dark, and system themes
64
- const toggleTheme = () => {
65
+ const toggleTheme = useCallback(() => {
65
66
  if (userTheme === "light") {
66
67
  setTheme("dark");
67
68
  }
@@ -71,14 +72,14 @@ export function useTheme() {
71
72
  else {
72
73
  setTheme("light");
73
74
  }
74
- };
75
+ }, [setTheme, userTheme]);
75
76
  /**
76
77
  * getShadowStyle
77
78
  * Returns platform-appropriate shadow styles
78
- * - Web: returns empty object (boxShadow not supported by RN Web)
79
+ * - Web: uses CSS boxShadow through React Native Web
79
80
  * - Native: uses shadowColor, shadowOffset, shadowOpacity, shadowRadius, elevation
80
81
  */
81
- const getShadowStyle = (type) => {
82
+ const getShadowStyle = useCallback((type) => {
82
83
  const shadowConfigs = {
83
84
  base: {
84
85
  shadowColor: theme.colors.overlay,
@@ -110,13 +111,29 @@ export function useTheme() {
110
111
  },
111
112
  };
112
113
  const config = shadowConfigs[type];
114
+ if (Platform.OS === "web") {
115
+ const webShadows = {
116
+ base: { boxShadow: theme.dark ? "0 1px 2px rgba(0, 0, 0, 0.45)" : "0 1px 2px rgba(0, 0, 0, 0.08)" },
117
+ soft: { boxShadow: theme.dark ? "0 8px 24px rgba(0, 0, 0, 0.36)" : "0 8px 24px rgba(0, 0, 0, 0.10)" },
118
+ sharp: { boxShadow: theme.dark ? "0 1px 1px rgba(0, 0, 0, 0.55)" : "0 1px 1px rgba(0, 0, 0, 0.12)" },
119
+ subtle: { boxShadow: theme.dark ? "0 1px 2px rgba(0, 0, 0, 0.32)" : "0 1px 2px rgba(0, 0, 0, 0.05)" },
120
+ };
121
+ return webShadows[type];
122
+ }
113
123
  return Platform.select({
114
- web: {}, // Empty on web - boxShadow causes crashes
115
124
  default: config,
116
125
  });
117
- };
126
+ }, [theme]);
127
+ const getFocusRingStyle = useCallback((offset = 2) => {
128
+ if (Platform.OS !== "web") {
129
+ return {};
130
+ }
131
+ return {
132
+ boxShadow: `0 0 0 ${offset}px ${theme.colors.background}, 0 0 0 ${offset + 2}px ${theme.colors.ring}`,
133
+ };
134
+ }, [theme.colors.background, theme.colors.ring]);
118
135
  // Helper to calculate contrast ratio between two colors
119
- const getContrastRatio = (color1, color2) => {
136
+ const getContrastRatio = useCallback((color1, color2) => {
120
137
  const rgb1 = parseColor(color1);
121
138
  const rgb2 = parseColor(color2);
122
139
  if (!rgb1 || !rgb2) {
@@ -126,17 +143,18 @@ export function useTheme() {
126
143
  const lum1 = calculateLuminance(rgb1[0], rgb1[1], rgb1[2]);
127
144
  const lum2 = calculateLuminance(rgb2[0], rgb2[1], rgb2[2]);
128
145
  return calculateContrastRatio(lum1, lum2);
129
- };
146
+ }, []);
130
147
  // Cached version of getContrastingColor for performance
131
148
  // Uses module-level cache to prevent memory leak
132
- const getCachedContrastingColor = (backgroundColor, color1, color2) => {
149
+ const getCachedContrastingColor = useCallback((backgroundColor, color1, color2) => {
133
150
  const cacheKey = `${backgroundColor}-${color1}-${color2}`;
134
151
  return getCachedOrCompute(cacheKey, () => getBetterContrast(backgroundColor, color1, color2));
135
- };
136
- return {
152
+ }, []);
153
+ return useMemo(() => ({
137
154
  theme,
138
155
  scheme: theme.dark ? "dark" : "light",
139
156
  getShadowStyle,
157
+ getFocusRingStyle,
140
158
  toggleTheme,
141
159
  setTheme,
142
160
  currentTheme: userTheme,
@@ -144,7 +162,16 @@ export function useTheme() {
144
162
  getTextColorForBackground,
145
163
  withAlpha,
146
164
  getContrastRatio,
147
- };
165
+ }), [
166
+ getCachedContrastingColor,
167
+ getContrastRatio,
168
+ getFocusRingStyle,
169
+ getShadowStyle,
170
+ setTheme,
171
+ theme,
172
+ toggleTheme,
173
+ userTheme,
174
+ ]);
148
175
  }
149
176
  /**
150
177
  * Parses a color string (hex or rgba) and returns RGB values.
@@ -292,7 +319,7 @@ function withAlpha(color, alpha) {
292
319
  * borderRadius: spacing.radiusMd,
293
320
  * },
294
321
  * text: {
295
- * color: theme.colors.textPrimary,
322
+ * color: theme.colors.text,
296
323
  * fontSize: 16,
297
324
  * },
298
325
  * }));
@@ -307,16 +334,17 @@ function withAlpha(color, alpha) {
307
334
  */
308
335
  export function useStyles(factory) {
309
336
  const themeContext = useTheme();
310
- const styles = StyleSheet.create(factory({
337
+ const styles = useMemo(() => StyleSheet.create(factory({
311
338
  theme: themeContext.theme,
312
339
  spacing: spacingConstants,
313
- }));
314
- return {
340
+ })), [factory, themeContext.theme]);
341
+ return useMemo(() => ({
315
342
  styles,
316
343
  theme: themeContext.theme,
317
344
  spacing: spacingConstants,
318
345
  scheme: themeContext.scheme,
319
346
  getShadowStyle: themeContext.getShadowStyle,
347
+ getFocusRingStyle: themeContext.getFocusRingStyle,
320
348
  getContrastingColor: themeContext.getContrastingColor,
321
349
  getTextColorForBackground: themeContext.getTextColorForBackground,
322
350
  withAlpha: themeContext.withAlpha,
@@ -324,5 +352,5 @@ export function useStyles(factory) {
324
352
  toggleTheme: themeContext.toggleTheme,
325
353
  setTheme: themeContext.setTheme,
326
354
  currentTheme: themeContext.currentTheme,
327
- };
355
+ }), [styles, themeContext]);
328
356
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrmeg/expo-ui",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "private": false,
5
5
  "description": "Reusable Expo and React Native UI primitives for MrMeg projects.",
6
6
  "keywords": [
@@ -79,11 +79,7 @@
79
79
  "publish:dry-run": "bun pm pack --dry-run"
80
80
  },
81
81
  "dependencies": {
82
- "@rn-primitives/portal": "~1.4.0"
83
- },
84
- "peerDependencies": {
85
82
  "@expo/vector-icons": ">=15.0.0 <16.0.0",
86
- "@react-native-async-storage/async-storage": ">=2.2.0 <2.3.0",
87
83
  "@rn-primitives/accordion": "~1.4.0",
88
84
  "@rn-primitives/alert-dialog": "~1.4.0",
89
85
  "@rn-primitives/checkbox": "~1.4.0",
@@ -92,6 +88,7 @@
92
88
  "@rn-primitives/dropdown-menu": "~1.4.0",
93
89
  "@rn-primitives/label": "~1.4.0",
94
90
  "@rn-primitives/popover": "~1.4.0",
91
+ "@rn-primitives/portal": "~1.4.0",
95
92
  "@rn-primitives/radio-group": "~1.4.0",
96
93
  "@rn-primitives/select": "~1.4.0",
97
94
  "@rn-primitives/separator": "~1.4.0",
@@ -100,8 +97,10 @@
100
97
  "@rn-primitives/tabs": "~1.4.0",
101
98
  "@rn-primitives/toggle": "~1.4.0",
102
99
  "@rn-primitives/toggle-group": "~1.4.0",
103
- "@rn-primitives/tooltip": "~1.4.0",
104
- "@rn-primitives/types": "~1.4.0",
100
+ "@rn-primitives/tooltip": "~1.4.0"
101
+ },
102
+ "peerDependencies": {
103
+ "@react-native-async-storage/async-storage": ">=2.2.0 <2.3.0",
105
104
  "expo": "~55.0.0",
106
105
  "expo-font": "~55.0.0",
107
106
  "expo-haptics": "~55.0.0",