@mrmeg/expo-ui 0.20.0 → 0.21.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 +150 -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/Carousel.d.ts +32 -6
- package/dist/components/Carousel.js +79 -17
- package/dist/components/Label.d.ts +30 -10
- package/dist/components/Label.js +19 -10
- package/dist/constants/fonts.js +5 -4
- package/dist/hooks/useDimensions.d.ts +19 -9
- package/dist/hooks/useDimensions.js +24 -32
- package/dist/hooks/useResources.d.ts +0 -14
- package/dist/hooks/useResources.js +7 -39
- package/dist/hooks/useTheme.js +2 -16
- package/dist/lib/themedStyles.d.ts +7 -7
- package/dist/lib/themedStyles.js +7 -7
- package/dist/state/index.d.ts +0 -2
- package/dist/state/index.js +0 -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 +1 -1
- package/dist/state/SsrViewportContext.d.ts +0 -41
- package/dist/state/SsrViewportContext.js +0 -42
- package/dist/state/ssrTheme.d.ts +0 -41
- package/dist/state/ssrTheme.js +0 -33
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import React, { useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
-
import { Platform, ScrollView, StyleSheet, Text, View, } from "react-native";
|
|
3
|
+
import { Platform, Pressable, ScrollView, StyleSheet, Text, View, } from "react-native";
|
|
4
4
|
import { useTheme } from "../hooks/useTheme.js";
|
|
5
5
|
import { useDimensions } from "../hooks/useDimensions.js";
|
|
6
6
|
import { spacing } from "../constants/spacing.js";
|
|
@@ -56,11 +56,14 @@ function clampIndex(index, count) {
|
|
|
56
56
|
* resolved item width.
|
|
57
57
|
*
|
|
58
58
|
* Built on `ScrollView` — not a virtualized list — on purpose: every slide is
|
|
59
|
-
* in the
|
|
60
|
-
* empty scroller that only fills in after measurement. Snapping
|
|
61
|
-
* platform scroller (`snapToInterval` on native, CSS scroll-snap on web); no
|
|
59
|
+
* in the prerendered tree, so a web build's HTML shell ships the real content
|
|
60
|
+
* instead of an empty scroller that only fills in after measurement. Snapping
|
|
61
|
+
* uses the platform scroller (`snapToInterval` on native, CSS scroll-snap on web); no
|
|
62
62
|
* animation library is involved.
|
|
63
63
|
*
|
|
64
|
+
* Slides sized by a fractional `itemWidth` measure against the viewport until
|
|
65
|
+
* the first `onLayout` lands — see the `itemWidth` caveat on `CarouselProps`.
|
|
66
|
+
*
|
|
64
67
|
* @example
|
|
65
68
|
* ```tsx
|
|
66
69
|
* <Carousel itemWidth={0.8} onIndexChange={setPage}>
|
|
@@ -78,9 +81,9 @@ export function Carousel({ children, itemWidth = 0.85, gap = spacing.md, content
|
|
|
78
81
|
const items = React.Children.toArray(children);
|
|
79
82
|
const count = items.length;
|
|
80
83
|
// Width source of truth: the measured container after mount, seeded from the
|
|
81
|
-
// viewport so
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
+
// viewport so a prerendered HTML shell and the client's first render agree.
|
|
85
|
+
// `useDimensions` seeds from a constant default rather than `window` on that
|
|
86
|
+
// first render, which is what keeps the seed hydration-safe.
|
|
84
87
|
const { width: viewportWidth } = useDimensions();
|
|
85
88
|
const [measuredWidth, setMeasuredWidth] = useState(null);
|
|
86
89
|
const containerWidth = measuredWidth ?? viewportWidth;
|
|
@@ -88,7 +91,8 @@ export function Carousel({ children, itemWidth = 0.85, gap = spacing.md, content
|
|
|
88
91
|
const interval = slideWidth + gap;
|
|
89
92
|
const [activeIndex, setActiveIndex] = useState(() => clampIndex(initialIndex, count));
|
|
90
93
|
// Mirrors activeIndex so the scroll handler can dedupe without re-reading
|
|
91
|
-
// state:
|
|
94
|
+
// state: web's throttled ticks report the same page many times over, and
|
|
95
|
+
// onIndexChange must fire once per page.
|
|
92
96
|
const activeIndexRef = useRef(activeIndex);
|
|
93
97
|
const scrollRef = useRef(null);
|
|
94
98
|
const hasScrolledToInitial = useRef(false);
|
|
@@ -99,9 +103,28 @@ export function Carousel({ children, itemWidth = 0.85, gap = spacing.md, content
|
|
|
99
103
|
setActiveIndex(next);
|
|
100
104
|
onIndexChange?.(next);
|
|
101
105
|
}, [onIndexChange]);
|
|
106
|
+
const isWeb = Platform.OS === "web";
|
|
102
107
|
const handleScroll = useCallback((event) => {
|
|
103
108
|
commitIndex(getCarouselIndex(event.nativeEvent.contentOffset.x, interval, count));
|
|
104
109
|
}, [commitIndex, count, interval]);
|
|
110
|
+
// Native fallback for a drag that releases with the content at rest: no
|
|
111
|
+
// momentum follows, so `onMomentumScrollEnd` never arrives and the settle
|
|
112
|
+
// would go unreported.
|
|
113
|
+
//
|
|
114
|
+
// Gated on velocity because an ungated drag-end handler is exactly the
|
|
115
|
+
// double-fire this replaced: a flick past a slide midpoint that snaps back
|
|
116
|
+
// reports the midpoint page on release and the original page on settle. A
|
|
117
|
+
// release that *will* glide reports a non-zero velocity (points/ms on iOS,
|
|
118
|
+
// density-scaled px/s on Android — either way, orders of magnitude above
|
|
119
|
+
// this epsilon), and momentum end is left to speak for it. A release at rest
|
|
120
|
+
// reports 0, and there `getCarouselIndex`'s round-to-nearest already agrees
|
|
121
|
+
// with where `snapToInterval` would land, so the two paths can't disagree.
|
|
122
|
+
const handleScrollEndDrag = useCallback((event) => {
|
|
123
|
+
const velocityX = event.nativeEvent.velocity?.x ?? 0;
|
|
124
|
+
if (Math.abs(velocityX) > 0.01)
|
|
125
|
+
return;
|
|
126
|
+
handleScroll(event);
|
|
127
|
+
}, [handleScroll]);
|
|
105
128
|
const handleLayout = useCallback((event) => {
|
|
106
129
|
const next = event.nativeEvent.layout.width;
|
|
107
130
|
if (!(next > 0))
|
|
@@ -120,24 +143,54 @@ export function Carousel({ children, itemWidth = 0.85, gap = spacing.md, content
|
|
|
120
143
|
return;
|
|
121
144
|
scrollRef.current?.scrollTo({ x: targetIndex * interval, animated: false });
|
|
122
145
|
}, [interval, measuredWidth, targetIndex]);
|
|
123
|
-
|
|
146
|
+
// Re-clamp when children shrink. Without this the status line reads "5 of 3"
|
|
147
|
+
// with no active dot until the next scroll, because activeIndex is only
|
|
148
|
+
// seeded once and the scroller — already past the new content width — emits
|
|
149
|
+
// nothing on its own. Scrolling back is what makes the offset agree with the
|
|
150
|
+
// reported page again.
|
|
151
|
+
useEffect(() => {
|
|
152
|
+
if (count <= 0 || activeIndexRef.current < count)
|
|
153
|
+
return;
|
|
154
|
+
const clamped = count - 1;
|
|
155
|
+
commitIndex(clamped);
|
|
156
|
+
if (interval > 0) {
|
|
157
|
+
scrollRef.current?.scrollTo({ x: clamped * interval, animated: false });
|
|
158
|
+
}
|
|
159
|
+
}, [commitIndex, count, interval]);
|
|
160
|
+
const handleDotPress = useCallback((index) => {
|
|
161
|
+
commitIndex(index);
|
|
162
|
+
if (interval > 0) {
|
|
163
|
+
scrollRef.current?.scrollTo({ x: index * interval, animated: true });
|
|
164
|
+
}
|
|
165
|
+
}, [commitIndex, interval]);
|
|
124
166
|
const useCssSnap = isWeb && snap;
|
|
125
167
|
const dotsVisible = showDots && count > 1;
|
|
126
168
|
return (_jsxs(View, { style: [styles.container, styleOverride], onLayout: handleLayout, testID: testID, children: [_jsx(ScrollView, { ref: scrollRef, testID: `${testID}-scroll`, horizontal: true, showsHorizontalScrollIndicator: false,
|
|
127
169
|
// Native snapping. react-native-web drops these (they aren't in its
|
|
128
170
|
// forwarded-prop list), so web uses the CSS scroll-snap styles instead.
|
|
129
|
-
snapToInterval: snap && interval > 0 ? interval : undefined, snapToAlignment: "start", decelerationRate: snap ? "fast" : "normal",
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
// wheel/trackpad scrolling.
|
|
133
|
-
scrollEventThrottle: 16, onScroll: handleScroll, onMomentumScrollEnd: handleScroll, style: useCssSnap
|
|
171
|
+
snapToInterval: snap && interval > 0 ? interval : undefined, snapToAlignment: "start", decelerationRate: snap ? "fast" : "normal", ...(isWeb
|
|
172
|
+
? { scrollEventThrottle: 16, onScroll: handleScroll }
|
|
173
|
+
: { onMomentumScrollEnd: handleScroll, onScrollEndDrag: handleScrollEndDrag }), style: useCssSnap
|
|
134
174
|
? [webStyles.scroller, { scrollPaddingLeft: contentPadding }]
|
|
135
|
-
: undefined, contentContainerStyle: [{ paddingHorizontal: contentPadding, gap }, contentContainerStyle], children: items.map((child, index) => (_jsx(View, { testID: `${testID}-item-${index}`, style: [slideWidth > 0 && { width: slideWidth }, useCssSnap && webStyles.item], children: child }, React.isValidElement(child) && child.key != null ? child.key : index))) }), count > 1 && (_jsx(View, { testID: `${testID}-status`, style: styles.status, "aria-live": "polite", children: _jsx(Text, { style: styles.statusText, children: `${activeIndex + 1} of ${count}` }) })), dotsVisible && (_jsx(View, { testID: `${testID}-dots`, style: styles.dots, accessibilityRole: "tablist", children: items.map((_, index) => (_jsx(
|
|
175
|
+
: undefined, contentContainerStyle: [{ paddingHorizontal: contentPadding, gap }, contentContainerStyle], children: items.map((child, index) => (_jsx(View, { testID: `${testID}-item-${index}`, style: [slideWidth > 0 && { width: slideWidth }, useCssSnap && webStyles.item], children: child }, React.isValidElement(child) && child.key != null ? child.key : index))) }), count > 1 && (_jsx(View, { testID: `${testID}-status`, style: styles.status, "aria-live": "polite", children: _jsx(Text, { style: styles.statusText, children: `${activeIndex + 1} of ${count}` }) })), dotsVisible && (_jsx(View, { testID: `${testID}-dots`, style: styles.dots, accessibilityRole: "tablist", children: items.map((_, index) => (_jsx(Pressable, { testID: `${testID}-dot-${index}`, accessibilityRole: "tab", accessibilityState: { selected: index === activeIndex }, "aria-selected": index === activeIndex, accessibilityLabel: `Slide ${index + 1} of ${count}`, hitSlop: DOT_HIT_SLOP, onPress: () => handleDotPress(index), style: styles.dotButton, children: _jsx(View, { testID: `${testID}-dot-${index}-indicator`, style: [styles.dot, index === activeIndex ? styles.dotActive : styles.dotInactive] }) }, index))) }))] }));
|
|
136
176
|
}
|
|
137
177
|
// ============================================================================
|
|
138
178
|
// Styles
|
|
139
179
|
// ============================================================================
|
|
140
180
|
const DOT_SIZE = 8;
|
|
181
|
+
// Press target around each 8px dot. The square is what separates the dots
|
|
182
|
+
// visually (the row itself has no gap, so neighboring targets are flush — no
|
|
183
|
+
// dead strip between them), and `DOT_HIT_SLOP` extends it vertically to
|
|
184
|
+
// `spacing.touchTarget` (24 + 2 x 10 = 44). The slop is vertical-only on
|
|
185
|
+
// purpose: horizontal slop would overlap the neighboring dot's target and make
|
|
186
|
+
// which dot a tap lands on ambiguous.
|
|
187
|
+
const DOT_HIT_SIZE = 24;
|
|
188
|
+
const DOT_HIT_SLOP = {
|
|
189
|
+
top: (spacing.touchTarget - DOT_HIT_SIZE) / 2,
|
|
190
|
+
bottom: (spacing.touchTarget - DOT_HIT_SIZE) / 2,
|
|
191
|
+
left: 0,
|
|
192
|
+
right: 0,
|
|
193
|
+
};
|
|
141
194
|
// Web-only CSS scroll-snap. `scrollPaddingLeft` is applied inline at the call
|
|
142
195
|
// site (it tracks the contentPadding prop) so the leading inset survives the
|
|
143
196
|
// browser snapping slide 0 to the scrollport start — which also makes page `i`
|
|
@@ -166,8 +219,17 @@ const createStyles = (theme) => StyleSheet.create({
|
|
|
166
219
|
flexDirection: "row",
|
|
167
220
|
alignItems: "center",
|
|
168
221
|
justifyContent: "center",
|
|
169
|
-
gap
|
|
170
|
-
|
|
222
|
+
// No `gap`: the DOT_HIT_SIZE squares carry the visual spacing, and a gap
|
|
223
|
+
// would open an unpressable strip between adjacent targets. marginTop
|
|
224
|
+
// absorbs the wrapper's own vertical padding so the row sits where the
|
|
225
|
+
// pre-Pressable dots did.
|
|
226
|
+
marginTop: spacing.md - (DOT_HIT_SIZE - DOT_SIZE) / 2,
|
|
227
|
+
},
|
|
228
|
+
dotButton: {
|
|
229
|
+
width: DOT_HIT_SIZE,
|
|
230
|
+
height: DOT_HIT_SIZE,
|
|
231
|
+
alignItems: "center",
|
|
232
|
+
justifyContent: "center",
|
|
171
233
|
},
|
|
172
234
|
dot: {
|
|
173
235
|
width: DOT_SIZE,
|
|
@@ -5,9 +5,20 @@ export interface LabelProps {
|
|
|
5
5
|
*/
|
|
6
6
|
children: string;
|
|
7
7
|
/**
|
|
8
|
-
*
|
|
8
|
+
* The label's OWN id. On web react-native-web maps it to `id`; on native it
|
|
9
|
+
* is the `nativeID` a control's `accessibilityLabelledBy` can point at.
|
|
10
|
+
*
|
|
11
|
+
* Must differ from the paired input's id — see `htmlFor`.
|
|
9
12
|
*/
|
|
10
13
|
nativeID?: string;
|
|
14
|
+
/**
|
|
15
|
+
* WEB ONLY — the id of the input this label describes (the input's
|
|
16
|
+
* `nativeID`). Passing it makes `@rn-primitives/label` render a real
|
|
17
|
+
* `<label for="…">`, which is what associates the two and makes clicking the
|
|
18
|
+
* label focus the input. No-op on native, where association goes the other
|
|
19
|
+
* way (the control points at the label's `nativeID`).
|
|
20
|
+
*/
|
|
21
|
+
htmlFor?: string;
|
|
11
22
|
/**
|
|
12
23
|
* Whether the field is required (shows asterisk)
|
|
13
24
|
*/
|
|
@@ -37,25 +48,34 @@ export interface LabelProps {
|
|
|
37
48
|
/**
|
|
38
49
|
* Label component for form fields using @rn-primitives/label
|
|
39
50
|
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
51
|
+
* Accessible labeling for form controls. Association needs **two distinct
|
|
52
|
+
* ids**: `nativeID` is the label's own id, `htmlFor` is the input's id. Giving
|
|
53
|
+
* both the same value renders two elements with one id on web and associates
|
|
54
|
+
* nothing.
|
|
42
55
|
*
|
|
43
56
|
* Usage:
|
|
44
57
|
* ```tsx
|
|
45
|
-
* //
|
|
46
|
-
*
|
|
58
|
+
* // Paired with an input. `htmlFor` (web) makes this a real <label for>, so
|
|
59
|
+
* // clicking it focuses the input; `nativeID` names the label itself, which is
|
|
60
|
+
* // what a control's `accessibilityLabelledBy` points at on native.
|
|
61
|
+
* <Label nativeID="email-label" htmlFor="email-input">Email</Label>
|
|
47
62
|
* <TextInput nativeID="email-input" />
|
|
48
63
|
*
|
|
49
64
|
* // Required field
|
|
50
|
-
* <Label nativeID="password" required>Password</Label>
|
|
65
|
+
* <Label nativeID="password-label" htmlFor="password-input" required>Password</Label>
|
|
66
|
+
* <TextInput nativeID="password-input" secureTextEntry />
|
|
51
67
|
*
|
|
52
68
|
* // With error state
|
|
53
|
-
* <Label nativeID="username" error>Username</Label>
|
|
69
|
+
* <Label nativeID="username-label" htmlFor="username-input" error>Username</Label>
|
|
70
|
+
*
|
|
71
|
+
* // Standalone caption with no control to associate — omit htmlFor
|
|
72
|
+
* <Label>Filters</Label>
|
|
54
73
|
*
|
|
55
|
-
* // With press handler to focus input
|
|
56
|
-
*
|
|
74
|
+
* // With press handler to focus input (needed on native, where htmlFor is a
|
|
75
|
+
* // no-op and pressing the label does nothing on its own)
|
|
76
|
+
* <Label nativeID="search-label" htmlFor="search-input" onPress={() => inputRef.current?.focus()}>
|
|
57
77
|
* Search
|
|
58
78
|
* </Label>
|
|
59
79
|
* ```
|
|
60
80
|
*/
|
|
61
|
-
export declare function Label({ children, nativeID, required, size, error, disabled, style, onPress, }: LabelProps): import("react").JSX.Element;
|
|
81
|
+
export declare function Label({ children, nativeID, htmlFor, required, size, error, disabled, style, onPress, }: LabelProps): import("react").JSX.Element;
|
package/dist/components/Label.js
CHANGED
|
@@ -14,28 +14,37 @@ const SIZE_CONFIGS = {
|
|
|
14
14
|
/**
|
|
15
15
|
* Label component for form fields using @rn-primitives/label
|
|
16
16
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
17
|
+
* Accessible labeling for form controls. Association needs **two distinct
|
|
18
|
+
* ids**: `nativeID` is the label's own id, `htmlFor` is the input's id. Giving
|
|
19
|
+
* both the same value renders two elements with one id on web and associates
|
|
20
|
+
* nothing.
|
|
19
21
|
*
|
|
20
22
|
* Usage:
|
|
21
23
|
* ```tsx
|
|
22
|
-
* //
|
|
23
|
-
*
|
|
24
|
+
* // Paired with an input. `htmlFor` (web) makes this a real <label for>, so
|
|
25
|
+
* // clicking it focuses the input; `nativeID` names the label itself, which is
|
|
26
|
+
* // what a control's `accessibilityLabelledBy` points at on native.
|
|
27
|
+
* <Label nativeID="email-label" htmlFor="email-input">Email</Label>
|
|
24
28
|
* <TextInput nativeID="email-input" />
|
|
25
29
|
*
|
|
26
30
|
* // Required field
|
|
27
|
-
* <Label nativeID="password" required>Password</Label>
|
|
31
|
+
* <Label nativeID="password-label" htmlFor="password-input" required>Password</Label>
|
|
32
|
+
* <TextInput nativeID="password-input" secureTextEntry />
|
|
28
33
|
*
|
|
29
34
|
* // With error state
|
|
30
|
-
* <Label nativeID="username" error>Username</Label>
|
|
35
|
+
* <Label nativeID="username-label" htmlFor="username-input" error>Username</Label>
|
|
31
36
|
*
|
|
32
|
-
* //
|
|
33
|
-
* <Label
|
|
37
|
+
* // Standalone caption with no control to associate — omit htmlFor
|
|
38
|
+
* <Label>Filters</Label>
|
|
39
|
+
*
|
|
40
|
+
* // With press handler to focus input (needed on native, where htmlFor is a
|
|
41
|
+
* // no-op and pressing the label does nothing on its own)
|
|
42
|
+
* <Label nativeID="search-label" htmlFor="search-input" onPress={() => inputRef.current?.focus()}>
|
|
34
43
|
* Search
|
|
35
44
|
* </Label>
|
|
36
45
|
* ```
|
|
37
46
|
*/
|
|
38
|
-
export function Label({ children, nativeID, required, size = "md", error, disabled, style, onPress, }) {
|
|
47
|
+
export function Label({ children, nativeID, htmlFor, required, size = "md", error, disabled, style, onPress, }) {
|
|
39
48
|
const { theme } = useTheme();
|
|
40
49
|
const styles = themedStyles(theme);
|
|
41
50
|
const sizeConfig = SIZE_CONFIGS[size];
|
|
@@ -53,7 +62,7 @@ export function Label({ children, nativeID, required, size = "md", error, disabl
|
|
|
53
62
|
return (_jsx(LabelPrimitive.Root, { nativeID: nativeID, onPress: onPress, style: {
|
|
54
63
|
...styles.root,
|
|
55
64
|
...(Platform.OS === "web" && onPress && { cursor: "pointer" }),
|
|
56
|
-
}, children: _jsxs(LabelPrimitive.Text, { style: textStyle, children: [children, required && _jsx(StyledText, { selectable: false, fontWeight: "bold", style: styles.required, children: " *" })] }) }));
|
|
65
|
+
}, children: _jsxs(LabelPrimitive.Text, { htmlFor: htmlFor, style: textStyle, children: [children, required && _jsx(StyledText, { selectable: false, fontWeight: "bold", style: styles.required, children: " *" })] }) }));
|
|
57
66
|
}
|
|
58
67
|
const createStyles = (theme) => StyleSheet.create({
|
|
59
68
|
root: {
|
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,11 @@ 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
|
-
* 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.
|
|
32
|
+
* On web the first render uses `DEFAULT_VIEWPORT_WIDTH` / `_HEIGHT` — the
|
|
33
|
+
* window can't be read while the HTML shell is rendered in Node at export
|
|
34
|
+
* time, and reading it during the browser's first render would disagree with
|
|
35
|
+
* that markup. A post-mount effect swaps in the real viewport and follows
|
|
36
|
+
* resize from there.
|
|
27
37
|
*/
|
|
28
38
|
export declare const useDimensions: () => WindowDimensions;
|
|
29
39
|
export {};
|
|
@@ -1,6 +1,19 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
2
|
import { Platform, useWindowDimensions } from "react-native";
|
|
3
|
-
|
|
3
|
+
/**
|
|
4
|
+
* Viewport the first web render is computed from, before the real window can
|
|
5
|
+
* be read. Desktop is chosen because:
|
|
6
|
+
* 1. A desktop visitor sees no reflow — their real viewport lands in the
|
|
7
|
+
* same breakpoint.
|
|
8
|
+
* 2. A mobile visitor gets one frame of desktop-styled content before the
|
|
9
|
+
* post-mount effect snaps to real dimensions — better than the inverse
|
|
10
|
+
* (every desktop visitor seeing mobile-tiny, then snapping).
|
|
11
|
+
* 3. It matches the frame the app hands `SafeAreaProvider`, and it is the
|
|
12
|
+
* value the HTML shell `expo export` renders in Node is built from, so
|
|
13
|
+
* the browser's first render agrees with the markup it hydrates.
|
|
14
|
+
*/
|
|
15
|
+
export const DEFAULT_VIEWPORT_WIDTH = 1280;
|
|
16
|
+
export const DEFAULT_VIEWPORT_HEIGHT = 800;
|
|
4
17
|
export const SCREEN_SIZES = {
|
|
5
18
|
SMALL: 768,
|
|
6
19
|
MEDIUM: 1000,
|
|
@@ -20,53 +33,32 @@ const calculateDimensionFlags = (width, height) => {
|
|
|
20
33
|
isLargeScreen: width > SCREEN_SIZES.MEDIUM,
|
|
21
34
|
};
|
|
22
35
|
};
|
|
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
36
|
/**
|
|
38
37
|
* Provides a consistent way to access window dimensions and screen size
|
|
39
38
|
* information across mobile and web.
|
|
40
39
|
*
|
|
41
|
-
* On web
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
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.
|
|
40
|
+
* On web the first render uses `DEFAULT_VIEWPORT_WIDTH` / `_HEIGHT` — the
|
|
41
|
+
* window can't be read while the HTML shell is rendered in Node at export
|
|
42
|
+
* time, and reading it during the browser's first render would disagree with
|
|
43
|
+
* that markup. A post-mount effect swaps in the real viewport and follows
|
|
44
|
+
* resize from there.
|
|
50
45
|
*/
|
|
51
46
|
export const useDimensions = () => {
|
|
52
47
|
const isWeb = Platform.OS === "web";
|
|
53
|
-
const ssrWidth = use(SsrViewportContext);
|
|
54
48
|
// Native reads come from useWindowDimensions, which subscribes to rotation /
|
|
55
49
|
// split-screen / resize and tears the listener down for us — no manual
|
|
56
50
|
// Dimensions.addEventListener to leak. On web we ignore it and drive layout
|
|
57
|
-
// from the
|
|
51
|
+
// from the default frame + the resize listener below.
|
|
58
52
|
const native = useWindowDimensions();
|
|
59
|
-
|
|
60
|
-
// flags from the context value, so hydration matches.
|
|
61
|
-
const [dimensions, setDimensions] = useState(() => calculateDimensionFlags(ssrWidth, SSR_VIEWPORT_DEFAULT_HEIGHT));
|
|
53
|
+
const [dimensions, setDimensions] = useState(() => calculateDimensionFlags(DEFAULT_VIEWPORT_WIDTH, DEFAULT_VIEWPORT_HEIGHT));
|
|
62
54
|
// Web: read the real viewport after mount and follow resize events. Keeping
|
|
63
|
-
// this in an effect (not render)
|
|
55
|
+
// this in an effect (not render) keeps the first render identical to the
|
|
56
|
+
// exported markup it hydrates.
|
|
64
57
|
useEffect(() => {
|
|
65
58
|
if (!isWeb)
|
|
66
59
|
return;
|
|
67
60
|
const syncFromWindow = () => {
|
|
68
61
|
setDimensions(calculateDimensionFlags(window.innerWidth, window.innerHeight));
|
|
69
|
-
writeViewportCookie(window.innerWidth);
|
|
70
62
|
};
|
|
71
63
|
syncFromWindow();
|
|
72
64
|
window.addEventListener("resize", syncFromWindow);
|
|
@@ -1,17 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Registers the Feather icon font in expo-font's per-request SSR store.
|
|
3
|
-
*
|
|
4
|
-
* Must be called from a **render body** on server-web (see `useResources`
|
|
5
|
-
* below): expo-font enters an `AsyncLocalStorage` store per request, so a
|
|
6
|
-
* module-scope registration lands in at most one request's store. Registering
|
|
7
|
-
* during render means every request emits the `@font-face` rule into
|
|
8
|
-
* `<style id="expo-generated-fonts">` and server-rendered `<Icon>`s agree with
|
|
9
|
-
* the client's first render.
|
|
10
|
-
*
|
|
11
|
-
* No-op on native and on client web (both already covered: native by the
|
|
12
|
-
* effect below, client web by the module-scope call above).
|
|
13
|
-
*/
|
|
14
|
-
export declare function ensureIconFontRegistered(): void;
|
|
15
1
|
interface LoadResourcesResult {
|
|
16
2
|
loaded: boolean;
|
|
17
3
|
error: Error | null;
|
|
@@ -4,45 +4,17 @@ 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
|
-
/**
|
|
24
|
-
* Registers the Feather icon font in expo-font's per-request SSR store.
|
|
25
|
-
*
|
|
26
|
-
* Must be called from a **render body** on server-web (see `useResources`
|
|
27
|
-
* below): expo-font enters an `AsyncLocalStorage` store per request, so a
|
|
28
|
-
* module-scope registration lands in at most one request's store. Registering
|
|
29
|
-
* during render means every request emits the `@font-face` rule into
|
|
30
|
-
* `<style id="expo-generated-fonts">` and server-rendered `<Icon>`s agree with
|
|
31
|
-
* the client's first render.
|
|
32
|
-
*
|
|
33
|
-
* No-op on native and on client web (both already covered: native by the
|
|
34
|
-
* effect below, client web by the module-scope call above).
|
|
35
|
-
*/
|
|
36
|
-
export function ensureIconFontRegistered() {
|
|
37
|
-
if (Platform.OS !== "web" || typeof window !== "undefined")
|
|
38
|
-
return;
|
|
39
|
-
if (Font.isLoaded("feather"))
|
|
40
|
-
return; // per-request store; cheap
|
|
41
|
-
// Synchronous on server-web (expo-font routes to registerStaticFont). No
|
|
42
|
-
// `void`/try-catch: a throw here means we're outside a request scope, which
|
|
43
|
-
// must be loud.
|
|
44
|
-
Font.loadAsync(Feather.font);
|
|
45
|
-
}
|
|
46
18
|
// The four static Inter weights StyledText's native family keys point at
|
|
47
19
|
// (see constants/fonts.ts). Native-only: web never renders these family names
|
|
48
20
|
// ("Inter_400Regular" etc.) — fontFamilies.sansSerif resolves every weight to
|
|
@@ -96,10 +68,6 @@ function ensureWebFontStylesheet() {
|
|
|
96
68
|
* un-download Inter. The Feather icon font always loads.
|
|
97
69
|
*/
|
|
98
70
|
export const useResources = () => {
|
|
99
|
-
// Render-body (not effect) so server-web registers the icon font in *this*
|
|
100
|
-
// request's expo-font store — effects never run on the server and a
|
|
101
|
-
// module-scope call only reaches one request. See ensureIconFontRegistered.
|
|
102
|
-
ensureIconFontRegistered();
|
|
103
71
|
const [loaded, setLoaded] = useState(false);
|
|
104
72
|
const [error, setError] = useState(null);
|
|
105
73
|
useEffect(() => {
|
package/dist/hooks/useTheme.js
CHANGED
|
@@ -3,7 +3,6 @@ import { colors } 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];
|
|
@@ -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
|
package/dist/state/index.d.ts
CHANGED