@mrmeg/expo-ui 0.27.0 → 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 (145) hide show
  1. package/CHANGELOG.md +712 -0
  2. package/LLM_USAGE.md +260 -32
  3. package/README.md +558 -101
  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 +66 -9
  11. package/dist/components/BottomSheet.js +143 -39
  12. package/dist/components/Button.d.ts +20 -4
  13. package/dist/components/Button.js +64 -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 +178 -29
  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 +7 -3
  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 +74 -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 +23 -32
  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 +73 -27
  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/bottomSheetHostSupport.d.ts +46 -0
  71. package/dist/components/bottomSheetHostSupport.js +67 -0
  72. package/dist/components/index.d.ts +46 -44
  73. package/dist/components/index.js +2 -0
  74. package/dist/components/keyboardDismiss.d.ts +25 -0
  75. package/dist/components/keyboardDismiss.js +60 -3
  76. package/dist/components/nativeTextField.android.js +13 -9
  77. package/dist/constants/colors.d.ts +40 -5
  78. package/dist/constants/colors.js +26 -8
  79. package/dist/constants/fonts.d.ts +41 -2
  80. package/dist/constants/fonts.js +46 -7
  81. package/dist/constants/index.d.ts +5 -4
  82. package/dist/constants/index.js +1 -0
  83. package/dist/constants/interaction.d.ts +18 -0
  84. package/dist/constants/interaction.js +18 -0
  85. package/dist/constants/spacing.d.ts +2 -1
  86. package/dist/constants/spacing.js +2 -1
  87. package/dist/design-system.json +154 -2
  88. package/dist/hooks/index.d.ts +12 -7
  89. package/dist/hooks/index.js +5 -0
  90. package/dist/hooks/useDimensions.d.ts +13 -8
  91. package/dist/hooks/useDimensions.js +123 -48
  92. package/dist/hooks/useFocusVisible.d.ts +30 -0
  93. package/dist/hooks/useFocusVisible.js +45 -0
  94. package/dist/hooks/useFontStyle.d.ts +2 -2
  95. package/dist/hooks/useFontStyle.js +3 -2
  96. package/dist/hooks/useHydrated.d.ts +13 -0
  97. package/dist/hooks/useHydrated.js +19 -0
  98. package/dist/hooks/useNotificationOffset.d.ts +20 -0
  99. package/dist/hooks/useNotificationOffset.js +34 -0
  100. package/dist/hooks/useResources.d.ts +21 -20
  101. package/dist/hooks/useResources.js +72 -68
  102. package/dist/hooks/useScalePress.d.ts +8 -5
  103. package/dist/hooks/useScalePress.js +16 -9
  104. package/dist/hooks/useShape.d.ts +24 -0
  105. package/dist/hooks/useShape.js +24 -0
  106. package/dist/hooks/useStaggeredEntrance.js +9 -7
  107. package/dist/hooks/useTheme.d.ts +3 -3
  108. package/dist/hooks/useTheme.js +177 -66
  109. package/dist/hooks/useWindowInsets.d.ts +13 -0
  110. package/dist/hooks/useWindowInsets.js +23 -0
  111. package/dist/index.d.ts +5 -5
  112. package/dist/lib/composeRefs.d.ts +8 -0
  113. package/dist/lib/composeRefs.js +20 -0
  114. package/dist/lib/haptics.d.ts +12 -0
  115. package/dist/lib/haptics.js +21 -0
  116. package/dist/lib/index.d.ts +6 -4
  117. package/dist/lib/index.js +2 -0
  118. package/dist/lib/interFonts.d.ts +15 -0
  119. package/dist/lib/interFonts.js +1 -0
  120. package/dist/lib/interFonts.native.d.ts +3 -0
  121. package/dist/lib/interFonts.native.js +20 -0
  122. package/dist/lib/popoverPlacement.d.ts +36 -0
  123. package/dist/lib/popoverPlacement.js +29 -0
  124. package/dist/lib/stateSurface.d.ts +28 -0
  125. package/dist/lib/stateSurface.js +30 -0
  126. package/dist/lib/themedStyles.d.ts +1 -1
  127. package/dist/lib/useAnimatedValue.d.ts +15 -0
  128. package/dist/lib/useAnimatedValue.js +19 -0
  129. package/dist/lib/webHitTarget.d.ts +40 -0
  130. package/dist/lib/webHitTarget.js +37 -0
  131. package/dist/state/feedbackStore.d.ts +24 -0
  132. package/dist/state/feedbackStore.js +16 -0
  133. package/dist/state/globalUIStore.d.ts +27 -0
  134. package/dist/state/globalUIStore.js +23 -0
  135. package/dist/state/index.d.ts +7 -5
  136. package/dist/state/index.js +2 -0
  137. package/dist/state/initialScheme.d.ts +27 -0
  138. package/dist/state/initialScheme.js +29 -0
  139. package/dist/state/notify.d.ts +1 -1
  140. package/dist/state/themeColorScope.d.ts +1 -1
  141. package/dist/state/themeStore.d.ts +69 -16
  142. package/dist/state/themeStore.js +65 -38
  143. package/llms-full.md +56 -8
  144. package/llms.txt +10 -1
  145. package/package.json +69 -2
@@ -1,8 +1,10 @@
1
1
  import { useCallback, useEffect, useMemo } from "react";
2
- import { colors, resolveRawColor } from "../constants/colors.js";
2
+ import { useShallow } from "zustand/react/shallow";
3
+ import { colors, palette, resolveRawColor } from "../constants/colors.js";
3
4
  import { Platform, StyleSheet } from "react-native";
4
5
  import { resolveThemePreference, useThemeStore } from "../state/themeStore.js";
5
6
  import { useThemeColorScope } from "../state/themeColorScope.js";
7
+ import { useInitialScheme } from "../state/initialScheme.js";
6
8
  import { spacing as spacingConstants } from "../constants/spacing.js";
7
9
  // Module-level cache for contrast calculations to avoid memory leak
8
10
  // and share across components
@@ -22,6 +24,144 @@ function getCachedOrCompute(key, compute) {
22
24
  contrastCache.set(key, result);
23
25
  return result;
24
26
  }
27
+ /**
28
+ * Web: keeps `<html data-theme>` and `color-scheme` — what the CSS variables
29
+ * from `getThemeCssVariables()` key on — on the store's resolved scheme.
30
+ *
31
+ * One store subscription for the whole app, started by the first `useTheme`
32
+ * consumer to mount (the same moment the old per-consumer effect first
33
+ * wrote), writing only when the resolved scheme changes. It used to be an
34
+ * effect in every consumer, which re-ran in each of them on every scheme
35
+ * switch.
36
+ */
37
+ let documentThemeSyncStarted = false;
38
+ let documentScheme = null;
39
+ function writeDocumentScheme(state) {
40
+ const scheme = resolveThemePreference(state.userTheme, state.systemTheme);
41
+ if (scheme === documentScheme)
42
+ return;
43
+ documentScheme = scheme;
44
+ document.documentElement.dataset.theme = scheme;
45
+ document.documentElement.style.colorScheme = scheme;
46
+ }
47
+ function startDocumentThemeSync() {
48
+ if (documentThemeSyncStarted || Platform.OS !== "web" || typeof document === "undefined") {
49
+ return;
50
+ }
51
+ documentThemeSyncStarted = true;
52
+ writeDocumentScheme(useThemeStore.getState());
53
+ useThemeStore.subscribe(writeDocumentScheme);
54
+ }
55
+ /**
56
+ * Shadow presets, per scheme.
57
+ *
58
+ * Light: the shadow does the work, at about twice the alpha the original
59
+ * presets used (0.04 became 0.08). Dark: the original presets tripled a 0.04
60
+ * alpha, which is invisible on the near-black base; these are black at real
61
+ * alpha, with the edge highlight in front. `subtle`, `soft` and `elevated`
62
+ * were tuned in the Surface Lab (`client/showcase/SurfaceLabScreen.tsx`); the
63
+ * other raised presets sit between them. `sharp` keeps its original light
64
+ * alpha and its original tripled dark alpha; `glow` is one vivid layer in both.
65
+ */
66
+ const SHADOW_CONFIGS = {
67
+ subtle: {
68
+ light: [
69
+ { x: 0, y: 1, blur: 3, opacity: 0.08 },
70
+ { x: 0, y: 2, blur: 8, opacity: 0.06 },
71
+ ],
72
+ dark: [
73
+ { x: 0, y: 1, blur: 2, opacity: 0.4 },
74
+ { x: 0, y: 8, blur: 24, opacity: 0.35 },
75
+ ],
76
+ highlight: 0.05,
77
+ },
78
+ base: {
79
+ light: [
80
+ { x: 0, y: 2, blur: 6, opacity: 0.1 },
81
+ { x: 0, y: 4, blur: 12, opacity: 0.06 },
82
+ ],
83
+ dark: [
84
+ { x: 0, y: 2, blur: 3, opacity: 0.4 },
85
+ { x: 0, y: 10, blur: 28, opacity: 0.38 },
86
+ ],
87
+ },
88
+ soft: {
89
+ light: [
90
+ { x: 0, y: 4, blur: 10, opacity: 0.1 },
91
+ { x: 0, y: 8, blur: 20, opacity: 0.06 },
92
+ ],
93
+ dark: [
94
+ { x: 0, y: 2, blur: 4, opacity: 0.45 },
95
+ { x: 0, y: 14, blur: 36, opacity: 0.45 },
96
+ ],
97
+ highlight: 0.06,
98
+ },
99
+ card: {
100
+ light: [
101
+ { x: 0, y: 4, blur: 12, opacity: 0.1 },
102
+ { x: 0, y: 8, blur: 24, opacity: 0.06 },
103
+ ],
104
+ dark: [
105
+ { x: 0, y: 2, blur: 4, opacity: 0.42 },
106
+ { x: 0, y: 10, blur: 30, opacity: 0.4 },
107
+ ],
108
+ highlight: 0.05,
109
+ },
110
+ cardSubtle: {
111
+ light: [
112
+ { x: 0, y: 1, blur: 3, opacity: 0.1 },
113
+ { x: 0, y: 3, blur: 9, opacity: 0.06 },
114
+ ],
115
+ dark: [
116
+ { x: 0, y: 1, blur: 2, opacity: 0.4 },
117
+ { x: 0, y: 4, blur: 12, opacity: 0.3 },
118
+ ],
119
+ highlight: 0.05,
120
+ },
121
+ cardHover: {
122
+ light: [
123
+ { x: 0, y: 8, blur: 24, opacity: 0.12 },
124
+ { x: 0, y: 16, blur: 48, opacity: 0.08 },
125
+ ],
126
+ dark: [
127
+ { x: 0, y: 3, blur: 6, opacity: 0.48 },
128
+ { x: 0, y: 18, blur: 48, opacity: 0.5 },
129
+ ],
130
+ highlight: 0.06,
131
+ },
132
+ elevated: {
133
+ light: [
134
+ { x: 0, y: 16, blur: 48, opacity: 0.16 },
135
+ { x: 0, y: 32, blur: 96, opacity: 0.1 },
136
+ ],
137
+ dark: [
138
+ { x: 0, y: 4, blur: 8, opacity: 0.5 },
139
+ { x: 0, y: 24, blur: 56, opacity: 0.55 },
140
+ ],
141
+ highlight: 0.06,
142
+ },
143
+ glass: {
144
+ light: [
145
+ { x: 0, y: 4, blur: 30, opacity: 0.1 },
146
+ { x: 0, y: 8, blur: 60, opacity: 0.06 },
147
+ ],
148
+ dark: [
149
+ { x: 0, y: 4, blur: 30, opacity: 0.4 },
150
+ { x: 0, y: 8, blur: 60, opacity: 0.3 },
151
+ ],
152
+ highlight: 0.05,
153
+ },
154
+ // Single layer — intentional crispness, not a soft dual-layer shadow.
155
+ sharp: {
156
+ light: [{ x: 0, y: 1, blur: 1, opacity: 0.15 }],
157
+ dark: [{ x: 0, y: 1, blur: 1, opacity: 0.45 }],
158
+ },
159
+ // Single layer — already a deliberate, vivid accent glow.
160
+ glow: {
161
+ light: [{ x: 0, y: 4, blur: 20, opacity: 0.4 }],
162
+ dark: [{ x: 0, y: 4, blur: 20, opacity: 0.4 }],
163
+ },
164
+ };
25
165
  /**
26
166
  * useTheme
27
167
  *
@@ -42,16 +182,26 @@ function getCachedOrCompute(key, compute) {
42
182
  * - getTextColorForBackground("#000") → "light"
43
183
  * - getContrastingColor("#f4f4f4", "#222", "#fff") → "#222"
44
184
  * - withAlpha("#336699", 0.6) → "rgba(51,102,153,0.6)"
45
- * - getShadowStyle('base') → { boxShadow: "0px 2px 6px rgba(0, 0, 0, 0.05), 0px 4px 12px rgba(0, 0, 0, 0.03)" }
185
+ * - getShadowStyle('base') → { boxShadow: "0px 2px 6px rgba(0, 0, 0, 0.1), 0px 4px 12px rgba(0, 0, 0, 0.06)" } (light)
46
186
  */
47
187
  export function useTheme() {
48
- const userTheme = useThemeStore((s) => s.userTheme);
49
- const systemTheme = useThemeStore((s) => s.systemTheme);
50
- const setTheme = useThemeStore((s) => s.setTheme);
51
- const colorOverrides = useThemeStore((s) => s.colorOverrides);
188
+ // One store subscription per consumer, re-rendering only when one of these
189
+ // four fields changes (shallow compare), instead of four subscriptions.
190
+ const { userTheme, systemTheme, hasLoadedTheme, setTheme, colorOverrides } = useThemeStore(useShallow((s) => ({
191
+ userTheme: s.userTheme,
192
+ systemTheme: s.systemTheme,
193
+ hasLoadedTheme: s.hasLoadedTheme,
194
+ setTheme: s.setTheme,
195
+ colorOverrides: s.colorOverrides,
196
+ })));
52
197
  const scoped = useThemeColorScope();
53
- // Determine which theme to use (user preference or system)
54
- const effectiveScheme = resolveThemePreference(userTheme, systemTheme);
198
+ const initialScheme = useInitialScheme();
199
+ // Determine which theme to use: the app's first-render hint until the
200
+ // persisted preference has been read (server render, hydration), then the
201
+ // user preference or system scheme. See state/initialScheme.tsx.
202
+ const effectiveScheme = initialScheme !== undefined && !hasLoadedTheme
203
+ ? initialScheme
204
+ : resolveThemePreference(userTheme, systemTheme);
55
205
  const base = colors[effectiveScheme];
56
206
  // Layer overrides on top of the package defaults for the active scheme, in
57
207
  // precedence order: package default → global store (app brand) → scoped
@@ -72,13 +222,9 @@ export function useTheme() {
72
222
  colors: { ...base.colors, ...storeOverride, ...scopedOverride },
73
223
  };
74
224
  }, [base, storeOverride, scopedOverride]);
75
- // Sync theme to DOM so CSS in +html.tsx follows the app's runtime theme
76
- useEffect(() => {
77
- if (Platform.OS === "web" && typeof document !== "undefined") {
78
- document.documentElement.dataset.theme = effectiveScheme;
79
- document.documentElement.style.colorScheme = effectiveScheme;
80
- }
81
- }, [effectiveScheme]);
225
+ // Sync theme to DOM so CSS in +html.tsx follows the app's runtime theme.
226
+ // Starts the app-wide sync on the first mount; a no-op in every later one.
227
+ useEffect(startDocumentThemeSync, []);
82
228
  // Toggle between light, dark, and system themes
83
229
  const toggleTheme = useCallback(() => {
84
230
  if (userTheme === "light") {
@@ -105,61 +251,26 @@ export function useTheme() {
105
251
  * single-layer) is a dual-layer shadow: a tight "contact" layer plus a
106
252
  * wider, softer "ambient" layer, pitsi-ui-style. Layers are comma-joined
107
253
  * into one `boxShadow` value.
254
+ *
255
+ * Light and dark have their own layers. Light is the shadow alone. Dark is
256
+ * edge-lit: a 1px inset white highlight along the top edge, plus black
257
+ * layers at real alpha, deeper and larger than light's. On the near-black
258
+ * dark base a faint drop shadow is invisible, so the highlight carries the
259
+ * "lit from above" cue and the shadow only grounds the surface. The
260
+ * highlight is on the raised presets; `sharp` and `glow` have none.
108
261
  */
109
262
  const getShadowStyle = useCallback((type) => {
110
- // Each preset is one or more layers of [offsetX, offsetY, blurRadius, color, opacity].
111
- // Darker themes get a stronger alpha so shadows stay visible.
112
- const boost = theme.dark ? 3 : 1;
113
263
  const overlay = theme.colors.overlay;
114
- const shadowConfigs = {
115
- subtle: [
116
- { x: 0, y: 1, blur: 3, color: overlay, opacity: 0.04 },
117
- { x: 0, y: 2, blur: 8, color: overlay, opacity: 0.03 },
118
- ],
119
- base: [
120
- { x: 0, y: 2, blur: 6, color: overlay, opacity: 0.05 },
121
- { x: 0, y: 4, blur: 12, color: overlay, opacity: 0.03 },
122
- ],
123
- soft: [
124
- { x: 0, y: 4, blur: 10, color: overlay, opacity: 0.05 },
125
- { x: 0, y: 8, blur: 20, color: overlay, opacity: 0.03 },
126
- ],
127
- card: [
128
- { x: 0, y: 4, blur: 12, color: overlay, opacity: 0.05 },
129
- { x: 0, y: 8, blur: 24, color: overlay, opacity: 0.03 },
130
- ],
131
- cardSubtle: [
132
- { x: 0, y: 1, blur: 3, color: overlay, opacity: 0.05 },
133
- { x: 0, y: 3, blur: 9, color: overlay, opacity: 0.03 },
134
- ],
135
- cardHover: [
136
- { x: 0, y: 8, blur: 24, color: overlay, opacity: 0.06 },
137
- { x: 0, y: 16, blur: 48, color: overlay, opacity: 0.04 },
138
- ],
139
- elevated: [
140
- { x: 0, y: 16, blur: 48, color: overlay, opacity: 0.08 },
141
- { x: 0, y: 32, blur: 96, color: overlay, opacity: 0.05 },
142
- ],
143
- glass: [
144
- { x: 0, y: 4, blur: 30, color: overlay, opacity: 0.05 },
145
- { x: 0, y: 8, blur: 60, color: overlay, opacity: 0.03 },
146
- ],
147
- // Single layer — intentional crispness, not a soft dual-layer shadow.
148
- sharp: [
149
- { x: 0, y: 1, blur: 1, color: overlay, opacity: 0.15 },
150
- ],
151
- // Single layer — already a deliberate, vivid accent glow.
152
- glow: [
153
- { x: 0, y: 4, blur: 20, color: theme.colors.primary, opacity: 0.4 },
154
- ],
155
- };
156
- const layers = shadowConfigs[type].map(({ x, y, blur, color, opacity }) => {
157
- // Don't boost the glow accent — it's already a deliberate, vivid alpha.
158
- const alpha = color === theme.colors.primary ? opacity : Math.min(opacity * boost, 1);
159
- return `${x}px ${y}px ${blur}px ${withAlpha(color, alpha)}`;
160
- });
264
+ const config = SHADOW_CONFIGS[type];
265
+ const layers = theme.dark ? config.dark : config.light;
266
+ // Don't tint `glow` with the overlay: it is a deliberate, vivid accent glow.
267
+ const color = type === "glow" ? theme.colors.primary : overlay;
268
+ const parts = layers.map(({ x, y, blur, opacity }) => `${x}px ${y}px ${blur}px ${withAlpha(color, opacity)}`);
269
+ if (theme.dark && config.highlight !== undefined) {
270
+ parts.unshift(`inset 0px 1px 0px ${withAlpha(palette.white, config.highlight)}`);
271
+ }
161
272
  return {
162
- boxShadow: layers.join(", "),
273
+ boxShadow: parts.join(", "),
163
274
  };
164
275
  }, [theme]);
165
276
  const getFocusRingStyle = useCallback((offset = 2) => {
@@ -0,0 +1,13 @@
1
+ import { type EdgeInsets } from "react-native-safe-area-context";
2
+ /**
3
+ * The window's safe-area insets, wherever the component renders.
4
+ *
5
+ * `useSafeAreaInsets()` throws outside a `SafeAreaProvider` and reads zeros
6
+ * inside content the system presents outside the provider's view — an iOS
7
+ * `Modal`, a native bottom sheet, a `UIProvider` mounted above the app's
8
+ * provider. This reads the provider's context when it is there and falls back
9
+ * to `initialWindowMetrics` (the window the app launched in), edge by edge, so
10
+ * overlays, toasts and `Screen` clear the Dynamic Island and home indicator in
11
+ * every one of those cases. Zeros on web and in tests without a provider.
12
+ */
13
+ export declare function useWindowInsets(): EdgeInsets;
@@ -0,0 +1,23 @@
1
+ import { use } from "react";
2
+ import { SafeAreaInsetsContext, initialWindowMetrics, } from "react-native-safe-area-context";
3
+ /**
4
+ * The window's safe-area insets, wherever the component renders.
5
+ *
6
+ * `useSafeAreaInsets()` throws outside a `SafeAreaProvider` and reads zeros
7
+ * inside content the system presents outside the provider's view — an iOS
8
+ * `Modal`, a native bottom sheet, a `UIProvider` mounted above the app's
9
+ * provider. This reads the provider's context when it is there and falls back
10
+ * to `initialWindowMetrics` (the window the app launched in), edge by edge, so
11
+ * overlays, toasts and `Screen` clear the Dynamic Island and home indicator in
12
+ * every one of those cases. Zeros on web and in tests without a provider.
13
+ */
14
+ export function useWindowInsets() {
15
+ const context = use(SafeAreaInsetsContext);
16
+ const fallback = initialWindowMetrics?.insets;
17
+ return {
18
+ top: context?.top || fallback?.top || 0,
19
+ bottom: context?.bottom || fallback?.bottom || 0,
20
+ left: context?.left || fallback?.left || 0,
21
+ right: context?.right || fallback?.right || 0,
22
+ };
23
+ }
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- export * from "./components";
2
- export * from "./constants";
3
- export * from "./hooks";
4
- export * from "./state";
5
- export * from "./lib";
1
+ export * from "./components/index.js";
2
+ export * from "./constants/index.js";
3
+ export * from "./hooks/index.js";
4
+ export * from "./state/index.js";
5
+ export * from "./lib/index.js";
@@ -0,0 +1,8 @@
1
+ import type { Ref, RefCallback } from "react";
2
+ /**
3
+ * One ref callback that feeds every ref given — object refs (`.current`) and
4
+ * callback refs alike, `null`/`undefined` skipped. For a component that needs
5
+ * the host node itself (e.g. to set a DOM attribute) while its consumer, or an
6
+ * `asChild` slot, also passes a `ref`.
7
+ */
8
+ export declare function composeRefs<T>(...refs: Array<Ref<T> | undefined>): RefCallback<T>;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * One ref callback that feeds every ref given — object refs (`.current`) and
3
+ * callback refs alike, `null`/`undefined` skipped. For a component that needs
4
+ * the host node itself (e.g. to set a DOM attribute) while its consumer, or an
5
+ * `asChild` slot, also passes a `ref`.
6
+ */
7
+ export function composeRefs(...refs) {
8
+ return (node) => {
9
+ for (const ref of refs) {
10
+ if (!ref)
11
+ continue;
12
+ if (typeof ref === "function") {
13
+ ref(node);
14
+ }
15
+ else {
16
+ ref.current = node;
17
+ }
18
+ }
19
+ };
20
+ }
@@ -1,3 +1,15 @@
1
+ /** A light tap, unconditionally. Prefer `hapticPress` / `hapticSelection`. */
1
2
  export declare function hapticLight(): void;
2
3
  export declare function hapticMedium(): void;
3
4
  export declare function hapticSuccess(): void;
5
+ /**
6
+ * The tap a control fires when its state changes (Switch, Checkbox, Toggle,
7
+ * ToggleGroup, SegmentedControl). Fires under the `"selection"` (default) and
8
+ * `"all"` settings; silent under `"off"`.
9
+ */
10
+ export declare function hapticSelection(): void;
11
+ /**
12
+ * The tap a pressable fires on press-in (Button, pressable Card and Item,
13
+ * `useScalePress` consumers). Fires only under the `"all"` setting.
14
+ */
15
+ export declare function hapticPress(): void;
@@ -1,4 +1,5 @@
1
1
  import { Platform } from "react-native";
2
+ import { useFeedbackStore } from "../state/feedbackStore.js";
2
3
  function runHaptic(fn) {
3
4
  if (Platform.OS === "web")
4
5
  return;
@@ -9,6 +10,7 @@ function runHaptic(fn) {
9
10
  // Haptics not available
10
11
  }
11
12
  }
13
+ /** A light tap, unconditionally. Prefer `hapticPress` / `hapticSelection`. */
12
14
  export function hapticLight() {
13
15
  runHaptic(() => {
14
16
  const H = require("expo-haptics");
@@ -27,3 +29,22 @@ export function hapticSuccess() {
27
29
  H.notificationAsync(H.NotificationFeedbackType.Success);
28
30
  });
29
31
  }
32
+ /**
33
+ * The tap a control fires when its state changes (Switch, Checkbox, Toggle,
34
+ * ToggleGroup, SegmentedControl). Fires under the `"selection"` (default) and
35
+ * `"all"` settings; silent under `"off"`.
36
+ */
37
+ export function hapticSelection() {
38
+ if (useFeedbackStore.getState().haptics === "off")
39
+ return;
40
+ hapticLight();
41
+ }
42
+ /**
43
+ * The tap a pressable fires on press-in (Button, pressable Card and Item,
44
+ * `useScalePress` consumers). Fires only under the `"all"` setting.
45
+ */
46
+ export function hapticPress() {
47
+ if (useFeedbackStore.getState().haptics !== "all")
48
+ return;
49
+ hapticLight();
50
+ }
@@ -1,4 +1,6 @@
1
- export * from "./animations";
2
- export * from "./haptics";
3
- export * from "./i18n";
4
- export * from "./themedStyles";
1
+ export * from "./animations.js";
2
+ export * from "./composeRefs.js";
3
+ export * from "./haptics.js";
4
+ export * from "./i18n.js";
5
+ export * from "./stateSurface.js";
6
+ export * from "./themedStyles.js";
package/dist/lib/index.js CHANGED
@@ -1,4 +1,6 @@
1
1
  export * from "./animations.js";
2
+ export * from "./composeRefs.js";
2
3
  export * from "./haptics.js";
3
4
  export * from "./i18n.js";
5
+ export * from "./stateSurface.js";
4
6
  export * from "./themedStyles.js";
@@ -0,0 +1,15 @@
1
+ /**
2
+ * The packaged Inter faces `useResources` registers through `expo-font` —
3
+ * web variant: none.
4
+ *
5
+ * Web loads Inter as one multi-weight CSS family from Google Fonts (see
6
+ * `useResources`), and `constants/fonts.ts` points every web weight at that
7
+ * family, so no static `.ttf` would ever be referenced. Importing one here
8
+ * would still make the bundler emit it as a web asset, so this variant imports
9
+ * nothing. Metro resolves `interFonts.native.ts` on iOS and Android instead.
10
+ *
11
+ * Private module: not re-exported from `lib/index.ts`, and `lib/*` is not an
12
+ * exported subpath, so consumers can't reach it.
13
+ */
14
+ export type InterFontMap = Readonly<Record<string, number>>;
15
+ export declare const interFontMap: InterFontMap | null;
@@ -0,0 +1 @@
1
+ export const interFontMap = null;
@@ -0,0 +1,3 @@
1
+ import type { InterFontMap } from "./interFonts.js";
2
+ export type { InterFontMap };
3
+ export declare const interFontMap: InterFontMap | null;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The packaged Inter faces `useResources` registers through `expo-font` —
3
+ * native variant.
4
+ *
5
+ * Exactly the four static weights the native `sansSerif` family keys point at
6
+ * (see `constants/fonts.ts`), each imported from its own per-weight subpath.
7
+ * The `@expo-google-fonts/inter` root entry `require`s all 18 files (every
8
+ * weight, plus italics), and Metro ships every file a bundle requires, so
9
+ * importing it added 14 unused faces (~4.8 MB) to every native app.
10
+ */
11
+ import { Inter_400Regular } from "@expo-google-fonts/inter/400Regular";
12
+ import { Inter_500Medium } from "@expo-google-fonts/inter/500Medium";
13
+ import { Inter_600SemiBold } from "@expo-google-fonts/inter/600SemiBold";
14
+ import { Inter_700Bold } from "@expo-google-fonts/inter/700Bold";
15
+ export const interFontMap = {
16
+ Inter_400Regular,
17
+ Inter_500Medium,
18
+ Inter_600SemiBold,
19
+ Inter_700Bold,
20
+ };
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Side and height cap for a native popover.
3
+ *
4
+ * `@rn-primitives/popover` places native content on the requested side and,
5
+ * when it does not fit, clamps it to the screen edge instead of flipping, so an
6
+ * oversized popover covers its own trigger or sits under the system bars.
7
+ * `PopoverContent` resolves the side and a height cap here first; the content
8
+ * then scrolls inside the cap. Web never calls this: Radix flips on its own.
9
+ */
10
+ export type PopoverSide = "top" | "bottom";
11
+ export interface PopoverPlacementInput {
12
+ /** Requested side; undefined means the primitive's default, `"bottom"`. */
13
+ side: PopoverSide | undefined;
14
+ sideOffset: number;
15
+ /** The trigger's measured screen frame, or null until the primitive measures it. */
16
+ trigger: {
17
+ pageY: number;
18
+ height: number;
19
+ } | null;
20
+ screenHeight: number;
21
+ insets: {
22
+ top?: number;
23
+ bottom?: number;
24
+ };
25
+ /** The content's uncapped height, card chrome included; undefined while unknown. */
26
+ naturalHeight: number | undefined;
27
+ avoidCollisions: boolean;
28
+ }
29
+ export interface PopoverPlacement {
30
+ side: PopoverSide;
31
+ /** Undefined when the popover is not capped. */
32
+ maxHeight: number | undefined;
33
+ }
34
+ /** Smallest cap; with less room than this the primitive's inset clamp places the card. */
35
+ export declare const POPOVER_MIN_CAP = 120;
36
+ export declare function resolvePopoverPlacement({ side, sideOffset, trigger, screenHeight, insets, naturalHeight, avoidCollisions, }: PopoverPlacementInput): PopoverPlacement;
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Side and height cap for a native popover.
3
+ *
4
+ * `@rn-primitives/popover` places native content on the requested side and,
5
+ * when it does not fit, clamps it to the screen edge instead of flipping, so an
6
+ * oversized popover covers its own trigger or sits under the system bars.
7
+ * `PopoverContent` resolves the side and a height cap here first; the content
8
+ * then scrolls inside the cap. Web never calls this: Radix flips on its own.
9
+ */
10
+ /** Smallest cap; with less room than this the primitive's inset clamp places the card. */
11
+ export const POPOVER_MIN_CAP = 120;
12
+ export function resolvePopoverPlacement({ side, sideOffset, trigger, screenHeight, insets, naturalHeight, avoidCollisions, }) {
13
+ const requested = side ?? "bottom";
14
+ if (!avoidCollisions || !trigger) {
15
+ return { side: requested, maxHeight: undefined };
16
+ }
17
+ const room = {
18
+ top: trigger.pageY - sideOffset - (insets.top ?? 0),
19
+ bottom: screenHeight - (insets.bottom ?? 0) - (trigger.pageY + trigger.height + sideOffset),
20
+ };
21
+ const opposite = requested === "top" ? "bottom" : "top";
22
+ // Without the natural height (not measured yet, or content that scrolls
23
+ // itself) there is nothing to compare, so only cap the requested side.
24
+ const flips = naturalHeight !== undefined &&
25
+ naturalHeight > room[requested] &&
26
+ room[opposite] > room[requested];
27
+ const chosen = flips ? opposite : requested;
28
+ return { side: chosen, maxHeight: Math.max(room[chosen], POPOVER_MIN_CAP) };
29
+ }
@@ -0,0 +1,28 @@
1
+ import { type ViewProps } from "react-native";
2
+ /**
3
+ * Props for a "state surface": a plain `View` whose `opacity`, `pointerEvents`
4
+ * or `display` is computed from component state (`disabled`, `loading`,
5
+ * `checked`, `editable`, …).
6
+ *
7
+ * On Android (Fabric) a View forms a stacking context only while one of those
8
+ * props says so. A filled or bordered View at opacity 1 still gets a native
9
+ * view, but its children are hoisted into the nearest stacking-context
10
+ * ancestor; the moment its opacity drops below 1 the children are pulled back
11
+ * under it. A state flip that races a navigation pop therefore re-parents
12
+ * views mid-mutation and crashed with `addViewAt: cannot insert view … View
13
+ * already has a parent` (doglog #57, Pixel 6a). `collapsable={false}` makes
14
+ * the View a permanent stacking context, so the native tree shape never
15
+ * changes with state. iOS and web return nothing: their mounting layers
16
+ * re-parent safely and the extra native view would only cost.
17
+ *
18
+ * RN's `Pressable` already sets `collapsable={false}` on its own View, so only
19
+ * plain `View` surfaces need this. Spread it before any consumer prop spread so
20
+ * a caller can still override it.
21
+ *
22
+ * ```tsx
23
+ * <View {...stateSurfaceProps()} style={{ opacity: disabled ? 0.5 : 1 }} />
24
+ * ```
25
+ *
26
+ * Reads `Platform.OS` at call time so per-test platform flips apply.
27
+ */
28
+ export declare function stateSurfaceProps(): Pick<ViewProps, "collapsable">;
@@ -0,0 +1,30 @@
1
+ import { Platform } from "react-native";
2
+ /**
3
+ * Props for a "state surface": a plain `View` whose `opacity`, `pointerEvents`
4
+ * or `display` is computed from component state (`disabled`, `loading`,
5
+ * `checked`, `editable`, …).
6
+ *
7
+ * On Android (Fabric) a View forms a stacking context only while one of those
8
+ * props says so. A filled or bordered View at opacity 1 still gets a native
9
+ * view, but its children are hoisted into the nearest stacking-context
10
+ * ancestor; the moment its opacity drops below 1 the children are pulled back
11
+ * under it. A state flip that races a navigation pop therefore re-parents
12
+ * views mid-mutation and crashed with `addViewAt: cannot insert view … View
13
+ * already has a parent` (doglog #57, Pixel 6a). `collapsable={false}` makes
14
+ * the View a permanent stacking context, so the native tree shape never
15
+ * changes with state. iOS and web return nothing: their mounting layers
16
+ * re-parent safely and the extra native view would only cost.
17
+ *
18
+ * RN's `Pressable` already sets `collapsable={false}` on its own View, so only
19
+ * plain `View` surfaces need this. Spread it before any consumer prop spread so
20
+ * a caller can still override it.
21
+ *
22
+ * ```tsx
23
+ * <View {...stateSurfaceProps()} style={{ opacity: disabled ? 0.5 : 1 }} />
24
+ * ```
25
+ *
26
+ * Reads `Platform.OS` at call time so per-test platform flips apply.
27
+ */
28
+ export function stateSurfaceProps() {
29
+ return Platform.OS === "android" ? { collapsable: false } : {};
30
+ }
@@ -1,4 +1,4 @@
1
- import { type Theme } from "../constants/colors";
1
+ import { type Theme } from "../constants/colors.js";
2
2
  /**
3
3
  * Registers theme-dependent styles at module scope so react-native-web
4
4
  * inserts their CSS rules into the sheet before the prerendered document
@@ -0,0 +1,15 @@
1
+ import { Animated } from "react-native";
2
+ /**
3
+ * An `Animated.Value` created once, on the first render, and stable for the
4
+ * component's lifetime — React Native's `useAnimatedValue`, which
5
+ * react-native-web 0.21 does not export.
6
+ *
7
+ * Replaces `useRef(new Animated.Value(x)).current`, which built and discarded
8
+ * a value on every render and read a ref during render, so the React Compiler
9
+ * skipped the whole component. A lazy state initializer is allocated once and
10
+ * returns a plain value the compiler can reason about. Like the ref version,
11
+ * only the first render's `initialValue` is used.
12
+ *
13
+ * Private to the package: not re-exported from `lib/index.ts`.
14
+ */
15
+ export declare function useAnimatedValue(initialValue: number): Animated.Value;
@@ -0,0 +1,19 @@
1
+ import { useState } from "react";
2
+ import { Animated } from "react-native";
3
+ /**
4
+ * An `Animated.Value` created once, on the first render, and stable for the
5
+ * component's lifetime — React Native's `useAnimatedValue`, which
6
+ * react-native-web 0.21 does not export.
7
+ *
8
+ * Replaces `useRef(new Animated.Value(x)).current`, which built and discarded
9
+ * a value on every render and read a ref during render, so the React Compiler
10
+ * skipped the whole component. A lazy state initializer is allocated once and
11
+ * returns a plain value the compiler can reason about. Like the ref version,
12
+ * only the first render's `initialValue` is used.
13
+ *
14
+ * Private to the package: not re-exported from `lib/index.ts`.
15
+ */
16
+ export function useAnimatedValue(initialValue) {
17
+ const [value] = useState(() => new Animated.Value(initialValue));
18
+ return value;
19
+ }