@mrmeg/expo-ui 0.20.0 → 0.22.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 +202 -0
- package/LLM_USAGE.md +4 -3
- package/README.md +2 -2
- package/dist/components/Avatar.d.ts +19 -5
- package/dist/components/Avatar.js +84 -22
- package/dist/components/BottomSheet.js +1 -1
- package/dist/components/Carousel.d.ts +32 -6
- package/dist/components/Carousel.js +79 -17
- package/dist/components/DismissKeyboard.js +1 -1
- package/dist/components/KeyboardAvoidingView.d.ts +4 -2
- package/dist/components/KeyboardAvoidingView.js +5 -3
- package/dist/components/Label.d.ts +30 -10
- package/dist/components/Label.js +19 -10
- package/dist/components/Notification.js +7 -7
- package/dist/components/keyboardController.d.ts +46 -0
- package/dist/components/keyboardController.js +89 -0
- package/dist/components/keyboardController.native.d.ts +9 -0
- package/dist/components/keyboardController.native.js +9 -0
- package/dist/constants/colors.d.ts +34 -0
- package/dist/constants/colors.js +93 -3
- package/dist/constants/fonts.js +5 -4
- package/dist/hooks/useDimensions.d.ts +21 -9
- package/dist/hooks/useDimensions.js +29 -18
- package/dist/hooks/useResources.d.ts +10 -7
- package/dist/hooks/useResources.js +21 -24
- package/dist/hooks/useTheme.d.ts +18 -0
- package/dist/hooks/useTheme.js +45 -29
- package/dist/lib/themedStyles.d.ts +7 -7
- package/dist/lib/themedStyles.js +7 -7
- package/dist/state/SsrViewportContext.d.ts +9 -38
- package/dist/state/SsrViewportContext.js +9 -38
- package/dist/state/index.d.ts +1 -2
- package/dist/state/index.js +1 -2
- package/dist/state/themeStore.d.ts +9 -13
- package/dist/state/themeStore.js +19 -40
- package/llms-full.md +1 -1
- package/package.json +2 -2
- package/dist/state/ssrTheme.d.ts +0 -41
- package/dist/state/ssrTheme.js +0 -33
package/dist/constants/colors.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { Platform } from "react-native";
|
|
1
2
|
import { navigationFonts } from "./fonts.js";
|
|
2
3
|
/**
|
|
3
4
|
* Raw color palette - use semantic colors in components instead
|
|
@@ -124,9 +125,98 @@ const darkTheme = {
|
|
|
124
125
|
notification: palette.teal400,
|
|
125
126
|
},
|
|
126
127
|
};
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
128
|
+
/**
|
|
129
|
+
* Raw hex/rgba theme colors, always literal strings on every platform. Use
|
|
130
|
+
* these for sinks that cannot take CSS `var()` — e.g. `<meta name="theme-color">`,
|
|
131
|
+
* color parsing/math — and for generating the CSS variable definitions below.
|
|
132
|
+
*/
|
|
133
|
+
export const rawThemeColors = {
|
|
134
|
+
light: lightTheme.colors,
|
|
135
|
+
dark: darkTheme.colors,
|
|
130
136
|
};
|
|
137
|
+
const themeColorTokens = Object.keys(lightTheme.colors);
|
|
138
|
+
// `surfaceSunken` → `--c-surface-sunken`
|
|
139
|
+
function cssVarName(token) {
|
|
140
|
+
return `--c-${token.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}`;
|
|
141
|
+
}
|
|
142
|
+
// Reverse lookup: "var(--c-surface-sunken)" → "surfaceSunken"
|
|
143
|
+
const varToToken = new Map(themeColorTokens.map((token) => [`var(${cssVarName(token)})`, token]));
|
|
144
|
+
/**
|
|
145
|
+
* Resolves a theme color value to a literal color string. On web, theme
|
|
146
|
+
* colors are `var(--c-*)` references; this maps one back to the given
|
|
147
|
+
* scheme's raw hex/rgba. Literal inputs (including app override colors)
|
|
148
|
+
* pass through unchanged.
|
|
149
|
+
*/
|
|
150
|
+
export function resolveRawColor(color, scheme) {
|
|
151
|
+
const token = varToToken.get(color);
|
|
152
|
+
return token ? rawThemeColors[scheme][token] : color;
|
|
153
|
+
}
|
|
154
|
+
// "R, G, B" from "#RRGGBB", "#RGB", or "rgb(a)(R, G, B[, A])" (alpha dropped).
|
|
155
|
+
function toRgbTriplet(value) {
|
|
156
|
+
const rgbaMatch = value.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
|
|
157
|
+
if (rgbaMatch)
|
|
158
|
+
return `${rgbaMatch[1]}, ${rgbaMatch[2]}, ${rgbaMatch[3]}`;
|
|
159
|
+
let hex = value.replace(/^#/, "");
|
|
160
|
+
if (hex.length === 3)
|
|
161
|
+
hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
|
|
162
|
+
const match = /^([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
|
163
|
+
if (!match)
|
|
164
|
+
return null;
|
|
165
|
+
return `${parseInt(match[1], 16)}, ${parseInt(match[2], 16)}, ${parseInt(match[3], 16)}`;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* CSS custom-property definitions for every semantic theme color, for
|
|
169
|
+
* embedding in a web app's `+html.tsx` global `<style>`. Each token gets
|
|
170
|
+
* `--c-<kebab-token>` (the color value) and `--c-<kebab-token>-rgb` (a
|
|
171
|
+
* comma-separated RGB triplet, alpha stripped — `withAlpha` builds
|
|
172
|
+
* `rgba(var(--c-x-rgb), a)` from it).
|
|
173
|
+
*
|
|
174
|
+
* Light values are defined on `:root`; dark values apply under
|
|
175
|
+
* `html[data-theme="dark"]`, with a `prefers-color-scheme` fallback for the
|
|
176
|
+
* paint before any theme script stamps `data-theme`.
|
|
177
|
+
*
|
|
178
|
+
* Apps that override brand colors can pass those overrides so the emitted
|
|
179
|
+
* CSS matches their theme.
|
|
180
|
+
*/
|
|
181
|
+
export function getThemeCssVariables(overrides) {
|
|
182
|
+
const declarations = (scheme) => themeColorTokens
|
|
183
|
+
.map((token) => {
|
|
184
|
+
const value = overrides?.[scheme]?.[token] ?? rawThemeColors[scheme][token];
|
|
185
|
+
const name = cssVarName(token);
|
|
186
|
+
const rgb = toRgbTriplet(value);
|
|
187
|
+
return `${name}: ${value};${rgb ? ` ${name}-rgb: ${rgb};` : ""}`;
|
|
188
|
+
})
|
|
189
|
+
.join("\n ");
|
|
190
|
+
return `
|
|
191
|
+
:root {
|
|
192
|
+
${declarations("light")}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
@media (prefers-color-scheme: dark) {
|
|
196
|
+
html:not([data-theme]) {
|
|
197
|
+
${declarations("dark")}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
html[data-theme="dark"] {
|
|
202
|
+
${declarations("dark")}
|
|
203
|
+
}
|
|
204
|
+
`;
|
|
205
|
+
}
|
|
206
|
+
// On web, semantic theme colors resolve to CSS custom properties so the
|
|
207
|
+
// build-time exported HTML is theme-agnostic: the first frame paints in the
|
|
208
|
+
// visitor's scheme purely from CSS, before any JS runs. Native keeps literal
|
|
209
|
+
// values (no CSS engine). The `navigation` maps stay literal on both
|
|
210
|
+
// platforms: @react-navigation internals color-parse their theme values.
|
|
211
|
+
const webVarColors = Object.fromEntries(themeColorTokens.map((token) => [token, `var(${cssVarName(token)})`]));
|
|
212
|
+
export const colors = Platform.OS === "web"
|
|
213
|
+
? {
|
|
214
|
+
light: { ...lightTheme, colors: { ...webVarColors } },
|
|
215
|
+
dark: { ...darkTheme, colors: { ...webVarColors } },
|
|
216
|
+
}
|
|
217
|
+
: {
|
|
218
|
+
light: lightTheme,
|
|
219
|
+
dark: darkTheme,
|
|
220
|
+
};
|
|
131
221
|
// Export palette for rare one-off cases
|
|
132
222
|
export { palette };
|
package/dist/constants/fonts.js
CHANGED
|
@@ -2,10 +2,11 @@ import { Platform } from "react-native";
|
|
|
2
2
|
// Web font stack fallback
|
|
3
3
|
const WEB_FONT_STACK = "system-ui, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\"";
|
|
4
4
|
// IMPORTANT: do NOT key these on `typeof document` / `typeof navigator`.
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
5
|
+
// A web bundle is also evaluated in Node when a host app's HTML shell is
|
|
6
|
+
// rendered at export time, where both are `undefined` while the browser sees
|
|
7
|
+
// them — that produces different module-load snapshots and a hydration
|
|
8
|
+
// mismatch on every <StyledText>. `Platform.OS` (from react-native-web)
|
|
9
|
+
// returns "web" in both environments, so the value is stable.
|
|
9
10
|
const isWebRuntime = Platform.OS === "web";
|
|
10
11
|
const isReactNativeRuntime = Platform.OS !== "web";
|
|
11
12
|
// Georgia ships as one face per platform; every weight slot points at the
|
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Viewport the first web render is computed from, before the real window can
|
|
3
|
+
* be read. Desktop is chosen because:
|
|
4
|
+
* 1. A desktop visitor sees no reflow — their real viewport lands in the
|
|
5
|
+
* same breakpoint.
|
|
6
|
+
* 2. A mobile visitor gets one frame of desktop-styled content before the
|
|
7
|
+
* post-mount effect snaps to real dimensions — better than the inverse
|
|
8
|
+
* (every desktop visitor seeing mobile-tiny, then snapping).
|
|
9
|
+
* 3. It matches the frame the app hands `SafeAreaProvider`, and it is the
|
|
10
|
+
* value the HTML shell `expo export` renders in Node is built from, so
|
|
11
|
+
* the browser's first render agrees with the markup it hydrates.
|
|
12
|
+
*/
|
|
13
|
+
export declare const DEFAULT_VIEWPORT_WIDTH = 1280;
|
|
14
|
+
export declare const DEFAULT_VIEWPORT_HEIGHT = 800;
|
|
1
15
|
export declare const SCREEN_SIZES: {
|
|
2
16
|
readonly SMALL: 768;
|
|
3
17
|
readonly MEDIUM: 1000;
|
|
@@ -15,15 +29,13 @@ type WindowDimensions = {
|
|
|
15
29
|
* Provides a consistent way to access window dimensions and screen size
|
|
16
30
|
* information across mobile and web.
|
|
17
31
|
*
|
|
18
|
-
* On web
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* (`SSR_VIEWPORT_DEFAULT_WIDTH` = 1280) — desktop-correct, mobile gets one
|
|
26
|
-
* frame of desktop layout before snapping.
|
|
32
|
+
* On web the first render can't read the window: server-side there is no DOM,
|
|
33
|
+
* and reading it during the browser's first render would disagree with the
|
|
34
|
+
* markup it hydrates. The width comes from `SsrViewportContext` when the host
|
|
35
|
+
* provides a per-request value (server render + first client render must
|
|
36
|
+
* provide the same one), falling back to `DEFAULT_VIEWPORT_WIDTH` / `_HEIGHT`.
|
|
37
|
+
* A post-mount effect swaps in the real viewport and follows resize from
|
|
38
|
+
* there.
|
|
27
39
|
*/
|
|
28
40
|
export declare const useDimensions: () => WindowDimensions;
|
|
29
41
|
export {};
|
|
@@ -1,6 +1,20 @@
|
|
|
1
1
|
import { use, useEffect, useState } from "react";
|
|
2
2
|
import { Platform, useWindowDimensions } from "react-native";
|
|
3
|
-
import { SsrViewportContext
|
|
3
|
+
import { SsrViewportContext } from "../state/SsrViewportContext.js";
|
|
4
|
+
/**
|
|
5
|
+
* Viewport the first web render is computed from, before the real window can
|
|
6
|
+
* be read. Desktop is chosen because:
|
|
7
|
+
* 1. A desktop visitor sees no reflow — their real viewport lands in the
|
|
8
|
+
* same breakpoint.
|
|
9
|
+
* 2. A mobile visitor gets one frame of desktop-styled content before the
|
|
10
|
+
* post-mount effect snaps to real dimensions — better than the inverse
|
|
11
|
+
* (every desktop visitor seeing mobile-tiny, then snapping).
|
|
12
|
+
* 3. It matches the frame the app hands `SafeAreaProvider`, and it is the
|
|
13
|
+
* value the HTML shell `expo export` renders in Node is built from, so
|
|
14
|
+
* the browser's first render agrees with the markup it hydrates.
|
|
15
|
+
*/
|
|
16
|
+
export const DEFAULT_VIEWPORT_WIDTH = 1280;
|
|
17
|
+
export const DEFAULT_VIEWPORT_HEIGHT = 800;
|
|
4
18
|
export const SCREEN_SIZES = {
|
|
5
19
|
SMALL: 768,
|
|
6
20
|
MEDIUM: 1000,
|
|
@@ -21,9 +35,9 @@ const calculateDimensionFlags = (width, height) => {
|
|
|
21
35
|
};
|
|
22
36
|
};
|
|
23
37
|
// Persist the real viewport width as a cookie on first mount so subsequent
|
|
24
|
-
// SSR requests can render
|
|
25
|
-
// visits). The server reads this cookie via
|
|
26
|
-
//
|
|
38
|
+
// SSR requests can render at the user's actual layout (no reflow on repeat
|
|
39
|
+
// visits). The server reads this cookie via resolveSsrViewportWidth in
|
|
40
|
+
// server/lib/ssrViewport.ts.
|
|
27
41
|
const SSR_VIEWPORT_COOKIE = "mrmeg-vw";
|
|
28
42
|
const SSR_VIEWPORT_COOKIE_MAX_AGE = 60 * 60 * 24 * 365; // 1 year
|
|
29
43
|
function writeViewportCookie(width) {
|
|
@@ -38,15 +52,13 @@ function writeViewportCookie(width) {
|
|
|
38
52
|
* Provides a consistent way to access window dimensions and screen size
|
|
39
53
|
* information across mobile and web.
|
|
40
54
|
*
|
|
41
|
-
* On web
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
* (`SSR_VIEWPORT_DEFAULT_WIDTH` = 1280) — desktop-correct, mobile gets one
|
|
49
|
-
* frame of desktop layout before snapping.
|
|
55
|
+
* On web the first render can't read the window: server-side there is no DOM,
|
|
56
|
+
* and reading it during the browser's first render would disagree with the
|
|
57
|
+
* markup it hydrates. The width comes from `SsrViewportContext` when the host
|
|
58
|
+
* provides a per-request value (server render + first client render must
|
|
59
|
+
* provide the same one), falling back to `DEFAULT_VIEWPORT_WIDTH` / `_HEIGHT`.
|
|
60
|
+
* A post-mount effect swaps in the real viewport and follows resize from
|
|
61
|
+
* there.
|
|
50
62
|
*/
|
|
51
63
|
export const useDimensions = () => {
|
|
52
64
|
const isWeb = Platform.OS === "web";
|
|
@@ -54,13 +66,12 @@ export const useDimensions = () => {
|
|
|
54
66
|
// Native reads come from useWindowDimensions, which subscribes to rotation /
|
|
55
67
|
// split-screen / resize and tears the listener down for us — no manual
|
|
56
68
|
// Dimensions.addEventListener to leak. On web we ignore it and drive layout
|
|
57
|
-
// from the
|
|
69
|
+
// from the seeded frame + the resize listener below.
|
|
58
70
|
const native = useWindowDimensions();
|
|
59
|
-
|
|
60
|
-
// flags from the context value, so hydration matches.
|
|
61
|
-
const [dimensions, setDimensions] = useState(() => calculateDimensionFlags(ssrWidth, SSR_VIEWPORT_DEFAULT_HEIGHT));
|
|
71
|
+
const [dimensions, setDimensions] = useState(() => calculateDimensionFlags(ssrWidth ?? DEFAULT_VIEWPORT_WIDTH, DEFAULT_VIEWPORT_HEIGHT));
|
|
62
72
|
// Web: read the real viewport after mount and follow resize events. Keeping
|
|
63
|
-
// this in an effect (not render)
|
|
73
|
+
// this in an effect (not render) keeps the first render identical to the
|
|
74
|
+
// exported markup it hydrates.
|
|
64
75
|
useEffect(() => {
|
|
65
76
|
if (!isWeb)
|
|
66
77
|
return;
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Registers the Feather icon font in expo-font's
|
|
2
|
+
* Registers the Feather icon font in expo-font's server store during
|
|
3
|
+
* export-time rendering (`expo export` renders every route's HTML shell in
|
|
4
|
+
* Node, where the module-scope client load above is guarded off).
|
|
3
5
|
*
|
|
4
|
-
* Must be called from a **render body**
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* the
|
|
6
|
+
* Must be called from a **render body** (see `useResources` below): expo-font
|
|
7
|
+
* scopes its server store per render context, so a module-scope registration
|
|
8
|
+
* lands in at most one route's shell. Registering during render means every
|
|
9
|
+
* exported shell emits the `@font-face` rule and renders `<Icon>` glyphs —
|
|
10
|
+
* without it the shell contains an EMPTY icon text node while the client's
|
|
11
|
+
* first render (font pre-loaded above) contains the glyph, and React throws
|
|
12
|
+
* hydration error #418 on every cold load and regenerates the tree.
|
|
10
13
|
*
|
|
11
14
|
* No-op on native and on client web (both already covered: native by the
|
|
12
15
|
* effect below, client web by the module-scope call above).
|
|
@@ -4,31 +4,29 @@ import Feather from "@expo/vector-icons/Feather";
|
|
|
4
4
|
import { Inter_400Regular, Inter_500Medium, Inter_600SemiBold, Inter_700Bold, } from "@expo-google-fonts/inter";
|
|
5
5
|
import { Platform } from "react-native";
|
|
6
6
|
import { useThemeStore } from "../state/themeStore.js";
|
|
7
|
-
// Eager, module-scope load that primes the Feather font
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
// (which then no-ops for the already-loaded font).
|
|
7
|
+
// Eager, module-scope load that primes the Feather font before first render:
|
|
8
|
+
// on web it synchronously injects the @font-face rule, so icons have their
|
|
9
|
+
// glyphs on the first paint; on native it just starts the fetch ahead of the
|
|
10
|
+
// effect below (which then no-ops for the already-loaded font).
|
|
12
11
|
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
// ever populated the store of whichever request happened to warm the module
|
|
17
|
-
// cache. Requests 2+ then shipped HTML with no @font-face and empty icons while
|
|
18
|
-
// the client rendered glyphs (React #418). Server registration happens
|
|
19
|
-
// per-request in ensureIconFontRegistered() instead.
|
|
12
|
+
// Guarded on `window` because web bundles are also evaluated in Node when
|
|
13
|
+
// `expo export` renders the HTML shell, where there is no document to inject
|
|
14
|
+
// into.
|
|
20
15
|
if (typeof window !== "undefined") {
|
|
21
16
|
void Font.loadAsync(Feather.font);
|
|
22
17
|
}
|
|
23
18
|
/**
|
|
24
|
-
* Registers the Feather icon font in expo-font's
|
|
19
|
+
* Registers the Feather icon font in expo-font's server store during
|
|
20
|
+
* export-time rendering (`expo export` renders every route's HTML shell in
|
|
21
|
+
* Node, where the module-scope client load above is guarded off).
|
|
25
22
|
*
|
|
26
|
-
* Must be called from a **render body**
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* the
|
|
23
|
+
* Must be called from a **render body** (see `useResources` below): expo-font
|
|
24
|
+
* scopes its server store per render context, so a module-scope registration
|
|
25
|
+
* lands in at most one route's shell. Registering during render means every
|
|
26
|
+
* exported shell emits the `@font-face` rule and renders `<Icon>` glyphs —
|
|
27
|
+
* without it the shell contains an EMPTY icon text node while the client's
|
|
28
|
+
* first render (font pre-loaded above) contains the glyph, and React throws
|
|
29
|
+
* hydration error #418 on every cold load and regenerates the tree.
|
|
32
30
|
*
|
|
33
31
|
* No-op on native and on client web (both already covered: native by the
|
|
34
32
|
* effect below, client web by the module-scope call above).
|
|
@@ -37,9 +35,9 @@ export function ensureIconFontRegistered() {
|
|
|
37
35
|
if (Platform.OS !== "web" || typeof window !== "undefined")
|
|
38
36
|
return;
|
|
39
37
|
if (Font.isLoaded("feather"))
|
|
40
|
-
return;
|
|
38
|
+
return;
|
|
41
39
|
// Synchronous on server-web (expo-font routes to registerStaticFont). No
|
|
42
|
-
// `void`/try-catch: a throw here means we're outside a
|
|
40
|
+
// `void`/try-catch: a throw here means we're outside a render scope, which
|
|
43
41
|
// must be loud.
|
|
44
42
|
Font.loadAsync(Feather.font);
|
|
45
43
|
}
|
|
@@ -96,9 +94,8 @@ function ensureWebFontStylesheet() {
|
|
|
96
94
|
* un-download Inter. The Feather icon font always loads.
|
|
97
95
|
*/
|
|
98
96
|
export const useResources = () => {
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
// module-scope call only reaches one request. See ensureIconFontRegistered.
|
|
97
|
+
// Export-time renders need the icon font registered per render scope; the
|
|
98
|
+
// module-scope call only reaches the client. See ensureIconFontRegistered.
|
|
102
99
|
ensureIconFontRegistered();
|
|
103
100
|
const [loaded, setLoaded] = useState(false);
|
|
104
101
|
const [error, setError] = useState(null);
|
package/dist/hooks/useTheme.d.ts
CHANGED
|
@@ -39,6 +39,24 @@ export declare function useTheme(): ExtendedColorScheme & {
|
|
|
39
39
|
setTheme: (theme: "system" | "light" | "dark") => void;
|
|
40
40
|
currentTheme: "system" | "light" | "dark";
|
|
41
41
|
};
|
|
42
|
+
/**
|
|
43
|
+
* Generates an alpha-modified version of a color
|
|
44
|
+
* @param color - The color to modify (hex, rgba, or a `var(--c-*)` theme color)
|
|
45
|
+
* @param alpha - Alpha value between 0 and 1 (replaces any existing alpha)
|
|
46
|
+
* @returns rgba color string with the specified alpha
|
|
47
|
+
*
|
|
48
|
+
* On web, theme colors are `var(--c-*)` references. Those rewrite onto the
|
|
49
|
+
* companion `--c-*-rgb` triplet variable (see `getThemeCssVariables`), so the
|
|
50
|
+
* result is pure CSS that re-themes with the variable — usable from
|
|
51
|
+
* module-scope style factories, which run once for both schemes.
|
|
52
|
+
*
|
|
53
|
+
* The result is wrapped in `var(--c-unset, …)` — a custom property that is
|
|
54
|
+
* never defined, so browsers always use the fallback. react-native-web's
|
|
55
|
+
* style compiler only passes a color value through verbatim when it starts
|
|
56
|
+
* with `var(`; a bare `rgba(var(…))` fails its color parser and the
|
|
57
|
+
* declaration is silently dropped.
|
|
58
|
+
*/
|
|
59
|
+
export declare function withAlpha(color: string, alpha: number): string;
|
|
42
60
|
/**
|
|
43
61
|
* Style factory context passed to the createStyles callback
|
|
44
62
|
*/
|
package/dist/hooks/useTheme.js
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { useCallback, useEffect, useMemo } from "react";
|
|
2
|
-
import { colors } from "../constants/colors.js";
|
|
2
|
+
import { colors, resolveRawColor } from "../constants/colors.js";
|
|
3
3
|
import { Platform, StyleSheet } from "react-native";
|
|
4
4
|
import { resolveThemePreference, useThemeStore } from "../state/themeStore.js";
|
|
5
5
|
import { useThemeColorScope } from "../state/themeColorScope.js";
|
|
6
|
-
import { useSsrThemeSeed } from "../state/ssrTheme.js";
|
|
7
6
|
import { spacing as spacingConstants } from "../constants/spacing.js";
|
|
8
7
|
// Module-level cache for contrast calculations to avoid memory leak
|
|
9
8
|
// and share across components
|
|
@@ -46,24 +45,11 @@ function getCachedOrCompute(key, compute) {
|
|
|
46
45
|
* - getShadowStyle('base') → { boxShadow: "0px 2px 6px rgba(0, 0, 0, 0.05), 0px 4px 12px rgba(0, 0, 0, 0.03)" }
|
|
47
46
|
*/
|
|
48
47
|
export function useTheme() {
|
|
49
|
-
const
|
|
50
|
-
const
|
|
51
|
-
const hasLoadedTheme = useThemeStore((s) => s.hasLoadedTheme);
|
|
48
|
+
const userTheme = useThemeStore((s) => s.userTheme);
|
|
49
|
+
const systemTheme = useThemeStore((s) => s.systemTheme);
|
|
52
50
|
const setTheme = useThemeStore((s) => s.setTheme);
|
|
53
51
|
const colorOverrides = useThemeStore((s) => s.colorOverrides);
|
|
54
52
|
const scoped = useThemeColorScope();
|
|
55
|
-
const seed = useSsrThemeSeed();
|
|
56
|
-
// Before persistence has been read, a web render must resolve from the
|
|
57
|
-
// per-request SSR seed instead of store state. The store is a module
|
|
58
|
-
// singleton shared across concurrent SSR requests, so it can only ever hold
|
|
59
|
-
// the SSR-safe boot defaults ("system"/light); the seed is cookie-derived,
|
|
60
|
-
// which is the one signal the server and the browser read identically —
|
|
61
|
-
// that's what keeps the hydrated tree matching the server HTML. After mount
|
|
62
|
-
// `syncThemeFromEnvironment()` flips `hasLoadedTheme` and store state (real
|
|
63
|
-
// localStorage/AsyncStorage + live OS listener) takes over for good.
|
|
64
|
-
const useSeed = Platform.OS === "web" && !hasLoadedTheme;
|
|
65
|
-
const userTheme = useSeed ? seed.userTheme : storeUserTheme;
|
|
66
|
-
const systemTheme = useSeed ? seed.systemTheme : storeSystemTheme;
|
|
67
53
|
// Determine which theme to use (user preference or system)
|
|
68
54
|
const effectiveScheme = resolveThemePreference(userTheme, systemTheme);
|
|
69
55
|
const base = colors[effectiveScheme];
|
|
@@ -184,10 +170,12 @@ export function useTheme() {
|
|
|
184
170
|
boxShadow: `0 0 0 ${offset}px ${theme.colors.background}, 0 0 0 ${offset + 2}px ${theme.colors.ring}`,
|
|
185
171
|
};
|
|
186
172
|
}, [theme.colors.background, theme.colors.ring]);
|
|
187
|
-
// Helper to calculate contrast ratio between two colors
|
|
173
|
+
// Helper to calculate contrast ratio between two colors.
|
|
174
|
+
// On web theme colors are `var(--c-*)` strings, which cannot be parsed —
|
|
175
|
+
// resolve them to the active scheme's literal value first.
|
|
188
176
|
const getContrastRatio = useCallback((color1, color2) => {
|
|
189
|
-
const rgb1 = parseColor(color1);
|
|
190
|
-
const rgb2 = parseColor(color2);
|
|
177
|
+
const rgb1 = parseColor(resolveRawColor(color1, effectiveScheme));
|
|
178
|
+
const rgb2 = parseColor(resolveRawColor(color2, effectiveScheme));
|
|
191
179
|
if (!rgb1 || !rgb2) {
|
|
192
180
|
console.error("Invalid colors for contrast ratio:", color1, color2);
|
|
193
181
|
return 1; // Default to minimum contrast
|
|
@@ -195,13 +183,25 @@ export function useTheme() {
|
|
|
195
183
|
const lum1 = calculateLuminance(rgb1[0], rgb1[1], rgb1[2]);
|
|
196
184
|
const lum2 = calculateLuminance(rgb2[0], rgb2[1], rgb2[2]);
|
|
197
185
|
return calculateContrastRatio(lum1, lum2);
|
|
198
|
-
}, []);
|
|
186
|
+
}, [effectiveScheme]);
|
|
199
187
|
// Cached version of getContrastingColor for performance
|
|
200
|
-
// Uses module-level cache to prevent memory leak
|
|
188
|
+
// Uses module-level cache to prevent memory leak. `var(--c-*)` inputs are
|
|
189
|
+
// resolved per scheme BEFORE the cache so keys stay scheme-specific, and
|
|
190
|
+
// the winning candidate is returned as originally passed (a `var()` input
|
|
191
|
+
// stays a `var()` output, so the picked color keeps re-theming via CSS).
|
|
201
192
|
const getCachedContrastingColor = useCallback((backgroundColor, color1, color2) => {
|
|
202
|
-
const
|
|
203
|
-
|
|
204
|
-
|
|
193
|
+
const bg = resolveRawColor(backgroundColor, effectiveScheme);
|
|
194
|
+
const c1 = color1 === undefined ? undefined : resolveRawColor(color1, effectiveScheme);
|
|
195
|
+
const c2 = color2 === undefined ? undefined : resolveRawColor(color2, effectiveScheme);
|
|
196
|
+
const cacheKey = `${bg}-${c1}-${c2}`;
|
|
197
|
+
const winner = getCachedOrCompute(cacheKey, () => getBetterContrast(bg, c1, c2));
|
|
198
|
+
if (color1 !== undefined && winner === c1)
|
|
199
|
+
return color1;
|
|
200
|
+
if (color2 !== undefined && winner === c2)
|
|
201
|
+
return color2;
|
|
202
|
+
return winner;
|
|
203
|
+
}, [effectiveScheme]);
|
|
204
|
+
const getResolvedTextColorForBackground = useCallback((backgroundColor) => getTextColorForBackground(resolveRawColor(backgroundColor, effectiveScheme)), [effectiveScheme]);
|
|
205
205
|
return useMemo(() => ({
|
|
206
206
|
theme,
|
|
207
207
|
scheme: theme.dark ? "dark" : "light",
|
|
@@ -211,13 +211,14 @@ export function useTheme() {
|
|
|
211
211
|
setTheme,
|
|
212
212
|
currentTheme: userTheme,
|
|
213
213
|
getContrastingColor: getCachedContrastingColor,
|
|
214
|
-
getTextColorForBackground,
|
|
214
|
+
getTextColorForBackground: getResolvedTextColorForBackground,
|
|
215
215
|
withAlpha,
|
|
216
216
|
getContrastRatio,
|
|
217
217
|
}), [
|
|
218
218
|
getCachedContrastingColor,
|
|
219
219
|
getContrastRatio,
|
|
220
220
|
getFocusRingStyle,
|
|
221
|
+
getResolvedTextColorForBackground,
|
|
221
222
|
getShadowStyle,
|
|
222
223
|
setTheme,
|
|
223
224
|
theme,
|
|
@@ -340,11 +341,26 @@ function getTextColorForBackground(backgroundColor) {
|
|
|
340
341
|
}
|
|
341
342
|
/**
|
|
342
343
|
* Generates an alpha-modified version of a color
|
|
343
|
-
* @param color - The color to modify (hex or
|
|
344
|
-
* @param alpha - Alpha value between 0 and 1
|
|
344
|
+
* @param color - The color to modify (hex, rgba, or a `var(--c-*)` theme color)
|
|
345
|
+
* @param alpha - Alpha value between 0 and 1 (replaces any existing alpha)
|
|
345
346
|
* @returns rgba color string with the specified alpha
|
|
347
|
+
*
|
|
348
|
+
* On web, theme colors are `var(--c-*)` references. Those rewrite onto the
|
|
349
|
+
* companion `--c-*-rgb` triplet variable (see `getThemeCssVariables`), so the
|
|
350
|
+
* result is pure CSS that re-themes with the variable — usable from
|
|
351
|
+
* module-scope style factories, which run once for both schemes.
|
|
352
|
+
*
|
|
353
|
+
* The result is wrapped in `var(--c-unset, …)` — a custom property that is
|
|
354
|
+
* never defined, so browsers always use the fallback. react-native-web's
|
|
355
|
+
* style compiler only passes a color value through verbatim when it starts
|
|
356
|
+
* with `var(`; a bare `rgba(var(…))` fails its color parser and the
|
|
357
|
+
* declaration is silently dropped.
|
|
346
358
|
*/
|
|
347
|
-
function withAlpha(color, alpha) {
|
|
359
|
+
export function withAlpha(color, alpha) {
|
|
360
|
+
const varMatch = /^var\((--c-[a-z0-9-]+)\)$/.exec(color);
|
|
361
|
+
if (varMatch) {
|
|
362
|
+
return `var(--c-unset, rgba(var(${varMatch[1]}-rgb), ${alpha}))`;
|
|
363
|
+
}
|
|
348
364
|
const rgb = parseColor(color);
|
|
349
365
|
if (!rgb) {
|
|
350
366
|
console.error("Invalid color for alpha:", color);
|
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
import { type Theme } from "../constants/colors";
|
|
2
2
|
/**
|
|
3
3
|
* Registers theme-dependent styles at module scope so react-native-web
|
|
4
|
-
* inserts their CSS rules into the
|
|
5
|
-
* <head> is serialized.
|
|
4
|
+
* inserts their CSS rules into the sheet before the prerendered document
|
|
5
|
+
* <head> is serialized at export time.
|
|
6
6
|
*
|
|
7
7
|
* Styles created lazily during render — the
|
|
8
8
|
* `useMemo(() => createStyles(theme), [theme])` idiom — are inserted into
|
|
9
9
|
* RNW's sheet *after* expo-router has already captured the
|
|
10
|
-
* `<style id="react-native-stylesheet">` snapshot for the head. The
|
|
11
|
-
* HTML then references class names that have no rules, which paints
|
|
12
|
-
* completely unstyled until
|
|
13
|
-
* on
|
|
14
|
-
*
|
|
10
|
+
* `<style id="react-native-stylesheet">` snapshot for the head. The exported
|
|
11
|
+
* HTML shell then references class names that have no rules, which paints
|
|
12
|
+
* completely unstyled until the client re-inserts them on hydration (worst
|
|
13
|
+
* on a cold load of the shell, and on every dev rebundle). See "Enable
|
|
14
|
+
* Server Output" in docs/server-guide.md.
|
|
15
15
|
*
|
|
16
16
|
* Both base themes are computed eagerly at module load. Themes carrying
|
|
17
17
|
* color overrides (global brand or scoped) are computed once per theme
|
package/dist/lib/themedStyles.js
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
import { colors } from "../constants/colors.js";
|
|
2
2
|
/**
|
|
3
3
|
* Registers theme-dependent styles at module scope so react-native-web
|
|
4
|
-
* inserts their CSS rules into the
|
|
5
|
-
* <head> is serialized.
|
|
4
|
+
* inserts their CSS rules into the sheet before the prerendered document
|
|
5
|
+
* <head> is serialized at export time.
|
|
6
6
|
*
|
|
7
7
|
* Styles created lazily during render — the
|
|
8
8
|
* `useMemo(() => createStyles(theme), [theme])` idiom — are inserted into
|
|
9
9
|
* RNW's sheet *after* expo-router has already captured the
|
|
10
|
-
* `<style id="react-native-stylesheet">` snapshot for the head. The
|
|
11
|
-
* HTML then references class names that have no rules, which paints
|
|
12
|
-
* completely unstyled until
|
|
13
|
-
* on
|
|
14
|
-
*
|
|
10
|
+
* `<style id="react-native-stylesheet">` snapshot for the head. The exported
|
|
11
|
+
* HTML shell then references class names that have no rules, which paints
|
|
12
|
+
* completely unstyled until the client re-inserts them on hydration (worst
|
|
13
|
+
* on a cold load of the shell, and on every dev rebundle). See "Enable
|
|
14
|
+
* Server Output" in docs/server-guide.md.
|
|
15
15
|
*
|
|
16
16
|
* Both base themes are computed eagerly at module load. Themes carrying
|
|
17
17
|
* color overrides (global brand or scoped) are computed once per theme
|
|
@@ -1,41 +1,12 @@
|
|
|
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
1
|
/**
|
|
17
2
|
* Per-request SSR viewport width. Consumed by `useDimensions` to seed the
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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
|
-
* ```
|
|
3
|
+
* server render and the browser's FIRST render, so a server that detected a
|
|
4
|
+
* phone lays the tree out at phone width and hydration matches byte-for-byte.
|
|
5
|
+
*
|
|
6
|
+
* `null` (the default, when no provider is mounted) means "no per-request
|
|
7
|
+
* signal" and `useDimensions` falls back to `DEFAULT_VIEWPORT_WIDTH`. The
|
|
8
|
+
* host app is expected to provide the same value on the server and on the
|
|
9
|
+
* client's first render — both derived from signals both sides can read
|
|
10
|
+
* (cookie / User-Agent), never from `window`.
|
|
40
11
|
*/
|
|
41
|
-
export declare const SsrViewportContext: import("react").Context<number>;
|
|
12
|
+
export declare const SsrViewportContext: import("react").Context<number | null>;
|