@mrmeg/expo-ui 0.26.0 → 0.27.1

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.
@@ -1,16 +1,87 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
2
- import * as React from "react";
3
- import { Platform, StyleSheet, View } from "react-native";
4
- import { FullWindowOverlay as RNFullWindowOverlay } from "react-native-screens";
1
+ import { jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { Modal, Platform, StyleSheet, View } from "react-native";
5
3
  import * as DialogPrimitive from "@rn-primitives/dialog";
6
4
  import * as AlertDialogPrimitive from "@rn-primitives/alert-dialog";
7
5
  import { AnimatedView } from "./AnimatedView.js";
6
+ import { KeyboardAvoidingView } from "./KeyboardAvoidingView.js";
8
7
  import { TextClassContext, TextColorContext } from "./StyledText.context";
9
8
  import { StyledText } from "./StyledText.js";
10
9
  import { useTheme } from "../hooks/useTheme.js";
11
10
  import { spacing } from "../constants/spacing.js";
12
11
  import { palette } from "../constants/colors.js";
13
- const FullWindowOverlay = Platform.OS === "ios" ? RNFullWindowOverlay : React.Fragment;
12
+ // ============================================================================
13
+ // Presentation
14
+ // ============================================================================
15
+ /** The Modal follows the app's own orientation mask instead of RN's portrait-only default. */
16
+ const SUPPORTED_ORIENTATIONS = [
17
+ "portrait",
18
+ "portrait-upside-down",
19
+ "landscape",
20
+ "landscape-left",
21
+ "landscape-right",
22
+ ];
23
+ /**
24
+ * Presentation for dialog content.
25
+ *
26
+ * iOS presents through React Native's `Modal`, rendered inline where the
27
+ * dialog sits in the tree rather than through the portal host. Until 0.27.1
28
+ * both dialogs rendered into `UIProvider`'s portal host inside
29
+ * react-native-screens' `FullWindowOverlay`, which stacks above native stack
30
+ * modals by adding its container straight to the `UIWindow`. No
31
+ * `UIViewController` sits above that container, and `expo-modules-core`'s
32
+ * `ExpoSwiftUI.HostingView` — which attaches its `UIHostingController` only
33
+ * when `reactViewController()` finds a parent controller — removed the SwiftUI
34
+ * view instead. Every `@expo/ui`-hosted control inside (`TextInput`, `Slider`,
35
+ * `SegmentedControl`) was a zero-height box: no editable element, no focus, no
36
+ * keyboard (fieldnest on iOS 27, `@mrmeg/expo-ui` 0.27.0; reproduced in the
37
+ * template's `Dialog` `form` variant — the accessibility tree listed only the
38
+ * field labels, and the same content mounted with the overlay removed).
39
+ *
40
+ * `Modal` presents a real view controller, so hosted controls mount and take
41
+ * focus on the first tap. RN presents it from the view controller nearest the
42
+ * `Modal`'s own host view, which is why it renders inline: a `Modal` in the
43
+ * root portal host presents from the root controller and silently fails
44
+ * whenever that controller already presents a native stack modal or a sheet
45
+ * (device-verified: the trigger reported expanded, nothing appeared). Inline,
46
+ * the presenter is whatever screen, native stack modal or sheet contains the
47
+ * dialog, and the dialog stacks above it. The trade-off: content hosted by
48
+ * `FullWindowOverlay` (`Drawer`, `Popover`, `Select`, `DropdownMenu`,
49
+ * `Tooltip`) has no view controller either, so a dialog placed inside that
50
+ * content cannot present on iOS — render it at screen level and open it from
51
+ * the item's `onPress`.
52
+ *
53
+ * Android and web render into the portal host, as before.
54
+ */
55
+ function DialogPresentation({ Portal, open, portalHost, onRequestClose, children, }) {
56
+ if (Platform.OS !== "ios") {
57
+ return _jsx(Portal, { hostName: portalHost, children: children });
58
+ }
59
+ if (!open) {
60
+ return null;
61
+ }
62
+ return (_jsx(Modal, { visible: true, transparent: true, animationType: "none", presentationStyle: "overFullScreen", supportedOrientations: SUPPORTED_ORIENTATIONS, onRequestClose: onRequestClose, children: children }));
63
+ }
64
+ /**
65
+ * Keyboard avoidance owner for dialog content.
66
+ *
67
+ * On iOS the `Modal` above is presented outside `UIProvider`'s root
68
+ * `KeyboardAvoidingView`, so nothing else can keep a focused dialog field and
69
+ * the footer above the keyboard: this wrapper pads the centered container by
70
+ * the keyboard height, and the card (capped at 85% of the remaining height)
71
+ * recenters in the space that is left. It is the package `KeyboardAvoidingView`,
72
+ * so `useKeyboardAvoidance()` is `true` inside `DialogContent` and a
73
+ * `DismissKeyboard` in dialog content adds no second layer. Do not wrap dialog
74
+ * content in another `KeyboardAvoidingView`.
75
+ *
76
+ * Android and web keep the portal-host tree unchanged; the portal host sits
77
+ * outside the root avoidance there, and a dialog is not keyboard-avoided yet.
78
+ */
79
+ function DialogKeyboardAvoidance({ children }) {
80
+ if (Platform.OS !== "ios") {
81
+ return _jsx(_Fragment, { children: children });
82
+ }
83
+ return (_jsx(KeyboardAvoidingView, { style: overlayStyles.fill, behavior: "padding", keyboardVerticalOffset: 0, children: children }));
84
+ }
14
85
  // ============================================================================
15
86
  // Dialog
16
87
  // ============================================================================
@@ -26,8 +97,18 @@ function DialogRoot({ children, ...props }) {
26
97
  }
27
98
  function DialogContent({ portalHost, style, children, ...props }) {
28
99
  const { theme, getShadowStyle, getContrastingColor } = useTheme();
100
+ const { open, onOpenChange } = DialogPrimitive.useRootContext();
29
101
  const textColor = getContrastingColor(theme.colors.popover, palette.white, palette.black);
30
- return (_jsx(DialogPrimitive.Portal, { hostName: portalHost, children: _jsx(FullWindowOverlay, { children: _jsx(DialogPrimitive.Overlay
102
+ return (_jsx(DialogPresentation, { Portal: DialogPrimitive.Portal, open: open, portalHost: portalHost, onRequestClose: () => onOpenChange(false), children: _jsx(DialogPrimitive.Overlay
103
+ // On web the primitive wraps the overlay in a react-native-web
104
+ // Pressable whose click handler stops propagation unconditionally.
105
+ // With that Pressable between the overlay and the Radix content,
106
+ // outside presses (mouse and touch) and Escape never dismissed the
107
+ // dialog, so a dialog without a Close button was stuck. `asChild`
108
+ // makes the primitive clone our fade wrapper instead, removing the
109
+ // Pressable; Radix dismissal then works. Native keeps the Pressable,
110
+ // which is what implements closeOnPress there.
111
+ , {
31
112
  // On web the primitive wraps the overlay in a react-native-web
32
113
  // Pressable whose click handler stops propagation unconditionally.
33
114
  // With that Pressable between the overlay and the Radix content,
@@ -36,20 +117,11 @@ function DialogContent({ portalHost, style, children, ...props }) {
36
117
  // makes the primitive clone our fade wrapper instead, removing the
37
118
  // Pressable; Radix dismissal then works. Native keeps the Pressable,
38
119
  // which is what implements closeOnPress there.
39
- , {
40
- // On web the primitive wraps the overlay in a react-native-web
41
- // Pressable whose click handler stops propagation unconditionally.
42
- // With that Pressable between the overlay and the Radix content,
43
- // outside presses (mouse and touch) and Escape never dismissed the
44
- // dialog, so a dialog without a Close button was stuck. `asChild`
45
- // makes the primitive clone our fade wrapper instead, removing the
46
- // Pressable; Radix dismissal then works. Native keeps the Pressable,
47
- // which is what implements closeOnPress there.
48
- asChild: Platform.OS === "web", style: StyleSheet.flatten([
49
- StyleSheet.absoluteFill,
50
- { backgroundColor: theme.colors.overlay },
51
- Platform.OS === "web" && { zIndex: 50 },
52
- ]), children: _jsx(AnimatedView, { type: "fade", enterDuration: 200, style: StyleSheet.absoluteFill, children: _jsx(View, { style: overlayStyles.centeredContainer, children: _jsx(AnimatedView, { type: "scale", enterDuration: 250, style: overlayStyles.sizer, children: _jsx(TextColorContext.Provider, { value: textColor, children: _jsx(TextClassContext.Provider, { value: "", children: _jsx(DialogPrimitive.Content, { style: StyleSheet.flatten([
120
+ asChild: Platform.OS === "web", style: StyleSheet.flatten([
121
+ StyleSheet.absoluteFill,
122
+ { backgroundColor: theme.colors.overlay },
123
+ Platform.OS === "web" && { zIndex: 50 },
124
+ ]), children: _jsx(AnimatedView, { type: "fade", enterDuration: 200, style: StyleSheet.absoluteFill, children: _jsx(DialogKeyboardAvoidance, { children: _jsx(View, { style: overlayStyles.centeredContainer, children: _jsx(AnimatedView, { type: "scale", enterDuration: 250, style: overlayStyles.sizer, children: _jsx(TextColorContext.Provider, { value: textColor, children: _jsx(TextClassContext.Provider, { value: "", children: _jsx(DialogPrimitive.Content, { style: StyleSheet.flatten([
53
125
  {
54
126
  backgroundColor: theme.colors.popover,
55
127
  borderColor: theme.colors.border,
@@ -119,12 +191,13 @@ function AlertDialogRoot({ children, ...props }) {
119
191
  }
120
192
  function AlertDialogContent({ portalHost, style, children, ...props }) {
121
193
  const { theme, getShadowStyle, getContrastingColor } = useTheme();
194
+ const { open, onOpenChange } = AlertDialogPrimitive.useRootContext();
122
195
  const textColor = getContrastingColor(theme.colors.popover, palette.white, palette.black);
123
- return (_jsx(AlertDialogPrimitive.Portal, { hostName: portalHost, children: _jsx(FullWindowOverlay, { children: _jsx(AlertDialogPrimitive.Overlay, { style: StyleSheet.flatten([
124
- StyleSheet.absoluteFill,
125
- { backgroundColor: theme.colors.overlay },
126
- Platform.OS === "web" && { zIndex: 52 },
127
- ]), children: _jsx(AnimatedView, { type: "fade", enterDuration: 200, style: StyleSheet.absoluteFill, children: _jsx(View, { style: overlayStyles.centeredContainer, children: _jsx(AnimatedView, { type: "scale", enterDuration: 250, style: overlayStyles.sizer, children: _jsx(TextColorContext.Provider, { value: textColor, children: _jsx(TextClassContext.Provider, { value: "", children: _jsx(AlertDialogPrimitive.Content, { style: StyleSheet.flatten([
196
+ return (_jsx(DialogPresentation, { Portal: AlertDialogPrimitive.Portal, open: open, portalHost: portalHost, onRequestClose: () => onOpenChange(false), children: _jsx(AlertDialogPrimitive.Overlay, { style: StyleSheet.flatten([
197
+ StyleSheet.absoluteFill,
198
+ { backgroundColor: theme.colors.overlay },
199
+ Platform.OS === "web" && { zIndex: 52 },
200
+ ]), children: _jsx(AnimatedView, { type: "fade", enterDuration: 200, style: StyleSheet.absoluteFill, children: _jsx(DialogKeyboardAvoidance, { children: _jsx(View, { style: overlayStyles.centeredContainer, children: _jsx(AnimatedView, { type: "scale", enterDuration: 250, style: overlayStyles.sizer, children: _jsx(TextColorContext.Provider, { value: textColor, children: _jsx(TextClassContext.Provider, { value: "", children: _jsx(AlertDialogPrimitive.Content, { style: StyleSheet.flatten([
128
201
  {
129
202
  backgroundColor: theme.colors.popover,
130
203
  borderColor: theme.colors.border,
@@ -175,6 +248,11 @@ const AlertDialog = Object.assign(AlertDialogRoot, {
175
248
  // Shared styles
176
249
  // ============================================================================
177
250
  const overlayStyles = StyleSheet.create({
251
+ // The keyboard-avoiding wrapper fills the overlay so its bottom padding
252
+ // shrinks the centered container instead of the card.
253
+ fill: {
254
+ flex: 1,
255
+ },
178
256
  centeredContainer: {
179
257
  flex: 1,
180
258
  justifyContent: "center",
@@ -5,6 +5,7 @@ import { useTheme } from "../hooks/useTheme.js";
5
5
  import { spacing } from "../constants/spacing.js";
6
6
  import { StyledText } from "./StyledText.js";
7
7
  import { createThemedStyles } from "../lib/themedStyles.js";
8
+ import { stateSurfaceProps } from "../lib/stateSurface.js";
8
9
  const CELL_WIDTH = 36;
9
10
  const CELL_HEIGHT = 40;
10
11
  const CELL_FONT_SIZE = 20;
@@ -89,7 +90,7 @@ function OTPCell({ index, total, char, isActive, hasError, disabled, theme, onPr
89
90
  : isActive
90
91
  ? theme.colors.primary
91
92
  : theme.colors.border;
92
- return (_jsx(Pressable, { onPress: onPress, disabled: disabled, accessibilityRole: "button", accessibilityLabel: `Digit ${index + 1} of ${total}`, accessibilityState: { disabled }, children: _jsx(View, { style: [
93
+ return (_jsx(Pressable, { onPress: onPress, disabled: disabled, accessibilityRole: "button", accessibilityLabel: `Digit ${index + 1} of ${total}`, accessibilityState: { disabled }, children: _jsx(View, { ...stateSurfaceProps(), style: [
93
94
  {
94
95
  width: CELL_WIDTH,
95
96
  height: CELL_HEIGHT,
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { palette } from "../constants/colors.js";
3
3
  import { useTheme } from "../hooks/useTheme.js";
4
4
  import { hapticLight } from "../lib/haptics.js";
5
+ import { stateSurfaceProps } from "../lib/stateSurface.js";
5
6
  import { useCallback, useRef } from "react";
6
7
  import { View } from "react-native";
7
8
  import { Slider as NativeSlider } from "@expo/ui/community/slider";
@@ -31,7 +32,7 @@ size: _size = "md", disabled = false, showValue = false, style: styleOverride, }
31
32
  }
32
33
  onValueChange?.(next);
33
34
  }, [onValueChange]);
34
- return (_jsxs(View, { style: [{ opacity: disabled ? 0.5 : 1, alignSelf: "stretch" }, styleOverride], children: [showValue && (_jsx(StyledText, { selectable: false, style: {
35
+ return (_jsxs(View, { ...stateSurfaceProps(), style: [{ opacity: disabled ? 0.5 : 1, alignSelf: "stretch" }, styleOverride], children: [showValue && (_jsx(StyledText, { selectable: false, style: {
35
36
  fontSize: 12,
36
37
  color: theme.colors.textDim,
37
38
  marginBottom: 4,
@@ -74,8 +74,12 @@ export type TextProps = RNTextProps & {
74
74
  * real Inter font files on native, and family + numeric fontWeight on web
75
75
  * - Per-size letter-spacing scale (tight on large headings, positive on micro
76
76
  * labels); explicit `style` letterSpacing always wins
77
- * - Text selection enabled by default; pass `selectable={false}` for control
78
- * chrome such as button labels, tabs, badges, and field labels
77
+ * - Text selection on by default on iOS and web, off by default on Android:
78
+ * a selectable Android `Text` is focusable-in-touch-mode, so a tap on a label
79
+ * next to a focused input moved view focus to the label and hid the keyboard.
80
+ * Pass `selectable` to opt copyable content in (codes, addresses, chat
81
+ * bubbles); pass `selectable={false}` on every platform for control chrome
82
+ * such as button labels, tabs, badges, and field labels
79
83
  * - numberOfLines and ellipsizeMode support from RN TextProps
80
84
  */
81
85
  export declare function StyledText(props: TextProps): import("react").JSX.Element;
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { use } from "react";
3
- import { Text as RNText, StyleSheet } from "react-native";
3
+ import { Platform, Text as RNText, StyleSheet } from "react-native";
4
4
  import { useTheme } from "../hooks/useTheme.js";
5
5
  import { resolveFontStyle } from "../constants/fonts.js";
6
6
  import { useThemeStore } from "../state/themeStore.js";
@@ -67,8 +67,12 @@ const SEMANTIC_CONFIGS = {
67
67
  * real Inter font files on native, and family + numeric fontWeight on web
68
68
  * - Per-size letter-spacing scale (tight on large headings, positive on micro
69
69
  * labels); explicit `style` letterSpacing always wins
70
- * - Text selection enabled by default; pass `selectable={false}` for control
71
- * chrome such as button labels, tabs, badges, and field labels
70
+ * - Text selection on by default on iOS and web, off by default on Android:
71
+ * a selectable Android `Text` is focusable-in-touch-mode, so a tap on a label
72
+ * next to a focused input moved view focus to the label and hid the keyboard.
73
+ * Pass `selectable` to opt copyable content in (codes, addresses, chat
74
+ * bubbles); pass `selectable={false}` on every platform for control chrome
75
+ * such as button labels, tabs, badges, and field labels
72
76
  * - numberOfLines and ellipsizeMode support from RN TextProps
73
77
  */
74
78
  export function StyledText(props) {
@@ -81,7 +85,9 @@ export function StyledText(props) {
81
85
  const contextColor = use(TextColorContext);
82
86
  const contextTextStyle = use(TextStyleContext);
83
87
  const contextSelectable = use(TextSelectabilityContext);
84
- const resolvedSelectable = selectable ?? contextSelectable ?? true;
88
+ // Read at render time (not module load) so tests can flip `Platform.OS`.
89
+ const platformDefaultSelectable = Platform.OS !== "android";
90
+ const resolvedSelectable = selectable ?? contextSelectable ?? platformDefaultSelectable;
85
91
  // Use context color if provided, otherwise use theme default
86
92
  const color = contextColor ?? theme.colors.text;
87
93
  // If semantic variant is provided, use its config
@@ -3,6 +3,7 @@ import { palette } from "../constants/colors.js";
3
3
  import { spacing } from "../constants/spacing.js";
4
4
  import { useTheme } from "../hooks/useTheme.js";
5
5
  import { hapticLight } from "../lib/haptics.js";
6
+ import { stateSurfaceProps } from "../lib/stateSurface.js";
6
7
  import * as SwitchPrimitives from "@rn-primitives/switch";
7
8
  import { useCallback, useEffect, useRef, useState } from "react";
8
9
  import { ActivityIndicator, Platform, StyleSheet, View, Animated } from "react-native";
@@ -113,7 +114,7 @@ function Switch({ variant = "default", labelOn, labelOff, size = { width: 44, he
113
114
  borderWidth: 1,
114
115
  borderColor: trackBorderColor,
115
116
  pointerEvents: "none",
116
- } }), labelOn && !isIOS && (_jsx(View, { style: {
117
+ } }), labelOn && !isIOS && (_jsx(View, { ...stateSurfaceProps(), style: {
117
118
  ...styles.label,
118
119
  left: labelHorizontalInset,
119
120
  right: thumbInset + thumbSize + labelGap,
@@ -136,7 +137,7 @@ function Switch({ variant = "default", labelOn, labelOff, size = { width: 44, he
136
137
  ...getShadowStyle("subtle"),
137
138
  },
138
139
  thumbAnimatedStyle,
139
- ], children: loading && (_jsx(ActivityIndicator, { size: "small", color: thumbIndicatorColor })) }) }), labelOff && !isIOS && (_jsx(View, { style: {
140
+ ], children: loading && (_jsx(ActivityIndicator, { size: "small", color: thumbIndicatorColor })) }) }), labelOff && !isIOS && (_jsx(View, { ...stateSurfaceProps(), style: {
140
141
  ...styles.label,
141
142
  left: thumbInset + thumbSize + labelGap,
142
143
  right: labelHorizontalInset,
@@ -10,6 +10,7 @@ import { StyledText } from "./StyledText.js";
10
10
  import { Icon } from "./Icon.js";
11
11
  import { hapticLight } from "../lib/haptics.js";
12
12
  import { createThemedStyles } from "../lib/themedStyles.js";
13
+ import { stateSurfaceProps } from "../lib/stateSurface.js";
13
14
  import { palette } from "../constants/colors.js";
14
15
  import { clearKeyboardFocusedInput, setKeyboardFocusedInput, } from "./keyboardFocusRegistry.js";
15
16
  import { useTextInputSurfaceResponder } from "./keyboardDismiss.js";
@@ -207,6 +208,10 @@ function WebTextInput({ variant = "outline", size = "md", label, helperText, err
207
208
  * toggle laid out beside the native Host. Clear buttons, left/right elements,
208
209
  * and the error icon remain web-only; RN overlays are not layered over the
209
210
  * native host view.
211
+ *
212
+ * On Android `onBlur` is reported only after a forwarded `onFocus`: Compose
213
+ * reports `isFocused=false` on first composition and the field forwards it,
214
+ * so an unguarded consumer saw a blur before any interaction.
210
215
  */
211
216
  function NativeTextInput({ variant = "outline", size = "md", label, helperText, errorText, error, required, rows, forceLight, inputMode, onChangeText, onFocus, onBlur, value, defaultValue, editable = true, multiline, secureTextEntry, showSecureEntryToggle, ref, wrapperStyle,
212
217
  // Web-only affordances. Destructured out of `rest` so they're NOT forwarded to
@@ -321,9 +326,16 @@ leftElement, rightElement, clearable, focusedStyle, style, ...rest }) {
321
326
  const handleBlur = useCallback((secure) => {
322
327
  if (Platform.OS === "ios" && secure !== activeSecureRef.current)
323
328
  return;
329
+ // Compose reports `isFocused=false` when the field is first composed and
330
+ // the Android field forwards it as a blur. A blur the field never
331
+ // preceded with a focus is not the user leaving the field: keep it from
332
+ // the consumer so blur validation does not flag untouched fields.
333
+ const wasFocused = isFocusedRef.current;
324
334
  isFocusedRef.current = false;
325
335
  restoreAfterSecureHandoffRef.current = false;
326
336
  clearKeyboardFocusedInput(focusRegistryToken);
337
+ if (Platform.OS === "android" && !wasFocused)
338
+ return;
327
339
  parentOnBlur?.();
328
340
  }, [focusRegistryToken, parentOnBlur]);
329
341
  const togglePasswordVisible = useCallback(() => {
@@ -476,7 +488,7 @@ leftElement, rightElement, clearable, focusedStyle, style, ...rest }) {
476
488
  fontSize: sizeConfig.fontSize,
477
489
  ...(nativeFontFamily ? { fontFamily: nativeFontFamily } : null),
478
490
  };
479
- return (_jsxs(View, { style: wrapperStyle, children: [!!label && (_jsx(View, { style: styles.labelContainer, children: _jsxs(StyledText, { selectable: false, size: "base", fontWeight: "medium", style: styles.label, children: [label, required && _jsx(StyledText, { selectable: false, fontWeight: "bold", style: styles.required, children: " *" })] }) })), _jsxs(View, { style: [surfaceStyle, hasSecureToggle && styles.nativeRow], hitSlop: { top: surfaceHitSlop, bottom: surfaceHitSlop }, ...surfaceResponderProps, children: [(Platform.OS === "android" ? [effectiveSecureTextEntry] : [true, false]).map((secure) => {
491
+ return (_jsxs(View, { style: wrapperStyle, children: [!!label && (_jsx(View, { style: styles.labelContainer, children: _jsxs(StyledText, { selectable: false, size: "base", fontWeight: "medium", style: styles.label, children: [label, required && _jsx(StyledText, { selectable: false, fontWeight: "bold", style: styles.required, children: " *" })] }) })), _jsxs(View, { ...stateSurfaceProps(), style: [surfaceStyle, hasSecureToggle && styles.nativeRow], hitSlop: { top: surfaceHitSlop, bottom: surfaceHitSlop }, ...surfaceResponderProps, children: [(Platform.OS === "android" ? [effectiveSecureTextEntry] : [true, false]).map((secure) => {
480
492
  const active = secure === effectiveSecureTextEntry;
481
493
  const outgoing = handoff != null && secure === handoff.outgoingSecure;
482
494
  if (!active && !outgoing)
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Android `BottomSheet` keyboard avoidance is platform-owned, and the part the
3
+ * package depends on lives in the host: Material3's `ModalBottomSheet` shrinks
4
+ * the sheet with `imePadding()`, `@expo/ui`'s `RNHostView` re-reports its
5
+ * Compose size to the shadow tree (`onSizeChanged` → `ShadowNodeProxy.setViewSize`),
6
+ * and the `flexGrow: 1, height: 0` column the host wraps our children in
7
+ * follows. `expo-modules-core` <= 57.0.3 flushed that report only from a
8
+ * one-shot pre-draw listener on the activity window, which does not draw while
9
+ * the sheet's own dialog window animates the IME, so the column kept its
10
+ * detent height and `Footer` sat under the keyboard until the activity redrew
11
+ * (expo/expo#47778, fixed by expo/expo#47810 in 57.0.4 and 58.0.0; no 56.x
12
+ * release has it). Nothing in JS can observe a size the shadow tree never
13
+ * received, so the package names the floor instead: once per session, in dev,
14
+ * on Android, when the *native* core compiled into the app is older.
15
+ */
16
+ export interface ExpoModulesCoreVersion {
17
+ version: string;
18
+ major: number;
19
+ minor: number;
20
+ patch: number;
21
+ }
22
+ /** First `expo-modules-core` release whose `RNHostView` size flush always lands. */
23
+ export declare const MIN_EXPO_MODULES_CORE: Readonly<Omit<ExpoModulesCoreVersion, "version">>;
24
+ /**
25
+ * The native `expo-modules-core` version compiled into the running app, as the
26
+ * core's own `CoreModule` publishes it on `globalThis.expo` (Android and iOS,
27
+ * present on every Expo release the package supports, 56 through 58).
28
+ * `undefined` on web, in Jest, or when the core is not loaded.
29
+ */
30
+ export declare function readExpoModulesCoreVersion(): ExpoModulesCoreVersion | undefined;
31
+ /**
32
+ * Whether this core drops the sheet host's size update when the keyboard opens:
33
+ * a defined version lexicographically below {@link MIN_EXPO_MODULES_CORE}.
34
+ * An unknown version is not reported as broken.
35
+ */
36
+ export declare function bottomSheetHostDropsResize(version: ExpoModulesCoreVersion | undefined): boolean;
37
+ export declare function bottomSheetHostWarning(version: ExpoModulesCoreVersion): string;
38
+ /**
39
+ * Warn once per JS session, in development, on Android, when the native
40
+ * `expo-modules-core` is older than {@link MIN_EXPO_MODULES_CORE}. Called when a
41
+ * `BottomSheet.Content` mounts. Silent on iOS and web, where the sheet's
42
+ * avoidance does not go through `RNHostView`'s size state.
43
+ */
44
+ export declare function warnIfBottomSheetHostDropsResize(): void;
45
+ /** Test hook: forget that the warning was already shown. */
46
+ export declare function resetBottomSheetHostWarningForTests(): void;
@@ -0,0 +1,67 @@
1
+ import { Platform } from "react-native";
2
+ /** First `expo-modules-core` release whose `RNHostView` size flush always lands. */
3
+ export const MIN_EXPO_MODULES_CORE = {
4
+ major: 57,
5
+ minor: 0,
6
+ patch: 4,
7
+ };
8
+ /**
9
+ * The native `expo-modules-core` version compiled into the running app, as the
10
+ * core's own `CoreModule` publishes it on `globalThis.expo` (Android and iOS,
11
+ * present on every Expo release the package supports, 56 through 58).
12
+ * `undefined` on web, in Jest, or when the core is not loaded.
13
+ */
14
+ export function readExpoModulesCoreVersion() {
15
+ const expo = globalThis.expo;
16
+ const candidate = expo?.expoModulesCoreVersion;
17
+ if (typeof candidate !== "object" ||
18
+ candidate === null ||
19
+ typeof candidate.major !== "number" ||
20
+ typeof candidate.minor !== "number" ||
21
+ typeof candidate.patch !== "number") {
22
+ return undefined;
23
+ }
24
+ return candidate;
25
+ }
26
+ /**
27
+ * Whether this core drops the sheet host's size update when the keyboard opens:
28
+ * a defined version lexicographically below {@link MIN_EXPO_MODULES_CORE}.
29
+ * An unknown version is not reported as broken.
30
+ */
31
+ export function bottomSheetHostDropsResize(version) {
32
+ if (!version)
33
+ return false;
34
+ const { major, minor, patch } = MIN_EXPO_MODULES_CORE;
35
+ if (version.major !== major)
36
+ return version.major < major;
37
+ if (version.minor !== minor)
38
+ return version.minor < minor;
39
+ return version.patch < patch;
40
+ }
41
+ export function bottomSheetHostWarning(version) {
42
+ const { major, minor, patch } = MIN_EXPO_MODULES_CORE;
43
+ return (`@mrmeg/expo-ui BottomSheet: expo-modules-core ${version.version} drops the sheet's ` +
44
+ "size update when the keyboard opens on Android, so BottomSheet.Footer and the tail " +
45
+ "of Body end up under the keyboard (expo/expo#47778). Update expo-modules-core to " +
46
+ `>= ${major}.${minor}.${patch} (fixed in 57.0.4 / 58.0.0) and rebuild the app.`);
47
+ }
48
+ let warned = false;
49
+ /**
50
+ * Warn once per JS session, in development, on Android, when the native
51
+ * `expo-modules-core` is older than {@link MIN_EXPO_MODULES_CORE}. Called when a
52
+ * `BottomSheet.Content` mounts. Silent on iOS and web, where the sheet's
53
+ * avoidance does not go through `RNHostView`'s size state.
54
+ */
55
+ export function warnIfBottomSheetHostDropsResize() {
56
+ if (warned || !__DEV__ || Platform.OS !== "android")
57
+ return;
58
+ const version = readExpoModulesCoreVersion();
59
+ if (!version || !bottomSheetHostDropsResize(version))
60
+ return;
61
+ warned = true;
62
+ console.warn(bottomSheetHostWarning(version));
63
+ }
64
+ /** Test hook: forget that the warning was already shown. */
65
+ export function resetBottomSheetHostWarningForTests() {
66
+ warned = false;
67
+ }
@@ -27,6 +27,31 @@ export type TextInputSurfaceResponderProps = Pick<ViewProps, "onStartShouldSetRe
27
27
  * for that.
28
28
  */
29
29
  export declare function useTextInputSurfaceResponder(focus: () => void): TextInputSurfaceResponderProps;
30
+ export type AncestorClaimWarningProps = Pick<ViewProps, "onStartShouldSetResponderCapture" | "onTouchStart">;
31
+ /** Test hook: let the once-per-session ancestor-claim warning fire again. */
32
+ export declare function resetAncestorClaimWarningForTests(): void;
33
+ /**
34
+ * Dev-only diagnostic for a boundary whose subtree is drawn in another native
35
+ * window but still lives in the screen's React tree (`BottomSheet.Content`).
36
+ *
37
+ * React Native's responder negotiation walks the React tree, so every ancestor
38
+ * of the sheet takes part in the capture phase for a tap inside it, even though
39
+ * none of them is under the finger. An ancestor `ScrollView` left on the default
40
+ * `keyboardShouldPersistTaps="never"` claims the tap there as soon as a text
41
+ * input holds focus and RN's `Keyboard` has reported the IME, and blurs the
42
+ * field on release: the keyboard closes and the tapped control never fires.
43
+ * Nothing inside the tree can preempt a capture-phase claim, so the boundary
44
+ * names it instead.
45
+ *
46
+ * The signal needs no heuristics: `onStartShouldSetResponderCapture` runs on
47
+ * this view for every touch that reaches the negotiation without an ancestor
48
+ * claiming it, while `onTouchStart` bubbles regardless. A touch start with no
49
+ * capture call for the same native event, while a package field is focused,
50
+ * is exactly a tap an ancestor took. Warns once per session, Android only,
51
+ * `__DEV__` only; elsewhere the inner props are returned untouched. The wrapped
52
+ * `onTouchStart` still runs, so the boundary's own behavior is unchanged.
53
+ */
54
+ export declare function useAncestorClaimWarning<P extends Pick<ViewProps, "onTouchStart">>(inner: P, message: string): P & AncestorClaimWarningProps;
30
55
  export type KeyboardDismissResponderProps = Pick<ViewProps, "onStartShouldSetResponder" | "onTouchStart" | "onTouchMove" | "onTouchEnd" | "onTouchCancel">;
31
56
  /**
32
57
  * View props for a tap-away keyboard-dismiss boundary. Spread onto the wrapper
@@ -191,6 +191,55 @@ export function useTextInputSurfaceResponder(focus) {
191
191
  };
192
192
  }, []);
193
193
  }
194
+ let warnedAncestorClaim = false;
195
+ /** Test hook: let the once-per-session ancestor-claim warning fire again. */
196
+ export function resetAncestorClaimWarningForTests() {
197
+ warnedAncestorClaim = false;
198
+ }
199
+ /**
200
+ * Dev-only diagnostic for a boundary whose subtree is drawn in another native
201
+ * window but still lives in the screen's React tree (`BottomSheet.Content`).
202
+ *
203
+ * React Native's responder negotiation walks the React tree, so every ancestor
204
+ * of the sheet takes part in the capture phase for a tap inside it, even though
205
+ * none of them is under the finger. An ancestor `ScrollView` left on the default
206
+ * `keyboardShouldPersistTaps="never"` claims the tap there as soon as a text
207
+ * input holds focus and RN's `Keyboard` has reported the IME, and blurs the
208
+ * field on release: the keyboard closes and the tapped control never fires.
209
+ * Nothing inside the tree can preempt a capture-phase claim, so the boundary
210
+ * names it instead.
211
+ *
212
+ * The signal needs no heuristics: `onStartShouldSetResponderCapture` runs on
213
+ * this view for every touch that reaches the negotiation without an ancestor
214
+ * claiming it, while `onTouchStart` bubbles regardless. A touch start with no
215
+ * capture call for the same native event, while a package field is focused,
216
+ * is exactly a tap an ancestor took. Warns once per session, Android only,
217
+ * `__DEV__` only; elsewhere the inner props are returned untouched. The wrapped
218
+ * `onTouchStart` still runs, so the boundary's own behavior is unchanged.
219
+ */
220
+ export function useAncestorClaimWarning(inner, message) {
221
+ const messageRef = useRef(message);
222
+ messageRef.current = message;
223
+ return useMemo(() => {
224
+ if (!__DEV__ || Platform.OS !== "android")
225
+ return inner;
226
+ let captured = null;
227
+ return {
228
+ ...inner,
229
+ onStartShouldSetResponderCapture: (event) => {
230
+ captured = keyOf(event);
231
+ return false;
232
+ },
233
+ onTouchStart: (event) => {
234
+ if (!warnedAncestorClaim && !isSameEvent(captured, event) && hasKeyboardFocusedInput()) {
235
+ warnedAncestorClaim = true;
236
+ console.warn(messageRef.current);
237
+ }
238
+ inner.onTouchStart?.(event);
239
+ },
240
+ };
241
+ }, [inner]);
242
+ }
194
243
  /**
195
244
  * View props for a tap-away keyboard-dismiss boundary. Spread onto the wrapper
196
245
  * `View` that encloses the screen (or a form). Nesting is safe: the innermost
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "package": "@mrmeg/expo-ui",
4
- "version": "0.26.0",
4
+ "version": "0.27.1",
5
5
  "tokens": {
6
6
  "spacing": {
7
7
  "entries": [
@@ -1,4 +1,5 @@
1
1
  export * from "./animations";
2
2
  export * from "./haptics";
3
3
  export * from "./i18n";
4
+ export * from "./stateSurface";
4
5
  export * from "./themedStyles";
package/dist/lib/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from "./animations.js";
2
2
  export * from "./haptics.js";
3
3
  export * from "./i18n.js";
4
+ export * from "./stateSurface.js";
4
5
  export * from "./themedStyles.js";
@@ -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
+ }
package/llms-full.md CHANGED
@@ -103,7 +103,7 @@ before creating a new app-local primitive.
103
103
  | `AnimatedView` | Entrance and visibility animation | Keep simple reveal effects in this wrapper. |
104
104
  | `Avatar`, `AvatarGroup` | Profile images with initials/icon fallback | Pass both `source` and `name`; set `size`/`shape` on the group, not per child. |
105
105
  | `Badge` | Short status labels | Prefer over custom pill views. |
106
- | `BottomSheet` | Mobile-first modal sheets | Requires root `UIProvider`. Native sheet via `@expo/ui`; `swipeEnabled`, `avoidKeyboard`, `dismissKeyboardOnDrag` are accepted but ignored. |
106
+ | `BottomSheet` | Mobile-first modal sheets | Requires root `UIProvider`. Native sheet via `@expo/ui`; `swipeEnabled`, `avoidKeyboard`, `dismissKeyboardOnDrag` are accepted but ignored (the iOS sheet presentation and Android Material3 keep content above the keyboard; never nest a `KeyboardAvoidingView` in a sheet). |
107
107
  | `Button` | Commands and CTAs | Use `preset`, not `variant`; heights are 28/32/40. |
108
108
  | `Card` | Individual framed content groups | Do not wrap whole page sections in cards. |
109
109
  | `Carousel` | Small known set of horizontally snapping slides | Renders every child (no virtualization); fractional `itemWidth` measures the viewport until first layout. |