@mrmeg/expo-ui 0.12.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,23 @@
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
+
6
23
  ## [0.12.0]
7
24
 
8
25
  ### Added
@@ -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,10 +1,11 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Pressable, StyleSheet, Platform, 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";
4
5
  import { KeyboardAvoidingView, useKeyboardAvoidance } from "./KeyboardAvoidingView.js";
6
+ import { isTouchInsideKeyboardFocusedInput } from "./keyboardFocusRegistry.js";
5
7
  /**
6
- * Full-screen tap-catcher that dismisses the keyboard, mounted ONLY while the
7
- * keyboard is visible.
8
+ * Native tap-away keyboard dismissal.
8
9
  *
9
10
  * Why window-level dismissal: the native `@expo/ui` TextInput is a SwiftUI /
10
11
  * Compose field that never registers with React Native's `TextInputState`, so
@@ -12,34 +13,46 @@ import { KeyboardAvoidingView, useKeyboardAvoidance } from "./KeyboardAvoidingVi
12
13
  * `KeyboardController.dismiss()` resigns the focused responder at the IME level,
13
14
  * which works for both native and RN fields.
14
15
  *
15
- * Why only-while-visible: an always-mounted catcher would intercept the very tap
16
- * that focuses a field and close the keyboard before it opens, and would fight
17
- * bottom sheets / modals. Rendering it solely while the keyboard is up means
18
- * nothing intercepts taps when no field is focused; once one is, a tap anywhere
19
- * 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.
20
20
  */
21
- function KeyboardDismissOverlay() {
21
+ function useDismissKeyboardOnOutsideTouch() {
22
22
  const isVisible = useKeyboardState((state) => state.isVisible);
23
- if (!isVisible)
24
- return null;
25
- return (_jsx(Pressable, { style: [StyleSheet.absoluteFill, styles.overlay], onPressIn: () => 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]);
26
34
  }
27
35
  /**
28
36
  * @returns Wrapper for a view that dismisses the keyboard when tapped outside of a text input
29
37
  */
30
- export const DismissKeyboard = ({ children, style, avoidKeyboard = true, scrollable = true }) => {
38
+ function DismissKeyboardLayout({ children, style, avoidKeyboard = true, scrollable = true, dismissOnTouchStart, }) {
31
39
  const hasKeyboardAvoidance = useKeyboardAvoidance();
32
40
  const content = scrollable ? (_jsx(ScrollView, { style: { flex: 1 }, contentContainerStyle: { flexGrow: 1, justifyContent: "center" }, keyboardShouldPersistTaps: "handled", showsVerticalScrollIndicator: false, children: children })) : (children);
33
- // Web has no software keyboard, so the tap-catcher is native-only. The overlay
34
- // is never created on web, keeping `useKeyboardState` off that platform.
35
- const overlay = Platform.OS !== "web" ? _jsx(KeyboardDismissOverlay, {}) : null;
36
41
  if (!avoidKeyboard || hasKeyboardAvoidance) {
37
- return (_jsxs(View, { style: { flex: 1 }, children: [content, overlay] }));
42
+ return (_jsx(View, { style: { flex: 1 }, onStartShouldSetResponderCapture: dismissOnTouchStart, children: content }));
38
43
  }
39
- return (_jsxs(KeyboardAvoidingView, { style: [{ flex: 1, width: "100%" }, style], 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 });
40
58
  };
41
- const styles = StyleSheet.create({
42
- overlay: {
43
- zIndex: 999,
44
- },
45
- });
@@ -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: {
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrmeg/expo-ui",
3
- "version": "0.12.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": [