@mrmeg/expo-ui 0.3.0 → 0.4.1
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/dist/components/Icon.d.ts +10 -7
- package/dist/components/Icon.js +12 -7
- package/dist/constants/fonts.js +8 -2
- package/dist/hooks/useDimensions.d.ts +11 -1
- package/dist/hooks/useDimensions.js +48 -19
- package/dist/hooks/useResources.js +9 -0
- package/dist/state/SsrViewportContext.d.ts +41 -0
- package/dist/state/SsrViewportContext.js +42 -0
- package/dist/state/index.d.ts +1 -0
- package/dist/state/index.js +1 -0
- package/package.json +10 -10
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as React from "react";
|
|
2
|
-
import { StyleProp,
|
|
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<
|
|
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
|
-
*
|
|
39
|
+
* Renders @expo/vector-icons Feather with theme integration and style support
|
|
37
40
|
*
|
|
38
41
|
* Usage:
|
|
39
42
|
* ```tsx
|
package/dist/components/Icon.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
}
|
|
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
|
}
|
package/dist/constants/fonts.js
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
|
+
import { Platform } from "react-native";
|
|
1
2
|
// Web font stack fallback
|
|
2
3
|
const WEB_FONT_STACK = "system-ui, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\"";
|
|
3
|
-
|
|
4
|
-
|
|
4
|
+
// IMPORTANT: do NOT key these on `typeof document` / `typeof navigator`.
|
|
5
|
+
// On a web bundle, Node SSR sees `undefined` for both and the client sees them,
|
|
6
|
+
// producing different snapshot values at module load -> hydration mismatch on
|
|
7
|
+
// every <StyledText>. `Platform.OS` (from react-native-web) returns "web" in
|
|
8
|
+
// both environments, so the value is stable.
|
|
9
|
+
const isWebRuntime = Platform.OS === "web";
|
|
10
|
+
const isReactNativeRuntime = Platform.OS !== "web";
|
|
5
11
|
const serifFamilies = isWebRuntime
|
|
6
12
|
? { regular: "Georgia, 'Times New Roman', serif", bold: "Georgia, 'Times New Roman', serif" }
|
|
7
13
|
: { regular: "Georgia", bold: "Georgia" };
|
|
@@ -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
|
|
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 {
|
|
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
|
-
*
|
|
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
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
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);
|
package/dist/state/index.d.ts
CHANGED
package/dist/state/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mrmeg/expo-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
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": "
|
|
107
|
-
"expo-font": "
|
|
108
|
-
"expo-haptics": "
|
|
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.
|
|
111
|
-
"react-native-gesture-handler": "
|
|
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": "
|
|
114
|
-
"react-native-safe-area-context": "
|
|
115
|
-
"react-native-screens": "
|
|
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": "
|
|
117
|
+
"react-native-worklets": ">=0.7.0 <0.9.0",
|
|
118
118
|
"zustand": ">=5.0.0 <6.0.0"
|
|
119
119
|
},
|
|
120
120
|
"devDependencies": {
|