@mrmeg/expo-ui 0.27.1 → 0.28.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.
Files changed (140) hide show
  1. package/CHANGELOG.md +598 -0
  2. package/LLM_USAGE.md +233 -35
  3. package/README.md +504 -103
  4. package/dist/components/Accordion.js +12 -6
  5. package/dist/components/AnimatedView.d.ts +1 -1
  6. package/dist/components/AnimatedView.js +5 -2
  7. package/dist/components/Avatar.d.ts +1 -1
  8. package/dist/components/Avatar.js +2 -2
  9. package/dist/components/Badge.js +5 -1
  10. package/dist/components/BottomSheet.d.ts +29 -1
  11. package/dist/components/BottomSheet.js +107 -30
  12. package/dist/components/Button.d.ts +20 -4
  13. package/dist/components/Button.js +60 -43
  14. package/dist/components/Card.d.ts +1 -1
  15. package/dist/components/Card.js +24 -4
  16. package/dist/components/Carousel.js +2 -2
  17. package/dist/components/Checkbox.js +51 -44
  18. package/dist/components/Collapsible.js +7 -3
  19. package/dist/components/Dialog.js +87 -16
  20. package/dist/components/Drawer.d.ts +1 -1
  21. package/dist/components/Drawer.js +65 -55
  22. package/dist/components/DropdownMenu.js +11 -7
  23. package/dist/components/EmptyState.d.ts +2 -2
  24. package/dist/components/EmptyState.js +5 -2
  25. package/dist/components/Hydrated.d.ts +31 -0
  26. package/dist/components/Hydrated.js +30 -0
  27. package/dist/components/Icon.d.ts +17 -5
  28. package/dist/components/Icon.js +31 -10
  29. package/dist/components/InputOTP.js +5 -2
  30. package/dist/components/Item.d.ts +103 -4
  31. package/dist/components/Item.js +168 -15
  32. package/dist/components/KeyboardAvoidingView.d.ts +1 -1
  33. package/dist/components/KeyboardAvoidingView.js +4 -1
  34. package/dist/components/Label.js +3 -2
  35. package/dist/components/MaxWidthContainer.js +1 -1
  36. package/dist/components/Notification.js +45 -27
  37. package/dist/components/Popover.d.ts +18 -5
  38. package/dist/components/Popover.js +72 -8
  39. package/dist/components/Progress.js +8 -6
  40. package/dist/components/RadioGroup.js +52 -49
  41. package/dist/components/Screen.d.ts +61 -0
  42. package/dist/components/Screen.js +62 -0
  43. package/dist/components/SectionHeader.d.ts +1 -1
  44. package/dist/components/SegmentedControl.d.ts +12 -4
  45. package/dist/components/SegmentedControl.js +94 -4
  46. package/dist/components/Select.js +21 -26
  47. package/dist/components/Skeleton.js +24 -10
  48. package/dist/components/Slider.d.ts +11 -3
  49. package/dist/components/Slider.js +73 -6
  50. package/dist/components/StatCard.d.ts +4 -2
  51. package/dist/components/StatCard.js +3 -1
  52. package/dist/components/StyledText.d.ts +7 -1
  53. package/dist/components/StyledText.js +5 -3
  54. package/dist/components/Switch.d.ts +1 -1
  55. package/dist/components/Switch.js +20 -30
  56. package/dist/components/Tabs.d.ts +2 -2
  57. package/dist/components/Tabs.js +13 -24
  58. package/dist/components/TextInput.d.ts +1 -1
  59. package/dist/components/TextInput.js +71 -26
  60. package/dist/components/Toggle.d.ts +2 -42
  61. package/dist/components/Toggle.js +38 -27
  62. package/dist/components/ToggleGroup.d.ts +1 -1
  63. package/dist/components/ToggleGroup.js +37 -32
  64. package/dist/components/Tooltip.d.ts +1 -1
  65. package/dist/components/Tooltip.js +9 -3
  66. package/dist/components/UIProvider.d.ts +22 -2
  67. package/dist/components/UIProvider.js +15 -3
  68. package/dist/components/bottomSheetDismiss.d.ts +19 -0
  69. package/dist/components/bottomSheetDismiss.js +44 -0
  70. package/dist/components/index.d.ts +46 -44
  71. package/dist/components/index.js +2 -0
  72. package/dist/components/keyboardDismiss.js +12 -4
  73. package/dist/components/nativeTextField.android.js +13 -9
  74. package/dist/constants/colors.d.ts +40 -5
  75. package/dist/constants/colors.js +26 -8
  76. package/dist/constants/fonts.d.ts +41 -2
  77. package/dist/constants/fonts.js +46 -7
  78. package/dist/constants/index.d.ts +5 -4
  79. package/dist/constants/index.js +1 -0
  80. package/dist/constants/interaction.d.ts +18 -0
  81. package/dist/constants/interaction.js +18 -0
  82. package/dist/constants/spacing.d.ts +2 -1
  83. package/dist/constants/spacing.js +2 -1
  84. package/dist/design-system.json +154 -2
  85. package/dist/hooks/index.d.ts +12 -7
  86. package/dist/hooks/index.js +5 -0
  87. package/dist/hooks/useDimensions.d.ts +13 -8
  88. package/dist/hooks/useDimensions.js +123 -48
  89. package/dist/hooks/useFocusVisible.d.ts +30 -0
  90. package/dist/hooks/useFocusVisible.js +45 -0
  91. package/dist/hooks/useFontStyle.d.ts +2 -2
  92. package/dist/hooks/useFontStyle.js +3 -2
  93. package/dist/hooks/useHydrated.d.ts +13 -0
  94. package/dist/hooks/useHydrated.js +19 -0
  95. package/dist/hooks/useNotificationOffset.d.ts +20 -0
  96. package/dist/hooks/useNotificationOffset.js +34 -0
  97. package/dist/hooks/useResources.d.ts +21 -20
  98. package/dist/hooks/useResources.js +72 -68
  99. package/dist/hooks/useScalePress.d.ts +8 -5
  100. package/dist/hooks/useScalePress.js +16 -9
  101. package/dist/hooks/useShape.d.ts +24 -0
  102. package/dist/hooks/useShape.js +24 -0
  103. package/dist/hooks/useStaggeredEntrance.js +9 -7
  104. package/dist/hooks/useTheme.d.ts +3 -3
  105. package/dist/hooks/useTheme.js +177 -66
  106. package/dist/hooks/useWindowInsets.d.ts +13 -0
  107. package/dist/hooks/useWindowInsets.js +23 -0
  108. package/dist/index.d.ts +5 -5
  109. package/dist/lib/composeRefs.d.ts +8 -0
  110. package/dist/lib/composeRefs.js +20 -0
  111. package/dist/lib/haptics.d.ts +12 -0
  112. package/dist/lib/haptics.js +21 -0
  113. package/dist/lib/index.d.ts +6 -5
  114. package/dist/lib/index.js +1 -0
  115. package/dist/lib/interFonts.d.ts +15 -0
  116. package/dist/lib/interFonts.js +1 -0
  117. package/dist/lib/interFonts.native.d.ts +3 -0
  118. package/dist/lib/interFonts.native.js +20 -0
  119. package/dist/lib/popoverPlacement.d.ts +36 -0
  120. package/dist/lib/popoverPlacement.js +29 -0
  121. package/dist/lib/themedStyles.d.ts +1 -1
  122. package/dist/lib/useAnimatedValue.d.ts +15 -0
  123. package/dist/lib/useAnimatedValue.js +19 -0
  124. package/dist/lib/webHitTarget.d.ts +40 -0
  125. package/dist/lib/webHitTarget.js +37 -0
  126. package/dist/state/feedbackStore.d.ts +24 -0
  127. package/dist/state/feedbackStore.js +16 -0
  128. package/dist/state/globalUIStore.d.ts +27 -0
  129. package/dist/state/globalUIStore.js +23 -0
  130. package/dist/state/index.d.ts +7 -5
  131. package/dist/state/index.js +2 -0
  132. package/dist/state/initialScheme.d.ts +27 -0
  133. package/dist/state/initialScheme.js +29 -0
  134. package/dist/state/notify.d.ts +1 -1
  135. package/dist/state/themeColorScope.d.ts +1 -1
  136. package/dist/state/themeStore.d.ts +69 -16
  137. package/dist/state/themeStore.js +65 -38
  138. package/llms-full.md +56 -8
  139. package/llms.txt +10 -1
  140. package/package.json +69 -2
@@ -1,4 +1,4 @@
1
- import { use, useEffect, useState } from "react";
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-mount effect snaps to real dimensions — better than the inverse
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 on first mount so subsequent
38
- // SSR requests can render at the user's actual layout (no reflow on repeat
39
- // visits). The server reads this cookie via resolveSsrViewportWidth in
40
- // server/lib/ssrViewport.ts.
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
- // Round to nearest 10 so resize-driven writes don't bust HTTP caching on
47
- // every pixel of horizontal movement.
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 first render can't read the window: server-side there is no DOM,
56
- * and reading it during the browser's first render would disagree with the
57
- * markup it hydrates. The width comes from `SsrViewportContext` when the host
58
- * provides a per-request value (server render + first client render must
59
- * provide the same one), falling back to `DEFAULT_VIEWPORT_WIDTH` / `_HEIGHT`.
60
- * A post-mount effect swaps in the real viewport and follows resize from
61
- * there.
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
- return resolveFontStyle(fontOverrides, variant, weight);
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
- * Loads essential app resources on startup.
7
- *
8
- * Native platforms load four static Inter weights (via
9
- * @expo-google-fonts/inter) so StyledText's weight range resolves to real
10
- * font files. Web loads Inter from Google Fonts as a single CSS family;
11
- * weight differentiation there comes from a numeric fontWeight instead.
12
- *
13
- * Icons need nothing here: `Icon` renders `lucide-react-native` SVGs, which
14
- * have no font face to register on any platform, so server-rendered HTML
15
- * carries the glyph markup and hydrates without a font round trip.
16
- *
17
- * A host app that overrides the sans-serif families via `setFonts` owns
18
- * loading its own faces (typically through `expo-font`), so the Inter fetch
19
- * is skipped entirely — nothing would reference those files. For the skip to
20
- * apply, call `setFonts` before this hook mounts (module scope or ahead of
21
- * rendering the root); a later call still re-skins text, it just doesn't
22
- * un-download Inter.
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
- const INTER_STYLESHEET_URL = "https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap";
26
- function ensureWebFontStylesheet() {
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(INTER_STYLESHEET_ID)) {
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 = INTER_STYLESHEET_ID;
27
+ link.id = id;
36
28
  link.rel = "stylesheet";
37
- link.href = INTER_STYLESHEET_URL;
29
+ link.href = href;
38
30
  link.onload = () => resolve();
39
- link.onerror = () => reject(new Error("Inter stylesheet failed to load"));
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
- * Loads essential app resources on startup.
45
- *
46
- * Native platforms load four static Inter weights (via
47
- * @expo-google-fonts/inter) so StyledText's weight range resolves to real
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
- export const useResources = () => {
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
- useEffect(() => {
66
- let timeoutId;
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
+ }
@@ -6,10 +6,12 @@ interface ScalePressOptions {
6
6
  */
7
7
  scaleTo?: number;
8
8
  /**
9
- * Whether to fire haptic feedback on press
10
- * @default true
9
+ * Haptic on press-in. `"setting"` follows the provider-level `haptics`
10
+ * setting (a light tap only under `"all"`); `true` always taps; `false`
11
+ * never does.
12
+ * @default "setting"
11
13
  */
12
- haptic?: boolean;
14
+ haptic?: boolean | "setting";
13
15
  /**
14
16
  * Spring damping for bounce-back
15
17
  * @default 20
@@ -29,8 +31,9 @@ interface ScalePressOptions {
29
31
  /**
30
32
  * Hook for press-feedback scale animation using React Native Animated.
31
33
  *
32
- * Returns an animated style and onPressIn/onPressOut handlers to spread onto a Pressable.
33
- * Respects reduced motion preferences.
34
+ * Returns an animated style and onPressIn/onPressOut handlers to spread onto a
35
+ * Pressable. Under reduced motion the scale stays at 1 — the pressed opacity
36
+ * the components layer on carries the feedback instead.
34
37
  *
35
38
  * @example
36
39
  * ```tsx