@mrmeg/expo-ui 0.17.0 → 0.20.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 +146 -0
- package/LLM_USAGE.md +18 -0
- package/README.md +8 -2
- package/dist/components/Avatar.d.ts +80 -0
- package/dist/components/Avatar.js +234 -0
- package/dist/components/BottomSheet.d.ts +9 -1
- package/dist/components/BottomSheet.js +8 -3
- package/dist/components/Carousel.d.ts +73 -0
- package/dist/components/Carousel.js +184 -0
- package/dist/components/Drawer.js +5 -2
- package/dist/components/index.d.ts +2 -0
- package/dist/components/index.js +2 -0
- package/dist/constants/colors.d.ts +4 -0
- package/dist/constants/colors.js +12 -4
- package/dist/hooks/useResources.d.ts +14 -0
- package/dist/hooks/useResources.js +43 -9
- package/dist/hooks/useTheme.js +16 -2
- package/dist/state/index.d.ts +1 -0
- package/dist/state/index.js +1 -0
- package/dist/state/ssrTheme.d.ts +41 -0
- package/dist/state/ssrTheme.js +33 -0
- package/dist/state/themeStore.d.ts +19 -0
- package/dist/state/themeStore.js +55 -6
- package/llms-full.md +8 -3
- package/package.json +1 -1
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { type StyleProp, type ViewStyle } from "react-native";
|
|
3
|
+
export interface CarouselProps {
|
|
4
|
+
/** Slides to page through. Each child is wrapped in a fixed-width slide. */
|
|
5
|
+
children?: React.ReactNode;
|
|
6
|
+
/**
|
|
7
|
+
* Slide width. Values `<= 1` are a fraction of the carousel's own width —
|
|
8
|
+
* the default `0.85` leaves the next slide peeking. Values `> 1` are
|
|
9
|
+
* absolute pixels.
|
|
10
|
+
* @default 0.85
|
|
11
|
+
*/
|
|
12
|
+
itemWidth?: number;
|
|
13
|
+
/** Space between slides. @default spacing.md */
|
|
14
|
+
gap?: number;
|
|
15
|
+
/** Horizontal inset before the first and after the last slide. @default spacing.lg */
|
|
16
|
+
contentPadding?: number;
|
|
17
|
+
/** Render the dot indicators. Auto-hidden for a single slide. @default true */
|
|
18
|
+
showDots?: boolean;
|
|
19
|
+
/** Slide shown on mount. @default 0 */
|
|
20
|
+
initialIndex?: number;
|
|
21
|
+
/** Called with the new active index whenever it changes. */
|
|
22
|
+
onIndexChange?: (index: number) => void;
|
|
23
|
+
/** Snap to slide boundaries. @default true */
|
|
24
|
+
snap?: boolean;
|
|
25
|
+
/** Custom style override for the outer container. */
|
|
26
|
+
style?: StyleProp<ViewStyle>;
|
|
27
|
+
/** Custom style override for the scroll content row. */
|
|
28
|
+
contentContainerStyle?: StyleProp<ViewStyle>;
|
|
29
|
+
/**
|
|
30
|
+
* testID prefix. Emits `<testID>`, `-scroll`, `-item-<i>`, `-dots`,
|
|
31
|
+
* `-dot-<i>`, and `-status`.
|
|
32
|
+
* @default "carousel"
|
|
33
|
+
*/
|
|
34
|
+
testID?: string;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Active slide index for a horizontal scroll offset.
|
|
38
|
+
*
|
|
39
|
+
* `interval` is the slide pitch (item width + gap), so page `i` sits at
|
|
40
|
+
* `i * interval` for both the native snap offsets and web's CSS scroll-snap
|
|
41
|
+
* (see `scrollPaddingLeft` below). Clamped to `[0, count - 1]` so rubber-band
|
|
42
|
+
* overscroll at either end can't report an out-of-range page. Returns `0` for
|
|
43
|
+
* a degenerate interval — which is also the pre-measurement state on the
|
|
44
|
+
* server.
|
|
45
|
+
*/
|
|
46
|
+
export declare function getCarouselIndex(offsetX: number, interval: number, count: number): number;
|
|
47
|
+
/** Resolves the `itemWidth` prop against a container width (fraction vs px). */
|
|
48
|
+
export declare function resolveCarouselItemWidth(itemWidth: number, containerWidth: number): number;
|
|
49
|
+
/**
|
|
50
|
+
* Carousel Component
|
|
51
|
+
*
|
|
52
|
+
* Horizontally snapping row of slides with dot indicators and an optional
|
|
53
|
+
* peek of the next slide. Children-based: every child becomes one slide at the
|
|
54
|
+
* resolved item width.
|
|
55
|
+
*
|
|
56
|
+
* Built on `ScrollView` — not a virtualized list — on purpose: every slide is
|
|
57
|
+
* in the server-rendered tree, so web SSR ships the real content instead of an
|
|
58
|
+
* empty scroller that only fills in after measurement. Snapping uses the
|
|
59
|
+
* platform scroller (`snapToInterval` on native, CSS scroll-snap on web); no
|
|
60
|
+
* animation library is involved.
|
|
61
|
+
*
|
|
62
|
+
* @example
|
|
63
|
+
* ```tsx
|
|
64
|
+
* <Carousel itemWidth={0.8} onIndexChange={setPage}>
|
|
65
|
+
* {testimonials.map((t) => (
|
|
66
|
+
* <Card key={t.name}>
|
|
67
|
+
* <BodyText>{t.quote}</BodyText>
|
|
68
|
+
* </Card>
|
|
69
|
+
* ))}
|
|
70
|
+
* </Carousel>
|
|
71
|
+
* ```
|
|
72
|
+
*/
|
|
73
|
+
export declare function Carousel({ children, itemWidth, gap, contentPadding, showDots, initialIndex, onIndexChange, snap, style: styleOverride, contentContainerStyle, testID, }: CarouselProps): React.JSX.Element;
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import React, { useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
+
import { Platform, ScrollView, StyleSheet, Text, View, } from "react-native";
|
|
4
|
+
import { useTheme } from "../hooks/useTheme.js";
|
|
5
|
+
import { useDimensions } from "../hooks/useDimensions.js";
|
|
6
|
+
import { spacing } from "../constants/spacing.js";
|
|
7
|
+
import { createThemedStyles } from "../lib/themedStyles.js";
|
|
8
|
+
// ============================================================================
|
|
9
|
+
// Index math
|
|
10
|
+
// ============================================================================
|
|
11
|
+
/**
|
|
12
|
+
* Active slide index for a horizontal scroll offset.
|
|
13
|
+
*
|
|
14
|
+
* `interval` is the slide pitch (item width + gap), so page `i` sits at
|
|
15
|
+
* `i * interval` for both the native snap offsets and web's CSS scroll-snap
|
|
16
|
+
* (see `scrollPaddingLeft` below). Clamped to `[0, count - 1]` so rubber-band
|
|
17
|
+
* overscroll at either end can't report an out-of-range page. Returns `0` for
|
|
18
|
+
* a degenerate interval — which is also the pre-measurement state on the
|
|
19
|
+
* server.
|
|
20
|
+
*/
|
|
21
|
+
export function getCarouselIndex(offsetX, interval, count) {
|
|
22
|
+
if (!Number.isFinite(offsetX) || !Number.isFinite(interval))
|
|
23
|
+
return 0;
|
|
24
|
+
if (interval <= 0 || count <= 0)
|
|
25
|
+
return 0;
|
|
26
|
+
const raw = Math.round(offsetX / interval);
|
|
27
|
+
if (raw < 0)
|
|
28
|
+
return 0;
|
|
29
|
+
if (raw > count - 1)
|
|
30
|
+
return count - 1;
|
|
31
|
+
return raw;
|
|
32
|
+
}
|
|
33
|
+
/** Resolves the `itemWidth` prop against a container width (fraction vs px). */
|
|
34
|
+
export function resolveCarouselItemWidth(itemWidth, containerWidth) {
|
|
35
|
+
if (!Number.isFinite(itemWidth) || itemWidth <= 0)
|
|
36
|
+
return 0;
|
|
37
|
+
if (itemWidth > 1)
|
|
38
|
+
return Math.round(itemWidth);
|
|
39
|
+
if (!Number.isFinite(containerWidth) || containerWidth <= 0)
|
|
40
|
+
return 0;
|
|
41
|
+
return Math.round(containerWidth * itemWidth);
|
|
42
|
+
}
|
|
43
|
+
function clampIndex(index, count) {
|
|
44
|
+
if (count <= 0)
|
|
45
|
+
return 0;
|
|
46
|
+
return Math.min(count - 1, Math.max(0, Math.trunc(index)));
|
|
47
|
+
}
|
|
48
|
+
// ============================================================================
|
|
49
|
+
// Carousel
|
|
50
|
+
// ============================================================================
|
|
51
|
+
/**
|
|
52
|
+
* Carousel Component
|
|
53
|
+
*
|
|
54
|
+
* Horizontally snapping row of slides with dot indicators and an optional
|
|
55
|
+
* peek of the next slide. Children-based: every child becomes one slide at the
|
|
56
|
+
* resolved item width.
|
|
57
|
+
*
|
|
58
|
+
* Built on `ScrollView` — not a virtualized list — on purpose: every slide is
|
|
59
|
+
* in the server-rendered tree, so web SSR ships the real content instead of an
|
|
60
|
+
* empty scroller that only fills in after measurement. Snapping uses the
|
|
61
|
+
* platform scroller (`snapToInterval` on native, CSS scroll-snap on web); no
|
|
62
|
+
* animation library is involved.
|
|
63
|
+
*
|
|
64
|
+
* @example
|
|
65
|
+
* ```tsx
|
|
66
|
+
* <Carousel itemWidth={0.8} onIndexChange={setPage}>
|
|
67
|
+
* {testimonials.map((t) => (
|
|
68
|
+
* <Card key={t.name}>
|
|
69
|
+
* <BodyText>{t.quote}</BodyText>
|
|
70
|
+
* </Card>
|
|
71
|
+
* ))}
|
|
72
|
+
* </Carousel>
|
|
73
|
+
* ```
|
|
74
|
+
*/
|
|
75
|
+
export function Carousel({ children, itemWidth = 0.85, gap = spacing.md, contentPadding = spacing.lg, showDots = true, initialIndex = 0, onIndexChange, snap = true, style: styleOverride, contentContainerStyle, testID = "carousel", }) {
|
|
76
|
+
const { theme } = useTheme();
|
|
77
|
+
const styles = themedStyles(theme);
|
|
78
|
+
const items = React.Children.toArray(children);
|
|
79
|
+
const count = items.length;
|
|
80
|
+
// Width source of truth: the measured container after mount, seeded from the
|
|
81
|
+
// viewport so the server and the client's first render agree. `useDimensions`
|
|
82
|
+
// reads the SSR viewport context rather than `window`, which is what keeps
|
|
83
|
+
// that seed hydration-safe (docs/ssr-hydration.md §4).
|
|
84
|
+
const { width: viewportWidth } = useDimensions();
|
|
85
|
+
const [measuredWidth, setMeasuredWidth] = useState(null);
|
|
86
|
+
const containerWidth = measuredWidth ?? viewportWidth;
|
|
87
|
+
const slideWidth = resolveCarouselItemWidth(itemWidth, containerWidth);
|
|
88
|
+
const interval = slideWidth + gap;
|
|
89
|
+
const [activeIndex, setActiveIndex] = useState(() => clampIndex(initialIndex, count));
|
|
90
|
+
// Mirrors activeIndex so the scroll handler can dedupe without re-reading
|
|
91
|
+
// state: one settle emits many scroll events, onIndexChange must fire once.
|
|
92
|
+
const activeIndexRef = useRef(activeIndex);
|
|
93
|
+
const scrollRef = useRef(null);
|
|
94
|
+
const hasScrolledToInitial = useRef(false);
|
|
95
|
+
const commitIndex = useCallback((next) => {
|
|
96
|
+
if (next === activeIndexRef.current)
|
|
97
|
+
return;
|
|
98
|
+
activeIndexRef.current = next;
|
|
99
|
+
setActiveIndex(next);
|
|
100
|
+
onIndexChange?.(next);
|
|
101
|
+
}, [onIndexChange]);
|
|
102
|
+
const handleScroll = useCallback((event) => {
|
|
103
|
+
commitIndex(getCarouselIndex(event.nativeEvent.contentOffset.x, interval, count));
|
|
104
|
+
}, [commitIndex, count, interval]);
|
|
105
|
+
const handleLayout = useCallback((event) => {
|
|
106
|
+
const next = event.nativeEvent.layout.width;
|
|
107
|
+
if (!(next > 0))
|
|
108
|
+
return;
|
|
109
|
+
setMeasuredWidth((current) => current !== null && Math.abs(current - next) < 1 ? current : next);
|
|
110
|
+
}, []);
|
|
111
|
+
// Jump to initialIndex once, after the first real layout — the seeded
|
|
112
|
+
// viewport width would put the offset in the wrong place. `contentOffset` is
|
|
113
|
+
// iOS-only, so drive it imperatively for Android/web parity.
|
|
114
|
+
const targetIndex = clampIndex(initialIndex, count);
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
if (hasScrolledToInitial.current || measuredWidth === null || interval <= 0)
|
|
117
|
+
return;
|
|
118
|
+
hasScrolledToInitial.current = true;
|
|
119
|
+
if (targetIndex === 0)
|
|
120
|
+
return;
|
|
121
|
+
scrollRef.current?.scrollTo({ x: targetIndex * interval, animated: false });
|
|
122
|
+
}, [interval, measuredWidth, targetIndex]);
|
|
123
|
+
const isWeb = Platform.OS === "web";
|
|
124
|
+
const useCssSnap = isWeb && snap;
|
|
125
|
+
const dotsVisible = showDots && count > 1;
|
|
126
|
+
return (_jsxs(View, { style: [styles.container, styleOverride], onLayout: handleLayout, testID: testID, children: [_jsx(ScrollView, { ref: scrollRef, testID: `${testID}-scroll`, horizontal: true, showsHorizontalScrollIndicator: false,
|
|
127
|
+
// Native snapping. react-native-web drops these (they aren't in its
|
|
128
|
+
// 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
|
+
// react-native-web only emits scroll ticks when the throttle is > 0 and
|
|
131
|
+
// never emits momentum events — this is what keeps the dots live for
|
|
132
|
+
// wheel/trackpad scrolling.
|
|
133
|
+
scrollEventThrottle: 16, onScroll: handleScroll, onMomentumScrollEnd: handleScroll, style: useCssSnap
|
|
134
|
+
? [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(View, { testID: `${testID}-dot-${index}`, accessibilityRole: "tab", accessibilityState: { selected: index === activeIndex }, accessibilityLabel: `Slide ${index + 1} of ${count}`, style: [styles.dot, index === activeIndex ? styles.dotActive : styles.dotInactive] }, index))) }))] }));
|
|
136
|
+
}
|
|
137
|
+
// ============================================================================
|
|
138
|
+
// Styles
|
|
139
|
+
// ============================================================================
|
|
140
|
+
const DOT_SIZE = 8;
|
|
141
|
+
// Web-only CSS scroll-snap. `scrollPaddingLeft` is applied inline at the call
|
|
142
|
+
// site (it tracks the contentPadding prop) so the leading inset survives the
|
|
143
|
+
// browser snapping slide 0 to the scrollport start — which also makes page `i`
|
|
144
|
+
// land on `i * interval`, matching the native snap offsets.
|
|
145
|
+
const webStyles = StyleSheet.create({
|
|
146
|
+
scroller: { scrollSnapType: "x mandatory" },
|
|
147
|
+
item: { scrollSnapAlign: "start" },
|
|
148
|
+
});
|
|
149
|
+
const createStyles = (theme) => StyleSheet.create({
|
|
150
|
+
container: {
|
|
151
|
+
width: "100%",
|
|
152
|
+
},
|
|
153
|
+
// Clipped to 1x1 rather than removed: screen readers skip
|
|
154
|
+
// display:none/opacity:0 nodes, and there is no visual affordance to show.
|
|
155
|
+
status: {
|
|
156
|
+
position: "absolute",
|
|
157
|
+
width: 1,
|
|
158
|
+
height: 1,
|
|
159
|
+
overflow: "hidden",
|
|
160
|
+
},
|
|
161
|
+
statusText: {
|
|
162
|
+
fontSize: 1,
|
|
163
|
+
color: "transparent",
|
|
164
|
+
},
|
|
165
|
+
dots: {
|
|
166
|
+
flexDirection: "row",
|
|
167
|
+
alignItems: "center",
|
|
168
|
+
justifyContent: "center",
|
|
169
|
+
gap: spacing.xs,
|
|
170
|
+
marginTop: spacing.md,
|
|
171
|
+
},
|
|
172
|
+
dot: {
|
|
173
|
+
width: DOT_SIZE,
|
|
174
|
+
height: DOT_SIZE,
|
|
175
|
+
borderRadius: spacing.radiusFull,
|
|
176
|
+
},
|
|
177
|
+
dotActive: {
|
|
178
|
+
backgroundColor: theme.colors.accent,
|
|
179
|
+
},
|
|
180
|
+
dotInactive: {
|
|
181
|
+
backgroundColor: theme.colors.border,
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
const themedStyles = createThemedStyles(createStyles);
|
|
@@ -395,7 +395,9 @@ function DrawerOverlayContent({ swipeEnabled = true, swipeThreshold = 0.3, veloc
|
|
|
395
395
|
bottom: 0,
|
|
396
396
|
width,
|
|
397
397
|
[side]: 0,
|
|
398
|
-
|
|
398
|
+
// App chrome sits on the sunken tier so it reads as a layer beneath the
|
|
399
|
+
// content pane rather than the same surface.
|
|
400
|
+
backgroundColor: theme.colors.surfaceSunken,
|
|
399
401
|
borderColor: theme.colors.border,
|
|
400
402
|
...(side === "left"
|
|
401
403
|
? { borderRightWidth: 1 }
|
|
@@ -502,7 +504,8 @@ swipeEnabled: _swipeEnabled, swipeThreshold: _swipeThreshold, velocityThreshold:
|
|
|
502
504
|
const panelStyle = {
|
|
503
505
|
width: Platform.OS === "web" ? targetWidth : widthAnim,
|
|
504
506
|
overflow: "hidden",
|
|
505
|
-
|
|
507
|
+
// Rail chrome sits on the sunken tier, one step below the content pane.
|
|
508
|
+
backgroundColor: theme.colors.surfaceSunken,
|
|
506
509
|
borderColor: theme.colors.border,
|
|
507
510
|
...(side === "left" ? { borderRightWidth: 1 } : { borderLeftWidth: 1 }),
|
|
508
511
|
paddingTop: insets.top,
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
export * from "./Accordion";
|
|
2
2
|
export * from "./Alert";
|
|
3
3
|
export * from "./AnimatedView";
|
|
4
|
+
export * from "./Avatar";
|
|
4
5
|
export * from "./Badge";
|
|
5
6
|
export * from "./BottomSheet";
|
|
6
7
|
export * from "./Button";
|
|
7
8
|
export * from "./Card";
|
|
9
|
+
export * from "./Carousel";
|
|
8
10
|
export * from "./Checkbox";
|
|
9
11
|
export * from "./Collapsible";
|
|
10
12
|
export * from "./Dialog";
|
package/dist/components/index.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
export * from "./Accordion.js";
|
|
2
2
|
export * from "./Alert.js";
|
|
3
3
|
export * from "./AnimatedView.js";
|
|
4
|
+
export * from "./Avatar.js";
|
|
4
5
|
export * from "./Badge.js";
|
|
5
6
|
export * from "./BottomSheet.js";
|
|
6
7
|
export * from "./Button.js";
|
|
7
8
|
export * from "./Card.js";
|
|
9
|
+
export * from "./Carousel.js";
|
|
8
10
|
export * from "./Checkbox.js";
|
|
9
11
|
export * from "./Collapsible.js";
|
|
10
12
|
export * from "./Dialog.js";
|
|
@@ -22,11 +22,13 @@ declare const palette: {
|
|
|
22
22
|
readonly gray900: "#18181B";
|
|
23
23
|
readonly gray950: "#09090B";
|
|
24
24
|
readonly black: "#000000";
|
|
25
|
+
readonly dark950: "#050506";
|
|
25
26
|
readonly dark900: "#09090B";
|
|
26
27
|
readonly dark800: "#18181B";
|
|
27
28
|
readonly dark700: "#27272A";
|
|
28
29
|
readonly dark600: "#3F3F46";
|
|
29
30
|
readonly dark400: "#A1A1AA";
|
|
31
|
+
readonly dark350: "#B0B0B8";
|
|
30
32
|
readonly dark300: "#D4D4D8";
|
|
31
33
|
readonly dark100: "#F4F4F5";
|
|
32
34
|
readonly green500: "#22C55E";
|
|
@@ -40,6 +42,7 @@ declare const palette: {
|
|
|
40
42
|
* Semantic color interface - what colors mean in the UI
|
|
41
43
|
*/
|
|
42
44
|
export interface ThemeColors {
|
|
45
|
+
surfaceSunken: string;
|
|
43
46
|
background: string;
|
|
44
47
|
foreground: string;
|
|
45
48
|
card: string;
|
|
@@ -61,6 +64,7 @@ export interface ThemeColors {
|
|
|
61
64
|
success: string;
|
|
62
65
|
warning: string;
|
|
63
66
|
border: string;
|
|
67
|
+
borderStrong: string;
|
|
64
68
|
input: string;
|
|
65
69
|
ring: string;
|
|
66
70
|
overlay: string;
|
package/dist/constants/colors.js
CHANGED
|
@@ -25,11 +25,15 @@ const palette = {
|
|
|
25
25
|
gray950: "#09090B",
|
|
26
26
|
black: "#000000",
|
|
27
27
|
// Dark mode surfaces — zinc-based
|
|
28
|
+
// `dark950` sits below zinc-950 as the app-chrome tier; `dark350` is a
|
|
29
|
+
// zinc-350 midpoint used for dim text that stays comfortably readable.
|
|
30
|
+
dark950: "#050506",
|
|
28
31
|
dark900: "#09090B",
|
|
29
32
|
dark800: "#18181B",
|
|
30
33
|
dark700: "#27272A",
|
|
31
34
|
dark600: "#3F3F46",
|
|
32
35
|
dark400: "#A1A1AA",
|
|
36
|
+
dark350: "#B0B0B8",
|
|
33
37
|
dark300: "#D4D4D8",
|
|
34
38
|
dark100: "#F4F4F5",
|
|
35
39
|
// Status
|
|
@@ -44,6 +48,7 @@ const lightTheme = {
|
|
|
44
48
|
dark: false,
|
|
45
49
|
fonts: navigationFonts,
|
|
46
50
|
colors: {
|
|
51
|
+
surfaceSunken: palette.gray50,
|
|
47
52
|
background: palette.white,
|
|
48
53
|
foreground: palette.gray950,
|
|
49
54
|
card: palette.white,
|
|
@@ -51,13 +56,13 @@ const lightTheme = {
|
|
|
51
56
|
popover: palette.white,
|
|
52
57
|
popoverForeground: palette.gray950,
|
|
53
58
|
text: palette.gray950,
|
|
54
|
-
textDim: palette.
|
|
59
|
+
textDim: palette.gray600,
|
|
55
60
|
primary: palette.gray900,
|
|
56
61
|
primaryForeground: palette.gray50,
|
|
57
62
|
secondary: palette.gray100,
|
|
58
63
|
secondaryForeground: palette.gray900,
|
|
59
64
|
muted: palette.gray100,
|
|
60
|
-
mutedForeground: palette.
|
|
65
|
+
mutedForeground: palette.gray600,
|
|
61
66
|
accent: palette.teal500,
|
|
62
67
|
accentForeground: palette.white,
|
|
63
68
|
destructive: palette.red500,
|
|
@@ -65,6 +70,7 @@ const lightTheme = {
|
|
|
65
70
|
success: palette.green500,
|
|
66
71
|
warning: palette.amber500,
|
|
67
72
|
border: palette.gray200,
|
|
73
|
+
borderStrong: palette.gray300,
|
|
68
74
|
input: palette.gray200,
|
|
69
75
|
ring: palette.gray400,
|
|
70
76
|
overlay: "rgba(0, 0, 0, 0.5)",
|
|
@@ -82,6 +88,7 @@ const darkTheme = {
|
|
|
82
88
|
dark: true,
|
|
83
89
|
fonts: navigationFonts,
|
|
84
90
|
colors: {
|
|
91
|
+
surfaceSunken: palette.dark950,
|
|
85
92
|
background: palette.dark900,
|
|
86
93
|
foreground: palette.dark100,
|
|
87
94
|
card: palette.dark800,
|
|
@@ -89,13 +96,13 @@ const darkTheme = {
|
|
|
89
96
|
popover: palette.dark800,
|
|
90
97
|
popoverForeground: palette.dark100,
|
|
91
98
|
text: palette.dark100,
|
|
92
|
-
textDim: palette.
|
|
99
|
+
textDim: palette.dark350,
|
|
93
100
|
primary: palette.gray50,
|
|
94
101
|
primaryForeground: palette.gray900,
|
|
95
102
|
secondary: palette.gray800,
|
|
96
103
|
secondaryForeground: palette.gray50,
|
|
97
104
|
muted: palette.dark700,
|
|
98
|
-
mutedForeground: palette.
|
|
105
|
+
mutedForeground: palette.dark350,
|
|
99
106
|
accent: palette.teal400,
|
|
100
107
|
accentForeground: palette.gray900,
|
|
101
108
|
destructive: palette.red400,
|
|
@@ -103,6 +110,7 @@ const darkTheme = {
|
|
|
103
110
|
success: palette.green400,
|
|
104
111
|
warning: palette.amber400,
|
|
105
112
|
border: palette.dark700,
|
|
113
|
+
borderStrong: palette.dark600,
|
|
106
114
|
input: palette.dark700,
|
|
107
115
|
ring: palette.dark400,
|
|
108
116
|
overlay: "rgba(0, 0, 0, 0.7)",
|
|
@@ -1,3 +1,17 @@
|
|
|
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;
|
|
1
15
|
interface LoadResourcesResult {
|
|
2
16
|
loaded: boolean;
|
|
3
17
|
error: Error | null;
|
|
@@ -4,15 +4,45 @@ 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
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
|
|
7
|
+
// Eager, module-scope load that primes the Feather font on the *client* before
|
|
8
|
+
// first render: on web it synchronously injects the @font-face rule, which is
|
|
9
|
+
// what makes the client's `Font.isLoaded("feather")` check read true during
|
|
10
|
+
// hydration; on native it just starts the fetch ahead of the effect below
|
|
11
|
+
// (which then no-ops for the already-loaded font).
|
|
12
|
+
//
|
|
13
|
+
// Deliberately guarded off server-web. expo-font's SSR store is per-request
|
|
14
|
+
// AsyncLocalStorage, and Metro's inlineRequires defers this module's evaluation
|
|
15
|
+
// until it is first required during a render — so on the server this line only
|
|
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.
|
|
20
|
+
if (typeof window !== "undefined") {
|
|
21
|
+
void Font.loadAsync(Feather.font);
|
|
22
|
+
}
|
|
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
|
+
}
|
|
16
46
|
// The four static Inter weights StyledText's native family keys point at
|
|
17
47
|
// (see constants/fonts.ts). Native-only: web never renders these family names
|
|
18
48
|
// ("Inter_400Regular" etc.) — fontFamilies.sansSerif resolves every weight to
|
|
@@ -66,6 +96,10 @@ function ensureWebFontStylesheet() {
|
|
|
66
96
|
* un-download Inter. The Feather icon font always loads.
|
|
67
97
|
*/
|
|
68
98
|
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();
|
|
69
103
|
const [loaded, setLoaded] = useState(false);
|
|
70
104
|
const [error, setError] = useState(null);
|
|
71
105
|
useEffect(() => {
|
package/dist/hooks/useTheme.js
CHANGED
|
@@ -3,6 +3,7 @@ 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";
|
|
6
7
|
import { spacing as spacingConstants } from "../constants/spacing.js";
|
|
7
8
|
// Module-level cache for contrast calculations to avoid memory leak
|
|
8
9
|
// and share across components
|
|
@@ -45,11 +46,24 @@ function getCachedOrCompute(key, compute) {
|
|
|
45
46
|
* - getShadowStyle('base') → { boxShadow: "0px 2px 6px rgba(0, 0, 0, 0.05), 0px 4px 12px rgba(0, 0, 0, 0.03)" }
|
|
46
47
|
*/
|
|
47
48
|
export function useTheme() {
|
|
48
|
-
const
|
|
49
|
-
const
|
|
49
|
+
const storeUserTheme = useThemeStore((s) => s.userTheme);
|
|
50
|
+
const storeSystemTheme = useThemeStore((s) => s.systemTheme);
|
|
51
|
+
const hasLoadedTheme = useThemeStore((s) => s.hasLoadedTheme);
|
|
50
52
|
const setTheme = useThemeStore((s) => s.setTheme);
|
|
51
53
|
const colorOverrides = useThemeStore((s) => s.colorOverrides);
|
|
52
54
|
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;
|
|
53
67
|
// Determine which theme to use (user preference or system)
|
|
54
68
|
const effectiveScheme = resolveThemePreference(userTheme, systemTheme);
|
|
55
69
|
const base = colors[effectiveScheme];
|
package/dist/state/index.d.ts
CHANGED
package/dist/state/index.js
CHANGED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { ResolvedTheme, ThemePreference } from "./themeStore";
|
|
2
|
+
/**
|
|
3
|
+
* The theme values the FIRST render must use on web — server and client alike.
|
|
4
|
+
*
|
|
5
|
+
* `useThemeStore` is a module singleton shared by every concurrent SSR request,
|
|
6
|
+
* so a per-request theme can never be written into it. This context is the
|
|
7
|
+
* per-request channel instead: the host app resolves it from the request (a
|
|
8
|
+
* cookie the client mirrors) and provides it above the tree, and `useTheme`
|
|
9
|
+
* reads it until the store has loaded real persistence after mount.
|
|
10
|
+
*/
|
|
11
|
+
export type SsrThemeSeed = {
|
|
12
|
+
userTheme: ThemePreference;
|
|
13
|
+
systemTheme: ResolvedTheme;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* What a first-time visitor gets: no persisted preference, light scheme. Same
|
|
17
|
+
* values the store boots with, so a host app that never provides the context
|
|
18
|
+
* behaves exactly as it did before the seed existed.
|
|
19
|
+
*/
|
|
20
|
+
export declare const SSR_THEME_SEED_DEFAULT: SsrThemeSeed;
|
|
21
|
+
/**
|
|
22
|
+
* Per-request theme seed consumed by `useTheme`/`useStyles` while
|
|
23
|
+
* `hasLoadedTheme` is false (i.e. on the server render and the client's first
|
|
24
|
+
* render, before `syncThemeFromEnvironment()` reads real persistence).
|
|
25
|
+
*
|
|
26
|
+
* Provide it near the top of the tree from a value BOTH the server and the
|
|
27
|
+
* browser can derive from the same bytes — a cookie. Reading `localStorage`
|
|
28
|
+
* for this would reintroduce the hydration mismatch it exists to remove,
|
|
29
|
+
* because the server cannot see `localStorage`.
|
|
30
|
+
*
|
|
31
|
+
* ```tsx
|
|
32
|
+
* import { SsrThemeSeedContext } from "@mrmeg/expo-ui/state";
|
|
33
|
+
*
|
|
34
|
+
* <SsrThemeSeedContext.Provider value={seedFromCookie}>
|
|
35
|
+
* <App />
|
|
36
|
+
* </SsrThemeSeedContext.Provider>
|
|
37
|
+
* ```
|
|
38
|
+
*/
|
|
39
|
+
export declare const SsrThemeSeedContext: import("react").Context<SsrThemeSeed>;
|
|
40
|
+
/** Read the active SSR theme seed (the default when no provider is mounted). */
|
|
41
|
+
export declare function useSsrThemeSeed(): SsrThemeSeed;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { createContext, use } from "react";
|
|
2
|
+
/**
|
|
3
|
+
* What a first-time visitor gets: no persisted preference, light scheme. Same
|
|
4
|
+
* values the store boots with, so a host app that never provides the context
|
|
5
|
+
* behaves exactly as it did before the seed existed.
|
|
6
|
+
*/
|
|
7
|
+
export const SSR_THEME_SEED_DEFAULT = {
|
|
8
|
+
userTheme: "system",
|
|
9
|
+
systemTheme: "light",
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Per-request theme seed consumed by `useTheme`/`useStyles` while
|
|
13
|
+
* `hasLoadedTheme` is false (i.e. on the server render and the client's first
|
|
14
|
+
* render, before `syncThemeFromEnvironment()` reads real persistence).
|
|
15
|
+
*
|
|
16
|
+
* Provide it near the top of the tree from a value BOTH the server and the
|
|
17
|
+
* browser can derive from the same bytes — a cookie. Reading `localStorage`
|
|
18
|
+
* for this would reintroduce the hydration mismatch it exists to remove,
|
|
19
|
+
* because the server cannot see `localStorage`.
|
|
20
|
+
*
|
|
21
|
+
* ```tsx
|
|
22
|
+
* import { SsrThemeSeedContext } from "@mrmeg/expo-ui/state";
|
|
23
|
+
*
|
|
24
|
+
* <SsrThemeSeedContext.Provider value={seedFromCookie}>
|
|
25
|
+
* <App />
|
|
26
|
+
* </SsrThemeSeedContext.Provider>
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
export const SsrThemeSeedContext = createContext(SSR_THEME_SEED_DEFAULT);
|
|
30
|
+
/** Read the active SSR theme seed (the default when no provider is mounted). */
|
|
31
|
+
export function useSsrThemeSeed() {
|
|
32
|
+
return use(SsrThemeSeedContext);
|
|
33
|
+
}
|
|
@@ -2,6 +2,16 @@ import type { ThemeColors } from "../constants/colors";
|
|
|
2
2
|
import type { FontOverrides } from "../constants/fonts";
|
|
3
3
|
export type ThemePreference = "system" | "light" | "dark";
|
|
4
4
|
export type ResolvedTheme = "light" | "dark";
|
|
5
|
+
/**
|
|
6
|
+
* Cookie name the web build mirrors the persisted preference into, so a
|
|
7
|
+
* server-rendered host can seed its first render with the visitor's real
|
|
8
|
+
* theme instead of shipping a light tree that recolors after hydration.
|
|
9
|
+
*
|
|
10
|
+
* Same name as the `localStorage` key on purpose: one string to grep, and the
|
|
11
|
+
* blocking color-scheme script in a host's `+html.tsx` already reads it.
|
|
12
|
+
* `localStorage` stays the source of truth; the cookie is a render hint only.
|
|
13
|
+
*/
|
|
14
|
+
export declare const THEME_COOKIE_NAME = "user-theme-preference";
|
|
5
15
|
/**
|
|
6
16
|
* Per-scheme color overrides a host app can inject to brand the package.
|
|
7
17
|
*
|
|
@@ -44,6 +54,15 @@ export type ShapeOverrides = {
|
|
|
44
54
|
export type ThemeStore = {
|
|
45
55
|
userTheme: ThemePreference;
|
|
46
56
|
systemTheme: ResolvedTheme;
|
|
57
|
+
/**
|
|
58
|
+
* True once the persisted preference has actually been read (or explicitly
|
|
59
|
+
* set by the user). Until then, a web render must not trust `userTheme` /
|
|
60
|
+
* `systemTheme` — they still hold the SSR-safe boot defaults, and `useTheme`
|
|
61
|
+
* uses `SsrThemeSeedContext` (cookie-derived, identical on server and
|
|
62
|
+
* client) instead. Mirrors `hasLoadedOnboarding` in the host app's
|
|
63
|
+
* onboarding store.
|
|
64
|
+
*/
|
|
65
|
+
hasLoadedTheme: boolean;
|
|
47
66
|
/**
|
|
48
67
|
* App-injected palette overrides, applied by `useTheme` on top of the
|
|
49
68
|
* package defaults. Empty by default — zero override means the package
|