@mrmeg/expo-ui 0.24.0 → 0.25.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.
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { useEffect } from "react";
3
3
  import { useTheme } from "../hooks/useTheme.js";
4
- import { StatusBar as RNStatusBar, Platform } from "react-native";
4
+ import { StatusBar as RNStatusBar } from "react-native";
5
5
  export const StatusBar = () => {
6
6
  const { scheme, theme } = useTheme();
7
7
  const barStyle = scheme === "dark" ? "light-content" : "dark-content";
@@ -10,10 +10,6 @@ export const StatusBar = () => {
10
10
  */
11
11
  useEffect(() => {
12
12
  RNStatusBar.setBarStyle(barStyle, true);
13
- if (Platform.OS === "android") {
14
- RNStatusBar.setBackgroundColor("transparent", true);
15
- RNStatusBar.setTranslucent(true);
16
- }
17
13
  }, [barStyle]);
18
- return (_jsx(RNStatusBar, { barStyle: barStyle, backgroundColor: Platform.OS === "android" ? "transparent" : undefined, translucent: true }));
14
+ return _jsx(RNStatusBar, { barStyle: barStyle });
19
15
  };
@@ -2,5 +2,5 @@ import React from "react";
2
2
  import type { StyleProp, TextStyle } from "react-native";
3
3
  export declare const TextClassContext: React.Context<string | undefined>;
4
4
  export declare const TextColorContext: React.Context<string | undefined>;
5
- export declare const TextStyleContext: React.Context<StyleProp<TextStyle>>;
5
+ export declare const TextStyleContext: React.Context<StyleProp<TextStyle> | undefined>;
6
6
  export declare const TextSelectabilityContext: React.Context<boolean | undefined>;
@@ -1,4 +1,4 @@
1
- import { type Ref } from "react";
1
+ import { type ComponentRef, type Ref } from "react";
2
2
  import { Text as RNText, TextProps as RNTextProps } from "react-native";
3
3
  import { type FontVariant } from "../constants/fonts";
4
4
  /**
@@ -55,8 +55,12 @@ export type TextProps = RNTextProps & {
55
55
  txOptions?: object;
56
56
  /**
57
57
  * Forwarded ref to the underlying RNText element.
58
+ *
59
+ * Written as `ComponentRef<typeof RNText>` rather than RN 0.88's `TextInstance`
60
+ * alias so the emitted declarations stay valid on RN 0.85/0.86, whose typings
61
+ * do not export that name.
58
62
  */
59
- ref?: Ref<RNText>;
63
+ ref?: Ref<ComponentRef<typeof RNText>>;
60
64
  };
61
65
  /**
62
66
  * Base Text component with theme and variant support
@@ -1,4 +1,4 @@
1
- import { type ReactNode, type Ref } from "react";
1
+ import { type ComponentRef, type ReactNode, type Ref } from "react";
2
2
  import { TextInput as RNTextInput, ViewStyle, TextStyle, TextInputProps, StyleProp } from "react-native";
3
3
  /**
4
4
  * Size variants for TextInput
@@ -10,9 +10,13 @@ export type TextInputSize = "sm" | "md" | "lg";
10
10
  export type TextInputVariant = "outline" | "filled" | "underlined";
11
11
  interface TextInputCustomProps extends TextInputProps {
12
12
  /**
13
- * Forwarded ref to the underlying RNTextInput element.
13
+ * RNTextInput ref on web; an RN-compatible imperative handle on native.
14
+ *
15
+ * Written as `ComponentRef<typeof RNTextInput>` rather than RN 0.88's
16
+ * `TextInputInstance` alias so the emitted declarations stay valid on
17
+ * RN 0.85/0.86, whose typings do not export that name.
14
18
  */
15
- ref?: Ref<RNTextInput>;
19
+ ref?: Ref<ComponentRef<typeof RNTextInput>>;
16
20
  /**
17
21
  * Visual variant
18
22
  * @default "outline"
@@ -191,11 +191,10 @@ function WebTextInput({ variant = "outline", size = "md", label, helperText, err
191
191
  * never round-trips through React state — eliminating the cursor flicker seen on
192
192
  * controlled RN inputs.
193
193
  *
194
- * By design (reliability over feature-parity) this path renders the field plus
195
- * sibling label / helper / error text only. The in-field overlays from the web
196
- * implementation — password visibility toggle, clear button, left/right
197
- * elements, and error icon — are intentionally omitted on native to avoid
198
- * layering RN views over the native host view.
194
+ * This path supports label / helper / error text and a password visibility
195
+ * toggle laid out beside the native Host. Clear buttons, left/right elements,
196
+ * and the error icon remain web-only; RN overlays are not layered over the
197
+ * native host view.
199
198
  */
200
199
  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,
201
200
  // Web-only affordances. Destructured out of `rest` so they're NOT forwarded to
@@ -231,9 +230,9 @@ leftElement, rightElement, clearable, focusedStyle, style, ...rest }) {
231
230
  // parity) once it settles.
232
231
  const queuedTogglesRef = useRef(0);
233
232
  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.
233
+ // iOS-only per-flavour mount generation. Bumped whenever a flavour becomes
234
+ // the incoming view so React mounts a FRESH native view (whose `autoFocus`
235
+ // will run) instead of reusing one still unmounting from the previous handoff.
237
236
  const generationRef = useRef({ secure: 0, plain: 0 });
238
237
  // Last text the JS side knows about; see handleChangeText.
239
238
  const lastTextRef = useRef(value ?? defaultValue ?? "");
@@ -256,12 +255,18 @@ leftElement, rightElement, clearable, focusedStyle, style, ...rest }) {
256
255
  // RNTextInput because that's what the public TextInputCustomProps declares.
257
256
  // We expose the subset consumers use, plus a `setNativeProps` shim so the
258
257
  // uncontrolled AuthTextField can push corrected text into the native buffer.
259
- // One ref per native view flavour; only the active one (or, mid-handoff on
260
- // iOS, both) is mounted.
258
+ // Android keeps one field/ref as visualTransformation changes in place.
259
+ // iOS has one ref per flavour; both are mounted only during a handoff.
260
+ const androidRef = useRef(null);
261
261
  const secureRef = useRef(null);
262
262
  const plainRef = useRef(null);
263
- const activeInput = useCallback(() => (activeSecureRef.current ? secureRef : plainRef).current, []);
263
+ const activeInput = useCallback(() => {
264
+ if (Platform.OS === "android")
265
+ return androidRef.current;
266
+ return (activeSecureRef.current ? secureRef : plainRef).current;
267
+ }, []);
264
268
  const blurAll = useCallback(() => {
269
+ androidRef.current?.blur();
265
270
  secureRef.current?.blur();
266
271
  plainRef.current?.blur();
267
272
  }, []);
@@ -282,19 +287,15 @@ leftElement, rightElement, clearable, focusedStyle, style, ...rest }) {
282
287
  }, []);
283
288
  const parentOnFocus = onFocus;
284
289
  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.
290
+ // On iOS, ignore focus/blur from the outgoing flavour during a handoff or
291
+ // unmount. Android events always belong to the same mounted field.
288
292
  const handleFocus = useCallback((secure) => {
289
- if (secure !== activeSecureRef.current)
293
+ if (Platform.OS === "ios" && secure !== activeSecureRef.current)
290
294
  return;
291
295
  isFocusedRef.current = true;
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.
296
+ // Register the field's own blur handle for tap-away dismissal and the
297
+ // BottomSheet overlay. RN supports Expo-hosted field interop; this direct
298
+ // ref also targets the actual field inside a native sheet's window.
298
299
  setKeyboardFocusedInput(focusRegistryToken, blurAll);
299
300
  if (handoffRef.current) {
300
301
  // The incoming view took first responder; the parent never saw focus leave.
@@ -305,7 +306,7 @@ leftElement, rightElement, clearable, focusedStyle, style, ...rest }) {
305
306
  parentOnFocus?.();
306
307
  }, [blurAll, finishHandoff, focusRegistryToken, parentOnFocus]);
307
308
  const handleBlur = useCallback((secure) => {
308
- if (secure !== activeSecureRef.current)
309
+ if (Platform.OS === "ios" && secure !== activeSecureRef.current)
309
310
  return;
310
311
  isFocusedRef.current = false;
311
312
  restoreAfterSecureHandoffRef.current = false;
@@ -321,10 +322,12 @@ leftElement, rightElement, clearable, focusedStyle, style, ...rest }) {
321
322
  }
322
323
  restoreAfterSecureHandoffRef.current = false;
323
324
  const outgoingSecure = activeSecureRef.current;
324
- if (outgoingSecure)
325
- generationRef.current.plain += 1;
326
- else
327
- generationRef.current.secure += 1;
325
+ if (Platform.OS === "ios") {
326
+ if (outgoingSecure)
327
+ generationRef.current.plain += 1;
328
+ else
329
+ generationRef.current.secure += 1;
330
+ }
328
331
  if (Platform.OS === "ios" && isFocusedRef.current) {
329
332
  const timer = setTimeout(() => {
330
333
  // The incoming view never took first responder. Rather than unmount
@@ -460,7 +463,7 @@ leftElement, rightElement, clearable, focusedStyle, style, ...rest }) {
460
463
  fontSize: sizeConfig.fontSize,
461
464
  ...(nativeFontFamily ? { fontFamily: nativeFontFamily } : null),
462
465
  };
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) => {
466
+ 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: [(Platform.OS === "android" ? [effectiveSecureTextEntry] : [true, false]).map((secure) => {
464
467
  const active = secure === effectiveSecureTextEntry;
465
468
  const outgoing = handoff != null && secure === handoff.outgoingSecure;
466
469
  if (!active && !outgoing)
@@ -472,7 +475,11 @@ leftElement, rightElement, clearable, focusedStyle, style, ...rest }) {
472
475
  ? hasSecureToggle
473
476
  ? styles.nativeHostFlex
474
477
  : 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}`));
478
+ : 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: Platform.OS === "android" ? androidRef : 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 }) }, Platform.OS === "android"
479
+ ? "android"
480
+ : secure
481
+ ? `secure-${generationRef.current.secure}`
482
+ : `plain-${generationRef.current.plain}`));
476
483
  }), 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 }))] }));
477
484
  }
478
485
  const createStyles = (theme, variant, size) => StyleSheet.create({
@@ -1,19 +1,11 @@
1
1
  import * as React from "react";
2
- import { View, ViewProps } from "react-native";
2
+ import { ViewProps } from "react-native";
3
3
  import * as TooltipPrimitive from "@rn-primitives/tooltip";
4
4
  /**
5
5
  * Tooltip Trigger Component
6
6
  * The element that triggers the tooltip to appear on hover (web) or press (native)
7
7
  */
8
- declare const TooltipTrigger: {
9
- ({ asChild, onPress: onPressProp, disabled, ref, ...props }: Omit<import("react-native").PressableProps & React.RefAttributes<View>, "ref"> & {
10
- asChild?: boolean;
11
- } & {
12
- onKeyDown?: (ev: React.KeyboardEvent) => void;
13
- onKeyUp?: (ev: React.KeyboardEvent) => void;
14
- } & React.RefAttributes<import("@rn-primitives/tooltip").TriggerRef>): React.JSX.Element;
15
- displayName: string;
16
- };
8
+ declare const TooltipTrigger: typeof TooltipPrimitive.Trigger;
17
9
  /**
18
10
  * Tooltip variant styles
19
11
  */
@@ -100,22 +92,16 @@ interface TooltipProps extends TooltipPrimitive.RootProps {
100
92
  * ```
101
93
  */
102
94
  declare function Tooltip({ delayDuration, skipDelayDuration, ...props }: TooltipProps): React.JSX.Element;
103
- /**
104
- * Tooltip Component with Sub-components
105
- * Properly typed interface for dot notation access (e.g., Tooltip.Trigger)
106
- */
107
- declare const TooltipComponent: typeof Tooltip & {
108
- Trigger: {
109
- ({ asChild, onPress: onPressProp, disabled, ref, ...props }: Omit<import("react-native").PressableProps & React.RefAttributes<View>, "ref"> & {
110
- asChild?: boolean;
111
- } & {
112
- onKeyDown?: (ev: React.KeyboardEvent) => void;
113
- onKeyUp?: (ev: React.KeyboardEvent) => void;
114
- } & React.RefAttributes<import("@rn-primitives/tooltip").TriggerRef>): React.JSX.Element;
115
- displayName: string;
116
- };
95
+ type TooltipCompound = typeof Tooltip & {
96
+ Trigger: typeof TooltipTrigger;
117
97
  Content: typeof TooltipContent;
118
98
  Body: typeof TooltipBody;
119
99
  };
100
+ /**
101
+ * Tooltip Component with Sub-components
102
+ * Properly typed interface for dot notation access (e.g., Tooltip.Trigger).
103
+ * The annotation is what keeps declaration emit portable — see Dialog.tsx (TS2883).
104
+ */
105
+ declare const TooltipComponent: TooltipCompound;
120
106
  export { TooltipComponent as Tooltip, TooltipTrigger, TooltipContent, TooltipBody, };
121
107
  export type { TooltipContentProps, TooltipBodyProps, TooltipProps, };
@@ -12,6 +12,8 @@ import { palette } from "../constants/colors.js";
12
12
  * Tooltip Trigger Component
13
13
  * The element that triggers the tooltip to appear on hover (web) or press (native)
14
14
  */
15
+ // Annotated with `typeof` for portable declaration emit — see the note in
16
+ // Dialog.tsx (TS2883). Without it the .d.ts inlines RN 0.88's `PressableInstance`.
15
17
  const TooltipTrigger = TooltipPrimitive.Trigger;
16
18
  /**
17
19
  * FullWindowOverlay wrapper - uses native overlay on iOS for proper z-index handling
@@ -68,7 +70,7 @@ function TooltipContent({ side = "top", align = "center", sideOffset = 4, portal
68
70
  maxWidth: 250,
69
71
  ...getShadowStyle("soft"),
70
72
  },
71
- ]);
73
+ ]) ?? undefined;
72
74
  return (_jsx(TooltipPrimitive.Portal, { hostName: portalHost, children: _jsx(FullWindowOverlay, { children: _jsx(TooltipPrimitive.Overlay, { style: Platform.select({ native: StyleSheet.absoluteFill }), children: _jsx(AnimatedView, { type: "fade", enterDuration: 150, children: _jsx(TextColorContext.Provider, { value: colors.text, children: _jsx(TextClassContext.Provider, { value: "", children: _jsx(TextSelectabilityContext.Provider, { value: false, children: _jsx(TooltipPrimitive.Content, { side: side, align: align, sideOffset: sideOffset, style: contentStyle, ...props }) }) }) }) }) }) }) }));
73
75
  }
74
76
  function TooltipBody({ children, style, ...props }) {
@@ -122,7 +124,8 @@ function Tooltip({ delayDuration = 700, skipDelayDuration = 300, ...props }) {
122
124
  }
123
125
  /**
124
126
  * Tooltip Component with Sub-components
125
- * Properly typed interface for dot notation access (e.g., Tooltip.Trigger)
127
+ * Properly typed interface for dot notation access (e.g., Tooltip.Trigger).
128
+ * The annotation is what keeps declaration emit portable — see Dialog.tsx (TS2883).
126
129
  */
127
130
  const TooltipComponent = Object.assign(Tooltip, {
128
131
  Trigger: TooltipTrigger,
@@ -19,7 +19,6 @@
19
19
  * function that has no hook calls of its own).
20
20
  */
21
21
  import React from "react";
22
- import { View } from "react-native";
23
22
  import type { IKeyboardState, KeyboardAnimationContext, KeyboardAvoidingViewProps, KeyboardControllerModule } from "react-native-keyboard-controller";
24
23
  /** No-op imperative module: there is no IME to dismiss or preload on web. */
25
24
  export declare const KeyboardController: KeyboardControllerModule;
@@ -41,6 +40,9 @@ type NativeKeyboardAvoidingViewProps = KeyboardAvoidingViewProps & {
41
40
  * Plain `View` passthrough. Keyboard-avoidance props are accepted (so callers
42
41
  * stay identical across platforms) and dropped, since the browser handles
43
42
  * viewport resizing itself.
43
+ *
44
+ * The explicit return type keeps declaration emit from inlining `View`'s ref
45
+ * type (RN 0.88's `ViewInstance`), which older RN typings do not export.
44
46
  */
45
- export declare function NativeKeyboardAvoidingView({ behavior, contentContainerStyle, keyboardVerticalOffset, automaticOffset, enabled, children, ...viewProps }: NativeKeyboardAvoidingViewProps): React.CElement<import("react-native").ViewProps, View>;
47
+ export declare function NativeKeyboardAvoidingView({ behavior, contentContainerStyle, keyboardVerticalOffset, automaticOffset, enabled, children, ...viewProps }: NativeKeyboardAvoidingViewProps): React.ReactElement;
46
48
  export {};
@@ -53,6 +53,7 @@ export const KeyboardController = {
53
53
  setDefaultMode: () => undefined,
54
54
  setInputMode: () => undefined,
55
55
  preload: () => undefined,
56
+ setTranslucent: () => undefined,
56
57
  dismiss: () => Promise.resolve(),
57
58
  setFocusTo: () => undefined,
58
59
  isVisible: () => false,
@@ -77,6 +78,9 @@ export function useKeyboardContext() {
77
78
  * Plain `View` passthrough. Keyboard-avoidance props are accepted (so callers
78
79
  * stay identical across platforms) and dropped, since the browser handles
79
80
  * viewport resizing itself.
81
+ *
82
+ * The explicit return type keeps declaration emit from inlining `View`'s ref
83
+ * type (RN 0.88's `ViewInstance`), which older RN typings do not export.
80
84
  */
81
85
  export function NativeKeyboardAvoidingView({ behavior, contentContainerStyle, keyboardVerticalOffset, automaticOffset, enabled, children, ...viewProps }) {
82
86
  // Dropped rather than forwarded so they never reach the DOM node.
@@ -1,34 +1,38 @@
1
1
  /**
2
2
  * Tap-away keyboard dismissal that never steals a touch.
3
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:
4
+ * Let the deepest interactive view win the responder negotiation, and blur on
5
+ * release only for an unclaimed, single-finger tap within the travel slop.
6
+ * Hosted `@expo/ui` inputs register with RN's `TextInputState`. A ScrollView
7
+ * using `keyboardShouldPersistTaps="handled"` can therefore claim dead space
8
+ * and blur on release if it observed no scroll, even after substantial travel.
9
+ * `DismissKeyboard`'s ScrollView uses `always` so this boundary owns tap dismissal
10
+ * with an explicit travel policy, independent of whether content can scroll.
11
+ * Two signals need no rectangles and no responder ownership:
10
12
  *
11
13
  * 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".
14
+ * view when no descendant claimed the touch. Pressables and RN TextInputs
15
+ * claim, as can scroll views depending on their tap policy. Being asked
16
+ * means no descendant took responder ownership, not necessarily dead space.
15
17
  * 2. The package `TextInput` tags touches that begin on its surface
16
18
  * (`markTextInputTouchStart`). Its bubble handler runs before any enclosing
17
19
  * boundary's, so the boundary can tell a tap on *any* native field — focused
18
20
  * or not — from dead space, and leave focus handoff, double-tap selection
19
21
  * and the eye toggle to the platform.
20
22
  *
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
+ * The boundary always answers `false`, so it never becomes the JS responder.
24
+ * Native controls (SwiftUI/Compose fields, sliders) keep receiving every touch phase
23
25
  * on both platforms, and scroll views take over drags exactly as before.
24
26
  * 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.
27
+ * or not anyone became the responder. Moves cancel permanently once past the
28
+ * slop; release coordinates also enforce it when move delivery was missed.
29
+ * Cancellation and multitouch drop the tap without rearming another finger.
26
30
  */
27
31
  import { useMemo, useRef } from "react";
28
32
  import { Platform } from "react-native";
29
33
  import { KeyboardController, useKeyboardState } from "./keyboardController";
30
34
  import { dismissKeyboardFocusedInput, hasKeyboardFocusedInput } from "./keyboardFocusRegistry.js";
31
- /** Finger travel (pt) after which a touch counts as a scroll, not a tap. */
35
+ /** Maximum displacement per axis (RN logical units) for a tap, scrollable or not. */
32
36
  const MOVE_SLOP = 10;
33
37
  function keyOf(event) {
34
38
  const { identifier, timestamp } = event.nativeEvent;
@@ -42,6 +46,10 @@ function isSameEvent(key, event) {
42
46
  }
43
47
  let textInputTouch = null;
44
48
  let claimedTouch = null;
49
+ // An ancestor can observe a second finger outside an inner boundary/surface.
50
+ // Invalidate already-armed taps there too, even if that finger lifts first and
51
+ // the inner view only receives its original finger's final touches=[] event.
52
+ let multiTouchVersion = 0;
45
53
  /**
46
54
  * `onStartShouldSetResponder` for a native text-field surface: decline the
47
55
  * responder (the native field keeps every touch phase) but tag the touch so an
@@ -72,6 +80,77 @@ function movedPastSlop(tap, event) {
72
80
  return (Math.abs(event.nativeEvent.pageX - tap.pageX) > MOVE_SLOP ||
73
81
  Math.abs(event.nativeEvent.pageY - tap.pageY) > MOVE_SLOP);
74
82
  }
83
+ /** Shared single-finger travel policy for tap-away dismissal and surface focus. */
84
+ function createTapTracker() {
85
+ let pending = null;
86
+ let armedVersion = multiTouchVersion;
87
+ let lastStart = null;
88
+ const active = new Set();
89
+ function readTouches(event) {
90
+ const { touches } = event.nativeEvent;
91
+ if (touches == null)
92
+ return false;
93
+ active.clear();
94
+ touches.forEach(({ identifier }) => active.add(identifier));
95
+ return true;
96
+ }
97
+ return {
98
+ start(event) {
99
+ // Negotiation precedes the plain bubbling touch-start for the same event.
100
+ // Observe it once; a child-claimed start reaches only onTouchStart, which
101
+ // clears any stale pending tap but never arms one.
102
+ if (isSameEvent(lastStart, event))
103
+ return false;
104
+ lastStart = keyOf(event);
105
+ pending = null;
106
+ // RN's array is authoritative, including after a missed end. The set is
107
+ // also a fallback for callers/tests omitting arrays, retaining other down
108
+ // fingers even after movement has cancelled the pending tap.
109
+ if (!readTouches(event))
110
+ active.add(event.nativeEvent.identifier);
111
+ if (active.size > 1 || (event.nativeEvent.changedTouches?.length ?? 1) > 1) {
112
+ multiTouchVersion += 1;
113
+ return false;
114
+ }
115
+ return active.size === 1;
116
+ },
117
+ arm(event) {
118
+ pending = { ...keyOf(event), pageX: event.nativeEvent.pageX, pageY: event.nativeEvent.pageY };
119
+ armedVersion = multiTouchVersion;
120
+ },
121
+ move(event) {
122
+ readTouches(event);
123
+ if (active.size > 1 || (event.nativeEvent.changedTouches?.length ?? 1) > 1) {
124
+ multiTouchVersion += 1;
125
+ pending = null;
126
+ }
127
+ else if (isSameTouch(pending, event) && movedPastSlop(pending, event)) {
128
+ pending = null;
129
+ }
130
+ },
131
+ end(event) {
132
+ const tap = pending;
133
+ pending = null;
134
+ // On touch end, touches contains the REMAINING fingers (empty on the last
135
+ // release); changedTouches contains those that ended. Never treat the
136
+ // final empty array as an invalid single-finger tap.
137
+ if (!readTouches(event)) {
138
+ active.delete(event.nativeEvent.identifier);
139
+ event.nativeEvent.changedTouches?.forEach(({ identifier }) => active.delete(identifier));
140
+ }
141
+ if (active.size === 0)
142
+ lastStart = null;
143
+ if (active.size > 0 || (event.nativeEvent.changedTouches?.length ?? 1) > 1) {
144
+ multiTouchVersion += 1;
145
+ }
146
+ return (armedVersion === multiTouchVersion &&
147
+ active.size === 0 &&
148
+ (event.nativeEvent.changedTouches?.length ?? 1) <= 1 &&
149
+ isSameTouch(tap, event) &&
150
+ !movedPastSlop(tap, event));
151
+ },
152
+ };
153
+ }
75
154
  /**
76
155
  * View props for the painted surface around a native (`@expo/ui`) text field.
77
156
  *
@@ -87,31 +166,30 @@ function movedPastSlop(tap, event) {
87
166
  export function useTextInputSurfaceResponder(focus) {
88
167
  const focusRef = useRef(focus);
89
168
  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
- }), []);
169
+ return useMemo(() => {
170
+ const tap = createTapTracker();
171
+ return {
172
+ onStartShouldSetResponder: (event) => {
173
+ markTextInputTouchStart(event);
174
+ if (tap.start(event))
175
+ tap.arm(event);
176
+ return false;
177
+ },
178
+ onTouchStart: (event) => {
179
+ tap.start(event);
180
+ },
181
+ onTouchMove: (event) => {
182
+ tap.move(event);
183
+ },
184
+ onTouchEnd: (event) => {
185
+ if (tap.end(event))
186
+ focusRef.current();
187
+ },
188
+ onTouchCancel: (event) => {
189
+ tap.end(event);
190
+ },
191
+ };
192
+ }, []);
115
193
  }
116
194
  /**
117
195
  * View props for a tap-away keyboard-dismiss boundary. Spread onto the wrapper
@@ -123,13 +201,14 @@ export function useKeyboardDismissResponder() {
123
201
  const isVisible = useKeyboardState((state) => state.isVisible);
124
202
  const isVisibleRef = useRef(isVisible);
125
203
  isVisibleRef.current = isVisible;
126
- const pending = useRef(null);
127
204
  return useMemo(() => {
128
205
  if (Platform.OS === "web")
129
206
  return {};
207
+ const tap = createTapTracker();
130
208
  return {
131
209
  onStartShouldSetResponder: (event) => {
132
- pending.current = null;
210
+ if (!tap.start(event))
211
+ return false;
133
212
  // `hasKeyboardFocusedInput` covers the window where a field has focus but
134
213
  // keyboard-controller has not reported the keyboard yet (or a hardware
135
214
  // keyboard is attached and there is no software keyboard to observe).
@@ -141,29 +220,21 @@ export function useKeyboardDismissResponder() {
141
220
  if (isSameEvent(claimedTouch, event))
142
221
  return false;
143
222
  claimedTouch = keyOf(event);
144
- pending.current = { ...claimedTouch, pageX: event.nativeEvent.pageX, pageY: event.nativeEvent.pageY };
223
+ tap.arm(event);
145
224
  return false;
146
225
  },
147
226
  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;
227
+ tap.start(event);
153
228
  },
154
229
  onTouchMove: (event) => {
155
- if (isSameTouch(pending.current, event) && movedPastSlop(pending.current, event)) {
156
- pending.current = null;
157
- }
230
+ tap.move(event);
158
231
  },
159
232
  onTouchEnd: (event) => {
160
- if (!isSameTouch(pending.current, event))
161
- return;
162
- pending.current = null;
163
- dismissKeyboard();
233
+ if (tap.end(event))
234
+ dismissKeyboard();
164
235
  },
165
- onTouchCancel: () => {
166
- pending.current = null;
236
+ onTouchCancel: (event) => {
237
+ tap.end(event);
167
238
  },
168
239
  };
169
240
  }, []);
@@ -1,10 +1,12 @@
1
1
  /**
2
2
  * Registry of the currently focused native (`@expo/ui`) text field.
3
3
  *
4
- * React Native's `TextInputState` never learns about SwiftUI / Compose fields,
5
- * so `Keyboard.dismiss()` cannot blur them. The package `TextInput` registers a
6
- * blur handle here on focus; tap-away dismissal (`keyboardDismiss.ts`) and the
7
- * `BottomSheet` overlay resign the field through it.
4
+ * Hosted `@expo/ui` inputs also register with React Native's `TextInputState`.
5
+ * This package registry supplies a window-independent native blur handle and
6
+ * focus presence even when keyboard-controller cannot observe the keyboard
7
+ * (for example, in an isolated native sheet window). The package `TextInput`
8
+ * registers on focus; tap-away dismissal (`keyboardDismiss.ts`) and the
9
+ * `BottomSheet` overlay resign the field through that handle.
8
10
  */
9
11
  export type KeyboardFocusedInputToken = object;
10
12
  /**
@@ -36,7 +36,7 @@ export declare const spacing: {
36
36
  readonly radiusNone: 0;
37
37
  readonly radiusXs: 4;
38
38
  readonly radiusSm: 8;
39
- readonly radiusMd: 12;
39
+ readonly radiusMd: 10;
40
40
  readonly radiusLg: 14;
41
41
  readonly radiusXl: 18;
42
42
  readonly radius2xl: 24;
@@ -47,5 +47,5 @@ export declare const spacing: {
47
47
  readonly iconLg: 32;
48
48
  readonly iconXl: 48;
49
49
  };
50
- export declare const base: 8, xxs: 2, xs: 4, sm: 8, smd: 12, md: 16, mdl: 20, lg: 24, xl: 32, xxl: 48, xxxl: 64, gutter: 16, gutterVertical: 24, screenPadding: 16, sectionSpacing: 24, rowPaddingY: 10, rowPaddingX: 16, rowGap: 12, rowMinHeight: 40, formRowMinHeight: 32, cardPadding: 16, dialogPadding: 20, touchTarget: 44, buttonPadding: 10, inputPadding: 10, listItemSpacing: 8, radiusNone: 0, radiusXs: 4, radiusSm: 8, radiusMd: 12, radiusLg: 14, radiusXl: 18, radius2xl: 24, radiusFull: 9999, iconXs: 12, iconSm: 16, iconMd: 24, iconLg: 32, iconXl: 48;
50
+ export declare const base: 8, xxs: 2, xs: 4, sm: 8, smd: 12, md: 16, mdl: 20, lg: 24, xl: 32, xxl: 48, xxxl: 64, gutter: 16, gutterVertical: 24, screenPadding: 16, sectionSpacing: 24, rowPaddingY: 10, rowPaddingX: 16, rowGap: 12, rowMinHeight: 40, formRowMinHeight: 32, cardPadding: 16, dialogPadding: 20, touchTarget: 44, buttonPadding: 10, inputPadding: 10, listItemSpacing: 8, radiusNone: 0, radiusXs: 4, radiusSm: 8, radiusMd: 10, radiusLg: 14, radiusXl: 18, radius2xl: 24, radiusFull: 9999, iconXs: 12, iconSm: 16, iconMd: 24, iconLg: 32, iconXl: 48;
51
51
  export declare const space: (multiplier: number) => number;
@@ -38,11 +38,11 @@ export const spacing = {
38
38
  buttonPadding: 10, // Default button padding
39
39
  inputPadding: 10, // Default input padding
40
40
  listItemSpacing: 8, // Space between list items
41
- // Border radius — shadcn-inspired scale (radiusMd = 12px default)
41
+ // Border radius — shadcn-inspired scale (radiusMd = 10px default)
42
42
  radiusNone: 0,
43
43
  radiusXs: 4,
44
44
  radiusSm: 8,
45
- radiusMd: 12,
45
+ radiusMd: 10,
46
46
  radiusLg: 14,
47
47
  radiusXl: 18,
48
48
  radius2xl: 24,