@mrmeg/expo-ui 0.22.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.
@@ -132,7 +132,7 @@ function AccordionTrigger({ children, style: styleOverride, disabled, ...props }
132
132
  justifyContent: "space-between",
133
133
  gap: spacing.md,
134
134
  borderRadius: spacing.radiusMd,
135
- paddingVertical: spacing.md,
135
+ paddingVertical: spacing.smd,
136
136
  ...(Platform.OS === "web" && {
137
137
  cursor: "pointer",
138
138
  userSelect: "none",
@@ -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.
@@ -98,24 +98,24 @@ const createCardStyles = (theme) => StyleSheet.create({
98
98
  backgroundColor: "transparent",
99
99
  },
100
100
  header: {
101
- padding: spacing.lg,
101
+ padding: spacing.cardPadding,
102
102
  paddingBottom: spacing.xs,
103
103
  gap: spacing.xs,
104
104
  },
105
105
  content: {
106
- paddingHorizontal: spacing.lg,
107
- paddingBottom: spacing.lg,
106
+ paddingHorizontal: spacing.cardPadding,
107
+ paddingBottom: spacing.cardPadding,
108
108
  },
109
109
  footer: {
110
110
  flexDirection: "row",
111
111
  alignItems: "center",
112
- paddingHorizontal: spacing.lg,
113
- paddingBottom: spacing.lg,
112
+ paddingHorizontal: spacing.cardPadding,
113
+ paddingBottom: spacing.cardPadding,
114
114
  paddingTop: 0,
115
115
  },
116
116
  title: {
117
- fontSize: 18,
118
- lineHeight: 24,
117
+ fontSize: 16,
118
+ lineHeight: 22,
119
119
  letterSpacing: 0,
120
120
  },
121
121
  description: {
@@ -117,7 +117,7 @@ const styles = StyleSheet.create({
117
117
  flexDirection: "row",
118
118
  alignItems: "center",
119
119
  gap: spacing.sm,
120
- minHeight: spacing.touchTarget,
120
+ minHeight: Platform.select({ web: spacing.formRowMinHeight, default: spacing.touchTarget }),
121
121
  },
122
122
  labelContainer: {
123
123
  flex: 1,
@@ -22,7 +22,25 @@ function DialogRoot({ children, ...props }) {
22
22
  function DialogContent({ portalHost, style, children, ...props }) {
23
23
  const { theme, getShadowStyle, getContrastingColor } = useTheme();
24
24
  const textColor = getContrastingColor(theme.colors.popover, palette.white, palette.black);
25
- return (_jsx(DialogPrimitive.Portal, { hostName: portalHost, children: _jsx(FullWindowOverlay, { children: _jsx(DialogPrimitive.Overlay, { style: StyleSheet.flatten([
25
+ return (_jsx(DialogPrimitive.Portal, { hostName: portalHost, children: _jsx(FullWindowOverlay, { children: _jsx(DialogPrimitive.Overlay
26
+ // On web the primitive wraps the overlay in a react-native-web
27
+ // Pressable whose click handler stops propagation unconditionally.
28
+ // With that Pressable between the overlay and the Radix content,
29
+ // outside presses (mouse and touch) and Escape never dismissed the
30
+ // dialog, so a dialog without a Close button was stuck. `asChild`
31
+ // makes the primitive clone our fade wrapper instead, removing the
32
+ // Pressable; Radix dismissal then works. Native keeps the Pressable,
33
+ // which is what implements closeOnPress there.
34
+ , {
35
+ // On web the primitive wraps the overlay in a react-native-web
36
+ // Pressable whose click handler stops propagation unconditionally.
37
+ // With that Pressable between the overlay and the Radix content,
38
+ // outside presses (mouse and touch) and Escape never dismissed the
39
+ // dialog, so a dialog without a Close button was stuck. `asChild`
40
+ // makes the primitive clone our fade wrapper instead, removing the
41
+ // Pressable; Radix dismissal then works. Native keeps the Pressable,
42
+ // which is what implements closeOnPress there.
43
+ asChild: Platform.OS === "web", style: StyleSheet.flatten([
26
44
  StyleSheet.absoluteFill,
27
45
  { backgroundColor: theme.colors.overlay },
28
46
  Platform.OS === "web" && { zIndex: 50 },
@@ -32,7 +50,8 @@ function DialogContent({ portalHost, style, children, ...props }) {
32
50
  borderColor: theme.colors.border,
33
51
  borderWidth: 1,
34
52
  borderRadius: spacing.radiusLg,
35
- padding: spacing.lg,
53
+ padding: spacing.dialogPadding,
54
+ gap: spacing.md,
36
55
  width: "100%",
37
56
  ...getShadowStyle("soft"),
38
57
  },
@@ -40,7 +59,7 @@ function DialogContent({ portalHost, style, children, ...props }) {
40
59
  ]), ...props, children: children }) }) }) }) }) }) }) }) }));
41
60
  }
42
61
  function DialogHeader({ children, style, ...props }) {
43
- return (_jsx(View, { style: StyleSheet.flatten([{ gap: spacing.xs, marginBottom: spacing.md }, style]), ...props, children: children }));
62
+ return (_jsx(View, { style: StyleSheet.flatten([{ gap: spacing.xs }, style]), ...props, children: children }));
44
63
  }
45
64
  function DialogFooter({ children, style, ...props }) {
46
65
  return (_jsx(View, { style: StyleSheet.flatten([
@@ -48,7 +67,6 @@ function DialogFooter({ children, style, ...props }) {
48
67
  flexDirection: "row",
49
68
  justifyContent: "flex-end",
50
69
  gap: spacing.sm,
51
- marginTop: spacing.lg,
52
70
  },
53
71
  style,
54
72
  ]), ...props, children: children }));
@@ -107,7 +125,8 @@ function AlertDialogContent({ portalHost, style, children, ...props }) {
107
125
  borderColor: theme.colors.border,
108
126
  borderWidth: 1,
109
127
  borderRadius: spacing.radiusLg,
110
- padding: spacing.lg,
128
+ padding: spacing.dialogPadding,
129
+ gap: spacing.md,
111
130
  width: "100%",
112
131
  ...getShadowStyle("soft"),
113
132
  },
@@ -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
- };
@@ -33,7 +33,7 @@ const createStyles = (theme) => StyleSheet.create({
33
33
  container: {
34
34
  alignItems: "center",
35
35
  justifyContent: "center",
36
- paddingVertical: spacing.xxl,
36
+ paddingVertical: spacing.xl,
37
37
  paddingHorizontal: spacing.lg,
38
38
  },
39
39
  bordered: {
@@ -41,7 +41,7 @@ const createStyles = (theme) => StyleSheet.create({
41
41
  borderStyle: "dashed",
42
42
  borderColor: theme.colors.border,
43
43
  borderRadius: spacing.radiusLg,
44
- padding: spacing.xl,
44
+ padding: spacing.lg,
45
45
  },
46
46
  iconWrapper: {
47
47
  width: MEDIA_SIZE,
@@ -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
@@ -16,8 +16,9 @@ export interface ItemProps {
16
16
  /**
17
17
  * Item
18
18
  *
19
- * Grouped-list row container: `spacing.md` padding and gap, a comfortable
20
- * 44pt min height, and an optional pressable scale interaction. Compose
19
+ * Grouped-list row container: compact row padding (`spacing.rowPaddingY` /
20
+ * `spacing.rowPaddingX`) and gap, a 44pt min height on native (40 on web),
21
+ * and an optional pressable scale interaction. Compose
21
22
  * with `ItemMedia`, `ItemContent` (+ `ItemTitle`/`ItemDescription`), and
22
23
  * `ItemActions`.
23
24
  *
@@ -8,12 +8,13 @@ import { spacing } from "../constants/spacing.js";
8
8
  // Default ItemMedia footprint (see ItemMedia's `size` prop) — used to inset
9
9
  // the optional separator past the media slot without needing a context.
10
10
  const DEFAULT_MEDIA_SIZE = 40;
11
- const SEPARATOR_INSET = spacing.md + DEFAULT_MEDIA_SIZE + spacing.md;
11
+ const SEPARATOR_INSET = spacing.rowPaddingX + DEFAULT_MEDIA_SIZE + spacing.rowGap;
12
12
  /**
13
13
  * Item
14
14
  *
15
- * Grouped-list row container: `spacing.md` padding and gap, a comfortable
16
- * 44pt min height, and an optional pressable scale interaction. Compose
15
+ * Grouped-list row container: compact row padding (`spacing.rowPaddingY` /
16
+ * `spacing.rowPaddingX`) and gap, a 44pt min height on native (40 on web),
17
+ * and an optional pressable scale interaction. Compose
17
18
  * with `ItemMedia`, `ItemContent` (+ `ItemTitle`/`ItemDescription`), and
18
19
  * `ItemActions`.
19
20
  *
@@ -104,9 +105,10 @@ const styles = StyleSheet.create({
104
105
  row: {
105
106
  flexDirection: "row",
106
107
  alignItems: "center",
107
- minHeight: spacing.touchTarget,
108
- padding: spacing.md,
109
- gap: spacing.md,
108
+ minHeight: Platform.select({ web: spacing.rowMinHeight, default: spacing.touchTarget }),
109
+ paddingVertical: spacing.rowPaddingY,
110
+ paddingHorizontal: spacing.rowPaddingX,
111
+ gap: spacing.rowGap,
110
112
  },
111
113
  content: {
112
114
  flex: 1,
@@ -151,7 +151,7 @@ const styles = StyleSheet.create({
151
151
  flexDirection: "row",
152
152
  alignItems: "center",
153
153
  gap: spacing.sm,
154
- minHeight: spacing.touchTarget,
154
+ minHeight: Platform.select({ web: spacing.formRowMinHeight, default: spacing.touchTarget }),
155
155
  },
156
156
  labelContainer: {
157
157
  flex: 1,
@@ -37,5 +37,5 @@ export function StatCard({ label, value, unit, change, icon, onPress, style }) {
37
37
  : change.direction === "down"
38
38
  ? theme.colors.destructive
39
39
  : theme.colors.textDim;
40
- return (_jsx(Card, { onPress: onPress, style: style, children: _jsxs(View, { style: { padding: spacing.lg, gap: spacing.xs }, children: [_jsxs(View, { style: { flexDirection: "row", alignItems: "center", justifyContent: "space-between" }, children: [_jsx(CaptionText, { selectable: false, style: { color: theme.colors.textDim, textTransform: "uppercase", letterSpacing: 0.96 }, children: label }), !!icon && _jsx(Icon, { name: icon, size: spacing.iconSm, color: theme.colors.mutedForeground, decorative: true })] }), _jsxs(View, { style: { flexDirection: "row", alignItems: "flex-end" }, children: [_jsx(StyledText, { selectable: false, size: "xxl", fontWeight: "bold", style: { color: theme.colors.foreground, fontVariant: ["tabular-nums"] }, children: value }), !!unit && (_jsx(StyledText, { selectable: false, size: "base", style: { color: theme.colors.textDim, marginLeft: spacing.xxs, marginBottom: 2 }, children: unit }))] }), !!change && (_jsxs(View, { style: { flexDirection: "row", alignItems: "center", gap: spacing.xxs }, children: [change.direction !== "neutral" && (_jsx(Icon, { name: DIRECTION_ICON[change.direction], size: spacing.iconXs, color: changeColor, decorative: true })), _jsx(StyledText, { selectable: false, size: "sm", style: { color: changeColor }, children: change.value })] }))] }) }));
40
+ return (_jsx(Card, { onPress: onPress, style: style, children: _jsxs(View, { style: { padding: spacing.cardPadding, gap: spacing.xs }, children: [_jsxs(View, { style: { flexDirection: "row", alignItems: "center", justifyContent: "space-between" }, children: [_jsx(CaptionText, { selectable: false, style: { color: theme.colors.textDim, textTransform: "uppercase", letterSpacing: 0.96 }, children: label }), !!icon && _jsx(Icon, { name: icon, size: spacing.iconSm, color: theme.colors.mutedForeground, decorative: true })] }), _jsxs(View, { style: { flexDirection: "row", alignItems: "flex-end" }, children: [_jsx(StyledText, { selectable: false, size: "xxl", fontWeight: "bold", style: { color: theme.colors.foreground, fontVariant: ["tabular-nums"] }, children: value }), !!unit && (_jsx(StyledText, { selectable: false, size: "base", style: { color: theme.colors.textDim, marginLeft: spacing.xxs, marginBottom: 2 }, children: unit }))] }), !!change && (_jsxs(View, { style: { flexDirection: "row", alignItems: "center", gap: spacing.xxs }, children: [change.direction !== "neutral" && (_jsx(Icon, { name: DIRECTION_ICON[change.direction], size: spacing.iconXs, color: changeColor, decorative: true })), _jsx(StyledText, { selectable: false, size: "sm", style: { color: changeColor }, children: change.value })] }))] }) }));
41
41
  }
@@ -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";