@mrmeg/expo-ui 0.11.0 → 0.12.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.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,42 @@
3
3
  All notable changes to `@mrmeg/expo-ui` are documented here. This project
4
4
  adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
5
5
 
6
+ ## [0.12.1]
7
+
8
+ ### Fixed
9
+
10
+ - **`BottomSheet` now dismisses the keyboard when tapping outside a focused
11
+ field on Android.** The native sheet hosts its content in a separate window
12
+ (via `@expo/ui`'s `RNHostView`), outside the app's `KeyboardProvider` — so the
13
+ app-wide tap-away never reached sheet content, and `useKeyboardState()` /
14
+ `KeyboardController.dismiss()` are blind to that window. iOS papered over this
15
+ (SwiftUI resigns first-responder on outside taps for free); Compose did not,
16
+ leaving a focused field stuck open. `BottomSheet.Content` now mounts a
17
+ transparent dismiss overlay while a field is focused, detecting focus via the
18
+ window-independent `keyboardFocusRegistry` and resigning the field through its
19
+ own native `blur()` ref. The registry gained `subscribeKeyboardFocus`,
20
+ `hasKeyboardFocusedInput`, and `dismissKeyboardFocusedInput`, and `TextInput`
21
+ now registers a `blur` handle on focus.
22
+
23
+ ## [0.12.0]
24
+
25
+ ### Added
26
+
27
+ - **`KeyboardAvoidingView` is now a public package component.** Native uses
28
+ `react-native-keyboard-controller` with `automaticOffset` enabled by default,
29
+ while web renders a plain `View`.
30
+ - **`UIProvider` now owns app-wide native keyboard avoidance by default.** Apps
31
+ that mount `KeyboardProvider` above `UIProvider` get root-level keyboard
32
+ avoiding behavior without adding per-screen `KeyboardAvoidingView` wrappers.
33
+ Pass `keyboardAvoiding={false}` to opt out, or `keyboardAvoidingProps` to tune
34
+ the root wrapper. Web skips the root keyboard wrapper unless explicitly
35
+ enabled.
36
+
37
+ ### Fixed
38
+
39
+ - **`DismissKeyboard` no longer nests keyboard-avoiding wrappers when the root
40
+ provider already owns keyboard avoidance.**
41
+
6
42
  ## [0.11.0]
7
43
 
8
44
  ### Added
package/LLM_USAGE.md CHANGED
@@ -177,6 +177,7 @@ Use this table before creating a new app-local primitive.
177
177
  | `Collapsible`, `CollapsibleTrigger`, `CollapsibleContent` | One-off disclosure | Local animated height wrappers | Advanced settings, hidden helper text |
178
178
  | `Dialog`, `AlertDialog` | Modal decisions and custom modal content | Custom modal overlays | Confirm delete, edit profile, invite user |
179
179
  | `DismissKeyboard` | Tap-away keyboard dismissal | Screen-level keyboard handling | Forms, search screens, sign-in screens |
180
+ | `KeyboardAvoidingView` | Native keyboard-aware layout root | Repeated app-local keyboard wrappers | Screen roots, composer footers, form-heavy subtrees |
180
181
  | `Drawer` | Side panels and drawer navigation | Custom sliding panels | Filter drawer, app navigation drawer, inspector panel |
181
182
  | `DropdownMenu` | Menus and command lists | Homemade popover menus | Row actions, account menu, sort menu |
182
183
  | `EmptyState` | No-data or recoverable error regions | One-off empty placeholders | Empty inbox, no search results, failed list load |
package/README.md CHANGED
@@ -222,6 +222,7 @@ All components are exported from `@mrmeg/expo-ui/components`; direct imports suc
222
222
  | `ErrorBoundary` | React render error fallback | Unhandled screen crashes | Route-level fallback, feature boundary, recoverable widget crashes |
223
223
  | `Icon` | Feather or custom icons with theme tokens | Raw vector icons with hardcoded colors | Button accessories, empty-state icons, menu icons, status glyphs |
224
224
  | `InputOTP` | Verification code entry | Multiple manually managed text inputs | Email codes, SMS codes, MFA, invite codes |
225
+ | `KeyboardAvoidingView` | Native keyboard-aware layout root | Repeated app-local keyboard wrappers | Screen roots, composer footers, form-heavy subtrees |
225
226
  | `Label` | Accessible form labels | Plain styled text labels | Required labels, disabled labels, field group labels |
226
227
  | `MaxWidthContainer` | Centered responsive width | Per-screen max-width wrappers | Web pages, tablet layouts, settings forms, auth panels |
227
228
  | `Notification` | Global toast surface | Screen-local toast state | Saved/error/sync notifications, action toasts, loading toast, bottom-position alerts |
@@ -272,7 +273,7 @@ Use `Button.preset`, not `variant`. `default` is the neutral primary action, `se
272
273
 
273
274
  Use `StyledText` or its aliases instead of raw `Text` whenever the text is part of app UI. Use `TextInput` for labeled fields because it already owns label, helper text, error text, clear buttons, password visibility, numeric filtering, and left/right elements.
274
275
 
275
- Mount `UIProvider` once near the root before using `Dialog`, `AlertDialog`, `BottomSheet`, `Drawer`, `DropdownMenu`, `Popover`, `SelectContent`, `Tooltip`, or package notifications. On native, `BottomSheet.Content` listens to React Native keyboard events when `avoidKeyboard` is enabled; it defaults to `true` and can be disabled per sheet. Trigger transient feedback with `notify`.
276
+ Mount `UIProvider` once near the root before using `Dialog`, `AlertDialog`, `BottomSheet`, `Drawer`, `DropdownMenu`, `Popover`, `SelectContent`, `Tooltip`, or package notifications. On native, `UIProvider` also wraps app content in the package keyboard-avoiding root by default, so ordinary screens and fixed footers stay above the soft keyboard without repeated app-local wrappers; pass `keyboardAvoiding={false}` to opt out, or use `KeyboardAvoidingView` directly for a subtree with custom behavior. Web skips the root keyboard wrapper unless `keyboardAvoiding` is explicitly enabled. `BottomSheet.Content` listens to React Native keyboard events when `avoidKeyboard` is enabled; it defaults to `true` and can be disabled per sheet. Trigger transient feedback with `notify`.
276
277
 
277
278
  Use `Skeleton` components for loading content with stable dimensions, `EmptyState` for no-data/recoverable errors, `Alert` for blocking confirm/alert dialogs, and `Notification` for transient global feedback.
278
279
 
@@ -1,12 +1,15 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import React, { createContext, use, useCallback, useEffect, useReducer, useRef } from "react";
3
- import { View, Pressable, ScrollView, Platform, useWindowDimensions, } from "react-native";
2
+ import React, { createContext, use, useCallback, useEffect, useMemo, useReducer, useRef, useSyncExternalStore } from "react";
3
+ import { View, Pressable, StyleSheet, ScrollView, Platform, useWindowDimensions, } from "react-native";
4
4
  import { BottomSheet as NativeBottomSheet } from "@expo/ui/community/bottom-sheet";
5
+ import { KeyboardController } from "react-native-keyboard-controller";
5
6
  import { useSafeAreaInsets, initialWindowMetrics } from "react-native-safe-area-context";
6
7
  import { useTheme } from "../hooks/useTheme.js";
7
8
  import { spacing } from "../constants/spacing.js";
8
9
  import { TextColorContext, TextClassContext } from "./StyledText.context";
9
10
  import { Icon } from "./Icon.js";
11
+ import { dismissKeyboardFocusedInput, hasKeyboardFocusedInput, subscribeKeyboardFocus, } from "./keyboardFocusRegistry.js";
12
+ const DEFAULT_SNAP_POINTS = ["50%"];
10
13
  function clampSnapIndex(index, maxSnapIndex) {
11
14
  return Math.min(Math.max(index, 0), maxSnapIndex);
12
15
  }
@@ -22,25 +25,42 @@ function createInitialRootState(defaultOpen, maxSnapIndex) {
22
25
  }
23
26
  function bottomSheetRootReducer(state, action) {
24
27
  switch (action.type) {
25
- case "setInternalOpen":
28
+ case "setInternalOpen": {
29
+ const snapIndex = action.open ? state.snapIndex : action.maxSnapIndex;
30
+ const snapDirection = action.open ? state.snapDirection : -1;
31
+ if (state.internalOpen === action.open &&
32
+ state.snapIndex === snapIndex &&
33
+ state.snapDirection === snapDirection) {
34
+ return state;
35
+ }
26
36
  return {
27
37
  ...state,
28
38
  internalOpen: action.open,
29
- snapIndex: action.open ? state.snapIndex : action.maxSnapIndex,
30
- snapDirection: action.open ? state.snapDirection : -1,
39
+ snapIndex,
40
+ snapDirection,
31
41
  };
42
+ }
32
43
  case "setScrollable":
44
+ if (state.scrollable === action.scrollable)
45
+ return state;
33
46
  return { ...state, scrollable: action.scrollable };
34
47
  case "setHasHeader":
48
+ if (state.hasHeader === action.present)
49
+ return state;
35
50
  return { ...state, hasHeader: action.present };
36
51
  case "setHasFooter":
52
+ if (state.hasFooter === action.present)
53
+ return state;
37
54
  return { ...state, hasFooter: action.present };
38
55
  case "setSnapIndex": {
39
56
  const snapIndex = clampSnapIndex(action.index, action.maxSnapIndex);
57
+ const snapDirection = snapIndex === 0 ? 1 : snapIndex === action.maxSnapIndex ? -1 : state.snapDirection;
58
+ if (state.snapIndex === snapIndex && state.snapDirection === snapDirection)
59
+ return state;
40
60
  return {
41
61
  ...state,
42
62
  snapIndex,
43
- snapDirection: snapIndex === 0 ? 1 : snapIndex === action.maxSnapIndex ? -1 : state.snapDirection,
63
+ snapDirection,
44
64
  };
45
65
  }
46
66
  case "cycleSnapPoint": {
@@ -140,9 +160,51 @@ function SheetCloseButton({ style }) {
140
160
  ], children: _jsx(Icon, { name: "x", size: 22, color: "text" }) }));
141
161
  }
142
162
  // ============================================================================
163
+ // Keyboard dismiss overlay — tap-away dismissal inside the sheet
164
+ // ============================================================================
165
+ /**
166
+ * Tap-away keyboard dismissal for sheet content.
167
+ *
168
+ * The native sheet (SwiftUI `.sheet()` / Material `ModalBottomSheet`) hosts its
169
+ * RN children in a separate native window via @expo/ui's `RNHostView`, OUTSIDE
170
+ * the app's `KeyboardProvider` / `KeyboardDismissBoundary`. Two consequences,
171
+ * both worked around here:
172
+ *
173
+ * 1. Detection: `react-native-keyboard-controller`'s `useKeyboardState` watches
174
+ * the main window and does NOT see a keyboard raised inside the sheet's
175
+ * window (the same isolation that makes `useSafeAreaInsets()` read zero in
176
+ * here). So presence is read from the `keyboardFocusRegistry` instead, which
177
+ * is fed by @expo/ui's own native focus events and is window-independent.
178
+ * 2. Dispatch: RN's JS responder chain (onStartShouldSetResponder*) is NOT
179
+ * dispatched across the `RNHostView` boundary on Android — only `Pressable`
180
+ * hit-testing (native `measure()`) works inside the host. So this is a
181
+ * transparent `Pressable` mounted ONLY while a field is focused, mirroring
182
+ * the app's `DismissKeyboardOverlay`. The tap blurs the focused field via
183
+ * its own native ref (`dismissKeyboardFocusedInput`), which resigns the
184
+ * responder regardless of window; `KeyboardController.dismiss()` is a
185
+ * best-effort fallback for the (rare) case with no registered blur handle.
186
+ *
187
+ * iOS doesn't strictly need this (SwiftUI resigns first-responder on outside
188
+ * taps for free), but the same path is harmless and keeps behavior identical.
189
+ */
190
+ function SheetKeyboardDismissOverlay() {
191
+ const hasFocus = useSyncExternalStore(subscribeKeyboardFocus, hasKeyboardFocusedInput, () => false);
192
+ if (Platform.OS === "web" || !hasFocus)
193
+ return null;
194
+ return (_jsx(Pressable, { style: [StyleSheet.absoluteFill, styles.keyboardDismissOverlay], onPressIn: () => {
195
+ if (!dismissKeyboardFocusedInput())
196
+ KeyboardController.dismiss();
197
+ }, accessibilityLabel: "Dismiss keyboard", accessibilityRole: "button" }));
198
+ }
199
+ const styles = StyleSheet.create({
200
+ keyboardDismissOverlay: {
201
+ zIndex: 999,
202
+ },
203
+ });
204
+ // ============================================================================
143
205
  // Root
144
206
  // ============================================================================
145
- function BottomSheetRoot({ open: controlledOpen, onOpenChange: controlledOnOpenChange, defaultOpen = false, snapPoints = ["50%"], closeOnBackdropPress = true, children, }) {
207
+ function BottomSheetRoot({ open: controlledOpen, onOpenChange: controlledOnOpenChange, defaultOpen = false, snapPoints = DEFAULT_SNAP_POINTS, closeOnBackdropPress = true, children, }) {
146
208
  const maxSnapIndex = Math.max(snapPoints.length - 1, 0);
147
209
  const [state, dispatch] = useReducer(bottomSheetRootReducer, undefined, () => createInitialRootState(defaultOpen, maxSnapIndex));
148
210
  const isControlled = controlledOpen !== undefined;
@@ -167,7 +229,16 @@ function BottomSheetRoot({ open: controlledOpen, onOpenChange: controlledOnOpenC
167
229
  }
168
230
  }, [controlledOnOpenChange, isControlled, maxSnapIndex]);
169
231
  const toggle = useCallback(() => onOpenChange(!open), [onOpenChange, open]);
170
- const contextValue = {
232
+ const setScrollable = useCallback((scrollable) => {
233
+ dispatch({ type: "setScrollable", scrollable });
234
+ }, []);
235
+ const setHasHeader = useCallback((present) => {
236
+ dispatch({ type: "setHasHeader", present });
237
+ }, []);
238
+ const setHasFooter = useCallback((present) => {
239
+ dispatch({ type: "setHasFooter", present });
240
+ }, []);
241
+ const contextValue = useMemo(() => ({
171
242
  open,
172
243
  onOpenChange,
173
244
  toggle,
@@ -177,12 +248,27 @@ function BottomSheetRoot({ open: controlledOpen, onOpenChange: controlledOnOpenC
177
248
  cycleSnapPoint,
178
249
  closeOnBackdropPress,
179
250
  scrollable: state.scrollable,
180
- setScrollable: (scrollable) => dispatch({ type: "setScrollable", scrollable }),
251
+ setScrollable,
181
252
  hasHeader: state.hasHeader,
182
- setHasHeader: (present) => dispatch({ type: "setHasHeader", present }),
253
+ setHasHeader,
183
254
  hasFooter: state.hasFooter,
184
- setHasFooter: (present) => dispatch({ type: "setHasFooter", present }),
185
- };
255
+ setHasFooter,
256
+ }), [
257
+ open,
258
+ onOpenChange,
259
+ toggle,
260
+ snapPoints,
261
+ snapIndex,
262
+ setSnapIndex,
263
+ cycleSnapPoint,
264
+ closeOnBackdropPress,
265
+ state.scrollable,
266
+ setScrollable,
267
+ state.hasHeader,
268
+ setHasHeader,
269
+ state.hasFooter,
270
+ setHasFooter,
271
+ ]);
186
272
  return (_jsx(BottomSheetContext.Provider, { value: contextValue, children: children }));
187
273
  }
188
274
  // ============================================================================
@@ -259,7 +345,7 @@ swipeEnabled: _swipeEnabled, avoidKeyboard: _avoidKeyboard, dismissKeyboardOnDra
259
345
  backgroundColor: theme.colors.card,
260
346
  },
261
347
  styleOverride,
262
- ], children: [children, showFloatingClose && (_jsx(SheetCloseButton, { style: {
348
+ ], children: [children, _jsx(SheetKeyboardDismissOverlay, {}), showFloatingClose && (_jsx(SheetCloseButton, { style: {
263
349
  // The sheet card sits below the status bar, so no top inset
264
350
  // is needed — just clear the sheet's own top edge / handle.
265
351
  position: "absolute",
@@ -11,5 +11,5 @@ type Props = {
11
11
  /**
12
12
  * @returns Wrapper for a view that dismisses the keyboard when tapped outside of a text input
13
13
  */
14
- export declare const DismissKeyboard: ({ children, style, avoidKeyboard, scrollable }: Props) => React.JSX.Element;
14
+ export declare const DismissKeyboard: (props: Props) => React.JSX.Element;
15
15
  export {};
@@ -1,9 +1,11 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Pressable, StyleSheet, Platform, KeyboardAvoidingView, ScrollView, View, } from "react-native";
3
- import { KeyboardController, useKeyboardState } from "react-native-keyboard-controller";
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import React from "react";
3
+ import { Platform, ScrollView, View, } from "react-native";
4
+ import { KeyboardController, useKeyboardContext, useKeyboardState, } from "react-native-keyboard-controller";
5
+ import { KeyboardAvoidingView, useKeyboardAvoidance } from "./KeyboardAvoidingView.js";
6
+ import { isTouchInsideKeyboardFocusedInput } from "./keyboardFocusRegistry.js";
4
7
  /**
5
- * Full-screen tap-catcher that dismisses the keyboard, mounted ONLY while the
6
- * keyboard is visible.
8
+ * Native tap-away keyboard dismissal.
7
9
  *
8
10
  * Why window-level dismissal: the native `@expo/ui` TextInput is a SwiftUI /
9
11
  * Compose field that never registers with React Native's `TextInputState`, so
@@ -11,28 +13,46 @@ import { KeyboardController, useKeyboardState } from "react-native-keyboard-cont
11
13
  * `KeyboardController.dismiss()` resigns the focused responder at the IME level,
12
14
  * which works for both native and RN fields.
13
15
  *
14
- * Why only-while-visible: an always-mounted catcher would intercept the very tap
15
- * that focuses a field and close the keyboard before it opens, and would fight
16
- * bottom sheets / modals. Rendering it solely while the keyboard is up means
17
- * nothing intercepts taps when no field is focused; once one is, a tap anywhere
18
- * off the field dismisses.
16
+ * Why responder capture: a full-screen overlay also catches taps on the focused
17
+ * input, which breaks double-tap text selection. Capture lets the tap continue
18
+ * to children while still dismissing when the touch starts outside the focused
19
+ * input bounds.
19
20
  */
20
- function KeyboardDismissOverlay() {
21
+ function useDismissKeyboardOnOutsideTouch() {
21
22
  const isVisible = useKeyboardState((state) => state.isVisible);
22
- if (!isVisible)
23
- return null;
24
- return (_jsx(Pressable, { style: StyleSheet.absoluteFill, onPress: () => KeyboardController.dismiss(), accessibilityLabel: "Dismiss keyboard", accessibilityRole: "button" }));
23
+ const { layout: focusedInput } = useKeyboardContext();
24
+ return React.useCallback((event) => {
25
+ if (Platform.OS === "web" || !isVisible) {
26
+ return false;
27
+ }
28
+ if (isTouchInsideKeyboardFocusedInput(event, focusedInput.value?.layout)) {
29
+ return false;
30
+ }
31
+ KeyboardController.dismiss();
32
+ return false;
33
+ }, [focusedInput, isVisible]);
25
34
  }
26
35
  /**
27
36
  * @returns Wrapper for a view that dismisses the keyboard when tapped outside of a text input
28
37
  */
29
- export const DismissKeyboard = ({ children, style, avoidKeyboard = true, scrollable = true }) => {
38
+ function DismissKeyboardLayout({ children, style, avoidKeyboard = true, scrollable = true, dismissOnTouchStart, }) {
39
+ const hasKeyboardAvoidance = useKeyboardAvoidance();
30
40
  const content = scrollable ? (_jsx(ScrollView, { style: { flex: 1 }, contentContainerStyle: { flexGrow: 1, justifyContent: "center" }, keyboardShouldPersistTaps: "handled", showsVerticalScrollIndicator: false, children: children })) : (children);
31
- // Web has no software keyboard, so the tap-catcher is native-only. The overlay
32
- // is never created on web, keeping `useKeyboardState` off that platform.
33
- const overlay = Platform.OS !== "web" ? _jsx(KeyboardDismissOverlay, {}) : null;
34
- if (!avoidKeyboard) {
35
- return (_jsxs(View, { style: { flex: 1 }, children: [content, overlay] }));
41
+ if (!avoidKeyboard || hasKeyboardAvoidance) {
42
+ return (_jsx(View, { style: { flex: 1 }, onStartShouldSetResponderCapture: dismissOnTouchStart, children: content }));
36
43
  }
37
- return (_jsxs(KeyboardAvoidingView, { style: [{ flex: 1, width: "100%" }, style], behavior: Platform.OS === "ios" ? "padding" : "height", keyboardVerticalOffset: Platform.OS === "ios" ? 0 : 0, children: [content, overlay] }));
44
+ return (_jsx(KeyboardAvoidingView, { style: [{ flex: 1, width: "100%" }, style], keyboardVerticalOffset: Platform.OS === "ios" ? 0 : 0, onStartShouldSetResponderCapture: dismissOnTouchStart, children: content }));
45
+ }
46
+ function NativeDismissKeyboard(props) {
47
+ const dismissOnTouchStart = useDismissKeyboardOnOutsideTouch();
48
+ return (_jsx(DismissKeyboardLayout, { ...props, dismissOnTouchStart: dismissOnTouchStart }));
49
+ }
50
+ /**
51
+ * @returns Wrapper for a view that dismisses the keyboard when tapped outside of a text input
52
+ */
53
+ export const DismissKeyboard = (props) => {
54
+ if (Platform.OS === "web") {
55
+ return _jsx(DismissKeyboardLayout, { ...props });
56
+ }
57
+ return _jsx(NativeDismissKeyboard, { ...props });
38
58
  };
@@ -0,0 +1,20 @@
1
+ import React from "react";
2
+ import { type StyleProp, type ViewProps, type ViewStyle } from "react-native";
3
+ type KeyboardAvoidingBehavior = "height" | "padding" | "position" | "translate-with-padding";
4
+ export interface KeyboardAvoidingViewProps extends ViewProps {
5
+ children: React.ReactNode;
6
+ style?: StyleProp<ViewStyle>;
7
+ behavior?: KeyboardAvoidingBehavior;
8
+ contentContainerStyle?: ViewProps["style"];
9
+ keyboardVerticalOffset?: number;
10
+ automaticOffset?: boolean;
11
+ }
12
+ export declare function useKeyboardAvoidance(): boolean;
13
+ /**
14
+ * Package-level keyboard avoiding wrapper.
15
+ *
16
+ * Native uses `react-native-keyboard-controller` so screens can avoid the soft
17
+ * keyboard with `automaticOffset`; web renders a plain `View`.
18
+ */
19
+ export declare function KeyboardAvoidingView({ children, style, behavior, automaticOffset, contentContainerStyle, keyboardVerticalOffset, ...props }: KeyboardAvoidingViewProps): React.JSX.Element;
20
+ export {};
@@ -0,0 +1,20 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { createContext, useContext } from "react";
3
+ import { Platform, View, } from "react-native";
4
+ import { KeyboardAvoidingView as NativeKeyboardAvoidingView, } from "react-native-keyboard-controller";
5
+ const KeyboardAvoidanceContext = createContext(false);
6
+ export function useKeyboardAvoidance() {
7
+ return useContext(KeyboardAvoidanceContext);
8
+ }
9
+ /**
10
+ * Package-level keyboard avoiding wrapper.
11
+ *
12
+ * Native uses `react-native-keyboard-controller` so screens can avoid the soft
13
+ * keyboard with `automaticOffset`; web renders a plain `View`.
14
+ */
15
+ export function KeyboardAvoidingView({ children, style, behavior = Platform.OS === "ios" ? "padding" : "height", automaticOffset = true, contentContainerStyle, keyboardVerticalOffset, ...props }) {
16
+ if (Platform.OS === "web") {
17
+ return (_jsx(KeyboardAvoidanceContext.Provider, { value: true, children: _jsx(View, { style: style, ...props, children: children }) }));
18
+ }
19
+ return (_jsx(KeyboardAvoidanceContext.Provider, { value: true, children: _jsx(NativeKeyboardAvoidingView, { style: style, behavior: behavior, automaticOffset: automaticOffset, contentContainerStyle: contentContainerStyle, keyboardVerticalOffset: keyboardVerticalOffset, ...props, children: children }) }));
20
+ }
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useEffect, useImperativeHandle, useMemo, useRef, useState, } from "react";
2
+ import { useEffect, useCallback, useImperativeHandle, useMemo, useRef, useState, } from "react";
3
3
  import { StyleSheet, TextInput as RNTextInput, Platform, View, Pressable, } from "react-native";
4
4
  import { Host, TextInput as ExpoTextInput, useNativeState, } from "@expo/ui";
5
5
  import { useTheme } from "../hooks/useTheme.js";
@@ -9,6 +9,7 @@ import { StyledText } from "./StyledText.js";
9
9
  import { Icon } from "./Icon.js";
10
10
  import { hapticLight } from "../lib/haptics.js";
11
11
  import { palette } from "../constants/colors.js";
12
+ import { clearKeyboardFocusedInputLayout, setKeyboardFocusedInputLayout, } from "./keyboardFocusRegistry.js";
12
13
  const NUMERIC_REGEX = /^[0-9]*$/;
13
14
  const SIZE_CONFIGS = {
14
15
  sm: {
@@ -188,7 +189,7 @@ function WebTextInput({ variant = "outline", size = "md", label, helperText, err
188
189
  * elements, and error icon — are intentionally omitted on native to avoid
189
190
  * layering RN views over the native host view.
190
191
  */
191
- function NativeTextInput({ variant = "outline", size = "md", label, helperText, errorText, error, required, rows, forceLight, inputMode, onChangeText, value, defaultValue, editable = true, multiline, secureTextEntry, showSecureEntryToggle, ref, wrapperStyle,
192
+ 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,
192
193
  // Web-only affordances. Destructured out of `rest` so they're NOT forwarded to
193
194
  // the native field; intentionally unused on this path (see doc comment above).
194
195
  // `style` (an RN TextStyle) is likewise dropped — it doesn't map to
@@ -220,6 +221,43 @@ leftElement, rightElement, clearable, focusedStyle, style, ...rest }) {
220
221
  // We expose the subset consumers use, plus a `setNativeProps` shim so the
221
222
  // uncontrolled AuthTextField can push corrected text into the native buffer.
222
223
  const innerRef = useRef(null);
224
+ const surfaceRef = useRef(null);
225
+ const isFocusedRef = useRef(false);
226
+ const focusRegistryToken = useMemo(() => ({}), []);
227
+ const syncFocusedInputLayout = useCallback(() => {
228
+ if (!isFocusedRef.current)
229
+ return;
230
+ surfaceRef.current?.measureInWindow((absoluteX, absoluteY, width, height) => {
231
+ if (!isFocusedRef.current)
232
+ return;
233
+ setKeyboardFocusedInputLayout(focusRegistryToken, {
234
+ x: 0,
235
+ y: 0,
236
+ width,
237
+ height,
238
+ absoluteX,
239
+ absoluteY,
240
+ },
241
+ // Window-independent dismissal: blur the native field directly. Inside a
242
+ // native bottom sheet the field lives in its own window, where
243
+ // `KeyboardController.dismiss()` can miss; calling the field's own ref
244
+ // always resigns it.
245
+ () => innerRef.current?.blur());
246
+ });
247
+ }, [focusRegistryToken]);
248
+ const handleFocus = useCallback(() => {
249
+ isFocusedRef.current = true;
250
+ syncFocusedInputLayout();
251
+ onFocus?.();
252
+ }, [onFocus, syncFocusedInputLayout]);
253
+ const handleBlur = useCallback(() => {
254
+ isFocusedRef.current = false;
255
+ clearKeyboardFocusedInputLayout(focusRegistryToken);
256
+ onBlur?.();
257
+ }, [focusRegistryToken, onBlur]);
258
+ useEffect(() => {
259
+ return () => clearKeyboardFocusedInputLayout(focusRegistryToken);
260
+ }, [focusRegistryToken]);
223
261
  useImperativeHandle(ref, () => ({
224
262
  focus: () => innerRef.current?.focus(),
225
263
  blur: () => innerRef.current?.blur(),
@@ -289,7 +327,7 @@ leftElement, rightElement, clearable, focusedStyle, style, ...rest }) {
289
327
  fontSize: sizeConfig.fontSize,
290
328
  ...(nativeFontFamily ? { fontFamily: nativeFontFamily } : null),
291
329
  };
292
- return (_jsxs(View, { style: wrapperStyle, children: [!!label && (_jsx(View, { style: styles.labelContainer, children: _jsxs(StyledText, { selectable: false, style: styles.label, children: [label, required && _jsx(StyledText, { selectable: false, style: styles.required, children: " *" })] }) })), _jsxs(View, { style: [surfaceStyle, hasSecureToggle && styles.nativeRow], children: [_jsx(Host, { matchContents: { vertical: true }, style: hasSecureToggle ? styles.nativeHostFlex : styles.nativeHost, children: _jsx(ExpoTextInput, { ...rest, ref: innerRef, value: state, defaultValue: defaultValue, onChangeText: onChangeText, editable: editable, multiline: multiline, rows: rows, inputMode: inputMode, secureTextEntry: effectiveSecureTextEntry, placeholderTextColor: theme.colors.textDim, style: boxStyle, textStyle: textStyle }) }), hasSecureToggle && (_jsx(Pressable, { style: styles.nativePasswordToggle, onPress: () => setPasswordVisible((v) => !v), accessibilityLabel: passwordVisible ? "Hide password" : "Show password", accessibilityRole: "button", children: _jsx(Icon, { name: passwordVisible ? "eye-off" : "eye", size: spacing.iconSm + 4, color: "textDim" }) }))] }), !!(helperText || errorText) && (_jsx(StyledText, { selectable: false, style: [styles.helperText, hasError && styles.errorText], children: errorText || helperText }))] }));
330
+ return (_jsxs(View, { style: wrapperStyle, children: [!!label && (_jsx(View, { style: styles.labelContainer, children: _jsxs(StyledText, { selectable: false, style: styles.label, children: [label, required && _jsx(StyledText, { selectable: false, style: styles.required, children: " *" })] }) })), _jsxs(View, { ref: surfaceRef, style: [surfaceStyle, hasSecureToggle && styles.nativeRow], onLayout: syncFocusedInputLayout, children: [_jsx(Host, { matchContents: { vertical: true }, style: hasSecureToggle ? styles.nativeHostFlex : styles.nativeHost, children: _jsx(ExpoTextInput, { ...rest, ref: innerRef, value: state, defaultValue: defaultValue, onChangeText: onChangeText, onFocus: handleFocus, onBlur: handleBlur, editable: editable, multiline: multiline, rows: rows, inputMode: inputMode, secureTextEntry: effectiveSecureTextEntry, placeholderTextColor: theme.colors.textDim, style: boxStyle, textStyle: textStyle }) }), hasSecureToggle && (_jsx(Pressable, { style: styles.nativePasswordToggle, onPress: () => setPasswordVisible((v) => !v), accessibilityLabel: passwordVisible ? "Hide password" : "Show password", accessibilityRole: "button", children: _jsx(Icon, { name: passwordVisible ? "eye-off" : "eye", size: spacing.iconSm + 4, color: "textDim" }) }))] }), !!(helperText || errorText) && (_jsx(StyledText, { selectable: false, style: [styles.helperText, hasError && styles.errorText], children: errorText || helperText }))] }));
293
331
  }
294
332
  const createStyles = (theme, variant, size) => StyleSheet.create({
295
333
  nativeHost: {
@@ -1,4 +1,5 @@
1
1
  import * as React from "react";
2
+ import { type KeyboardAvoidingViewProps } from "./KeyboardAvoidingView";
2
3
  export interface UIProviderProps {
3
4
  children: React.ReactNode;
4
5
  /**
@@ -19,5 +20,15 @@ export interface UIProviderProps {
19
20
  * @default true
20
21
  */
21
22
  statusBar?: boolean;
23
+ /**
24
+ * Wrap app content in the package keyboard-avoiding root.
25
+ *
26
+ * @default true on native, false on web
27
+ */
28
+ keyboardAvoiding?: boolean;
29
+ /**
30
+ * Props forwarded to the keyboard-avoiding root when enabled.
31
+ */
32
+ keyboardAvoidingProps?: Omit<KeyboardAvoidingViewProps, "children">;
22
33
  }
23
- export declare function UIProvider({ children, notification, portalHost, statusBar, }: UIProviderProps): React.JSX.Element;
34
+ export declare function UIProvider({ children, notification, portalHost, statusBar, keyboardAvoiding, keyboardAvoidingProps, }: UIProviderProps): React.JSX.Element;
@@ -1,7 +1,11 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Platform } from "react-native";
2
3
  import { PortalHost } from "@rn-primitives/portal";
3
4
  import { Notification } from "./Notification.js";
4
5
  import { StatusBar } from "./StatusBar.js";
5
- export function UIProvider({ children, notification = true, portalHost = true, statusBar = true, }) {
6
- return (_jsxs(_Fragment, { children: [children, notification ? _jsx(Notification, {}) : null, portalHost ? _jsx(PortalHost, {}) : null, statusBar ? _jsx(StatusBar, {}) : null] }));
6
+ import { KeyboardAvoidingView, } from "./KeyboardAvoidingView.js";
7
+ export function UIProvider({ children, notification = true, portalHost = true, statusBar = true, keyboardAvoiding = Platform.OS !== "web", keyboardAvoidingProps, }) {
8
+ const { style: keyboardAvoidingStyle, ...restKeyboardAvoidingProps } = keyboardAvoidingProps ?? {};
9
+ const content = keyboardAvoiding ? (_jsx(KeyboardAvoidingView, { style: [{ flex: 1 }, keyboardAvoidingStyle], ...restKeyboardAvoidingProps, children: children })) : (children);
10
+ return (_jsxs(_Fragment, { children: [content, notification ? _jsx(Notification, {}) : null, portalHost ? _jsx(PortalHost, {}) : null, statusBar ? _jsx(StatusBar, {}) : null] }));
7
11
  }
@@ -15,6 +15,7 @@ export * from "./EmptyState";
15
15
  export * from "./ErrorBoundary";
16
16
  export * from "./Icon";
17
17
  export * from "./InputOTP";
18
+ export * from "./KeyboardAvoidingView";
18
19
  export * from "./Label";
19
20
  export * from "./MaxWidthContainer";
20
21
  export * from "./Notification";
@@ -15,6 +15,7 @@ export * from "./EmptyState.js";
15
15
  export * from "./ErrorBoundary.js";
16
16
  export * from "./Icon.js";
17
17
  export * from "./InputOTP.js";
18
+ export * from "./KeyboardAvoidingView.js";
18
19
  export * from "./Label.js";
19
20
  export * from "./MaxWidthContainer.js";
20
21
  export * from "./Notification.js";
@@ -0,0 +1,29 @@
1
+ import type { GestureResponderEvent } from "react-native";
2
+ import type { FocusedInputLayoutChangedEvent } from "react-native-keyboard-controller";
3
+ export type KeyboardFocusedInputLayout = FocusedInputLayoutChangedEvent["layout"];
4
+ export type KeyboardFocusedInputToken = object;
5
+ /**
6
+ * Subscribe to focus presence changes. Pairs with {@link hasKeyboardFocusedInput}
7
+ * via `useSyncExternalStore` so a component can mount/unmount with focus WITHOUT
8
+ * `react-native-keyboard-controller`'s `useKeyboardState` — which doesn't observe
9
+ * keyboards raised inside a native sheet's isolated window.
10
+ */
11
+ export declare function subscribeKeyboardFocus(listener: () => void): () => void;
12
+ export declare function setKeyboardFocusedInputLayout(token: KeyboardFocusedInputToken, layout: KeyboardFocusedInputLayout, blur?: () => void): void;
13
+ export declare function clearKeyboardFocusedInputLayout(token: KeyboardFocusedInputToken): void;
14
+ export declare function getKeyboardFocusedInputLayout(): {
15
+ x: number;
16
+ y: number;
17
+ width: number;
18
+ height: number;
19
+ absoluteX: number;
20
+ absoluteY: number;
21
+ } | undefined;
22
+ /** Whether any native field currently holds focus. Stable boolean for snapshots. */
23
+ export declare function hasKeyboardFocusedInput(): boolean;
24
+ /**
25
+ * Resign the currently-focused native field, dismissing its keyboard. Returns
26
+ * `true` if a field was focused and a blur handle was available.
27
+ */
28
+ export declare function dismissKeyboardFocusedInput(): boolean;
29
+ export declare function isTouchInsideKeyboardFocusedInput(event: GestureResponderEvent, nativeLayout?: KeyboardFocusedInputLayout | null): boolean;
@@ -0,0 +1,64 @@
1
+ const FOCUSED_INPUT_TOUCH_SLOP = 16;
2
+ let focusedInput = null;
3
+ const listeners = new Set();
4
+ function emit() {
5
+ listeners.forEach((listener) => listener());
6
+ }
7
+ /**
8
+ * Subscribe to focus presence changes. Pairs with {@link hasKeyboardFocusedInput}
9
+ * via `useSyncExternalStore` so a component can mount/unmount with focus WITHOUT
10
+ * `react-native-keyboard-controller`'s `useKeyboardState` — which doesn't observe
11
+ * keyboards raised inside a native sheet's isolated window.
12
+ */
13
+ export function subscribeKeyboardFocus(listener) {
14
+ listeners.add(listener);
15
+ return () => {
16
+ listeners.delete(listener);
17
+ };
18
+ }
19
+ export function setKeyboardFocusedInputLayout(token, layout, blur) {
20
+ const wasPresent = focusedInput != null;
21
+ focusedInput = { token, layout, blur };
22
+ // Only notify subscribers on a presence transition; re-measures (scroll,
23
+ // layout) update the layout silently so the overlay doesn't churn.
24
+ if (!wasPresent)
25
+ emit();
26
+ }
27
+ export function clearKeyboardFocusedInputLayout(token) {
28
+ if (focusedInput?.token === token) {
29
+ focusedInput = null;
30
+ emit();
31
+ }
32
+ }
33
+ export function getKeyboardFocusedInputLayout() {
34
+ return focusedInput?.layout;
35
+ }
36
+ /** Whether any native field currently holds focus. Stable boolean for snapshots. */
37
+ export function hasKeyboardFocusedInput() {
38
+ return focusedInput != null;
39
+ }
40
+ /**
41
+ * Resign the currently-focused native field, dismissing its keyboard. Returns
42
+ * `true` if a field was focused and a blur handle was available.
43
+ */
44
+ export function dismissKeyboardFocusedInput() {
45
+ if (focusedInput?.blur) {
46
+ focusedInput.blur();
47
+ return true;
48
+ }
49
+ return false;
50
+ }
51
+ function isTouchInsideLayout(event, layout) {
52
+ if (!layout)
53
+ return false;
54
+ const { pageX, pageY } = event.nativeEvent;
55
+ const left = layout.absoluteX - FOCUSED_INPUT_TOUCH_SLOP;
56
+ const right = layout.absoluteX + layout.width + FOCUSED_INPUT_TOUCH_SLOP;
57
+ const top = layout.absoluteY - FOCUSED_INPUT_TOUCH_SLOP;
58
+ const bottom = layout.absoluteY + layout.height + FOCUSED_INPUT_TOUCH_SLOP;
59
+ return pageX >= left && pageX <= right && pageY >= top && pageY <= bottom;
60
+ }
61
+ export function isTouchInsideKeyboardFocusedInput(event, nativeLayout) {
62
+ return (isTouchInsideLayout(event, getKeyboardFocusedInputLayout()) ||
63
+ isTouchInsideLayout(event, nativeLayout));
64
+ }
package/llms-full.md CHANGED
@@ -31,10 +31,14 @@ app-local component files.
31
31
  Call `useResources()` once near the Expo app root. Mount `UIProvider` once near
32
32
  the root when the app uses package feedback or overlay components.
33
33
 
34
- `UIProvider` owns the package `Notification`, `StatusBar`, and default
35
- `@rn-primitives` portal host. Mount it before using `Dialog`, `AlertDialog`,
36
- `BottomSheet`, `Drawer`, `DropdownMenu`, `Popover`, `SelectContent`,
37
- `Tooltip`, or `notify` / `globalUIStore` notifications.
34
+ `UIProvider` owns the package `Notification`, `StatusBar`, default
35
+ `@rn-primitives` portal host, and native keyboard-avoiding root. Mount it
36
+ before using `Dialog`, `AlertDialog`, `BottomSheet`, `Drawer`, `DropdownMenu`,
37
+ `Popover`, `SelectContent`, `Tooltip`, or `notify` / `globalUIStore`
38
+ notifications. Pass `keyboardAvoiding={false}` to opt out of the native root
39
+ keyboard avoidance, or use `KeyboardAvoidingView` directly for a subtree with
40
+ custom behavior. Web skips the root keyboard wrapper unless `keyboardAvoiding`
41
+ is explicitly enabled.
38
42
 
39
43
  On native, `BottomSheet.Content` composes its sheet transform with React Native
40
44
  keyboard event values. Pass `avoidKeyboard={false}` for sheets that should not
@@ -99,6 +103,7 @@ Use this catalog before creating a new app-local primitive.
99
103
  | `ErrorBoundary` | `@mrmeg/expo-ui/components` | React render error fallback | Use for route or feature boundaries. |
100
104
  | `Icon` | `@mrmeg/expo-ui/components` | Feather or custom icons with theme tokens | Avoid raw vector icons with hardcoded colors. |
101
105
  | `InputOTP` | `@mrmeg/expo-ui/components` | Verification code entry | Prefer over manually managed text input groups. |
106
+ | `KeyboardAvoidingView` | `@mrmeg/expo-ui/components` | Native keyboard-aware layout roots, composer footers, and form-heavy subtrees | `UIProvider` already mounts one root by default; use this directly only for custom subtrees. |
102
107
  | `Label` | `@mrmeg/expo-ui/components` | Accessible form labels | Use with package form controls. |
103
108
  | `MaxWidthContainer` | `@mrmeg/expo-ui/components` | Centered responsive width | Use for web and tablet constrained layouts. |
104
109
  | `Notification` | `@mrmeg/expo-ui/components` | Global toast surface | Trigger through `notify` (or `globalUIStore` for subscriptions/tests) with root `UIProvider`; optional actions dismiss after press. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrmeg/expo-ui",
3
- "version": "0.11.0",
3
+ "version": "0.12.1",
4
4
  "private": false,
5
5
  "description": "Reusable Expo and React Native UI primitives for MrMeg projects.",
6
6
  "keywords": [