@mrmeg/expo-ui 0.23.0 → 0.25.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.
Files changed (38) hide show
  1. package/CHANGELOG.md +84 -0
  2. package/LLM_USAGE.md +172 -157
  3. package/README.md +447 -352
  4. package/dist/components/Avatar.js +6 -1
  5. package/dist/components/BottomSheet.js +1 -1
  6. package/dist/components/Button.d.ts +1 -1
  7. package/dist/components/Carousel.js +2 -0
  8. package/dist/components/Dialog.d.ts +15 -93
  9. package/dist/components/Dialog.js +5 -0
  10. package/dist/components/DismissKeyboard.d.ts +14 -3
  11. package/dist/components/DismissKeyboard.js +17 -48
  12. package/dist/components/DropdownMenu.d.ts +5 -34
  13. package/dist/components/DropdownMenu.js +2 -1
  14. package/dist/components/InputOTP.js +6 -1
  15. package/dist/components/Popover.d.ts +11 -11
  16. package/dist/components/Popover.js +3 -2
  17. package/dist/components/Select.d.ts +12 -20
  18. package/dist/components/Select.js +2 -1
  19. package/dist/components/StatusBar.js +2 -6
  20. package/dist/components/StyledText.context.d.ts +1 -1
  21. package/dist/components/StyledText.d.ts +6 -2
  22. package/dist/components/TextInput.d.ts +6 -2
  23. package/dist/components/TextInput.js +189 -42
  24. package/dist/components/Tooltip.d.ts +10 -24
  25. package/dist/components/Tooltip.js +5 -2
  26. package/dist/components/index.d.ts +1 -0
  27. package/dist/components/index.js +1 -0
  28. package/dist/components/keyboardController.d.ts +4 -2
  29. package/dist/components/keyboardController.js +4 -0
  30. package/dist/components/keyboardDismiss.d.ts +37 -0
  31. package/dist/components/keyboardDismiss.js +170 -0
  32. package/dist/components/keyboardFocusRegistry.d.ts +10 -14
  33. package/dist/components/keyboardFocusRegistry.js +8 -24
  34. package/dist/design-system.json +2236 -0
  35. package/dist/hooks/useTheme.d.ts +11 -3
  36. package/llms-full.md +134 -124
  37. package/llms.txt +24 -22
  38. package/package.json +11 -8
@@ -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
  },
@@ -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,
@@ -11,6 +11,7 @@ export * from "./Checkbox";
11
11
  export * from "./Collapsible";
12
12
  export * from "./Dialog";
13
13
  export * from "./DismissKeyboard";
14
+ export * from "./keyboardDismiss";
14
15
  export * from "./Drawer";
15
16
  export * from "./DropdownMenu";
16
17
  export * from "./EmptyState";
@@ -11,6 +11,7 @@ export * from "./Checkbox.js";
11
11
  export * from "./Collapsible.js";
12
12
  export * from "./Dialog.js";
13
13
  export * from "./DismissKeyboard.js";
14
+ export * from "./keyboardDismiss.js";
14
15
  export * from "./Drawer.js";
15
16
  export * from "./DropdownMenu.js";
16
17
  export * from "./EmptyState.js";
@@ -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.
@@ -0,0 +1,37 @@
1
+ import { type GestureResponderEvent, type ViewProps } from "react-native";
2
+ /**
3
+ * `onStartShouldSetResponder` for a native text-field surface: decline the
4
+ * responder (the native field keeps every touch phase) but tag the touch so an
5
+ * enclosing tap-away boundary leaves it alone. The package `TextInput` uses
6
+ * this through {@link useTextInputSurfaceResponder}.
7
+ */
8
+ export declare function markTextInputTouchStart(event: GestureResponderEvent): boolean;
9
+ /**
10
+ * Hide the software keyboard for whatever is focused. Prefers the registered
11
+ * native field's own blur handle (window-independent), then falls back to
12
+ * `KeyboardController.dismiss()`, which resigns at the IME level and covers RN
13
+ * or third-party inputs. No-op on web, which has no software keyboard to hide.
14
+ */
15
+ export declare function dismissKeyboard(): void;
16
+ export type TextInputSurfaceResponderProps = Pick<ViewProps, "onStartShouldSetResponder" | "onTouchStart" | "onTouchMove" | "onTouchEnd" | "onTouchCancel">;
17
+ /**
18
+ * View props for the painted surface around a native (`@expo/ui`) text field.
19
+ *
20
+ * Two jobs. It tags every touch that begins on the surface so the tap-away
21
+ * boundary leaves it alone (see {@link markTextInputTouchStart}). And it
22
+ * focuses the field on release of a tap that no child claimed: the SwiftUI
23
+ * `TextField` only hit-tests its text line, so taps on the box's vertical
24
+ * padding never reach it, and a tap on the field's `hitSlop` never can. Calling
25
+ * `focus` is idempotent when the native field already took the tap, and the eye
26
+ * toggle is a `Pressable` that claims the responder first, so it never fires
27
+ * for that.
28
+ */
29
+ export declare function useTextInputSurfaceResponder(focus: () => void): TextInputSurfaceResponderProps;
30
+ export type KeyboardDismissResponderProps = Pick<ViewProps, "onStartShouldSetResponder" | "onTouchStart" | "onTouchMove" | "onTouchEnd" | "onTouchCancel">;
31
+ /**
32
+ * View props for a tap-away keyboard-dismiss boundary. Spread onto the wrapper
33
+ * `View` that encloses the screen (or a form). Nesting is safe: the innermost
34
+ * boundary owns each touch, so the keyboard is dismissed once. Returns `{}` on
35
+ * web.
36
+ */
37
+ export declare function useKeyboardDismissResponder(): KeyboardDismissResponderProps;
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Tap-away keyboard dismissal that never steals a touch.
3
+ *
4
+ * React Native's own `keyboardShouldPersistTaps="handled"` is the model: let the
5
+ * deepest interactive view win the responder negotiation, and blur on *release*
6
+ * only when nothing else took the tap and the finger did not scroll. RN cannot
7
+ * apply that to the native `@expo/ui` field (it is invisible to
8
+ * `TextInputState`), so this module re-creates the same decision with two
9
+ * signals that need no rectangles and no responder ownership:
10
+ *
11
+ * 1. `onStartShouldSetResponder` in the *bubble* phase is only ever called on a
12
+ * view when no descendant claimed the touch. Pressables, RN TextInputs and
13
+ * scroll views all claim, so being asked at all means "this tap is on dead
14
+ * space or on a native view that does not participate".
15
+ * 2. The package `TextInput` tags touches that begin on its surface
16
+ * (`markTextInputTouchStart`). Its bubble handler runs before any enclosing
17
+ * boundary's, so the boundary can tell a tap on *any* native field — focused
18
+ * or not — from dead space, and leave focus handoff, double-tap selection
19
+ * and the eye toggle to the platform.
20
+ *
21
+ * The boundary always answers `false`, so no JS responder is ever set: native
22
+ * controls (SwiftUI/Compose fields, sliders) keep receiving every touch phase
23
+ * on both platforms, and scroll views take over drags exactly as before.
24
+ * Dismissal happens in `onTouchEnd`, a plain bubbling event that fires whether
25
+ * or not anyone became the responder; a drag past the slop or a cancel drops it.
26
+ */
27
+ import { useMemo, useRef } from "react";
28
+ import { Platform } from "react-native";
29
+ import { KeyboardController, useKeyboardState } from "./keyboardController";
30
+ import { dismissKeyboardFocusedInput, hasKeyboardFocusedInput } from "./keyboardFocusRegistry.js";
31
+ /** Finger travel (pt) after which a touch counts as a scroll, not a tap. */
32
+ const MOVE_SLOP = 10;
33
+ function keyOf(event) {
34
+ const { identifier, timestamp } = event.nativeEvent;
35
+ return { identifier, timestamp };
36
+ }
37
+ /** Same native event: the responder negotiation hands one event to every listener. */
38
+ function isSameEvent(key, event) {
39
+ return (key != null &&
40
+ key.identifier === event.nativeEvent.identifier &&
41
+ key.timestamp === event.nativeEvent.timestamp);
42
+ }
43
+ let textInputTouch = null;
44
+ let claimedTouch = null;
45
+ /**
46
+ * `onStartShouldSetResponder` for a native text-field surface: decline the
47
+ * responder (the native field keeps every touch phase) but tag the touch so an
48
+ * enclosing tap-away boundary leaves it alone. The package `TextInput` uses
49
+ * this through {@link useTextInputSurfaceResponder}.
50
+ */
51
+ export function markTextInputTouchStart(event) {
52
+ textInputTouch = keyOf(event);
53
+ return false;
54
+ }
55
+ /**
56
+ * Hide the software keyboard for whatever is focused. Prefers the registered
57
+ * native field's own blur handle (window-independent), then falls back to
58
+ * `KeyboardController.dismiss()`, which resigns at the IME level and covers RN
59
+ * or third-party inputs. No-op on web, which has no software keyboard to hide.
60
+ */
61
+ export function dismissKeyboard() {
62
+ if (Platform.OS === "web")
63
+ return;
64
+ if (!dismissKeyboardFocusedInput()) {
65
+ void KeyboardController.dismiss();
66
+ }
67
+ }
68
+ function isSameTouch(tap, event) {
69
+ return tap != null && tap.identifier === event.nativeEvent.identifier;
70
+ }
71
+ function movedPastSlop(tap, event) {
72
+ return (Math.abs(event.nativeEvent.pageX - tap.pageX) > MOVE_SLOP ||
73
+ Math.abs(event.nativeEvent.pageY - tap.pageY) > MOVE_SLOP);
74
+ }
75
+ /**
76
+ * View props for the painted surface around a native (`@expo/ui`) text field.
77
+ *
78
+ * Two jobs. It tags every touch that begins on the surface so the tap-away
79
+ * boundary leaves it alone (see {@link markTextInputTouchStart}). And it
80
+ * focuses the field on release of a tap that no child claimed: the SwiftUI
81
+ * `TextField` only hit-tests its text line, so taps on the box's vertical
82
+ * padding never reach it, and a tap on the field's `hitSlop` never can. Calling
83
+ * `focus` is idempotent when the native field already took the tap, and the eye
84
+ * toggle is a `Pressable` that claims the responder first, so it never fires
85
+ * for that.
86
+ */
87
+ export function useTextInputSurfaceResponder(focus) {
88
+ const focusRef = useRef(focus);
89
+ focusRef.current = focus;
90
+ const tap = useRef(null);
91
+ return useMemo(() => ({
92
+ onStartShouldSetResponder: (event) => {
93
+ markTextInputTouchStart(event);
94
+ tap.current = { ...keyOf(event), pageX: event.nativeEvent.pageX, pageY: event.nativeEvent.pageY };
95
+ return false;
96
+ },
97
+ onTouchStart: (event) => {
98
+ if (tap.current && !isSameEvent(tap.current, event))
99
+ tap.current = null;
100
+ },
101
+ onTouchMove: (event) => {
102
+ if (isSameTouch(tap.current, event) && movedPastSlop(tap.current, event))
103
+ tap.current = null;
104
+ },
105
+ onTouchEnd: (event) => {
106
+ if (!isSameTouch(tap.current, event))
107
+ return;
108
+ tap.current = null;
109
+ focusRef.current();
110
+ },
111
+ onTouchCancel: () => {
112
+ tap.current = null;
113
+ },
114
+ }), []);
115
+ }
116
+ /**
117
+ * View props for a tap-away keyboard-dismiss boundary. Spread onto the wrapper
118
+ * `View` that encloses the screen (or a form). Nesting is safe: the innermost
119
+ * boundary owns each touch, so the keyboard is dismissed once. Returns `{}` on
120
+ * web.
121
+ */
122
+ export function useKeyboardDismissResponder() {
123
+ const isVisible = useKeyboardState((state) => state.isVisible);
124
+ const isVisibleRef = useRef(isVisible);
125
+ isVisibleRef.current = isVisible;
126
+ const pending = useRef(null);
127
+ return useMemo(() => {
128
+ if (Platform.OS === "web")
129
+ return {};
130
+ return {
131
+ onStartShouldSetResponder: (event) => {
132
+ pending.current = null;
133
+ // `hasKeyboardFocusedInput` covers the window where a field has focus but
134
+ // keyboard-controller has not reported the keyboard yet (or a hardware
135
+ // keyboard is attached and there is no software keyboard to observe).
136
+ if (!isVisibleRef.current && !hasKeyboardFocusedInput())
137
+ return false;
138
+ if (isSameEvent(textInputTouch, event))
139
+ return false;
140
+ // An inner boundary already runs this touch (bubble order is inner-first).
141
+ if (isSameEvent(claimedTouch, event))
142
+ return false;
143
+ claimedTouch = keyOf(event);
144
+ pending.current = { ...claimedTouch, pageX: event.nativeEvent.pageX, pageY: event.nativeEvent.pageY };
145
+ return false;
146
+ },
147
+ onTouchStart: (event) => {
148
+ // Bubbling touch events run after the negotiation for the same native
149
+ // event, so a fresh pending tap matches here; anything else is stale
150
+ // (e.g. a touch whose end never reached this view).
151
+ if (pending.current && !isSameEvent(pending.current, event))
152
+ pending.current = null;
153
+ },
154
+ onTouchMove: (event) => {
155
+ if (isSameTouch(pending.current, event) && movedPastSlop(pending.current, event)) {
156
+ pending.current = null;
157
+ }
158
+ },
159
+ onTouchEnd: (event) => {
160
+ if (!isSameTouch(pending.current, event))
161
+ return;
162
+ pending.current = null;
163
+ dismissKeyboard();
164
+ },
165
+ onTouchCancel: () => {
166
+ pending.current = null;
167
+ },
168
+ };
169
+ }, []);
170
+ }