@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 +17 -0
- package/dist/components/BottomSheet.js +99 -13
- package/dist/components/DismissKeyboard.d.ts +1 -1
- package/dist/components/DismissKeyboard.js +38 -25
- package/dist/components/TextInput.js +41 -3
- package/dist/components/keyboardFocusRegistry.d.ts +29 -0
- package/dist/components/keyboardFocusRegistry.js +64 -0
- package/package.json +1 -1
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
|
|
30
|
-
snapDirection
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
251
|
+
setScrollable,
|
|
181
252
|
hasHeader: state.hasHeader,
|
|
182
|
-
setHasHeader
|
|
253
|
+
setHasHeader,
|
|
183
254
|
hasFooter: state.hasFooter,
|
|
184
|
-
setHasFooter
|
|
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: (
|
|
14
|
+
export declare const DismissKeyboard: (props: Props) => React.JSX.Element;
|
|
15
15
|
export {};
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { jsx as _jsx
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
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
|
-
*
|
|
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
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
|
|
21
|
+
function useDismissKeyboardOnOutsideTouch() {
|
|
22
22
|
const isVisible = useKeyboardState((state) => state.isVisible);
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
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 (
|
|
42
|
+
return (_jsx(View, { style: { flex: 1 }, onStartShouldSetResponderCapture: dismissOnTouchStart, children: content }));
|
|
38
43
|
}
|
|
39
|
-
return (
|
|
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
|
+
}
|