@mrmeg/expo-ui 0.22.0 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +89 -0
- package/LLM_USAGE.md +166 -157
- package/README.md +433 -346
- package/dist/components/Accordion.js +1 -1
- package/dist/components/BottomSheet.js +1 -1
- package/dist/components/Card.js +7 -7
- package/dist/components/Checkbox.js +1 -1
- package/dist/components/Dialog.js +24 -5
- package/dist/components/DismissKeyboard.d.ts +14 -3
- package/dist/components/DismissKeyboard.js +17 -48
- package/dist/components/EmptyState.js +2 -2
- package/dist/components/InputOTP.js +4 -1
- package/dist/components/Item.d.ts +3 -2
- package/dist/components/Item.js +8 -6
- package/dist/components/RadioGroup.js +1 -1
- package/dist/components/StatCard.js +1 -1
- package/dist/components/TextInput.js +189 -42
- package/dist/components/index.d.ts +1 -0
- package/dist/components/index.js +1 -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/constants/spacing.d.ts +16 -6
- package/dist/constants/spacing.js +17 -6
- package/llms-full.md +134 -124
- package/llms.txt +24 -22
- package/package.json +3 -3
|
@@ -132,7 +132,7 @@ function AccordionTrigger({ children, style: styleOverride, disabled, ...props }
|
|
|
132
132
|
justifyContent: "space-between",
|
|
133
133
|
gap: spacing.md,
|
|
134
134
|
borderRadius: spacing.radiusMd,
|
|
135
|
-
paddingVertical: spacing.
|
|
135
|
+
paddingVertical: spacing.smd,
|
|
136
136
|
...(Platform.OS === "web" && {
|
|
137
137
|
cursor: "pointer",
|
|
138
138
|
userSelect: "none",
|
|
@@ -181,7 +181,7 @@ function SheetCloseButton({ style }) {
|
|
|
181
181
|
* dispatched across the `RNHostView` boundary on Android — only `Pressable`
|
|
182
182
|
* hit-testing (native `measure()`) works inside the host. So this is a
|
|
183
183
|
* transparent `Pressable` mounted ONLY while a field is focused, mirroring
|
|
184
|
-
* the app
|
|
184
|
+
* a tap-to-dismiss overlay in the app. The tap blurs the focused field via
|
|
185
185
|
* its own native ref (`dismissKeyboardFocusedInput`), which resigns the
|
|
186
186
|
* responder regardless of window; `KeyboardController.dismiss()` is a
|
|
187
187
|
* best-effort fallback for the (rare) case with no registered blur handle.
|
package/dist/components/Card.js
CHANGED
|
@@ -98,24 +98,24 @@ const createCardStyles = (theme) => StyleSheet.create({
|
|
|
98
98
|
backgroundColor: "transparent",
|
|
99
99
|
},
|
|
100
100
|
header: {
|
|
101
|
-
padding: spacing.
|
|
101
|
+
padding: spacing.cardPadding,
|
|
102
102
|
paddingBottom: spacing.xs,
|
|
103
103
|
gap: spacing.xs,
|
|
104
104
|
},
|
|
105
105
|
content: {
|
|
106
|
-
paddingHorizontal: spacing.
|
|
107
|
-
paddingBottom: spacing.
|
|
106
|
+
paddingHorizontal: spacing.cardPadding,
|
|
107
|
+
paddingBottom: spacing.cardPadding,
|
|
108
108
|
},
|
|
109
109
|
footer: {
|
|
110
110
|
flexDirection: "row",
|
|
111
111
|
alignItems: "center",
|
|
112
|
-
paddingHorizontal: spacing.
|
|
113
|
-
paddingBottom: spacing.
|
|
112
|
+
paddingHorizontal: spacing.cardPadding,
|
|
113
|
+
paddingBottom: spacing.cardPadding,
|
|
114
114
|
paddingTop: 0,
|
|
115
115
|
},
|
|
116
116
|
title: {
|
|
117
|
-
fontSize:
|
|
118
|
-
lineHeight:
|
|
117
|
+
fontSize: 16,
|
|
118
|
+
lineHeight: 22,
|
|
119
119
|
letterSpacing: 0,
|
|
120
120
|
},
|
|
121
121
|
description: {
|
|
@@ -117,7 +117,7 @@ const styles = StyleSheet.create({
|
|
|
117
117
|
flexDirection: "row",
|
|
118
118
|
alignItems: "center",
|
|
119
119
|
gap: spacing.sm,
|
|
120
|
-
minHeight: spacing.touchTarget,
|
|
120
|
+
minHeight: Platform.select({ web: spacing.formRowMinHeight, default: spacing.touchTarget }),
|
|
121
121
|
},
|
|
122
122
|
labelContainer: {
|
|
123
123
|
flex: 1,
|
|
@@ -22,7 +22,25 @@ function DialogRoot({ children, ...props }) {
|
|
|
22
22
|
function DialogContent({ portalHost, style, children, ...props }) {
|
|
23
23
|
const { theme, getShadowStyle, getContrastingColor } = useTheme();
|
|
24
24
|
const textColor = getContrastingColor(theme.colors.popover, palette.white, palette.black);
|
|
25
|
-
return (_jsx(DialogPrimitive.Portal, { hostName: portalHost, children: _jsx(FullWindowOverlay, { children: _jsx(DialogPrimitive.Overlay
|
|
25
|
+
return (_jsx(DialogPrimitive.Portal, { hostName: portalHost, children: _jsx(FullWindowOverlay, { children: _jsx(DialogPrimitive.Overlay
|
|
26
|
+
// On web the primitive wraps the overlay in a react-native-web
|
|
27
|
+
// Pressable whose click handler stops propagation unconditionally.
|
|
28
|
+
// With that Pressable between the overlay and the Radix content,
|
|
29
|
+
// outside presses (mouse and touch) and Escape never dismissed the
|
|
30
|
+
// dialog, so a dialog without a Close button was stuck. `asChild`
|
|
31
|
+
// makes the primitive clone our fade wrapper instead, removing the
|
|
32
|
+
// Pressable; Radix dismissal then works. Native keeps the Pressable,
|
|
33
|
+
// which is what implements closeOnPress there.
|
|
34
|
+
, {
|
|
35
|
+
// On web the primitive wraps the overlay in a react-native-web
|
|
36
|
+
// Pressable whose click handler stops propagation unconditionally.
|
|
37
|
+
// With that Pressable between the overlay and the Radix content,
|
|
38
|
+
// outside presses (mouse and touch) and Escape never dismissed the
|
|
39
|
+
// dialog, so a dialog without a Close button was stuck. `asChild`
|
|
40
|
+
// makes the primitive clone our fade wrapper instead, removing the
|
|
41
|
+
// Pressable; Radix dismissal then works. Native keeps the Pressable,
|
|
42
|
+
// which is what implements closeOnPress there.
|
|
43
|
+
asChild: Platform.OS === "web", style: StyleSheet.flatten([
|
|
26
44
|
StyleSheet.absoluteFill,
|
|
27
45
|
{ backgroundColor: theme.colors.overlay },
|
|
28
46
|
Platform.OS === "web" && { zIndex: 50 },
|
|
@@ -32,7 +50,8 @@ function DialogContent({ portalHost, style, children, ...props }) {
|
|
|
32
50
|
borderColor: theme.colors.border,
|
|
33
51
|
borderWidth: 1,
|
|
34
52
|
borderRadius: spacing.radiusLg,
|
|
35
|
-
padding: spacing.
|
|
53
|
+
padding: spacing.dialogPadding,
|
|
54
|
+
gap: spacing.md,
|
|
36
55
|
width: "100%",
|
|
37
56
|
...getShadowStyle("soft"),
|
|
38
57
|
},
|
|
@@ -40,7 +59,7 @@ function DialogContent({ portalHost, style, children, ...props }) {
|
|
|
40
59
|
]), ...props, children: children }) }) }) }) }) }) }) }) }));
|
|
41
60
|
}
|
|
42
61
|
function DialogHeader({ children, style, ...props }) {
|
|
43
|
-
return (_jsx(View, { style: StyleSheet.flatten([{ gap: spacing.xs
|
|
62
|
+
return (_jsx(View, { style: StyleSheet.flatten([{ gap: spacing.xs }, style]), ...props, children: children }));
|
|
44
63
|
}
|
|
45
64
|
function DialogFooter({ children, style, ...props }) {
|
|
46
65
|
return (_jsx(View, { style: StyleSheet.flatten([
|
|
@@ -48,7 +67,6 @@ function DialogFooter({ children, style, ...props }) {
|
|
|
48
67
|
flexDirection: "row",
|
|
49
68
|
justifyContent: "flex-end",
|
|
50
69
|
gap: spacing.sm,
|
|
51
|
-
marginTop: spacing.lg,
|
|
52
70
|
},
|
|
53
71
|
style,
|
|
54
72
|
]), ...props, children: children }));
|
|
@@ -107,7 +125,8 @@ function AlertDialogContent({ portalHost, style, children, ...props }) {
|
|
|
107
125
|
borderColor: theme.colors.border,
|
|
108
126
|
borderWidth: 1,
|
|
109
127
|
borderRadius: spacing.radiusLg,
|
|
110
|
-
padding: spacing.
|
|
128
|
+
padding: spacing.dialogPadding,
|
|
129
|
+
gap: spacing.md,
|
|
111
130
|
width: "100%",
|
|
112
131
|
...getShadowStyle("soft"),
|
|
113
132
|
},
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
-
import { StyleProp, ViewStyle } from "react-native";
|
|
2
|
+
import { type StyleProp, type ViewStyle } from "react-native";
|
|
3
3
|
type Props = {
|
|
4
4
|
children: React.ReactNode;
|
|
5
5
|
style?: StyleProp<ViewStyle>;
|
|
@@ -9,7 +9,18 @@ type Props = {
|
|
|
9
9
|
scrollable?: boolean;
|
|
10
10
|
};
|
|
11
11
|
/**
|
|
12
|
-
*
|
|
12
|
+
* Wrapper for a screen (or form) that dismisses the keyboard when the user taps
|
|
13
|
+
* outside of a text input.
|
|
14
|
+
*
|
|
15
|
+
* Mirrors RN's `keyboardShouldPersistTaps="handled"` for the native `@expo/ui`
|
|
16
|
+
* field, which RN itself cannot see: the wrapper never claims the touch, so
|
|
17
|
+
* buttons, other fields and the focused field's own gestures win, and it
|
|
18
|
+
* dismisses on release only when nothing else took the tap and the finger did
|
|
19
|
+
* not scroll. See `useKeyboardDismissResponder`.
|
|
20
|
+
*
|
|
21
|
+
* A drag on the inner ScrollView also hides the keyboard: `interactive` on iOS
|
|
22
|
+
* (UIKit resigns any first responder, SwiftUI fields included) and an explicit
|
|
23
|
+
* dismiss on drag start on Android, where RN's `on-drag` only knows RN inputs.
|
|
13
24
|
*/
|
|
14
|
-
export declare
|
|
25
|
+
export declare function DismissKeyboard({ children, style, avoidKeyboard, scrollable, }: Props): React.JSX.Element;
|
|
15
26
|
export {};
|
|
@@ -1,58 +1,27 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
import
|
|
3
|
-
import { Platform, ScrollView, View, } from "react-native";
|
|
4
|
-
import { KeyboardController, useKeyboardContext, useKeyboardState, } from "./keyboardController";
|
|
2
|
+
import { Platform, ScrollView, View } from "react-native";
|
|
5
3
|
import { KeyboardAvoidingView, useKeyboardAvoidance } from "./KeyboardAvoidingView.js";
|
|
6
|
-
import {
|
|
4
|
+
import { dismissKeyboard, useKeyboardDismissResponder } from "./keyboardDismiss.js";
|
|
7
5
|
/**
|
|
8
|
-
*
|
|
6
|
+
* Wrapper for a screen (or form) that dismisses the keyboard when the user taps
|
|
7
|
+
* outside of a text input.
|
|
9
8
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
9
|
+
* Mirrors RN's `keyboardShouldPersistTaps="handled"` for the native `@expo/ui`
|
|
10
|
+
* field, which RN itself cannot see: the wrapper never claims the touch, so
|
|
11
|
+
* buttons, other fields and the focused field's own gestures win, and it
|
|
12
|
+
* dismisses on release only when nothing else took the tap and the finger did
|
|
13
|
+
* not scroll. See `useKeyboardDismissResponder`.
|
|
15
14
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* input bounds.
|
|
15
|
+
* A drag on the inner ScrollView also hides the keyboard: `interactive` on iOS
|
|
16
|
+
* (UIKit resigns any first responder, SwiftUI fields included) and an explicit
|
|
17
|
+
* dismiss on drag start on Android, where RN's `on-drag` only knows RN inputs.
|
|
20
18
|
*/
|
|
21
|
-
function
|
|
22
|
-
const
|
|
23
|
-
const { layout: focusedInput } = useKeyboardContext();
|
|
24
|
-
return React.useCallback((event) => {
|
|
25
|
-
if (Platform.OS === "web" || !isVisible) {
|
|
26
|
-
return false;
|
|
27
|
-
}
|
|
28
|
-
if (isTouchInsideKeyboardFocusedInput(event, focusedInput.value?.layout)) {
|
|
29
|
-
return false;
|
|
30
|
-
}
|
|
31
|
-
KeyboardController.dismiss();
|
|
32
|
-
return false;
|
|
33
|
-
}, [focusedInput, isVisible]);
|
|
34
|
-
}
|
|
35
|
-
/**
|
|
36
|
-
* @returns Wrapper for a view that dismisses the keyboard when tapped outside of a text input
|
|
37
|
-
*/
|
|
38
|
-
function DismissKeyboardLayout({ children, style, avoidKeyboard = true, scrollable = true, dismissOnTouchStart, }) {
|
|
19
|
+
export function DismissKeyboard({ children, style, avoidKeyboard = true, scrollable = true, }) {
|
|
20
|
+
const responderProps = useKeyboardDismissResponder();
|
|
39
21
|
const hasKeyboardAvoidance = useKeyboardAvoidance();
|
|
40
|
-
const content = scrollable ? (_jsx(ScrollView, { style: { flex: 1 }, contentContainerStyle: { flexGrow: 1, justifyContent: "center" }, keyboardShouldPersistTaps: "handled", showsVerticalScrollIndicator: false, children: children })) : (children);
|
|
22
|
+
const content = scrollable ? (_jsx(ScrollView, { style: { flex: 1 }, contentContainerStyle: { flexGrow: 1, justifyContent: "center" }, keyboardShouldPersistTaps: "handled", keyboardDismissMode: Platform.OS === "ios" ? "interactive" : "none", onScrollBeginDrag: Platform.OS === "android" ? dismissKeyboard : undefined, showsVerticalScrollIndicator: false, children: children })) : (children);
|
|
41
23
|
if (!avoidKeyboard || hasKeyboardAvoidance) {
|
|
42
|
-
return (_jsx(View, { style: { flex: 1 },
|
|
24
|
+
return (_jsx(View, { style: { flex: 1 }, ...responderProps, children: content }));
|
|
43
25
|
}
|
|
44
|
-
return (_jsx(KeyboardAvoidingView, { style: [{ flex: 1, width: "100%" }, style], keyboardVerticalOffset:
|
|
45
|
-
}
|
|
46
|
-
function NativeDismissKeyboard(props) {
|
|
47
|
-
const dismissOnTouchStart = useDismissKeyboardOnOutsideTouch();
|
|
48
|
-
return (_jsx(DismissKeyboardLayout, { ...props, dismissOnTouchStart: dismissOnTouchStart }));
|
|
26
|
+
return (_jsx(KeyboardAvoidingView, { style: [{ flex: 1, width: "100%" }, style], keyboardVerticalOffset: 0, ...responderProps, children: content }));
|
|
49
27
|
}
|
|
50
|
-
/**
|
|
51
|
-
* @returns Wrapper for a view that dismisses the keyboard when tapped outside of a text input
|
|
52
|
-
*/
|
|
53
|
-
export const DismissKeyboard = (props) => {
|
|
54
|
-
if (Platform.OS === "web") {
|
|
55
|
-
return _jsx(DismissKeyboardLayout, { ...props });
|
|
56
|
-
}
|
|
57
|
-
return _jsx(NativeDismissKeyboard, { ...props });
|
|
58
|
-
};
|
|
@@ -33,7 +33,7 @@ const createStyles = (theme) => StyleSheet.create({
|
|
|
33
33
|
container: {
|
|
34
34
|
alignItems: "center",
|
|
35
35
|
justifyContent: "center",
|
|
36
|
-
paddingVertical: spacing.
|
|
36
|
+
paddingVertical: spacing.xl,
|
|
37
37
|
paddingHorizontal: spacing.lg,
|
|
38
38
|
},
|
|
39
39
|
bordered: {
|
|
@@ -41,7 +41,7 @@ const createStyles = (theme) => StyleSheet.create({
|
|
|
41
41
|
borderStyle: "dashed",
|
|
42
42
|
borderColor: theme.colors.border,
|
|
43
43
|
borderRadius: spacing.radiusLg,
|
|
44
|
-
padding: spacing.
|
|
44
|
+
padding: spacing.lg,
|
|
45
45
|
},
|
|
46
46
|
iconWrapper: {
|
|
47
47
|
width: MEDIA_SIZE,
|
|
@@ -63,7 +63,10 @@ function InputOTP({ length = 6, value = "", onChangeText, onComplete, error = fa
|
|
|
63
63
|
const markOtpBlurred = useCallback(() => {
|
|
64
64
|
setFocused(false);
|
|
65
65
|
}, []);
|
|
66
|
-
return (_jsxs(View, { style: StyleSheet.flatten([styles.container, styleOverride]), children: [_jsx(RNTextInput, { ref: inputRef, value: value, onChangeText: handleChangeText, onKeyPress: handleKeyPress, onFocus: markOtpFocused, onBlur: markOtpBlurred, maxLength: length, autoFocus: autoFocus, editable: !disabled, inputMode: inputMode,
|
|
66
|
+
return (_jsxs(View, { style: StyleSheet.flatten([styles.container, styleOverride]), children: [_jsx(RNTextInput, { ref: inputRef, value: value, onChangeText: handleChangeText, onKeyPress: handleKeyPress, onFocus: markOtpFocused, onBlur: markOtpBlurred, maxLength: length, autoFocus: autoFocus, editable: !disabled, inputMode: inputMode,
|
|
67
|
+
// Android only recognises the `sms-otp` autofill hint; iOS and web use
|
|
68
|
+
// `one-time-code` + `oneTimeCode` to offer codes from Messages / Mail.
|
|
69
|
+
autoComplete: Platform.OS === "android" ? "sms-otp" : "one-time-code", textContentType: "oneTimeCode", caretHidden: true, style: styles.hiddenInput, accessibilityLabel: "Verification code input", importantForAccessibility: "yes" }), _jsx(View, { style: styles.cellRow, children: Array.from({ length }, (_, index) => {
|
|
67
70
|
const char = value[index] ?? "";
|
|
68
71
|
const isActive = focused && index === activeIndex;
|
|
69
72
|
const displayChar = char
|
|
@@ -16,8 +16,9 @@ export interface ItemProps {
|
|
|
16
16
|
/**
|
|
17
17
|
* Item
|
|
18
18
|
*
|
|
19
|
-
* Grouped-list row container: `spacing.
|
|
20
|
-
* 44pt min height
|
|
19
|
+
* Grouped-list row container: compact row padding (`spacing.rowPaddingY` /
|
|
20
|
+
* `spacing.rowPaddingX`) and gap, a 44pt min height on native (40 on web),
|
|
21
|
+
* and an optional pressable scale interaction. Compose
|
|
21
22
|
* with `ItemMedia`, `ItemContent` (+ `ItemTitle`/`ItemDescription`), and
|
|
22
23
|
* `ItemActions`.
|
|
23
24
|
*
|
package/dist/components/Item.js
CHANGED
|
@@ -8,12 +8,13 @@ import { spacing } from "../constants/spacing.js";
|
|
|
8
8
|
// Default ItemMedia footprint (see ItemMedia's `size` prop) — used to inset
|
|
9
9
|
// the optional separator past the media slot without needing a context.
|
|
10
10
|
const DEFAULT_MEDIA_SIZE = 40;
|
|
11
|
-
const SEPARATOR_INSET = spacing.
|
|
11
|
+
const SEPARATOR_INSET = spacing.rowPaddingX + DEFAULT_MEDIA_SIZE + spacing.rowGap;
|
|
12
12
|
/**
|
|
13
13
|
* Item
|
|
14
14
|
*
|
|
15
|
-
* Grouped-list row container: `spacing.
|
|
16
|
-
* 44pt min height
|
|
15
|
+
* Grouped-list row container: compact row padding (`spacing.rowPaddingY` /
|
|
16
|
+
* `spacing.rowPaddingX`) and gap, a 44pt min height on native (40 on web),
|
|
17
|
+
* and an optional pressable scale interaction. Compose
|
|
17
18
|
* with `ItemMedia`, `ItemContent` (+ `ItemTitle`/`ItemDescription`), and
|
|
18
19
|
* `ItemActions`.
|
|
19
20
|
*
|
|
@@ -104,9 +105,10 @@ const styles = StyleSheet.create({
|
|
|
104
105
|
row: {
|
|
105
106
|
flexDirection: "row",
|
|
106
107
|
alignItems: "center",
|
|
107
|
-
minHeight: spacing.touchTarget,
|
|
108
|
-
|
|
109
|
-
|
|
108
|
+
minHeight: Platform.select({ web: spacing.rowMinHeight, default: spacing.touchTarget }),
|
|
109
|
+
paddingVertical: spacing.rowPaddingY,
|
|
110
|
+
paddingHorizontal: spacing.rowPaddingX,
|
|
111
|
+
gap: spacing.rowGap,
|
|
110
112
|
},
|
|
111
113
|
content: {
|
|
112
114
|
flex: 1,
|
|
@@ -151,7 +151,7 @@ const styles = StyleSheet.create({
|
|
|
151
151
|
flexDirection: "row",
|
|
152
152
|
alignItems: "center",
|
|
153
153
|
gap: spacing.sm,
|
|
154
|
-
minHeight: spacing.touchTarget,
|
|
154
|
+
minHeight: Platform.select({ web: spacing.formRowMinHeight, default: spacing.touchTarget }),
|
|
155
155
|
},
|
|
156
156
|
labelContainer: {
|
|
157
157
|
flex: 1,
|
|
@@ -37,5 +37,5 @@ export function StatCard({ label, value, unit, change, icon, onPress, style }) {
|
|
|
37
37
|
: change.direction === "down"
|
|
38
38
|
? theme.colors.destructive
|
|
39
39
|
: theme.colors.textDim;
|
|
40
|
-
return (_jsx(Card, { onPress: onPress, style: style, children: _jsxs(View, { style: { padding: spacing.
|
|
40
|
+
return (_jsx(Card, { onPress: onPress, style: style, children: _jsxs(View, { style: { padding: spacing.cardPadding, gap: spacing.xs }, children: [_jsxs(View, { style: { flexDirection: "row", alignItems: "center", justifyContent: "space-between" }, children: [_jsx(CaptionText, { selectable: false, style: { color: theme.colors.textDim, textTransform: "uppercase", letterSpacing: 0.96 }, children: label }), !!icon && _jsx(Icon, { name: icon, size: spacing.iconSm, color: theme.colors.mutedForeground, decorative: true })] }), _jsxs(View, { style: { flexDirection: "row", alignItems: "flex-end" }, children: [_jsx(StyledText, { selectable: false, size: "xxl", fontWeight: "bold", style: { color: theme.colors.foreground, fontVariant: ["tabular-nums"] }, children: value }), !!unit && (_jsx(StyledText, { selectable: false, size: "base", style: { color: theme.colors.textDim, marginLeft: spacing.xxs, marginBottom: 2 }, children: unit }))] }), !!change && (_jsxs(View, { style: { flexDirection: "row", alignItems: "center", gap: spacing.xxs }, children: [change.direction !== "neutral" && (_jsx(Icon, { name: DIRECTION_ICON[change.direction], size: spacing.iconXs, color: changeColor, decorative: true })), _jsx(StyledText, { selectable: false, size: "sm", style: { color: changeColor }, children: change.value })] }))] }) }));
|
|
41
41
|
}
|
|
@@ -10,8 +10,11 @@ import { Icon } from "./Icon.js";
|
|
|
10
10
|
import { hapticLight } from "../lib/haptics.js";
|
|
11
11
|
import { createThemedStyles } from "../lib/themedStyles.js";
|
|
12
12
|
import { palette } from "../constants/colors.js";
|
|
13
|
-
import {
|
|
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
|
},
|
|
@@ -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";
|