@mrmeg/expo-ui 0.27.1 → 0.29.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 +666 -0
- package/LLM_USAGE.md +241 -37
- package/README.md +591 -136
- package/bin/expo-ui-icons.mjs +509 -0
- package/dist/components/Accordion.js +12 -6
- package/dist/components/AnimatedView.d.ts +1 -1
- package/dist/components/AnimatedView.js +5 -2
- package/dist/components/Avatar.d.ts +1 -1
- package/dist/components/Avatar.js +2 -2
- package/dist/components/Badge.js +5 -1
- package/dist/components/BottomSheet.d.ts +29 -1
- package/dist/components/BottomSheet.js +107 -30
- package/dist/components/Button.d.ts +20 -4
- package/dist/components/Button.js +60 -43
- package/dist/components/Card.d.ts +1 -1
- package/dist/components/Card.js +24 -4
- package/dist/components/Carousel.js +2 -2
- package/dist/components/Checkbox.js +51 -44
- package/dist/components/Collapsible.js +7 -3
- package/dist/components/Dialog.js +87 -16
- package/dist/components/Drawer.d.ts +1 -1
- package/dist/components/Drawer.js +65 -55
- package/dist/components/DropdownMenu.js +11 -7
- package/dist/components/EmptyState.d.ts +2 -2
- package/dist/components/EmptyState.js +5 -2
- package/dist/components/Hydrated.d.ts +31 -0
- package/dist/components/Hydrated.js +30 -0
- package/dist/components/Icon.d.ts +71 -28
- package/dist/components/Icon.js +70 -28
- package/dist/components/IconGlyph.d.ts +15 -0
- package/dist/components/IconGlyph.js +32 -0
- package/dist/components/IconGlyph.native.d.ts +10 -0
- package/dist/components/IconGlyph.native.js +30 -0
- package/dist/components/InputOTP.js +5 -2
- package/dist/components/Item.d.ts +103 -4
- package/dist/components/Item.js +168 -15
- package/dist/components/KeyboardAvoidingView.d.ts +1 -1
- package/dist/components/KeyboardAvoidingView.js +4 -1
- package/dist/components/Label.js +3 -2
- package/dist/components/MaxWidthContainer.js +1 -1
- package/dist/components/Notification.js +45 -27
- package/dist/components/Popover.d.ts +18 -5
- package/dist/components/Popover.js +72 -8
- package/dist/components/Progress.js +8 -6
- package/dist/components/RadioGroup.js +52 -49
- package/dist/components/Screen.d.ts +61 -0
- package/dist/components/Screen.js +62 -0
- package/dist/components/SectionHeader.d.ts +1 -1
- package/dist/components/SegmentedControl.d.ts +12 -4
- package/dist/components/SegmentedControl.js +94 -4
- package/dist/components/Select.js +21 -26
- package/dist/components/Skeleton.js +24 -10
- package/dist/components/Slider.d.ts +11 -3
- package/dist/components/Slider.js +73 -6
- package/dist/components/StatCard.d.ts +4 -2
- package/dist/components/StatCard.js +3 -1
- package/dist/components/StyledText.d.ts +7 -1
- package/dist/components/StyledText.js +5 -3
- package/dist/components/Switch.d.ts +1 -1
- package/dist/components/Switch.js +20 -30
- package/dist/components/Tabs.d.ts +2 -2
- package/dist/components/Tabs.js +13 -24
- package/dist/components/TextInput.d.ts +1 -1
- package/dist/components/TextInput.js +71 -26
- package/dist/components/ThemeSelector.d.ts +52 -0
- package/dist/components/ThemeSelector.js +90 -0
- package/dist/components/Toggle.d.ts +5 -49
- package/dist/components/Toggle.js +53 -29
- package/dist/components/ToggleGroup.d.ts +4 -8
- package/dist/components/ToggleGroup.js +52 -34
- package/dist/components/Tooltip.d.ts +1 -1
- package/dist/components/Tooltip.js +9 -3
- package/dist/components/UIProvider.d.ts +22 -2
- package/dist/components/UIProvider.js +15 -3
- package/dist/components/bottomSheetDismiss.d.ts +19 -0
- package/dist/components/bottomSheetDismiss.js +44 -0
- package/dist/components/iconBuiltins.generated.d.ts +143 -0
- package/dist/components/iconBuiltins.generated.js +25 -0
- package/dist/components/iconTypes.d.ts +37 -0
- package/dist/components/iconTypes.js +1 -0
- package/dist/components/index.d.ts +47 -44
- package/dist/components/index.js +3 -0
- package/dist/components/keyboardDismiss.js +12 -4
- package/dist/components/nativeTextField.android.js +13 -9
- package/dist/constants/colors.d.ts +40 -5
- package/dist/constants/colors.js +26 -8
- package/dist/constants/fonts.d.ts +41 -2
- package/dist/constants/fonts.js +46 -7
- package/dist/constants/index.d.ts +5 -4
- package/dist/constants/index.js +1 -0
- package/dist/constants/interaction.d.ts +18 -0
- package/dist/constants/interaction.js +18 -0
- package/dist/constants/spacing.d.ts +2 -1
- package/dist/constants/spacing.js +2 -1
- package/dist/design-system.json +429 -132
- package/dist/hooks/index.d.ts +12 -7
- package/dist/hooks/index.js +5 -0
- package/dist/hooks/useDimensions.d.ts +13 -8
- package/dist/hooks/useDimensions.js +123 -48
- package/dist/hooks/useFocusVisible.d.ts +30 -0
- package/dist/hooks/useFocusVisible.js +45 -0
- package/dist/hooks/useFontStyle.d.ts +2 -2
- package/dist/hooks/useFontStyle.js +3 -2
- package/dist/hooks/useHydrated.d.ts +13 -0
- package/dist/hooks/useHydrated.js +19 -0
- package/dist/hooks/useNotificationOffset.d.ts +20 -0
- package/dist/hooks/useNotificationOffset.js +34 -0
- package/dist/hooks/useResources.d.ts +21 -20
- package/dist/hooks/useResources.js +72 -68
- package/dist/hooks/useScalePress.d.ts +8 -5
- package/dist/hooks/useScalePress.js +16 -9
- package/dist/hooks/useShape.d.ts +24 -0
- package/dist/hooks/useShape.js +24 -0
- package/dist/hooks/useStaggeredEntrance.js +9 -7
- package/dist/hooks/useTheme.d.ts +3 -3
- package/dist/hooks/useTheme.js +177 -66
- package/dist/hooks/useWindowInsets.d.ts +13 -0
- package/dist/hooks/useWindowInsets.js +23 -0
- package/dist/icons/LICENSE.lucide +43 -0
- package/dist/icons/lucide.json +2144 -0
- package/dist/index.d.ts +5 -5
- package/dist/lib/composeRefs.d.ts +8 -0
- package/dist/lib/composeRefs.js +20 -0
- package/dist/lib/haptics.d.ts +12 -0
- package/dist/lib/haptics.js +21 -0
- package/dist/lib/index.d.ts +6 -5
- package/dist/lib/index.js +1 -0
- package/dist/lib/interFonts.d.ts +15 -0
- package/dist/lib/interFonts.js +1 -0
- package/dist/lib/interFonts.native.d.ts +3 -0
- package/dist/lib/interFonts.native.js +20 -0
- package/dist/lib/popoverPlacement.d.ts +36 -0
- package/dist/lib/popoverPlacement.js +29 -0
- package/dist/lib/themedStyles.d.ts +1 -1
- package/dist/lib/useAnimatedValue.d.ts +15 -0
- package/dist/lib/useAnimatedValue.js +19 -0
- package/dist/lib/webHitTarget.d.ts +40 -0
- package/dist/lib/webHitTarget.js +37 -0
- package/dist/state/feedbackStore.d.ts +24 -0
- package/dist/state/feedbackStore.js +16 -0
- package/dist/state/globalUIStore.d.ts +27 -0
- package/dist/state/globalUIStore.js +23 -0
- package/dist/state/index.d.ts +7 -5
- package/dist/state/index.js +2 -0
- package/dist/state/initialScheme.d.ts +27 -0
- package/dist/state/initialScheme.js +29 -0
- package/dist/state/notify.d.ts +1 -1
- package/dist/state/themeColorScope.d.ts +1 -1
- package/dist/state/themeStore.d.ts +69 -16
- package/dist/state/themeStore.js +65 -38
- package/llms-full.md +59 -10
- package/llms.txt +10 -1
- package/package.json +79 -4
- package/dist/components/iconRegistry.generated.d.ts +0 -148
- package/dist/components/iconRegistry.generated.js +0 -296
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* 1. A desktop visitor sees no reflow — their real viewport lands in the
|
|
5
5
|
* same breakpoint.
|
|
6
6
|
* 2. A mobile visitor gets one frame of desktop-styled content before the
|
|
7
|
-
* post-
|
|
7
|
+
* post-hydration update snaps to real dimensions — better than the inverse
|
|
8
8
|
* (every desktop visitor seeing mobile-tiny, then snapping).
|
|
9
9
|
* 3. It matches the frame the app hands `SafeAreaProvider`, and it is the
|
|
10
10
|
* value the HTML shell `expo export` renders in Node is built from, so
|
|
@@ -29,13 +29,18 @@ type WindowDimensions = {
|
|
|
29
29
|
* Provides a consistent way to access window dimensions and screen size
|
|
30
30
|
* information across mobile and web.
|
|
31
31
|
*
|
|
32
|
-
* On web the
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
* provides a per-request value (server render + first client render
|
|
36
|
-
* provide the same one), falling back to `DEFAULT_VIEWPORT_WIDTH` /
|
|
37
|
-
*
|
|
38
|
-
* there.
|
|
32
|
+
* On web the server render and the browser's hydration pass can't read the
|
|
33
|
+
* window: server-side there is no DOM, and reading it while hydrating would
|
|
34
|
+
* disagree with the markup. Both use the width from `SsrViewportContext` when
|
|
35
|
+
* the host provides a per-request value (server render + first client render
|
|
36
|
+
* must provide the same one), falling back to `DEFAULT_VIEWPORT_WIDTH` /
|
|
37
|
+
* `_HEIGHT`. Right after hydration the real viewport takes over, and resize
|
|
38
|
+
* is followed from there. Every consumer shares one window listener and one
|
|
39
|
+
* snapshot, re-renders only when the width or height changes, and a component
|
|
40
|
+
* that mounts after hydration reads the current viewport straight away.
|
|
41
|
+
*
|
|
42
|
+
* The platform branch is chosen once, at module load (`Platform.OS` never
|
|
43
|
+
* changes at runtime), so each platform calls exactly one set of hooks.
|
|
39
44
|
*/
|
|
40
45
|
export declare const useDimensions: () => WindowDimensions;
|
|
41
46
|
export {};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { use,
|
|
1
|
+
import { use, useMemo, useSyncExternalStore } from "react";
|
|
2
2
|
import { Platform, useWindowDimensions } from "react-native";
|
|
3
3
|
import { SsrViewportContext } from "../state/SsrViewportContext.js";
|
|
4
4
|
/**
|
|
@@ -7,7 +7,7 @@ import { SsrViewportContext } from "../state/SsrViewportContext.js";
|
|
|
7
7
|
* 1. A desktop visitor sees no reflow — their real viewport lands in the
|
|
8
8
|
* same breakpoint.
|
|
9
9
|
* 2. A mobile visitor gets one frame of desktop-styled content before the
|
|
10
|
-
* post-
|
|
10
|
+
* post-hydration update snaps to real dimensions — better than the inverse
|
|
11
11
|
* (every desktop visitor seeing mobile-tiny, then snapping).
|
|
12
12
|
* 3. It matches the frame the app hands `SafeAreaProvider`, and it is the
|
|
13
13
|
* value the HTML shell `expo export` renders in Node is built from, so
|
|
@@ -34,61 +34,136 @@ const calculateDimensionFlags = (width, height) => {
|
|
|
34
34
|
isLargeScreen: width > SCREEN_SIZES.MEDIUM,
|
|
35
35
|
};
|
|
36
36
|
};
|
|
37
|
-
// Persist the real viewport width as a cookie
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
// server/lib/ssrViewport.ts.
|
|
37
|
+
// Persist the real viewport width as a cookie so subsequent SSR requests can
|
|
38
|
+
// render at the user's actual layout (no reflow on repeat visits). The server
|
|
39
|
+
// reads this cookie via resolveSsrViewportWidth in server/lib/ssrViewport.ts.
|
|
41
40
|
const SSR_VIEWPORT_COOKIE = "mrmeg-vw";
|
|
42
41
|
const SSR_VIEWPORT_COOKIE_MAX_AGE = 60 * 60 * 24 * 365; // 1 year
|
|
42
|
+
// Resize fires continuously while a window is dragged; the cookie only has to
|
|
43
|
+
// hold the width the drag settled on.
|
|
44
|
+
const SSR_VIEWPORT_COOKIE_DEBOUNCE_MS = 250;
|
|
45
|
+
// Round to nearest 10 so resize-driven writes don't bust HTTP caching on
|
|
46
|
+
// every pixel of horizontal movement.
|
|
47
|
+
const roundViewportWidth = (width) => Math.round(width / 10) * 10;
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// Web: one window store shared by every useDimensions() consumer (same shape
|
|
50
|
+
// as useReduceMotion's). A single `resize` listener, attached while at least
|
|
51
|
+
// one consumer is mounted, updates one snapshot and notifies consumers only
|
|
52
|
+
// when the width or height actually changed.
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
let windowSnapshot = null;
|
|
55
|
+
const windowListeners = new Set();
|
|
56
|
+
let writtenCookieWidth = null;
|
|
57
|
+
let pendingCookieWidth = null;
|
|
58
|
+
let cookieTimer = null;
|
|
43
59
|
function writeViewportCookie(width) {
|
|
44
60
|
if (typeof document === "undefined")
|
|
45
61
|
return;
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const rounded = Math.round(width / 10) * 10;
|
|
62
|
+
const rounded = roundViewportWidth(width);
|
|
63
|
+
writtenCookieWidth = rounded;
|
|
49
64
|
document.cookie = `${SSR_VIEWPORT_COOKIE}=${rounded}; path=/; max-age=${SSR_VIEWPORT_COOKIE_MAX_AGE}; SameSite=Lax`;
|
|
50
65
|
}
|
|
66
|
+
/** Reads the window, reusing the current snapshot when nothing changed. */
|
|
67
|
+
function readWindow() {
|
|
68
|
+
const width = typeof window === "undefined" ? DEFAULT_VIEWPORT_WIDTH : window.innerWidth;
|
|
69
|
+
const height = typeof window === "undefined" ? DEFAULT_VIEWPORT_HEIGHT : window.innerHeight;
|
|
70
|
+
if (!windowSnapshot || windowSnapshot.width !== width || windowSnapshot.height !== height) {
|
|
71
|
+
windowSnapshot = calculateDimensionFlags(width, height);
|
|
72
|
+
}
|
|
73
|
+
return windowSnapshot;
|
|
74
|
+
}
|
|
75
|
+
function flushViewportCookie() {
|
|
76
|
+
if (cookieTimer !== null) {
|
|
77
|
+
clearTimeout(cookieTimer);
|
|
78
|
+
cookieTimer = null;
|
|
79
|
+
}
|
|
80
|
+
if (pendingCookieWidth !== null) {
|
|
81
|
+
const width = pendingCookieWidth;
|
|
82
|
+
pendingCookieWidth = null;
|
|
83
|
+
writeViewportCookie(width);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function scheduleViewportCookie(width) {
|
|
87
|
+
if (roundViewportWidth(width) === writtenCookieWidth) {
|
|
88
|
+
pendingCookieWidth = null;
|
|
89
|
+
if (cookieTimer !== null) {
|
|
90
|
+
clearTimeout(cookieTimer);
|
|
91
|
+
cookieTimer = null;
|
|
92
|
+
}
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
pendingCookieWidth = width;
|
|
96
|
+
if (cookieTimer !== null)
|
|
97
|
+
clearTimeout(cookieTimer);
|
|
98
|
+
cookieTimer = setTimeout(flushViewportCookie, SSR_VIEWPORT_COOKIE_DEBOUNCE_MS);
|
|
99
|
+
}
|
|
100
|
+
function handleWindowResize() {
|
|
101
|
+
const previous = windowSnapshot;
|
|
102
|
+
const next = readWindow();
|
|
103
|
+
if (next !== previous) {
|
|
104
|
+
for (const listener of windowListeners)
|
|
105
|
+
listener();
|
|
106
|
+
}
|
|
107
|
+
scheduleViewportCookie(next.width);
|
|
108
|
+
}
|
|
109
|
+
// useSyncExternalStore contract: the first consumer attaches the one window
|
|
110
|
+
// listener (and records the real width in the cookie, once per page view
|
|
111
|
+
// rather than once per consumer); the last one to unmount detaches it.
|
|
112
|
+
function subscribeToWindow(listener) {
|
|
113
|
+
windowListeners.add(listener);
|
|
114
|
+
if (windowListeners.size === 1 && canListenToWindow()) {
|
|
115
|
+
window.addEventListener("resize", handleWindowResize);
|
|
116
|
+
writeViewportCookie(readWindow().width);
|
|
117
|
+
}
|
|
118
|
+
return () => {
|
|
119
|
+
windowListeners.delete(listener);
|
|
120
|
+
if (windowListeners.size === 0 && canListenToWindow()) {
|
|
121
|
+
window.removeEventListener("resize", handleWindowResize);
|
|
122
|
+
flushViewportCookie();
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
function canListenToWindow() {
|
|
127
|
+
return typeof window !== "undefined" && typeof window.addEventListener === "function";
|
|
128
|
+
}
|
|
129
|
+
function getWindowSnapshot() {
|
|
130
|
+
// While subscribed, the resize listener keeps the snapshot current, so a
|
|
131
|
+
// render never reads (and forces layout on) the window.
|
|
132
|
+
return windowListeners.size > 0 && windowSnapshot ? windowSnapshot : readWindow();
|
|
133
|
+
}
|
|
134
|
+
// The server has no window, and the browser's hydration pass must render what
|
|
135
|
+
// the server did: `null` selects the seeded frame below for both.
|
|
136
|
+
function getServerWindowSnapshot() {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
function useWebDimensions() {
|
|
140
|
+
const ssrWidth = use(SsrViewportContext);
|
|
141
|
+
const live = useSyncExternalStore(subscribeToWindow, getWindowSnapshot, getServerWindowSnapshot);
|
|
142
|
+
const seeded = useMemo(() => calculateDimensionFlags(ssrWidth ?? DEFAULT_VIEWPORT_WIDTH, DEFAULT_VIEWPORT_HEIGHT), [ssrWidth]);
|
|
143
|
+
return live ?? seeded;
|
|
144
|
+
}
|
|
145
|
+
// Native reads come from useWindowDimensions, which subscribes to rotation /
|
|
146
|
+
// split-screen / resize and tears the listener down for us — no manual
|
|
147
|
+
// Dimensions.addEventListener to leak.
|
|
148
|
+
function useNativeDimensions() {
|
|
149
|
+
const { width, height } = useWindowDimensions();
|
|
150
|
+
return useMemo(() => calculateDimensionFlags(width, height), [width, height]);
|
|
151
|
+
}
|
|
51
152
|
/**
|
|
52
153
|
* Provides a consistent way to access window dimensions and screen size
|
|
53
154
|
* information across mobile and web.
|
|
54
155
|
*
|
|
55
|
-
* On web the
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
* provides a per-request value (server render + first client render
|
|
59
|
-
* provide the same one), falling back to `DEFAULT_VIEWPORT_WIDTH` /
|
|
60
|
-
*
|
|
61
|
-
* there.
|
|
156
|
+
* On web the server render and the browser's hydration pass can't read the
|
|
157
|
+
* window: server-side there is no DOM, and reading it while hydrating would
|
|
158
|
+
* disagree with the markup. Both use the width from `SsrViewportContext` when
|
|
159
|
+
* the host provides a per-request value (server render + first client render
|
|
160
|
+
* must provide the same one), falling back to `DEFAULT_VIEWPORT_WIDTH` /
|
|
161
|
+
* `_HEIGHT`. Right after hydration the real viewport takes over, and resize
|
|
162
|
+
* is followed from there. Every consumer shares one window listener and one
|
|
163
|
+
* snapshot, re-renders only when the width or height changes, and a component
|
|
164
|
+
* that mounts after hydration reads the current viewport straight away.
|
|
165
|
+
*
|
|
166
|
+
* The platform branch is chosen once, at module load (`Platform.OS` never
|
|
167
|
+
* changes at runtime), so each platform calls exactly one set of hooks.
|
|
62
168
|
*/
|
|
63
|
-
export const useDimensions =
|
|
64
|
-
const isWeb = Platform.OS === "web";
|
|
65
|
-
const ssrWidth = use(SsrViewportContext);
|
|
66
|
-
// Native reads come from useWindowDimensions, which subscribes to rotation /
|
|
67
|
-
// split-screen / resize and tears the listener down for us — no manual
|
|
68
|
-
// Dimensions.addEventListener to leak. On web we ignore it and drive layout
|
|
69
|
-
// from the seeded frame + the resize listener below.
|
|
70
|
-
const native = useWindowDimensions();
|
|
71
|
-
const [dimensions, setDimensions] = useState(() => calculateDimensionFlags(ssrWidth ?? DEFAULT_VIEWPORT_WIDTH, DEFAULT_VIEWPORT_HEIGHT));
|
|
72
|
-
// Web: read the real viewport after mount and follow resize events. Keeping
|
|
73
|
-
// this in an effect (not render) keeps the first render identical to the
|
|
74
|
-
// exported markup it hydrates.
|
|
75
|
-
useEffect(() => {
|
|
76
|
-
if (!isWeb)
|
|
77
|
-
return;
|
|
78
|
-
const syncFromWindow = () => {
|
|
79
|
-
setDimensions(calculateDimensionFlags(window.innerWidth, window.innerHeight));
|
|
80
|
-
writeViewportCookie(window.innerWidth);
|
|
81
|
-
};
|
|
82
|
-
syncFromWindow();
|
|
83
|
-
window.addEventListener("resize", syncFromWindow);
|
|
84
|
-
return () => {
|
|
85
|
-
window.removeEventListener("resize", syncFromWindow);
|
|
86
|
-
};
|
|
87
|
-
}, [isWeb]);
|
|
88
|
-
// Native: useWindowDimensions already reacts to changes; mirror it into our
|
|
89
|
-
// enriched flags. (On web `native` is unused — the effect above wins.)
|
|
90
|
-
if (!isWeb) {
|
|
91
|
-
return calculateDimensionFlags(native.width, native.height);
|
|
92
|
-
}
|
|
93
|
-
return dimensions;
|
|
94
|
-
};
|
|
169
|
+
export const useDimensions = Platform.OS === "web" ? useWebDimensions : useNativeDimensions;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { type NativeSyntheticEvent, type TargetedEvent } from "react-native";
|
|
2
|
+
export type FocusVisibleEvent = NativeSyntheticEvent<TargetedEvent>;
|
|
3
|
+
export interface UseFocusVisibleOptions<E = FocusVisibleEvent> {
|
|
4
|
+
/** Caller's focus handler, invoked after the ring state updates. */
|
|
5
|
+
onFocus?: ((event: E) => void) | null;
|
|
6
|
+
/** Caller's blur handler, invoked after the ring state updates. */
|
|
7
|
+
onBlur?: ((event: E) => void) | null;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Focus-ring state gated on `:focus-visible`.
|
|
11
|
+
*
|
|
12
|
+
* On web a pointer tap focuses the element too, which left the ring visible
|
|
13
|
+
* after every click; `:focus-visible` is only true for keyboard-driven focus,
|
|
14
|
+
* so the ring follows it and falls back to showing when the target can't be
|
|
15
|
+
* queried (non-DOM targets, older engines). Off web every focus shows the
|
|
16
|
+
* ring. Spread `onFocus`/`onBlur` onto the Pressable and layer
|
|
17
|
+
* `getFocusRingStyle()` while `focused` is true and the control is enabled.
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* ```tsx
|
|
21
|
+
* const focus = useFocusVisible({ onFocus, onBlur });
|
|
22
|
+
* <Pressable onFocus={focus.onFocus} onBlur={focus.onBlur}
|
|
23
|
+
* style={[styles.base, focus.focused && !disabled && getFocusRingStyle()]} />
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
export declare function useFocusVisible<E = FocusVisibleEvent>(options?: UseFocusVisibleOptions<E>): {
|
|
27
|
+
focused: boolean;
|
|
28
|
+
onFocus: (event: E) => void;
|
|
29
|
+
onBlur: (event: E) => void;
|
|
30
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { useCallback, useState } from "react";
|
|
2
|
+
import { Platform } from "react-native";
|
|
3
|
+
/**
|
|
4
|
+
* Focus-ring state gated on `:focus-visible`.
|
|
5
|
+
*
|
|
6
|
+
* On web a pointer tap focuses the element too, which left the ring visible
|
|
7
|
+
* after every click; `:focus-visible` is only true for keyboard-driven focus,
|
|
8
|
+
* so the ring follows it and falls back to showing when the target can't be
|
|
9
|
+
* queried (non-DOM targets, older engines). Off web every focus shows the
|
|
10
|
+
* ring. Spread `onFocus`/`onBlur` onto the Pressable and layer
|
|
11
|
+
* `getFocusRingStyle()` while `focused` is true and the control is enabled.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```tsx
|
|
15
|
+
* const focus = useFocusVisible({ onFocus, onBlur });
|
|
16
|
+
* <Pressable onFocus={focus.onFocus} onBlur={focus.onBlur}
|
|
17
|
+
* style={[styles.base, focus.focused && !disabled && getFocusRingStyle()]} />
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
export function useFocusVisible(options = {}) {
|
|
21
|
+
const { onFocus: onFocusProp, onBlur: onBlurProp } = options;
|
|
22
|
+
const [focused, setFocused] = useState(false);
|
|
23
|
+
const onFocus = useCallback((event) => {
|
|
24
|
+
setFocused(isKeyboardFocus(event));
|
|
25
|
+
onFocusProp?.(event);
|
|
26
|
+
}, [onFocusProp]);
|
|
27
|
+
const onBlur = useCallback((event) => {
|
|
28
|
+
setFocused(false);
|
|
29
|
+
onBlurProp?.(event);
|
|
30
|
+
}, [onBlurProp]);
|
|
31
|
+
return { focused, onFocus, onBlur };
|
|
32
|
+
}
|
|
33
|
+
function isKeyboardFocus(event) {
|
|
34
|
+
if (Platform.OS !== "web")
|
|
35
|
+
return true;
|
|
36
|
+
const target = event?.nativeEvent?.target;
|
|
37
|
+
if (!target || typeof target.matches !== "function")
|
|
38
|
+
return true;
|
|
39
|
+
try {
|
|
40
|
+
return target.matches(":focus-visible");
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type FontFamilyWeight, type FontVariant, type ResolvedFontStyle } from "../constants/fonts";
|
|
1
|
+
import { type FontFamilyWeight, type FontStyleOptions, type FontVariant, type ResolvedFontStyle } from "../constants/fonts.js";
|
|
2
2
|
/**
|
|
3
3
|
* Resolve a `{ fontFamily, fontWeight? }` style through the theme store's
|
|
4
4
|
* font overrides, re-rendering when a host app calls `setFonts`.
|
|
@@ -8,4 +8,4 @@ import { type FontFamilyWeight, type FontVariant, type ResolvedFontStyle } from
|
|
|
8
8
|
* is identical to the package's hardcoded families, so layering it after a
|
|
9
9
|
* static themed style changes nothing by default.
|
|
10
10
|
*/
|
|
11
|
-
export declare function useFontStyle(weight?: FontFamilyWeight, variant?: FontVariant): ResolvedFontStyle;
|
|
11
|
+
export declare function useFontStyle(weight?: FontFamilyWeight, variant?: FontVariant, options?: Pick<FontStyleOptions, "italic">): ResolvedFontStyle;
|
|
@@ -9,9 +9,10 @@ import { useThemeStore } from "../state/themeStore.js";
|
|
|
9
9
|
* is identical to the package's hardcoded families, so layering it after a
|
|
10
10
|
* static themed style changes nothing by default.
|
|
11
11
|
*/
|
|
12
|
-
export function useFontStyle(weight = "regular", variant = "sansSerif") {
|
|
12
|
+
export function useFontStyle(weight = "regular", variant = "sansSerif", options = {}) {
|
|
13
13
|
// Subscribed, not read via getState(), so a `setFonts` call after mount
|
|
14
14
|
// re-renders instead of leaving stale families on screen.
|
|
15
15
|
const fontOverrides = useThemeStore((s) => s.fontOverrides);
|
|
16
|
-
|
|
16
|
+
const serifPreset = useThemeStore((s) => s.serifPreset);
|
|
17
|
+
return resolveFontStyle(fontOverrides, variant, weight, { italic: options.italic, serifPreset });
|
|
17
18
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* False during server rendering and the hydration pass, true once the tree
|
|
3
|
+
* is live on the client (and always true on native, which never hydrates).
|
|
4
|
+
*
|
|
5
|
+
* Use it to defer DOM output that cannot be reproduced on the server. The one
|
|
6
|
+
* case in the kit: `useId()`-based `id` / `aria-labelledby` pairs. Under Expo
|
|
7
|
+
* Router's streamed SSR the client's hydration pass computes different tree
|
|
8
|
+
* ids than the server did, so an id emitted on both sides logs "A tree hydrated
|
|
9
|
+
* but some attributes … didn't match" and the link points at a stale id.
|
|
10
|
+
* Adding the attributes in the first client render after hydration is an
|
|
11
|
+
* ordinary update instead.
|
|
12
|
+
*/
|
|
13
|
+
export declare function useHydrated(): boolean;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { useSyncExternalStore } from "react";
|
|
2
|
+
const subscribe = () => () => { };
|
|
3
|
+
const clientSnapshot = () => true;
|
|
4
|
+
const serverSnapshot = () => false;
|
|
5
|
+
/**
|
|
6
|
+
* False during server rendering and the hydration pass, true once the tree
|
|
7
|
+
* is live on the client (and always true on native, which never hydrates).
|
|
8
|
+
*
|
|
9
|
+
* Use it to defer DOM output that cannot be reproduced on the server. The one
|
|
10
|
+
* case in the kit: `useId()`-based `id` / `aria-labelledby` pairs. Under Expo
|
|
11
|
+
* Router's streamed SSR the client's hydration pass computes different tree
|
|
12
|
+
* ids than the server did, so an id emitted on both sides logs "A tree hydrated
|
|
13
|
+
* but some attributes … didn't match" and the link points at a stale id.
|
|
14
|
+
* Adding the attributes in the first client render after hydration is an
|
|
15
|
+
* ordinary update instead.
|
|
16
|
+
*/
|
|
17
|
+
export function useHydrated() {
|
|
18
|
+
return useSyncExternalStore(subscribe, clientSnapshot, serverSnapshot);
|
|
19
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type NotificationOffset } from "../state/globalUIStore.js";
|
|
2
|
+
/**
|
|
3
|
+
* Keep global toasts clear of a bar this layout owns, for as long as it is
|
|
4
|
+
* mounted. The offset is measured from the window edge and includes the
|
|
5
|
+
* safe-area inset there, so a JS tab layout passes `useBottomTabBarHeight()`
|
|
6
|
+
* as-is and a native tab layout passes the bar height plus the bottom inset;
|
|
7
|
+
* `Notification` then sits 8 pt past the larger of the inset and the offset.
|
|
8
|
+
* Pass `null` (or `undefined`) to register nothing — for instance while the
|
|
9
|
+
* tab bar is hidden behind the keyboard. Several layouts may register at once;
|
|
10
|
+
* the largest value per edge wins. A no-op during server rendering.
|
|
11
|
+
*
|
|
12
|
+
* ```tsx
|
|
13
|
+
* // app/(tabs)/_layout.tsx with Expo Router's JS <Tabs>
|
|
14
|
+
* function TabBarOffset() {
|
|
15
|
+
* useNotificationOffset({ bottom: useBottomTabBarHeight() });
|
|
16
|
+
* return null;
|
|
17
|
+
* }
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
export declare function useNotificationOffset(offset: NotificationOffset | null | undefined): void;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { useEffect, useId } from "react";
|
|
2
|
+
import { globalUIStore } from "../state/globalUIStore.js";
|
|
3
|
+
/**
|
|
4
|
+
* Keep global toasts clear of a bar this layout owns, for as long as it is
|
|
5
|
+
* mounted. The offset is measured from the window edge and includes the
|
|
6
|
+
* safe-area inset there, so a JS tab layout passes `useBottomTabBarHeight()`
|
|
7
|
+
* as-is and a native tab layout passes the bar height plus the bottom inset;
|
|
8
|
+
* `Notification` then sits 8 pt past the larger of the inset and the offset.
|
|
9
|
+
* Pass `null` (or `undefined`) to register nothing — for instance while the
|
|
10
|
+
* tab bar is hidden behind the keyboard. Several layouts may register at once;
|
|
11
|
+
* the largest value per edge wins. A no-op during server rendering.
|
|
12
|
+
*
|
|
13
|
+
* ```tsx
|
|
14
|
+
* // app/(tabs)/_layout.tsx with Expo Router's JS <Tabs>
|
|
15
|
+
* function TabBarOffset() {
|
|
16
|
+
* useNotificationOffset({ bottom: useBottomTabBarHeight() });
|
|
17
|
+
* return null;
|
|
18
|
+
* }
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
export function useNotificationOffset(offset) {
|
|
22
|
+
const key = useId();
|
|
23
|
+
const top = offset?.top;
|
|
24
|
+
const bottom = offset?.bottom;
|
|
25
|
+
const active = offset != null && (top !== undefined || bottom !== undefined);
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
if (!active)
|
|
28
|
+
return;
|
|
29
|
+
globalUIStore.getState().setNotificationOffset(key, { top, bottom });
|
|
30
|
+
return () => {
|
|
31
|
+
globalUIStore.getState().clearNotificationOffset(key);
|
|
32
|
+
};
|
|
33
|
+
}, [active, key, top, bottom]);
|
|
34
|
+
}
|
|
@@ -1,25 +1,26 @@
|
|
|
1
|
+
import type { SerifPreset } from "../constants/fonts.js";
|
|
1
2
|
interface LoadResourcesResult {
|
|
2
3
|
loaded: boolean;
|
|
3
4
|
error: Error | null;
|
|
4
5
|
}
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
export declare const useResources: () => LoadResourcesResult;
|
|
6
|
+
export interface UseResourcesOptions {
|
|
7
|
+
/**
|
|
8
|
+
* Which serif to load and switch the `serif` variant to. `"georgia"`
|
|
9
|
+
* (default) loads nothing: the system face. `"newsreader"` injects the
|
|
10
|
+
* Google Fonts Newsreader stylesheet on web and, on native, registers the
|
|
11
|
+
* files passed as `serifFonts`, then sets the theme store's `serifPreset`
|
|
12
|
+
* so serif text resolves to Newsreader. An app `setFonts` serif override
|
|
13
|
+
* skips the preset entirely (the override wins anyway).
|
|
14
|
+
*/
|
|
15
|
+
serif?: SerifPreset;
|
|
16
|
+
/**
|
|
17
|
+
* Native font map for the serif preset, from the app's own dependency:
|
|
18
|
+
* `@expo-google-fonts/newsreader/400Regular` … `700Bold` and
|
|
19
|
+
* `400Regular_Italic` (the names `newsreaderFamilies.native` expects). The
|
|
20
|
+
* package does not depend on the font package, so an app that never opts in
|
|
21
|
+
* ships none of it; on web this is ignored (the stylesheet serves the faces).
|
|
22
|
+
*/
|
|
23
|
+
serifFonts?: Record<string, number | string> | null;
|
|
24
|
+
}
|
|
25
|
+
export declare const useResources: (options?: UseResourcesOptions) => LoadResourcesResult;
|
|
25
26
|
export {};
|
|
@@ -1,96 +1,100 @@
|
|
|
1
1
|
import { useEffect, useState } from "react";
|
|
2
2
|
import * as Font from "expo-font";
|
|
3
|
-
import { Inter_400Regular, Inter_500Medium, Inter_600SemiBold, Inter_700Bold, } from "@expo-google-fonts/inter";
|
|
4
3
|
import { Platform } from "react-native";
|
|
4
|
+
import { interFontMap } from "../lib/interFonts";
|
|
5
5
|
import { useThemeStore } from "../state/themeStore.js";
|
|
6
|
-
// The four static Inter weights StyledText's native family keys point at
|
|
7
|
-
// (see constants/fonts.ts). Native-only: web never renders these family names
|
|
8
|
-
// ("Inter_400Regular" etc.) — fontFamilies.sansSerif resolves every weight to
|
|
9
|
-
// the single "Inter" CSS family on web (loaded via ensureWebFontStylesheet
|
|
10
|
-
// below), so fetching these .ttf assets there would just be ~1.3MB of dead
|
|
11
|
-
// weight with nothing pointing at them.
|
|
12
|
-
const interFontMap = {
|
|
13
|
-
Inter_400Regular,
|
|
14
|
-
Inter_500Medium,
|
|
15
|
-
Inter_600SemiBold,
|
|
16
|
-
Inter_700Bold,
|
|
17
|
-
};
|
|
18
6
|
function loadNativeInterFonts() {
|
|
19
|
-
if (Platform.OS === "web") {
|
|
7
|
+
if (Platform.OS === "web" || !interFontMap) {
|
|
20
8
|
return Promise.resolve();
|
|
21
9
|
}
|
|
22
10
|
return Font.loadAsync(interFontMap);
|
|
23
11
|
}
|
|
24
12
|
const INTER_STYLESHEET_ID = "mrmeg-expo-ui-inter";
|
|
25
|
-
|
|
26
|
-
|
|
13
|
+
// Four upright weights plus the 400 italic, so `italic` renders a real italic
|
|
14
|
+
// on web at body weight (other weights synthesize).
|
|
15
|
+
const INTER_STYLESHEET_URL = "https://fonts.googleapis.com/css2?family=Inter:ital,wght@0,400;0,500;0,600;0,700;1,400&display=swap";
|
|
16
|
+
const NEWSREADER_STYLESHEET_ID = "mrmeg-expo-ui-newsreader";
|
|
17
|
+
const NEWSREADER_STYLESHEET_URL = "https://fonts.googleapis.com/css2?family=Newsreader:ital,wght@0,400;0,500;0,600;0,700;1,400&display=swap";
|
|
18
|
+
function ensureWebFontStylesheet(id, href, label) {
|
|
27
19
|
if (Platform.OS !== "web" || typeof document === "undefined") {
|
|
28
20
|
return Promise.resolve();
|
|
29
21
|
}
|
|
30
|
-
if (document.getElementById(
|
|
22
|
+
if (document.getElementById(id)) {
|
|
31
23
|
return Promise.resolve();
|
|
32
24
|
}
|
|
33
25
|
return new Promise((resolve, reject) => {
|
|
34
26
|
const link = document.createElement("link");
|
|
35
|
-
link.id =
|
|
27
|
+
link.id = id;
|
|
36
28
|
link.rel = "stylesheet";
|
|
37
|
-
link.href =
|
|
29
|
+
link.href = href;
|
|
38
30
|
link.onload = () => resolve();
|
|
39
|
-
link.onerror = () => reject(new Error(
|
|
31
|
+
link.onerror = () => reject(new Error(`${label} stylesheet failed to load`));
|
|
40
32
|
document.head.appendChild(link);
|
|
41
33
|
});
|
|
42
34
|
}
|
|
35
|
+
let warnedMissingSerifFonts = false;
|
|
43
36
|
/**
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
* font files. Web loads Inter from Google Fonts as a single CSS family;
|
|
49
|
-
* weight differentiation there comes from a numeric fontWeight instead.
|
|
50
|
-
*
|
|
51
|
-
* Icons need nothing here: `Icon` renders `lucide-react-native` SVGs, which
|
|
52
|
-
* have no font face to register on any platform, so server-rendered HTML
|
|
53
|
-
* carries the glyph markup and hydrates without a font round trip.
|
|
54
|
-
*
|
|
55
|
-
* A host app that overrides the sans-serif families via `setFonts` owns
|
|
56
|
-
* loading its own faces (typically through `expo-font`), so the Inter fetch
|
|
57
|
-
* is skipped entirely — nothing would reference those files. For the skip to
|
|
58
|
-
* apply, call `setFonts` before this hook mounts (module scope or ahead of
|
|
59
|
-
* rendering the root); a later call still re-skins text, it just doesn't
|
|
60
|
-
* un-download Inter.
|
|
37
|
+
* The Newsreader preset: the stylesheet on web (the preset switches at once;
|
|
38
|
+
* the stack falls back to Georgia until the faces arrive), the app-supplied
|
|
39
|
+
* files on native (the preset switches after they register, since a native
|
|
40
|
+
* font name that is not loaded is an error, not a fallback).
|
|
61
41
|
*/
|
|
62
|
-
|
|
42
|
+
async function loadSerifPreset(options) {
|
|
43
|
+
if (options.serif !== "newsreader")
|
|
44
|
+
return;
|
|
45
|
+
if (useThemeStore.getState().fontOverrides.families?.serif)
|
|
46
|
+
return;
|
|
47
|
+
if (Platform.OS === "web") {
|
|
48
|
+
useThemeStore.getState().setSerifPreset("newsreader");
|
|
49
|
+
await ensureWebFontStylesheet(NEWSREADER_STYLESHEET_ID, NEWSREADER_STYLESHEET_URL, "Newsreader");
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (!options.serifFonts) {
|
|
53
|
+
if (!warnedMissingSerifFonts) {
|
|
54
|
+
warnedMissingSerifFonts = true;
|
|
55
|
+
console.warn("useResources({ serif: \"newsreader\" }) needs `serifFonts` on native: pass the map of " +
|
|
56
|
+
"@expo-google-fonts/newsreader faces (400Regular, 500Medium, 600SemiBold, 700Bold, " +
|
|
57
|
+
"400Regular_Italic). Keeping Georgia.");
|
|
58
|
+
}
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
await Font.loadAsync(options.serifFonts);
|
|
62
|
+
useThemeStore.getState().setSerifPreset("newsreader");
|
|
63
|
+
}
|
|
64
|
+
export const useResources = (options = {}) => {
|
|
63
65
|
const [loaded, setLoaded] = useState(false);
|
|
64
66
|
const [error, setError] = useState(null);
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
// Read once at mount, not subscribed: font loading is a one-shot startup
|
|
68
|
-
// effect and cannot be undone by a later override.
|
|
69
|
-
const sansSerifOverridden = !!useThemeStore.getState().fontOverrides.families?.sansSerif;
|
|
70
|
-
async function loadResourcesAndDataAsync() {
|
|
71
|
-
try {
|
|
72
|
-
const fontPromise = Promise.all([
|
|
73
|
-
sansSerifOverridden ? Promise.resolve() : loadNativeInterFonts(),
|
|
74
|
-
sansSerifOverridden ? Promise.resolve() : ensureWebFontStylesheet(),
|
|
75
|
-
]);
|
|
76
|
-
// Timeout after 5 seconds — proceed with system fallback fonts
|
|
77
|
-
const timeoutPromise = new Promise((_, reject) => {
|
|
78
|
-
timeoutId = setTimeout(() => reject(new Error("Font loading timed out after 5s")), 5000);
|
|
79
|
-
});
|
|
80
|
-
await Promise.race([fontPromise, timeoutPromise]);
|
|
81
|
-
}
|
|
82
|
-
catch (e) {
|
|
83
|
-
const error = e instanceof Error ? e : new Error(String(e));
|
|
84
|
-
console.warn("Font loading issue (proceeding with fallback):", error.message);
|
|
85
|
-
setError(error);
|
|
86
|
-
}
|
|
87
|
-
finally {
|
|
88
|
-
clearTimeout(timeoutId);
|
|
89
|
-
setLoaded(true);
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
loadResourcesAndDataAsync();
|
|
93
|
-
return () => clearTimeout(timeoutId);
|
|
94
|
-
}, []);
|
|
67
|
+
const { serif, serifFonts } = options;
|
|
68
|
+
useEffect(() => startResourceLoad({ serif, serifFonts }, setError, () => setLoaded(true)), [serif, serifFonts]);
|
|
95
69
|
return { loaded, error };
|
|
96
70
|
};
|
|
71
|
+
function startResourceLoad(options, onError, onLoaded) {
|
|
72
|
+
let timeoutId;
|
|
73
|
+
const sansSerifOverridden = !!useThemeStore.getState().fontOverrides.families?.sansSerif;
|
|
74
|
+
async function loadResourcesAndDataAsync() {
|
|
75
|
+
try {
|
|
76
|
+
const fontPromise = Promise.all([
|
|
77
|
+
sansSerifOverridden ? Promise.resolve() : loadNativeInterFonts(),
|
|
78
|
+
sansSerifOverridden
|
|
79
|
+
? Promise.resolve()
|
|
80
|
+
: ensureWebFontStylesheet(INTER_STYLESHEET_ID, INTER_STYLESHEET_URL, "Inter"),
|
|
81
|
+
loadSerifPreset(options),
|
|
82
|
+
]);
|
|
83
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
84
|
+
timeoutId = setTimeout(() => reject(new Error("Font loading timed out after 5s")), 5000);
|
|
85
|
+
});
|
|
86
|
+
await Promise.race([fontPromise, timeoutPromise]);
|
|
87
|
+
}
|
|
88
|
+
catch (e) {
|
|
89
|
+
const error = e instanceof Error ? e : new Error(String(e));
|
|
90
|
+
console.warn("Font loading issue (proceeding with fallback):", error.message);
|
|
91
|
+
onError(error);
|
|
92
|
+
}
|
|
93
|
+
finally {
|
|
94
|
+
clearTimeout(timeoutId);
|
|
95
|
+
onLoaded();
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
loadResourcesAndDataAsync();
|
|
99
|
+
return () => clearTimeout(timeoutId);
|
|
100
|
+
}
|