@mrmeg/expo-ui 0.25.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.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,38 @@
3
3
  All notable changes to `@mrmeg/expo-ui` are documented here. This project
4
4
  adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
5
5
 
6
+ ## [Unreleased]
7
+
8
+ ## [0.25.1]
9
+
10
+ ### Fixed
11
+
12
+ - **Android password toggles retain the native field.** The secure/plain Host
13
+ keys previously replaced the Compose field on every eye press, leaving the
14
+ keyboard visible but disconnecting subsequent typing. Android now changes
15
+ password transformation on one mounted Host/input/ref, preserving focus,
16
+ selection, and the input connection. iOS retains its two-field handoff.
17
+ - **Non-scrolling drags no longer trigger tap-away dismissal.** `DismissKeyboard`
18
+ gives its boundary sole tap-dismiss ownership (`keyboardShouldPersistTaps="always"`)
19
+ instead of letting RN ScrollView independently blur a registered hosted field
20
+ on release. Both tap-away dismissal and text-surface focus check final travel
21
+ even when move events are missed, and cancel multitouch. Actual scrolling still
22
+ dismisses through the existing platform-specific scroll behavior.
23
+
24
+ Consumer note: raw `Pressable` labels made from `StyledText` must set
25
+ `selectable={false}`. On Android selectable label text can take native focus from
26
+ an input even when the press handler does not dismiss. The template's auth
27
+ controls and standalone scroll shell are corrected separately in app code;
28
+ updating this package does not update a consuming app's copied forms.
29
+
30
+ ### Changed
31
+
32
+ - **`spacing.radiusMd` is 10 instead of 12.** Buttons, inputs, selects, toggles,
33
+ tabs, menus, popovers, tooltips, list items and every other component on the
34
+ default radius render slightly squarer. `radiusSm` (8) and `radiusLg` (14) are
35
+ unchanged, so the scale still increases monotonically. Apps that want the old
36
+ look can pass `borderRadius: 12` through `setShape` or a `style` override.
37
+
6
38
  ## [0.25.0]
7
39
 
8
40
  ### Added
package/LLM_USAGE.md CHANGED
@@ -74,6 +74,13 @@ avoidance is `react-native-keyboard-controller`, so mount its `KeyboardProvider`
74
74
  above `UIProvider`; use `KeyboardAvoidingView` directly only for a subtree with
75
75
  custom behavior.
76
76
 
77
+ `DismissKeyboard` owns tap dismissal; its ScrollView uses
78
+ `keyboardShouldPersistTaps="always"` so RN cannot independently blur a hosted
79
+ field after a non-scrolling drag. Use `always` for your own scroll view inside
80
+ that boundary too. Dead-space taps dismiss on release; movement and multitouch
81
+ cancel. Actual scrolling retains platform-specific dismissal. Submit handlers
82
+ call `dismissKeyboard()` explicitly.
83
+
77
84
  `BottomSheet` renders the platform's native sheet through `@expo/ui` (iOS
78
85
  SwiftUI `.sheet()`, Android Material3 `ModalBottomSheet`, web `vaul`). The
79
86
  platform owns gestures and keyboard avoidance: `swipeEnabled`, `avoidKeyboard`,
@@ -255,6 +262,11 @@ Check this before creating a new app-local primitive. All components come from
255
262
  `BodyText`, `CaptionText`, `LabelText`, `EyebrowText`, `MonoText`, `SerifText`,
256
263
  `SansSerifText`, `SerifBoldText`, `SansSerifBoldText`.
257
264
 
265
+ Set `selectable={false}` on text labels inside app-owned `Pressable`s. Selectable
266
+ Android text can take input focus even if the press handler never dismisses the
267
+ keyboard. Package controls already scope label selectability; ordinary text
268
+ should remain selectable.
269
+
258
270
  ## Component Selection Rules
259
271
 
260
272
  - `Button` commands · `Toggle` one pressed state · `ToggleGroup` a related set · `Switch` binary settings · `RadioGroup` few exclusive choices · `Select` longer option sets.
package/README.md CHANGED
@@ -110,6 +110,20 @@ For a subtree with custom keyboard behavior, use `KeyboardAvoidingView`
110
110
  directly (`behavior`, `automaticOffset`, `contentContainerStyle`,
111
111
  `keyboardVerticalOffset`).
112
112
 
113
+ ### Keyboard dismissal
114
+
115
+ `DismissKeyboard` owns tap-away dismissal without claiming touches. Its scroll
116
+ view uses `keyboardShouldPersistTaps="always"`: RN's `handled` policy can blur
117
+ Expo-hosted fields independently when a finger travels without scrolling.
118
+ Single-finger dead-space taps dismiss on release; movement past 10 logical units
119
+ and multitouch cancel the tap. Real scrolling still dismisses (interactive on
120
+ iOS, an explicit scroll-begin handler on Android).
121
+
122
+ If a form supplies its own ScrollView inside `DismissKeyboard scrollable={false}`
123
+ or an app-level `useKeyboardDismissResponder` boundary, use `always` there too.
124
+ Avoid nested keyboard-avoiding wrappers; the package reuses `UIProvider`'s owner.
125
+ Call `dismissKeyboard()` explicitly from submit handlers.
126
+
113
127
  ## Theme System
114
128
 
115
129
  ```tsx
@@ -210,7 +224,7 @@ too, because @react-navigation parses them with a color library.
210
224
 
211
225
  `spacing` exposes an 8px base scale (`xxs` 2, `xs` 4, `sm` 8, `smd` 12, `md`
212
226
  16, `mdl` 20, `lg` 24, `xl` 32, `xxl` 48, `xxxl` 64), radii (`radiusNone` 0,
213
- `radiusXs` 4, `radiusSm` 8, `radiusMd` 12, `radiusLg` 14, `radiusXl` 18,
227
+ `radiusXs` 4, `radiusSm` 8, `radiusMd` 10, `radiusLg` 14, `radiusXl` 18,
214
228
  `radius2xl` 24, `radiusFull` 9999), icon sizes (`iconXs` 12, `iconSm` 16,
215
229
  `iconMd` 24, `iconLg` 32, `iconXl` 48), and the semantic density tokens package
216
230
  components read from. Prefer the semantic names so a density tune lands
@@ -300,6 +314,10 @@ import { BodyText, CaptionText, HeadingText, StyledText } from "@mrmeg/expo-ui/c
300
314
  - `selectable`: defaults to `true`; package controls disable it for labels and
301
315
  interactive chrome where accidental drag selection would feel broken.
302
316
 
317
+ For app-owned `Pressable` labels, explicitly set `selectable={false}` on the
318
+ nested `StyledText`. On Android selectable label text can take focus and the IME
319
+ connection from an active input. Preserve selection for ordinary readable text.
320
+
303
321
  Aliases: `DisplayText`, `TitleText`, `HeadingText`, `SubheadingText`,
304
322
  `BodyText`, `CaptionText`, `LabelText`, `EyebrowText`, `MonoText` (code, IDs,
305
323
  tabular figures), `SerifText`, `SansSerifText`, `SerifBoldText`,
@@ -12,15 +12,17 @@ type Props = {
12
12
  * Wrapper for a screen (or form) that dismisses the keyboard when the user taps
13
13
  * outside of a text input.
14
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`.
15
+ * The wrapper never claims the touch: buttons, other fields and the focused
16
+ * field's own gestures win. It dismisses only an unclaimed single-finger tap
17
+ * within the travel slop, even if no scroll/move event was delivered. The inner
18
+ * ScrollView uses `always` to leave tap dismissal to this boundary; RN's
19
+ * `handled` policy can independently blur registered hosted inputs on release
20
+ * after a drag that produced no scroll. See `useKeyboardDismissResponder`.
20
21
  *
21
22
  * A drag on the inner ScrollView also hides the keyboard: `interactive` on iOS
22
23
  * (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.
24
+ * dismiss on drag start on Android, using the package's window-independent
25
+ * native blur handle with a keyboard-controller fallback.
24
26
  */
25
27
  export declare function DismissKeyboard({ children, style, avoidKeyboard, scrollable, }: Props): React.JSX.Element;
26
28
  export {};
@@ -6,20 +6,22 @@ import { dismissKeyboard, useKeyboardDismissResponder } from "./keyboardDismiss.
6
6
  * Wrapper for a screen (or form) that dismisses the keyboard when the user taps
7
7
  * outside of a text input.
8
8
  *
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`.
9
+ * The wrapper never claims the touch: buttons, other fields and the focused
10
+ * field's own gestures win. It dismisses only an unclaimed single-finger tap
11
+ * within the travel slop, even if no scroll/move event was delivered. The inner
12
+ * ScrollView uses `always` to leave tap dismissal to this boundary; RN's
13
+ * `handled` policy can independently blur registered hosted inputs on release
14
+ * after a drag that produced no scroll. See `useKeyboardDismissResponder`.
14
15
  *
15
16
  * A drag on the inner ScrollView also hides the keyboard: `interactive` on iOS
16
17
  * (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.
18
+ * dismiss on drag start on Android, using the package's window-independent
19
+ * native blur handle with a keyboard-controller fallback.
18
20
  */
19
21
  export function DismissKeyboard({ children, style, avoidKeyboard = true, scrollable = true, }) {
20
22
  const responderProps = useKeyboardDismissResponder();
21
23
  const hasKeyboardAvoidance = useKeyboardAvoidance();
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);
24
+ const content = scrollable ? (_jsx(ScrollView, { style: { flex: 1 }, contentContainerStyle: { flexGrow: 1, justifyContent: "center" }, keyboardShouldPersistTaps: "always", keyboardDismissMode: Platform.OS === "ios" ? "interactive" : "none", onScrollBeginDrag: Platform.OS === "android" ? dismissKeyboard : undefined, showsVerticalScrollIndicator: false, children: children })) : (children);
23
25
  if (!avoidKeyboard || hasKeyboardAvoidance) {
24
26
  return (_jsx(View, { style: { flex: 1 }, ...responderProps, children: content }));
25
27
  }
@@ -10,7 +10,7 @@ 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
14
  *
15
15
  * Written as `ComponentRef<typeof RNTextInput>` rather than RN 0.88's
16
16
  * `TextInputInstance` alias so the emitted declarations stay valid on
@@ -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,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,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "package": "@mrmeg/expo-ui",
4
- "version": "0.25.0",
4
+ "version": "0.25.1",
5
5
  "tokens": {
6
6
  "spacing": {
7
7
  "entries": [
@@ -127,7 +127,7 @@
127
127
  },
128
128
  {
129
129
  "name": "radiusMd",
130
- "value": 12
130
+ "value": 10
131
131
  },
132
132
  {
133
133
  "name": "radiusLg",
@@ -36,7 +36,7 @@ export type ShapeOverrides = {
36
36
  button?: {
37
37
  /**
38
38
  * Border radius applied to every Button preset. Package default:
39
- * `spacing.radiusMd` (12). Use 9999 for pill buttons. A caller `style`
39
+ * `spacing.radiusMd` (10). Use 9999 for pill buttons. A caller `style`
40
40
  * still wins over this, as it always has.
41
41
  */
42
42
  borderRadius?: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrmeg/expo-ui",
3
- "version": "0.25.0",
3
+ "version": "0.25.1",
4
4
  "private": false,
5
5
  "description": "Reusable Expo and React Native UI primitives for MrMeg projects.",
6
6
  "keywords": [