@mrmeg/expo-ui 0.23.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -181,7 +181,7 @@ function SheetCloseButton({ style }) {
181
181
  * dispatched across the `RNHostView` boundary on Android — only `Pressable`
182
182
  * hit-testing (native `measure()`) works inside the host. So this is a
183
183
  * transparent `Pressable` mounted ONLY while a field is focused, mirroring
184
- * the app's `DismissKeyboardOverlay`. The tap blurs the focused field via
184
+ * a tap-to-dismiss overlay in the app. The tap blurs the focused field via
185
185
  * its own native ref (`dismissKeyboardFocusedInput`), which resigns the
186
186
  * responder regardless of window; `KeyboardController.dismiss()` is a
187
187
  * best-effort fallback for the (rare) case with no registered blur handle.
@@ -1,5 +1,5 @@
1
1
  import React from "react";
2
- import { StyleProp, ViewStyle } from "react-native";
2
+ import { type StyleProp, type ViewStyle } from "react-native";
3
3
  type Props = {
4
4
  children: React.ReactNode;
5
5
  style?: StyleProp<ViewStyle>;
@@ -9,7 +9,18 @@ type Props = {
9
9
  scrollable?: boolean;
10
10
  };
11
11
  /**
12
- * @returns Wrapper for a view that dismisses the keyboard when tapped outside of a text input
12
+ * Wrapper for a screen (or form) that dismisses the keyboard when the user taps
13
+ * outside of a text input.
14
+ *
15
+ * Mirrors RN's `keyboardShouldPersistTaps="handled"` for the native `@expo/ui`
16
+ * field, which RN itself cannot see: the wrapper never claims the touch, so
17
+ * buttons, other fields and the focused field's own gestures win, and it
18
+ * dismisses on release only when nothing else took the tap and the finger did
19
+ * not scroll. See `useKeyboardDismissResponder`.
20
+ *
21
+ * A drag on the inner ScrollView also hides the keyboard: `interactive` on iOS
22
+ * (UIKit resigns any first responder, SwiftUI fields included) and an explicit
23
+ * dismiss on drag start on Android, where RN's `on-drag` only knows RN inputs.
13
24
  */
14
- export declare const DismissKeyboard: (props: Props) => React.JSX.Element;
25
+ export declare function DismissKeyboard({ children, style, avoidKeyboard, scrollable, }: Props): React.JSX.Element;
15
26
  export {};
@@ -1,58 +1,27 @@
1
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 "./keyboardController";
2
+ import { Platform, ScrollView, View } from "react-native";
5
3
  import { KeyboardAvoidingView, useKeyboardAvoidance } from "./KeyboardAvoidingView.js";
6
- import { isTouchInsideKeyboardFocusedInput } from "./keyboardFocusRegistry.js";
4
+ import { dismissKeyboard, useKeyboardDismissResponder } from "./keyboardDismiss.js";
7
5
  /**
8
- * Native tap-away keyboard dismissal.
6
+ * Wrapper for a screen (or form) that dismisses the keyboard when the user taps
7
+ * outside of a text input.
9
8
  *
10
- * Why window-level dismissal: the native `@expo/ui` TextInput is a SwiftUI /
11
- * Compose field that never registers with React Native's `TextInputState`, so
12
- * `Keyboard.dismiss()` (which only blurs RN-tracked inputs) does nothing for it.
13
- * `KeyboardController.dismiss()` resigns the focused responder at the IME level,
14
- * which works for both native and RN fields.
9
+ * Mirrors RN's `keyboardShouldPersistTaps="handled"` for the native `@expo/ui`
10
+ * field, which RN itself cannot see: the wrapper never claims the touch, so
11
+ * buttons, other fields and the focused field's own gestures win, and it
12
+ * dismisses on release only when nothing else took the tap and the finger did
13
+ * not scroll. See `useKeyboardDismissResponder`.
15
14
  *
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.
15
+ * A drag on the inner ScrollView also hides the keyboard: `interactive` on iOS
16
+ * (UIKit resigns any first responder, SwiftUI fields included) and an explicit
17
+ * dismiss on drag start on Android, where RN's `on-drag` only knows RN inputs.
20
18
  */
21
- function useDismissKeyboardOnOutsideTouch() {
22
- const isVisible = useKeyboardState((state) => state.isVisible);
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]);
34
- }
35
- /**
36
- * @returns Wrapper for a view that dismisses the keyboard when tapped outside of a text input
37
- */
38
- function DismissKeyboardLayout({ children, style, avoidKeyboard = true, scrollable = true, dismissOnTouchStart, }) {
19
+ export function DismissKeyboard({ children, style, avoidKeyboard = true, scrollable = true, }) {
20
+ const responderProps = useKeyboardDismissResponder();
39
21
  const hasKeyboardAvoidance = useKeyboardAvoidance();
40
- const content = scrollable ? (_jsx(ScrollView, { style: { flex: 1 }, contentContainerStyle: { flexGrow: 1, justifyContent: "center" }, keyboardShouldPersistTaps: "handled", showsVerticalScrollIndicator: false, children: children })) : (children);
22
+ const content = scrollable ? (_jsx(ScrollView, { style: { flex: 1 }, contentContainerStyle: { flexGrow: 1, justifyContent: "center" }, keyboardShouldPersistTaps: "handled", keyboardDismissMode: Platform.OS === "ios" ? "interactive" : "none", onScrollBeginDrag: Platform.OS === "android" ? dismissKeyboard : undefined, showsVerticalScrollIndicator: false, children: children })) : (children);
41
23
  if (!avoidKeyboard || hasKeyboardAvoidance) {
42
- return (_jsx(View, { style: { flex: 1 }, onStartShouldSetResponderCapture: dismissOnTouchStart, children: content }));
24
+ return (_jsx(View, { style: { flex: 1 }, ...responderProps, children: content }));
43
25
  }
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 }));
26
+ return (_jsx(KeyboardAvoidingView, { style: [{ flex: 1, width: "100%" }, style], keyboardVerticalOffset: 0, ...responderProps, children: content }));
49
27
  }
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 });
58
- };
@@ -63,7 +63,10 @@ function InputOTP({ length = 6, value = "", onChangeText, onComplete, error = fa
63
63
  const markOtpBlurred = useCallback(() => {
64
64
  setFocused(false);
65
65
  }, []);
66
- return (_jsxs(View, { style: StyleSheet.flatten([styles.container, styleOverride]), children: [_jsx(RNTextInput, { ref: inputRef, value: value, onChangeText: handleChangeText, onKeyPress: handleKeyPress, onFocus: markOtpFocused, onBlur: markOtpBlurred, maxLength: length, autoFocus: autoFocus, editable: !disabled, inputMode: inputMode, autoComplete: "one-time-code", textContentType: "oneTimeCode", caretHidden: true, style: styles.hiddenInput, accessibilityLabel: "Verification code input", importantForAccessibility: "yes" }), _jsx(View, { style: styles.cellRow, children: Array.from({ length }, (_, index) => {
66
+ return (_jsxs(View, { style: StyleSheet.flatten([styles.container, styleOverride]), children: [_jsx(RNTextInput, { ref: inputRef, value: value, onChangeText: handleChangeText, onKeyPress: handleKeyPress, onFocus: markOtpFocused, onBlur: markOtpBlurred, maxLength: length, autoFocus: autoFocus, editable: !disabled, inputMode: inputMode,
67
+ // Android only recognises the `sms-otp` autofill hint; iOS and web use
68
+ // `one-time-code` + `oneTimeCode` to offer codes from Messages / Mail.
69
+ autoComplete: Platform.OS === "android" ? "sms-otp" : "one-time-code", textContentType: "oneTimeCode", caretHidden: true, style: styles.hiddenInput, accessibilityLabel: "Verification code input", importantForAccessibility: "yes" }), _jsx(View, { style: styles.cellRow, children: Array.from({ length }, (_, index) => {
67
70
  const char = value[index] ?? "";
68
71
  const isActive = focused && index === activeIndex;
69
72
  const displayChar = char
@@ -10,8 +10,11 @@ import { Icon } from "./Icon.js";
10
10
  import { hapticLight } from "../lib/haptics.js";
11
11
  import { createThemedStyles } from "../lib/themedStyles.js";
12
12
  import { palette } from "../constants/colors.js";
13
- import { clearKeyboardFocusedInputLayout, setKeyboardFocusedInputLayout, } from "./keyboardFocusRegistry.js";
13
+ import { clearKeyboardFocusedInput, setKeyboardFocusedInput, } from "./keyboardFocusRegistry.js";
14
+ import { useTextInputSurfaceResponder } from "./keyboardDismiss.js";
14
15
  const NUMERIC_REGEX = /^[0-9]*$/;
16
+ /** Platform touch-target guideline (pt); the compact fields sit below it. */
17
+ const MIN_TOUCH_TARGET = 44;
15
18
  const SIZE_CONFIGS = {
16
19
  sm: {
17
20
  height: 32,
@@ -207,11 +210,36 @@ leftElement, rightElement, clearable, focusedStyle, style, ...rest }) {
207
210
  const [passwordVisible, setPasswordVisible] = useState(false);
208
211
  const hasError = error || !!errorText;
209
212
  const sizeConfig = SIZE_CONFIGS[size];
210
- // Password visibility toggle. Flipping `secureTextEntry` swaps SwiftUI's
211
- // SecureField <-> TextField on iOS and toggles Compose's visualTransformation
212
- // on Android; both bind the same `state` observable, so the text survives.
213
+ // Password visibility toggle. Flipping `secureTextEntry` toggles Compose's
214
+ // visualTransformation in place on Android, but on iOS it selects a different
215
+ // native view (SwiftUI SecureField vs TextField), so React remounts the field
216
+ // and the keyboard drops with a full dismiss + re-present bounce.
217
+ //
218
+ // iOS handoff: while the field is focused, the toggle keeps the OLD view
219
+ // mounted (and visible) and mounts the NEW view hidden with `autoFocus`.
220
+ // UIKit hands first responder from old to new without hiding the keyboard;
221
+ // once the new view reports focus, it takes the layout slot and the old one
222
+ // unmounts. Both views bind the same `state`, so the text carries over.
213
223
  const hasSecureToggle = !!(secureTextEntry && showSecureEntryToggle);
214
- const effectiveSecureTextEntry = secureTextEntry && !passwordVisible;
224
+ const effectiveSecureTextEntry = !!secureTextEntry && !passwordVisible;
225
+ const [handoff, setHandoff] = useState(null);
226
+ const handoffRef = useRef(null);
227
+ const isFocusedRef = useRef(false);
228
+ const activeSecureRef = useRef(effectiveSecureTextEntry);
229
+ activeSecureRef.current = effectiveSecureTextEntry;
230
+ // Taps that land while a handoff is in flight are counted and applied (by
231
+ // parity) once it settles.
232
+ const queuedTogglesRef = useRef(0);
233
+ const toggleRef = useRef(() => { });
234
+ // Per-flavour mount generation. Bumped whenever a flavour becomes the incoming
235
+ // view so React always mounts a FRESH native view (whose `autoFocus` will run)
236
+ // instead of reusing one that is still unmounting from the previous handoff.
237
+ const generationRef = useRef({ secure: 0, plain: 0 });
238
+ // Last text the JS side knows about; see handleChangeText.
239
+ const lastTextRef = useRef(value ?? defaultValue ?? "");
240
+ // Armed when the hide toggle hands focus to the SecureField (iOS); see
241
+ // handleChangeText.
242
+ const restoreAfterSecureHandoffRef = useRef(false);
215
243
  // Native text buffer. Seeded once; `value` changes are reconciled below.
216
244
  const state = useNativeState(value ?? defaultValue ?? "");
217
245
  // Reconcile controlled `value` -> native buffer WITHOUT echoing keystrokes.
@@ -221,62 +249,159 @@ leftElement, rightElement, clearable, focusedStyle, style, ...rest }) {
221
249
  if (value !== undefined && value !== state.value) {
222
250
  state.value = value;
223
251
  }
252
+ if (value !== undefined)
253
+ lastTextRef.current = value;
224
254
  }, [value, state]);
225
255
  // The inner ref is the @expo/ui handle; the outward ref is typed as
226
256
  // RNTextInput because that's what the public TextInputCustomProps declares.
227
257
  // We expose the subset consumers use, plus a `setNativeProps` shim so the
228
258
  // uncontrolled AuthTextField can push corrected text into the native buffer.
229
- const innerRef = useRef(null);
230
- const surfaceRef = useRef(null);
231
- const isFocusedRef = useRef(false);
259
+ // One ref per native view flavour; only the active one (or, mid-handoff on
260
+ // iOS, both) is mounted.
261
+ const secureRef = useRef(null);
262
+ const plainRef = useRef(null);
263
+ const activeInput = useCallback(() => (activeSecureRef.current ? secureRef : plainRef).current, []);
264
+ const blurAll = useCallback(() => {
265
+ secureRef.current?.blur();
266
+ plainRef.current?.blur();
267
+ }, []);
232
268
  const focusRegistryToken = useMemo(() => ({}), []);
233
- const syncFocusedInputLayout = useCallback(() => {
234
- if (!isFocusedRef.current)
269
+ const finishHandoff = useCallback(() => {
270
+ const pending = handoffRef.current;
271
+ if (!pending)
272
+ return;
273
+ clearTimeout(pending.timer);
274
+ handoffRef.current = null;
275
+ setHandoff(null);
276
+ const queued = queuedTogglesRef.current;
277
+ queuedTogglesRef.current = 0;
278
+ if (queued % 2 === 1) {
279
+ // Next tick: let this commit settle first.
280
+ setTimeout(() => toggleRef.current(), 0);
281
+ }
282
+ }, []);
283
+ const parentOnFocus = onFocus;
284
+ const parentOnBlur = onBlur;
285
+ // Focus/blur from a view that is not the active flavour is ignored: that is
286
+ // the outgoing view resigning during a handoff, or a stale event from a view
287
+ // React is unmounting.
288
+ const handleFocus = useCallback((secure) => {
289
+ if (secure !== activeSecureRef.current)
235
290
  return;
236
- surfaceRef.current?.measureInWindow((absoluteX, absoluteY, width, height) => {
237
- if (!isFocusedRef.current)
238
- return;
239
- setKeyboardFocusedInputLayout(focusRegistryToken, {
240
- x: 0,
241
- y: 0,
242
- width,
243
- height,
244
- absoluteX,
245
- absoluteY,
246
- },
247
- // Window-independent dismissal: blur the native field directly. Inside a
248
- // native bottom sheet the field lives in its own window, where
249
- // `KeyboardController.dismiss()` can miss; calling the field's own ref
250
- // always resigns it.
251
- () => innerRef.current?.blur());
252
- });
253
- }, [focusRegistryToken]);
254
- const handleFocus = useCallback(() => {
255
291
  isFocusedRef.current = true;
256
- syncFocusedInputLayout();
257
- onFocus?.();
258
- }, [onFocus, syncFocusedInputLayout]);
259
- const handleBlur = useCallback(() => {
292
+ // Register a blur handle so tap-away dismissal (`keyboardDismiss.ts`) and
293
+ // the BottomSheet overlay can resign this field: RN's `TextInputState`
294
+ // never sees SwiftUI / Compose fields, so `Keyboard.dismiss()` cannot.
295
+ // Blurring through the field's own ref is window-independent — inside a
296
+ // native bottom sheet `KeyboardController.dismiss()` can miss, the ref
297
+ // never does.
298
+ setKeyboardFocusedInput(focusRegistryToken, blurAll);
299
+ if (handoffRef.current) {
300
+ // The incoming view took first responder; the parent never saw focus leave.
301
+ restoreAfterSecureHandoffRef.current = secure && lastTextRef.current.length > 0;
302
+ finishHandoff();
303
+ return;
304
+ }
305
+ parentOnFocus?.();
306
+ }, [blurAll, finishHandoff, focusRegistryToken, parentOnFocus]);
307
+ const handleBlur = useCallback((secure) => {
308
+ if (secure !== activeSecureRef.current)
309
+ return;
260
310
  isFocusedRef.current = false;
261
- clearKeyboardFocusedInputLayout(focusRegistryToken);
262
- onBlur?.();
263
- }, [focusRegistryToken, onBlur]);
311
+ restoreAfterSecureHandoffRef.current = false;
312
+ clearKeyboardFocusedInput(focusRegistryToken);
313
+ parentOnBlur?.();
314
+ }, [focusRegistryToken, parentOnBlur]);
315
+ const togglePasswordVisible = useCallback(() => {
316
+ if (handoffRef.current) {
317
+ // Mid-handoff: flipping now would unmount the view about to take first
318
+ // responder and drop the keyboard. Apply it once the handoff settles.
319
+ queuedTogglesRef.current += 1;
320
+ return;
321
+ }
322
+ restoreAfterSecureHandoffRef.current = false;
323
+ const outgoingSecure = activeSecureRef.current;
324
+ if (outgoingSecure)
325
+ generationRef.current.plain += 1;
326
+ else
327
+ generationRef.current.secure += 1;
328
+ if (Platform.OS === "ios" && isFocusedRef.current) {
329
+ const timer = setTimeout(() => {
330
+ // The incoming view never took first responder. Rather than unmount
331
+ // the focused outgoing view (which would drop the keyboard), undo the
332
+ // toggle so the outgoing flavour stays active; the user can tap again.
333
+ finishHandoff();
334
+ if (!activeInput()?.isFocused()) {
335
+ setPasswordVisible(!outgoingSecure);
336
+ setTimeout(() => {
337
+ // If the outgoing view lost focus in the meantime (e.g. a tap
338
+ // away during the handoff), report the blur its handler ignored.
339
+ if (!activeInput()?.isFocused()) {
340
+ isFocusedRef.current = false;
341
+ clearKeyboardFocusedInput(focusRegistryToken);
342
+ parentOnBlur?.();
343
+ }
344
+ }, 0);
345
+ }
346
+ }, 600);
347
+ handoffRef.current = { outgoingSecure, timer };
348
+ setHandoff({ outgoingSecure });
349
+ }
350
+ setPasswordVisible((v) => !v);
351
+ }, [activeInput, finishHandoff, focusRegistryToken, parentOnBlur]);
352
+ toggleRef.current = togglePasswordVisible;
353
+ // iOS clears a secure field's existing text on the first keystroke after it
354
+ // becomes first responder (text it did not see typed). Right after the hide
355
+ // toggle hands focus to the SecureField that reads as "type one character,
356
+ // lose the password", so the wiped text is put back with the keystroke
357
+ // applied. Only the first change after that handoff is eligible. A wipe
358
+ // shows up as a single character that cannot be a backspace of the previous
359
+ // text, or as an empty string (the wipe followed by a backspace that hit
360
+ // nothing) while the previous text had more than one character.
361
+ const handleChangeText = useCallback((text) => {
362
+ const previous = lastTextRef.current;
363
+ const armed = restoreAfterSecureHandoffRef.current && previous.length > 0;
364
+ const wipedByKey = armed && text.length === 1 && !previous.startsWith(text);
365
+ const wipedByBackspace = armed && text === "" && previous.length > 1;
366
+ const wiped = wipedByKey || wipedByBackspace;
367
+ restoreAfterSecureHandoffRef.current = false;
368
+ const next = wipedByKey ? previous + text : wipedByBackspace ? previous.slice(0, -1) : text;
369
+ lastTextRef.current = next;
370
+ if (wiped)
371
+ state.value = next;
372
+ onChangeText?.(next);
373
+ }, [onChangeText, state]);
264
374
  useEffect(() => {
265
- return () => clearKeyboardFocusedInputLayout(focusRegistryToken);
375
+ return () => {
376
+ if (handoffRef.current)
377
+ clearTimeout(handoffRef.current.timer);
378
+ clearKeyboardFocusedInput(focusRegistryToken);
379
+ };
266
380
  }, [focusRegistryToken]);
381
+ // The SwiftUI field only hit-tests its text line, so a tap on the box's
382
+ // vertical padding (or on the hitSlop below) would otherwise be a no-op.
383
+ const surfaceResponderProps = useTextInputSurfaceResponder(() => {
384
+ if (editable === false)
385
+ return;
386
+ activeInput()?.focus();
387
+ });
388
+ // Stretch the touch target to the 44pt guideline without changing the box.
389
+ const surfaceHitSlop = Math.max(0, Math.ceil((MIN_TOUCH_TARGET - sizeConfig.height) / 2));
267
390
  useImperativeHandle(ref, () => ({
268
- focus: () => innerRef.current?.focus(),
269
- blur: () => innerRef.current?.blur(),
391
+ focus: () => activeInput()?.focus(),
392
+ blur: () => blurAll(),
270
393
  clear: () => {
271
394
  state.value = "";
395
+ lastTextRef.current = "";
272
396
  },
273
- isFocused: () => innerRef.current?.isFocused() ?? false,
397
+ isFocused: () => activeInput()?.isFocused() ?? false,
274
398
  setNativeProps: (props) => {
275
399
  if (typeof props?.text === "string") {
276
400
  state.value = props.text;
401
+ lastTextRef.current = props.text;
277
402
  }
278
403
  },
279
- }), [state]);
404
+ }), [activeInput, blurAll, state]);
280
405
  const backgroundColor = forceLight
281
406
  ? palette.white
282
407
  : variant === "filled"
@@ -335,7 +460,20 @@ leftElement, rightElement, clearable, focusedStyle, style, ...rest }) {
335
460
  fontSize: sizeConfig.fontSize,
336
461
  ...(nativeFontFamily ? { fontFamily: nativeFontFamily } : null),
337
462
  };
338
- return (_jsxs(View, { style: wrapperStyle, children: [!!label && (_jsx(View, { style: styles.labelContainer, children: _jsxs(StyledText, { selectable: false, size: "base", fontWeight: "medium", style: styles.label, children: [label, required && _jsx(StyledText, { selectable: false, fontWeight: "bold", style: styles.required, children: " *" })] }) })), _jsxs(View, { 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, size: "sm", style: [styles.helperText, hasError && styles.errorText], children: errorText || helperText }))] }));
463
+ return (_jsxs(View, { style: wrapperStyle, children: [!!label && (_jsx(View, { style: styles.labelContainer, children: _jsxs(StyledText, { selectable: false, size: "base", fontWeight: "medium", style: styles.label, children: [label, required && _jsx(StyledText, { selectable: false, fontWeight: "bold", style: styles.required, children: " *" })] }) })), _jsxs(View, { style: [surfaceStyle, hasSecureToggle && styles.nativeRow], hitSlop: { top: surfaceHitSlop, bottom: surfaceHitSlop }, ...surfaceResponderProps, children: [[true, false].map((secure) => {
464
+ const active = secure === effectiveSecureTextEntry;
465
+ const outgoing = handoff != null && secure === handoff.outgoingSecure;
466
+ if (!active && !outgoing)
467
+ return null;
468
+ // Mid-handoff the outgoing view keeps the visible slot (and focus)
469
+ // while the incoming one mounts hidden and takes first responder.
470
+ const inLayout = handoff ? outgoing : active;
471
+ return (_jsx(Host, { matchContents: { vertical: true }, style: inLayout
472
+ ? hasSecureToggle
473
+ ? styles.nativeHostFlex
474
+ : styles.nativeHost
475
+ : styles.nativeHostHandoff, pointerEvents: inLayout ? "auto" : "none", accessibilityElementsHidden: !inLayout, importantForAccessibility: inLayout ? "auto" : "no-hide-descendants", children: _jsx(ExpoTextInput, { ...rest, autoFocus: rest.autoFocus || (handoff != null && active), ref: secure ? secureRef : plainRef, value: state, defaultValue: defaultValue, onChangeText: handleChangeText, onFocus: () => handleFocus(secure), onBlur: () => handleBlur(secure), editable: editable, multiline: multiline, rows: rows, inputMode: inputMode, secureTextEntry: secure, placeholderTextColor: theme.colors.textDim, style: boxStyle, textStyle: textStyle }) }, secure ? `secure-${generationRef.current.secure}` : `plain-${generationRef.current.plain}`));
476
+ }), hasSecureToggle && (_jsx(Pressable, { style: styles.nativePasswordToggle, onPress: togglePasswordVisible, 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, size: "sm", style: [styles.helperText, hasError && styles.errorText], children: errorText || helperText }))] }));
339
477
  }
340
478
  const createStyles = (theme, variant, size) => StyleSheet.create({
341
479
  nativeHost: {
@@ -349,6 +487,15 @@ const createStyles = (theme, variant, size) => StyleSheet.create({
349
487
  nativeHostFlex: {
350
488
  flex: 1,
351
489
  },
490
+ // Incoming view during an iOS secure/plain handoff: mounted and focusable,
491
+ // but invisible and out of the row's layout until it holds first responder.
492
+ nativeHostHandoff: {
493
+ position: "absolute",
494
+ top: 0,
495
+ left: 0,
496
+ right: 0,
497
+ opacity: 0,
498
+ },
352
499
  nativePasswordToggle: {
353
500
  paddingHorizontal: spacing.xs,
354
501
  },
@@ -11,6 +11,7 @@ export * from "./Checkbox";
11
11
  export * from "./Collapsible";
12
12
  export * from "./Dialog";
13
13
  export * from "./DismissKeyboard";
14
+ export * from "./keyboardDismiss";
14
15
  export * from "./Drawer";
15
16
  export * from "./DropdownMenu";
16
17
  export * from "./EmptyState";
@@ -11,6 +11,7 @@ export * from "./Checkbox.js";
11
11
  export * from "./Collapsible.js";
12
12
  export * from "./Dialog.js";
13
13
  export * from "./DismissKeyboard.js";
14
+ export * from "./keyboardDismiss.js";
14
15
  export * from "./Drawer.js";
15
16
  export * from "./DropdownMenu.js";
16
17
  export * from "./EmptyState.js";
@@ -0,0 +1,37 @@
1
+ import { type GestureResponderEvent, type ViewProps } from "react-native";
2
+ /**
3
+ * `onStartShouldSetResponder` for a native text-field surface: decline the
4
+ * responder (the native field keeps every touch phase) but tag the touch so an
5
+ * enclosing tap-away boundary leaves it alone. The package `TextInput` uses
6
+ * this through {@link useTextInputSurfaceResponder}.
7
+ */
8
+ export declare function markTextInputTouchStart(event: GestureResponderEvent): boolean;
9
+ /**
10
+ * Hide the software keyboard for whatever is focused. Prefers the registered
11
+ * native field's own blur handle (window-independent), then falls back to
12
+ * `KeyboardController.dismiss()`, which resigns at the IME level and covers RN
13
+ * or third-party inputs. No-op on web, which has no software keyboard to hide.
14
+ */
15
+ export declare function dismissKeyboard(): void;
16
+ export type TextInputSurfaceResponderProps = Pick<ViewProps, "onStartShouldSetResponder" | "onTouchStart" | "onTouchMove" | "onTouchEnd" | "onTouchCancel">;
17
+ /**
18
+ * View props for the painted surface around a native (`@expo/ui`) text field.
19
+ *
20
+ * Two jobs. It tags every touch that begins on the surface so the tap-away
21
+ * boundary leaves it alone (see {@link markTextInputTouchStart}). And it
22
+ * focuses the field on release of a tap that no child claimed: the SwiftUI
23
+ * `TextField` only hit-tests its text line, so taps on the box's vertical
24
+ * padding never reach it, and a tap on the field's `hitSlop` never can. Calling
25
+ * `focus` is idempotent when the native field already took the tap, and the eye
26
+ * toggle is a `Pressable` that claims the responder first, so it never fires
27
+ * for that.
28
+ */
29
+ export declare function useTextInputSurfaceResponder(focus: () => void): TextInputSurfaceResponderProps;
30
+ export type KeyboardDismissResponderProps = Pick<ViewProps, "onStartShouldSetResponder" | "onTouchStart" | "onTouchMove" | "onTouchEnd" | "onTouchCancel">;
31
+ /**
32
+ * View props for a tap-away keyboard-dismiss boundary. Spread onto the wrapper
33
+ * `View` that encloses the screen (or a form). Nesting is safe: the innermost
34
+ * boundary owns each touch, so the keyboard is dismissed once. Returns `{}` on
35
+ * web.
36
+ */
37
+ export declare function useKeyboardDismissResponder(): KeyboardDismissResponderProps;
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Tap-away keyboard dismissal that never steals a touch.
3
+ *
4
+ * React Native's own `keyboardShouldPersistTaps="handled"` is the model: let the
5
+ * deepest interactive view win the responder negotiation, and blur on *release*
6
+ * only when nothing else took the tap and the finger did not scroll. RN cannot
7
+ * apply that to the native `@expo/ui` field (it is invisible to
8
+ * `TextInputState`), so this module re-creates the same decision with two
9
+ * signals that need no rectangles and no responder ownership:
10
+ *
11
+ * 1. `onStartShouldSetResponder` in the *bubble* phase is only ever called on a
12
+ * view when no descendant claimed the touch. Pressables, RN TextInputs and
13
+ * scroll views all claim, so being asked at all means "this tap is on dead
14
+ * space or on a native view that does not participate".
15
+ * 2. The package `TextInput` tags touches that begin on its surface
16
+ * (`markTextInputTouchStart`). Its bubble handler runs before any enclosing
17
+ * boundary's, so the boundary can tell a tap on *any* native field — focused
18
+ * or not — from dead space, and leave focus handoff, double-tap selection
19
+ * and the eye toggle to the platform.
20
+ *
21
+ * The boundary always answers `false`, so no JS responder is ever set: native
22
+ * controls (SwiftUI/Compose fields, sliders) keep receiving every touch phase
23
+ * on both platforms, and scroll views take over drags exactly as before.
24
+ * Dismissal happens in `onTouchEnd`, a plain bubbling event that fires whether
25
+ * or not anyone became the responder; a drag past the slop or a cancel drops it.
26
+ */
27
+ import { useMemo, useRef } from "react";
28
+ import { Platform } from "react-native";
29
+ import { KeyboardController, useKeyboardState } from "./keyboardController";
30
+ import { dismissKeyboardFocusedInput, hasKeyboardFocusedInput } from "./keyboardFocusRegistry.js";
31
+ /** Finger travel (pt) after which a touch counts as a scroll, not a tap. */
32
+ const MOVE_SLOP = 10;
33
+ function keyOf(event) {
34
+ const { identifier, timestamp } = event.nativeEvent;
35
+ return { identifier, timestamp };
36
+ }
37
+ /** Same native event: the responder negotiation hands one event to every listener. */
38
+ function isSameEvent(key, event) {
39
+ return (key != null &&
40
+ key.identifier === event.nativeEvent.identifier &&
41
+ key.timestamp === event.nativeEvent.timestamp);
42
+ }
43
+ let textInputTouch = null;
44
+ let claimedTouch = null;
45
+ /**
46
+ * `onStartShouldSetResponder` for a native text-field surface: decline the
47
+ * responder (the native field keeps every touch phase) but tag the touch so an
48
+ * enclosing tap-away boundary leaves it alone. The package `TextInput` uses
49
+ * this through {@link useTextInputSurfaceResponder}.
50
+ */
51
+ export function markTextInputTouchStart(event) {
52
+ textInputTouch = keyOf(event);
53
+ return false;
54
+ }
55
+ /**
56
+ * Hide the software keyboard for whatever is focused. Prefers the registered
57
+ * native field's own blur handle (window-independent), then falls back to
58
+ * `KeyboardController.dismiss()`, which resigns at the IME level and covers RN
59
+ * or third-party inputs. No-op on web, which has no software keyboard to hide.
60
+ */
61
+ export function dismissKeyboard() {
62
+ if (Platform.OS === "web")
63
+ return;
64
+ if (!dismissKeyboardFocusedInput()) {
65
+ void KeyboardController.dismiss();
66
+ }
67
+ }
68
+ function isSameTouch(tap, event) {
69
+ return tap != null && tap.identifier === event.nativeEvent.identifier;
70
+ }
71
+ function movedPastSlop(tap, event) {
72
+ return (Math.abs(event.nativeEvent.pageX - tap.pageX) > MOVE_SLOP ||
73
+ Math.abs(event.nativeEvent.pageY - tap.pageY) > MOVE_SLOP);
74
+ }
75
+ /**
76
+ * View props for the painted surface around a native (`@expo/ui`) text field.
77
+ *
78
+ * Two jobs. It tags every touch that begins on the surface so the tap-away
79
+ * boundary leaves it alone (see {@link markTextInputTouchStart}). And it
80
+ * focuses the field on release of a tap that no child claimed: the SwiftUI
81
+ * `TextField` only hit-tests its text line, so taps on the box's vertical
82
+ * padding never reach it, and a tap on the field's `hitSlop` never can. Calling
83
+ * `focus` is idempotent when the native field already took the tap, and the eye
84
+ * toggle is a `Pressable` that claims the responder first, so it never fires
85
+ * for that.
86
+ */
87
+ export function useTextInputSurfaceResponder(focus) {
88
+ const focusRef = useRef(focus);
89
+ focusRef.current = focus;
90
+ const tap = useRef(null);
91
+ return useMemo(() => ({
92
+ onStartShouldSetResponder: (event) => {
93
+ markTextInputTouchStart(event);
94
+ tap.current = { ...keyOf(event), pageX: event.nativeEvent.pageX, pageY: event.nativeEvent.pageY };
95
+ return false;
96
+ },
97
+ onTouchStart: (event) => {
98
+ if (tap.current && !isSameEvent(tap.current, event))
99
+ tap.current = null;
100
+ },
101
+ onTouchMove: (event) => {
102
+ if (isSameTouch(tap.current, event) && movedPastSlop(tap.current, event))
103
+ tap.current = null;
104
+ },
105
+ onTouchEnd: (event) => {
106
+ if (!isSameTouch(tap.current, event))
107
+ return;
108
+ tap.current = null;
109
+ focusRef.current();
110
+ },
111
+ onTouchCancel: () => {
112
+ tap.current = null;
113
+ },
114
+ }), []);
115
+ }
116
+ /**
117
+ * View props for a tap-away keyboard-dismiss boundary. Spread onto the wrapper
118
+ * `View` that encloses the screen (or a form). Nesting is safe: the innermost
119
+ * boundary owns each touch, so the keyboard is dismissed once. Returns `{}` on
120
+ * web.
121
+ */
122
+ export function useKeyboardDismissResponder() {
123
+ const isVisible = useKeyboardState((state) => state.isVisible);
124
+ const isVisibleRef = useRef(isVisible);
125
+ isVisibleRef.current = isVisible;
126
+ const pending = useRef(null);
127
+ return useMemo(() => {
128
+ if (Platform.OS === "web")
129
+ return {};
130
+ return {
131
+ onStartShouldSetResponder: (event) => {
132
+ pending.current = null;
133
+ // `hasKeyboardFocusedInput` covers the window where a field has focus but
134
+ // keyboard-controller has not reported the keyboard yet (or a hardware
135
+ // keyboard is attached and there is no software keyboard to observe).
136
+ if (!isVisibleRef.current && !hasKeyboardFocusedInput())
137
+ return false;
138
+ if (isSameEvent(textInputTouch, event))
139
+ return false;
140
+ // An inner boundary already runs this touch (bubble order is inner-first).
141
+ if (isSameEvent(claimedTouch, event))
142
+ return false;
143
+ claimedTouch = keyOf(event);
144
+ pending.current = { ...claimedTouch, pageX: event.nativeEvent.pageX, pageY: event.nativeEvent.pageY };
145
+ return false;
146
+ },
147
+ onTouchStart: (event) => {
148
+ // Bubbling touch events run after the negotiation for the same native
149
+ // event, so a fresh pending tap matches here; anything else is stale
150
+ // (e.g. a touch whose end never reached this view).
151
+ if (pending.current && !isSameEvent(pending.current, event))
152
+ pending.current = null;
153
+ },
154
+ onTouchMove: (event) => {
155
+ if (isSameTouch(pending.current, event) && movedPastSlop(pending.current, event)) {
156
+ pending.current = null;
157
+ }
158
+ },
159
+ onTouchEnd: (event) => {
160
+ if (!isSameTouch(pending.current, event))
161
+ return;
162
+ pending.current = null;
163
+ dismissKeyboard();
164
+ },
165
+ onTouchCancel: () => {
166
+ pending.current = null;
167
+ },
168
+ };
169
+ }, []);
170
+ }