@mrmeg/expo-ui 0.4.0 → 0.4.2

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,5 +1,5 @@
1
1
  import React, { ComponentType } from "react";
2
- import { PressableProps, PressableStateCallbackType, StyleProp, TextStyle, ViewStyle, ImageStyle } from "react-native";
2
+ import { PressableProps, PressableStateCallbackType, StyleProp, TextStyle, ViewStyle } from "react-native";
3
3
  import { TextProps } from "./StyledText";
4
4
  /**
5
5
  * Button variants
@@ -9,8 +9,9 @@ type Presets = "default" | "outline" | "ghost" | "link" | "destructive" | "secon
9
9
  * Button size variants
10
10
  */
11
11
  export type ButtonSize = "sm" | "md" | "lg";
12
+ export type ButtonAccessoryStyle = Pick<ViewStyle, "margin" | "marginHorizontal" | "marginVertical" | "marginTop" | "marginRight" | "marginBottom" | "marginLeft" | "marginStart" | "marginEnd">;
12
13
  export interface ButtonAccessoryProps {
13
- style: StyleProp<ViewStyle | TextStyle | ImageStyle>;
14
+ style: StyleProp<ButtonAccessoryStyle>;
14
15
  pressableState: PressableStateCallbackType;
15
16
  disabled?: boolean;
16
17
  }
@@ -1,5 +1,5 @@
1
1
  import * as React from "react";
2
- import { StyleProp, ViewStyle } from "react-native";
2
+ import type { StyleProp, TextProps, TextStyle } from "react-native";
3
3
  import Feather from "@expo/vector-icons/Feather";
4
4
  /**
5
5
  * Theme color names that can be used as shortcuts
@@ -13,10 +13,16 @@ type IconBaseProps = {
13
13
  /** Icon color - can be a hex color or a theme color name. Defaults to theme's text color */
14
14
  color?: string | ThemeColorName;
15
15
  /** Additional styles for positioning, transforms, etc. */
16
- style?: StyleProp<ViewStyle>;
16
+ style?: StyleProp<TextStyle>;
17
17
  /** When true, hides the icon from the accessibility tree. @default false */
18
18
  decorative?: boolean;
19
19
  };
20
+ type IconAccessibilityProps = Pick<TextProps, "accessible" | "importantForAccessibility" | "accessibilityElementsHidden" | "aria-hidden">;
21
+ type CustomIconComponentProps = {
22
+ size: number;
23
+ color: string;
24
+ style?: StyleProp<TextStyle>;
25
+ } & Partial<IconAccessibilityProps>;
20
26
  type FeatherIconProps = IconBaseProps & {
21
27
  /** The icon name to render (Feather icons) */
22
28
  name: IconName;
@@ -25,15 +31,12 @@ type FeatherIconProps = IconBaseProps & {
25
31
  type CustomIconProps = IconBaseProps & {
26
32
  name?: never;
27
33
  /** Custom component to render instead of Feather. Receives size and color as props. */
28
- component: React.ComponentType<{
29
- size: number;
30
- color: string;
31
- }>;
34
+ component: React.ComponentType<CustomIconComponentProps>;
32
35
  };
33
36
  export type IconProps = FeatherIconProps | CustomIconProps;
34
37
  /**
35
38
  * Universal Icon Component
36
- * Wraps @expo/vector-icons Feather with theme integration and style support
39
+ * Renders @expo/vector-icons Feather with theme integration and style support
37
40
  *
38
41
  * Usage:
39
42
  * ```tsx
@@ -1,5 +1,4 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { View } from "react-native";
3
2
  import { useTheme } from "../hooks/useTheme.js";
4
3
  import Feather from "@expo/vector-icons/Feather";
5
4
  const THEME_COLOR_KEYS = [
@@ -16,7 +15,7 @@ function resolveIconColor(color, themeColors) {
16
15
  }
17
16
  /**
18
17
  * Universal Icon Component
19
- * Wraps @expo/vector-icons Feather with theme integration and style support
18
+ * Renders @expo/vector-icons Feather with theme integration and style support
20
19
  *
21
20
  * Usage:
22
21
  * ```tsx
@@ -30,11 +29,17 @@ export function Icon(props) {
30
29
  const { theme } = useTheme();
31
30
  const iconColor = resolveIconColor(color, theme.colors);
32
31
  const CustomComponent = "component" in props ? props.component : undefined;
33
- // Wrap in View with pointerEvents="none" to prevent icons from
34
- // intercepting touches when used inside TouchableOpacity on iOS
35
- return (_jsx(View, { style: [style, { pointerEvents: "none" }], accessible: !decorative, ...(decorative && {
36
- importantForAccessibility: "no",
32
+ const accessibilityProps = decorative
33
+ ? {
34
+ accessible: false,
35
+ importantForAccessibility: "no-hide-descendants",
37
36
  accessibilityElementsHidden: true,
38
37
  "aria-hidden": true,
39
- }), children: CustomComponent ? (_jsx(CustomComponent, { size: size, color: iconColor })) : (_jsx(Feather, { name: props.name, size: size, color: iconColor })) }));
38
+ }
39
+ : { accessible: true };
40
+ const iconStyle = [style, { pointerEvents: "none" }];
41
+ if (CustomComponent) {
42
+ return (_jsx(CustomComponent, { size: size, color: iconColor, style: iconStyle, ...accessibilityProps }));
43
+ }
44
+ return (_jsx(Feather, { name: props.name, size: size, color: iconColor, style: iconStyle, ...accessibilityProps }));
40
45
  }
@@ -12,8 +12,18 @@ type WindowDimensions = {
12
12
  isLargeScreen: boolean;
13
13
  };
14
14
  /**
15
- * Provides a consistent way to access window dimensions and screen size information across mobile and web.
15
+ * Provides a consistent way to access window dimensions and screen size
16
+ * information across mobile and web.
16
17
  *
18
+ * On web SSR, the initial width comes from `SsrViewportContext`, which a
19
+ * route's loader can populate from a `mrmeg-vw` cookie (precise, from
20
+ * previous visit) or User-Agent heuristics. Both server and the initial
21
+ * client render read the same context value so hydration matches; after
22
+ * mount, real dimensions take over and the cookie is updated for next time.
23
+ *
24
+ * Routes that don't provide the context get the package default
25
+ * (`SSR_VIEWPORT_DEFAULT_WIDTH` = 1280) — desktop-correct, mobile gets one
26
+ * frame of desktop layout before snapping.
17
27
  */
18
28
  export declare const useDimensions: () => WindowDimensions;
19
29
  export {};
@@ -1,43 +1,72 @@
1
- import { useState, useEffect } from "react";
1
+ import { useContext, useEffect, useState } from "react";
2
2
  import { Dimensions, Platform } from "react-native";
3
+ import { SsrViewportContext, SSR_VIEWPORT_DEFAULT_HEIGHT, } from "../state/SsrViewportContext.js";
3
4
  export const SCREEN_SIZES = {
4
5
  SMALL: 768,
5
6
  MEDIUM: 1000,
6
7
  LARGE: 1200,
7
8
  };
8
9
  /**
9
- * Provides a consistent way to access window dimensions and screen size information across mobile and web.
10
+ * Helper function to calculate dimension-based flags
11
+ */
12
+ const calculateDimensionFlags = (width, height) => {
13
+ const orientation = width > height ? "landscape" : "portrait";
14
+ return {
15
+ width,
16
+ height,
17
+ orientation,
18
+ isSmallScreen: width <= SCREEN_SIZES.SMALL,
19
+ isMediumScreen: width > SCREEN_SIZES.SMALL && width <= SCREEN_SIZES.MEDIUM,
20
+ isLargeScreen: width > SCREEN_SIZES.MEDIUM,
21
+ };
22
+ };
23
+ // Persist the real viewport width as a cookie on first mount so subsequent
24
+ // SSR requests can render with the user's actual layout (no flash on repeat
25
+ // visits). The server reads this cookie via your route loader using
26
+ // `detectSsrViewportWidth(request)` from server/lib/ssrViewport.ts.
27
+ const SSR_VIEWPORT_COOKIE = "mrmeg-vw";
28
+ const SSR_VIEWPORT_COOKIE_MAX_AGE = 60 * 60 * 24 * 365; // 1 year
29
+ function writeViewportCookie(width) {
30
+ if (typeof document === "undefined")
31
+ return;
32
+ // Round to nearest 10 so resize-driven writes don't bust HTTP caching on
33
+ // every pixel of horizontal movement.
34
+ const rounded = Math.round(width / 10) * 10;
35
+ document.cookie = `${SSR_VIEWPORT_COOKIE}=${rounded}; path=/; max-age=${SSR_VIEWPORT_COOKIE_MAX_AGE}; SameSite=Lax`;
36
+ }
37
+ /**
38
+ * Provides a consistent way to access window dimensions and screen size
39
+ * information across mobile and web.
40
+ *
41
+ * On web SSR, the initial width comes from `SsrViewportContext`, which a
42
+ * route's loader can populate from a `mrmeg-vw` cookie (precise, from
43
+ * previous visit) or User-Agent heuristics. Both server and the initial
44
+ * client render read the same context value so hydration matches; after
45
+ * mount, real dimensions take over and the cookie is updated for next time.
10
46
  *
47
+ * Routes that don't provide the context get the package default
48
+ * (`SSR_VIEWPORT_DEFAULT_WIDTH` = 1280) — desktop-correct, mobile gets one
49
+ * frame of desktop layout before snapping.
11
50
  */
12
51
  export const useDimensions = () => {
13
52
  const isWeb = Platform.OS === "web";
14
- const [dimensions, setDimensions] = useState({
15
- width: 0,
16
- height: 0,
17
- orientation: "portrait",
18
- isSmallScreen: true,
19
- isMediumScreen: false,
20
- isLargeScreen: false,
21
- });
53
+ const ssrWidth = useContext(SsrViewportContext);
54
+ // Lazy initializer: both server and client first render compute identical
55
+ // flags from the context value, so hydration matches.
56
+ const [dimensions, setDimensions] = useState(() => calculateDimensionFlags(ssrWidth, SSR_VIEWPORT_DEFAULT_HEIGHT));
22
57
  useEffect(() => {
23
58
  const initialDimensions = isWeb
24
59
  ? { width: window.innerWidth, height: window.innerHeight }
25
60
  : Dimensions.get("window");
26
61
  const updateDimensions = (width, height) => {
27
- const orientation = width > height ? "landscape" : "portrait";
28
- setDimensions({
29
- width,
30
- height,
31
- orientation,
32
- isSmallScreen: width <= SCREEN_SIZES.SMALL,
33
- isMediumScreen: width > SCREEN_SIZES.SMALL,
34
- isLargeScreen: width > SCREEN_SIZES.MEDIUM,
35
- });
62
+ setDimensions(calculateDimensionFlags(width, height));
36
63
  };
37
64
  updateDimensions(initialDimensions.width, initialDimensions.height);
38
65
  if (isWeb) {
66
+ writeViewportCookie(initialDimensions.width);
39
67
  const handleResize = () => {
40
68
  updateDimensions(window.innerWidth, window.innerHeight);
69
+ writeViewportCookie(window.innerWidth);
41
70
  };
42
71
  window.addEventListener("resize", handleResize);
43
72
  return () => {
@@ -2,6 +2,15 @@ import { useEffect, useState } from "react";
2
2
  import * as Font from "expo-font";
3
3
  import Feather from "@expo/vector-icons/Feather";
4
4
  import { Platform } from "react-native";
5
+ // Eager, module-scope load so expo-font registers Feather in its SSR
6
+ // serverContext. When the server renders the HTML document, expo-font emits
7
+ // an @font-face <style> into the head — without this kickoff, server-rendered
8
+ // <Icon> components paint as empty glyphs until hydration runs the effect
9
+ // below, producing a visible icon-pop flash on icon-heavy SSR screens
10
+ // (e.g. OnboardingFlow). On the client this also primes the font ahead of
11
+ // the effect; the effect's loadAsync becomes a no-op for the already-loaded
12
+ // font.
13
+ void Font.loadAsync(Feather.font);
5
14
  const LATO_STYLESHEET_ID = "mrmeg-expo-ui-lato";
6
15
  const LATO_STYLESHEET_URL = "https://fonts.googleapis.com/css2?family=Lato:wght@400;700&display=swap";
7
16
  function ensureWebFontStylesheet() {
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Default viewport used during SSR and the initial client render. Desktop is
3
+ * chosen because:
4
+ * 1. Most web traffic for app dashboards / marketing pages skews desktop.
5
+ * 2. Desktop visitors see no layout reflow on hydration (SSR layout matches
6
+ * their actual viewport's breakpoint).
7
+ * 3. Mobile visitors get one frame of desktop-styled content before
8
+ * `useDimensions`'s post-mount effect snaps to real dimensions — better
9
+ * than the inverse (every desktop visitor sees mobile-tiny then snap).
10
+ *
11
+ * Projects that skew mobile can override with their own Provider higher in
12
+ * the tree, or per-page via a loader that detects from cookie / User-Agent.
13
+ */
14
+ export declare const SSR_VIEWPORT_DEFAULT_WIDTH = 1280;
15
+ export declare const SSR_VIEWPORT_DEFAULT_HEIGHT = 800;
16
+ /**
17
+ * Per-request SSR viewport width. Consumed by `useDimensions` to seed the
18
+ * initial responsive render so it matches what the user sees.
19
+ *
20
+ * Set the provider near the top of your tree (typically in a route component
21
+ * whose loader detects the viewport from a cookie or User-Agent):
22
+ *
23
+ * ```tsx
24
+ * import { SsrViewportContext } from "@mrmeg/expo-ui/state";
25
+ * import { detectSsrViewportWidth } from "@/server/lib/ssrViewport";
26
+ *
27
+ * export const loader: LoaderFunction<{ ssrViewportWidth?: number }> = (request) => ({
28
+ * ssrViewportWidth: detectSsrViewportWidth(request),
29
+ * });
30
+ *
31
+ * export default function Page() {
32
+ * const { ssrViewportWidth } = useLoaderData<typeof loader>();
33
+ * return (
34
+ * <SsrViewportContext.Provider value={ssrViewportWidth ?? SSR_VIEWPORT_DEFAULT_WIDTH}>
35
+ * ...
36
+ * </SsrViewportContext.Provider>
37
+ * );
38
+ * }
39
+ * ```
40
+ */
41
+ export declare const SsrViewportContext: import("react").Context<number>;
@@ -0,0 +1,42 @@
1
+ import { createContext } from "react";
2
+ /**
3
+ * Default viewport used during SSR and the initial client render. Desktop is
4
+ * chosen because:
5
+ * 1. Most web traffic for app dashboards / marketing pages skews desktop.
6
+ * 2. Desktop visitors see no layout reflow on hydration (SSR layout matches
7
+ * their actual viewport's breakpoint).
8
+ * 3. Mobile visitors get one frame of desktop-styled content before
9
+ * `useDimensions`'s post-mount effect snaps to real dimensions — better
10
+ * than the inverse (every desktop visitor sees mobile-tiny then snap).
11
+ *
12
+ * Projects that skew mobile can override with their own Provider higher in
13
+ * the tree, or per-page via a loader that detects from cookie / User-Agent.
14
+ */
15
+ export const SSR_VIEWPORT_DEFAULT_WIDTH = 1280;
16
+ export const SSR_VIEWPORT_DEFAULT_HEIGHT = 800;
17
+ /**
18
+ * Per-request SSR viewport width. Consumed by `useDimensions` to seed the
19
+ * initial responsive render so it matches what the user sees.
20
+ *
21
+ * Set the provider near the top of your tree (typically in a route component
22
+ * whose loader detects the viewport from a cookie or User-Agent):
23
+ *
24
+ * ```tsx
25
+ * import { SsrViewportContext } from "@mrmeg/expo-ui/state";
26
+ * import { detectSsrViewportWidth } from "@/server/lib/ssrViewport";
27
+ *
28
+ * export const loader: LoaderFunction<{ ssrViewportWidth?: number }> = (request) => ({
29
+ * ssrViewportWidth: detectSsrViewportWidth(request),
30
+ * });
31
+ *
32
+ * export default function Page() {
33
+ * const { ssrViewportWidth } = useLoaderData<typeof loader>();
34
+ * return (
35
+ * <SsrViewportContext.Provider value={ssrViewportWidth ?? SSR_VIEWPORT_DEFAULT_WIDTH}>
36
+ * ...
37
+ * </SsrViewportContext.Provider>
38
+ * );
39
+ * }
40
+ * ```
41
+ */
42
+ export const SsrViewportContext = createContext(SSR_VIEWPORT_DEFAULT_WIDTH);
@@ -1,2 +1,3 @@
1
1
  export * from "./globalUIStore";
2
2
  export * from "./themeStore";
3
+ export * from "./SsrViewportContext";
@@ -1,2 +1,3 @@
1
1
  export * from "./globalUIStore.js";
2
2
  export * from "./themeStore.js";
3
+ export * from "./SsrViewportContext.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrmeg/expo-ui",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "private": false,
5
5
  "description": "Reusable Expo and React Native UI primitives for MrMeg projects.",
6
6
  "keywords": [
@@ -103,18 +103,18 @@
103
103
  },
104
104
  "peerDependencies": {
105
105
  "@react-native-async-storage/async-storage": ">=2.2.0 <2.3.0",
106
- "expo": "~55.0.0",
107
- "expo-font": "~55.0.0",
108
- "expo-haptics": "~55.0.0",
106
+ "expo": ">=55.0.0 <57.0.0",
107
+ "expo-font": ">=55.0.0 <57.0.0",
108
+ "expo-haptics": ">=55.0.0 <57.0.0",
109
109
  "react": ">=19.2.0 <20.0.0",
110
- "react-native": ">=0.83.0 <0.84.0",
111
- "react-native-gesture-handler": "~2.30.0",
110
+ "react-native": ">=0.83.0 <0.86.0",
111
+ "react-native-gesture-handler": ">=2.30.0 <2.32.0",
112
112
  "react-native-keyboard-controller": ">=1.20.0 <2.0.0",
113
- "react-native-reanimated": "~4.2.0",
114
- "react-native-safe-area-context": "~5.6.0",
115
- "react-native-screens": "~4.23.0",
113
+ "react-native-reanimated": ">=4.2.0 <5.0.0",
114
+ "react-native-safe-area-context": ">=5.6.0 <6.0.0",
115
+ "react-native-screens": ">=4.23.0 <5.0.0",
116
116
  "react-native-web": ">=0.21.0 <0.22.0",
117
- "react-native-worklets": "~0.7.0",
117
+ "react-native-worklets": ">=0.7.0 <0.9.0",
118
118
  "zustand": ">=5.0.0 <6.0.0"
119
119
  },
120
120
  "devDependencies": {