@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.
- package/CHANGELOG.md +84 -0
- package/LLM_USAGE.md +172 -157
- package/README.md +447 -352
- package/dist/components/Avatar.js +6 -1
- package/dist/components/BottomSheet.js +1 -1
- package/dist/components/Button.d.ts +1 -1
- package/dist/components/Carousel.js +2 -0
- package/dist/components/Dialog.d.ts +15 -93
- package/dist/components/Dialog.js +5 -0
- package/dist/components/DismissKeyboard.d.ts +14 -3
- package/dist/components/DismissKeyboard.js +17 -48
- package/dist/components/DropdownMenu.d.ts +5 -34
- package/dist/components/DropdownMenu.js +2 -1
- package/dist/components/InputOTP.js +6 -1
- package/dist/components/Popover.d.ts +11 -11
- package/dist/components/Popover.js +3 -2
- package/dist/components/Select.d.ts +12 -20
- package/dist/components/Select.js +2 -1
- package/dist/components/StatusBar.js +2 -6
- package/dist/components/StyledText.context.d.ts +1 -1
- package/dist/components/StyledText.d.ts +6 -2
- package/dist/components/TextInput.d.ts +6 -2
- package/dist/components/TextInput.js +189 -42
- package/dist/components/Tooltip.d.ts +10 -24
- package/dist/components/Tooltip.js +5 -2
- package/dist/components/index.d.ts +1 -0
- package/dist/components/index.js +1 -0
- package/dist/components/keyboardController.d.ts +4 -2
- package/dist/components/keyboardController.js +4 -0
- package/dist/components/keyboardDismiss.d.ts +37 -0
- package/dist/components/keyboardDismiss.js +170 -0
- package/dist/components/keyboardFocusRegistry.d.ts +10 -14
- package/dist/components/keyboardFocusRegistry.js +8 -24
- package/dist/design-system.json +2236 -0
- package/dist/hooks/useTheme.d.ts +11 -3
- package/llms-full.md +134 -124
- package/llms.txt +24 -22
- 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 {
|
|
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`
|
|
211
|
-
//
|
|
212
|
-
//
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
const
|
|
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
|
|
234
|
-
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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 () =>
|
|
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: () =>
|
|
269
|
-
blur: () =>
|
|
391
|
+
focus: () => activeInput()?.focus(),
|
|
392
|
+
blur: () => blurAll(),
|
|
270
393
|
clear: () => {
|
|
271
394
|
state.value = "";
|
|
395
|
+
lastTextRef.current = "";
|
|
272
396
|
},
|
|
273
|
-
isFocused: () =>
|
|
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, {
|
|
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 {
|
|
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
|
-
|
|
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";
|
package/dist/components/index.js
CHANGED
|
@@ -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.
|
|
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
|
+
}
|