@mrmeg/expo-ui 0.26.0 → 0.27.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +168 -0
- package/LLM_USAGE.md +44 -6
- package/README.md +91 -17
- package/dist/components/BottomSheet.d.ts +57 -8
- package/dist/components/BottomSheet.js +45 -10
- package/dist/components/Button.js +6 -2
- package/dist/components/Dialog.js +103 -25
- package/dist/components/InputOTP.js +2 -1
- package/dist/components/Slider.js +2 -1
- package/dist/components/StyledText.d.ts +6 -2
- package/dist/components/StyledText.js +10 -4
- package/dist/components/Switch.js +3 -2
- package/dist/components/TextInput.js +13 -1
- package/dist/components/bottomSheetHostSupport.d.ts +46 -0
- package/dist/components/bottomSheetHostSupport.js +67 -0
- package/dist/components/keyboardDismiss.d.ts +25 -0
- package/dist/components/keyboardDismiss.js +49 -0
- package/dist/design-system.json +1 -1
- package/dist/lib/index.d.ts +1 -0
- package/dist/lib/index.js +1 -0
- package/dist/lib/stateSurface.d.ts +28 -0
- package/dist/lib/stateSurface.js +30 -0
- package/llms-full.md +1 -1
- package/llms.txt +4 -1
- package/package.json +1 -1
|
@@ -1,16 +1,87 @@
|
|
|
1
|
-
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
import
|
|
3
|
-
import { Platform, StyleSheet, View } from "react-native";
|
|
4
|
-
import { FullWindowOverlay as RNFullWindowOverlay } from "react-native-screens";
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { Modal, Platform, StyleSheet, View } from "react-native";
|
|
5
3
|
import * as DialogPrimitive from "@rn-primitives/dialog";
|
|
6
4
|
import * as AlertDialogPrimitive from "@rn-primitives/alert-dialog";
|
|
7
5
|
import { AnimatedView } from "./AnimatedView.js";
|
|
6
|
+
import { KeyboardAvoidingView } from "./KeyboardAvoidingView.js";
|
|
8
7
|
import { TextClassContext, TextColorContext } from "./StyledText.context";
|
|
9
8
|
import { StyledText } from "./StyledText.js";
|
|
10
9
|
import { useTheme } from "../hooks/useTheme.js";
|
|
11
10
|
import { spacing } from "../constants/spacing.js";
|
|
12
11
|
import { palette } from "../constants/colors.js";
|
|
13
|
-
|
|
12
|
+
// ============================================================================
|
|
13
|
+
// Presentation
|
|
14
|
+
// ============================================================================
|
|
15
|
+
/** The Modal follows the app's own orientation mask instead of RN's portrait-only default. */
|
|
16
|
+
const SUPPORTED_ORIENTATIONS = [
|
|
17
|
+
"portrait",
|
|
18
|
+
"portrait-upside-down",
|
|
19
|
+
"landscape",
|
|
20
|
+
"landscape-left",
|
|
21
|
+
"landscape-right",
|
|
22
|
+
];
|
|
23
|
+
/**
|
|
24
|
+
* Presentation for dialog content.
|
|
25
|
+
*
|
|
26
|
+
* iOS presents through React Native's `Modal`, rendered inline where the
|
|
27
|
+
* dialog sits in the tree rather than through the portal host. Until 0.27.1
|
|
28
|
+
* both dialogs rendered into `UIProvider`'s portal host inside
|
|
29
|
+
* react-native-screens' `FullWindowOverlay`, which stacks above native stack
|
|
30
|
+
* modals by adding its container straight to the `UIWindow`. No
|
|
31
|
+
* `UIViewController` sits above that container, and `expo-modules-core`'s
|
|
32
|
+
* `ExpoSwiftUI.HostingView` — which attaches its `UIHostingController` only
|
|
33
|
+
* when `reactViewController()` finds a parent controller — removed the SwiftUI
|
|
34
|
+
* view instead. Every `@expo/ui`-hosted control inside (`TextInput`, `Slider`,
|
|
35
|
+
* `SegmentedControl`) was a zero-height box: no editable element, no focus, no
|
|
36
|
+
* keyboard (fieldnest on iOS 27, `@mrmeg/expo-ui` 0.27.0; reproduced in the
|
|
37
|
+
* template's `Dialog` `form` variant — the accessibility tree listed only the
|
|
38
|
+
* field labels, and the same content mounted with the overlay removed).
|
|
39
|
+
*
|
|
40
|
+
* `Modal` presents a real view controller, so hosted controls mount and take
|
|
41
|
+
* focus on the first tap. RN presents it from the view controller nearest the
|
|
42
|
+
* `Modal`'s own host view, which is why it renders inline: a `Modal` in the
|
|
43
|
+
* root portal host presents from the root controller and silently fails
|
|
44
|
+
* whenever that controller already presents a native stack modal or a sheet
|
|
45
|
+
* (device-verified: the trigger reported expanded, nothing appeared). Inline,
|
|
46
|
+
* the presenter is whatever screen, native stack modal or sheet contains the
|
|
47
|
+
* dialog, and the dialog stacks above it. The trade-off: content hosted by
|
|
48
|
+
* `FullWindowOverlay` (`Drawer`, `Popover`, `Select`, `DropdownMenu`,
|
|
49
|
+
* `Tooltip`) has no view controller either, so a dialog placed inside that
|
|
50
|
+
* content cannot present on iOS — render it at screen level and open it from
|
|
51
|
+
* the item's `onPress`.
|
|
52
|
+
*
|
|
53
|
+
* Android and web render into the portal host, as before.
|
|
54
|
+
*/
|
|
55
|
+
function DialogPresentation({ Portal, open, portalHost, onRequestClose, children, }) {
|
|
56
|
+
if (Platform.OS !== "ios") {
|
|
57
|
+
return _jsx(Portal, { hostName: portalHost, children: children });
|
|
58
|
+
}
|
|
59
|
+
if (!open) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
return (_jsx(Modal, { visible: true, transparent: true, animationType: "none", presentationStyle: "overFullScreen", supportedOrientations: SUPPORTED_ORIENTATIONS, onRequestClose: onRequestClose, children: children }));
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Keyboard avoidance owner for dialog content.
|
|
66
|
+
*
|
|
67
|
+
* On iOS the `Modal` above is presented outside `UIProvider`'s root
|
|
68
|
+
* `KeyboardAvoidingView`, so nothing else can keep a focused dialog field and
|
|
69
|
+
* the footer above the keyboard: this wrapper pads the centered container by
|
|
70
|
+
* the keyboard height, and the card (capped at 85% of the remaining height)
|
|
71
|
+
* recenters in the space that is left. It is the package `KeyboardAvoidingView`,
|
|
72
|
+
* so `useKeyboardAvoidance()` is `true` inside `DialogContent` and a
|
|
73
|
+
* `DismissKeyboard` in dialog content adds no second layer. Do not wrap dialog
|
|
74
|
+
* content in another `KeyboardAvoidingView`.
|
|
75
|
+
*
|
|
76
|
+
* Android and web keep the portal-host tree unchanged; the portal host sits
|
|
77
|
+
* outside the root avoidance there, and a dialog is not keyboard-avoided yet.
|
|
78
|
+
*/
|
|
79
|
+
function DialogKeyboardAvoidance({ children }) {
|
|
80
|
+
if (Platform.OS !== "ios") {
|
|
81
|
+
return _jsx(_Fragment, { children: children });
|
|
82
|
+
}
|
|
83
|
+
return (_jsx(KeyboardAvoidingView, { style: overlayStyles.fill, behavior: "padding", keyboardVerticalOffset: 0, children: children }));
|
|
84
|
+
}
|
|
14
85
|
// ============================================================================
|
|
15
86
|
// Dialog
|
|
16
87
|
// ============================================================================
|
|
@@ -26,8 +97,18 @@ function DialogRoot({ children, ...props }) {
|
|
|
26
97
|
}
|
|
27
98
|
function DialogContent({ portalHost, style, children, ...props }) {
|
|
28
99
|
const { theme, getShadowStyle, getContrastingColor } = useTheme();
|
|
100
|
+
const { open, onOpenChange } = DialogPrimitive.useRootContext();
|
|
29
101
|
const textColor = getContrastingColor(theme.colors.popover, palette.white, palette.black);
|
|
30
|
-
return (_jsx(DialogPrimitive.Portal,
|
|
102
|
+
return (_jsx(DialogPresentation, { Portal: DialogPrimitive.Portal, open: open, portalHost: portalHost, onRequestClose: () => onOpenChange(false), children: _jsx(DialogPrimitive.Overlay
|
|
103
|
+
// On web the primitive wraps the overlay in a react-native-web
|
|
104
|
+
// Pressable whose click handler stops propagation unconditionally.
|
|
105
|
+
// With that Pressable between the overlay and the Radix content,
|
|
106
|
+
// outside presses (mouse and touch) and Escape never dismissed the
|
|
107
|
+
// dialog, so a dialog without a Close button was stuck. `asChild`
|
|
108
|
+
// makes the primitive clone our fade wrapper instead, removing the
|
|
109
|
+
// Pressable; Radix dismissal then works. Native keeps the Pressable,
|
|
110
|
+
// which is what implements closeOnPress there.
|
|
111
|
+
, {
|
|
31
112
|
// On web the primitive wraps the overlay in a react-native-web
|
|
32
113
|
// Pressable whose click handler stops propagation unconditionally.
|
|
33
114
|
// With that Pressable between the overlay and the Radix content,
|
|
@@ -36,20 +117,11 @@ function DialogContent({ portalHost, style, children, ...props }) {
|
|
|
36
117
|
// makes the primitive clone our fade wrapper instead, removing the
|
|
37
118
|
// Pressable; Radix dismissal then works. Native keeps the Pressable,
|
|
38
119
|
// which is what implements closeOnPress there.
|
|
39
|
-
,
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
// dialog, so a dialog without a Close button was stuck. `asChild`
|
|
45
|
-
// makes the primitive clone our fade wrapper instead, removing the
|
|
46
|
-
// Pressable; Radix dismissal then works. Native keeps the Pressable,
|
|
47
|
-
// which is what implements closeOnPress there.
|
|
48
|
-
asChild: Platform.OS === "web", style: StyleSheet.flatten([
|
|
49
|
-
StyleSheet.absoluteFill,
|
|
50
|
-
{ backgroundColor: theme.colors.overlay },
|
|
51
|
-
Platform.OS === "web" && { zIndex: 50 },
|
|
52
|
-
]), children: _jsx(AnimatedView, { type: "fade", enterDuration: 200, style: StyleSheet.absoluteFill, children: _jsx(View, { style: overlayStyles.centeredContainer, children: _jsx(AnimatedView, { type: "scale", enterDuration: 250, style: overlayStyles.sizer, children: _jsx(TextColorContext.Provider, { value: textColor, children: _jsx(TextClassContext.Provider, { value: "", children: _jsx(DialogPrimitive.Content, { style: StyleSheet.flatten([
|
|
120
|
+
asChild: Platform.OS === "web", style: StyleSheet.flatten([
|
|
121
|
+
StyleSheet.absoluteFill,
|
|
122
|
+
{ backgroundColor: theme.colors.overlay },
|
|
123
|
+
Platform.OS === "web" && { zIndex: 50 },
|
|
124
|
+
]), children: _jsx(AnimatedView, { type: "fade", enterDuration: 200, style: StyleSheet.absoluteFill, children: _jsx(DialogKeyboardAvoidance, { children: _jsx(View, { style: overlayStyles.centeredContainer, children: _jsx(AnimatedView, { type: "scale", enterDuration: 250, style: overlayStyles.sizer, children: _jsx(TextColorContext.Provider, { value: textColor, children: _jsx(TextClassContext.Provider, { value: "", children: _jsx(DialogPrimitive.Content, { style: StyleSheet.flatten([
|
|
53
125
|
{
|
|
54
126
|
backgroundColor: theme.colors.popover,
|
|
55
127
|
borderColor: theme.colors.border,
|
|
@@ -119,12 +191,13 @@ function AlertDialogRoot({ children, ...props }) {
|
|
|
119
191
|
}
|
|
120
192
|
function AlertDialogContent({ portalHost, style, children, ...props }) {
|
|
121
193
|
const { theme, getShadowStyle, getContrastingColor } = useTheme();
|
|
194
|
+
const { open, onOpenChange } = AlertDialogPrimitive.useRootContext();
|
|
122
195
|
const textColor = getContrastingColor(theme.colors.popover, palette.white, palette.black);
|
|
123
|
-
return (_jsx(AlertDialogPrimitive.Portal,
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
196
|
+
return (_jsx(DialogPresentation, { Portal: AlertDialogPrimitive.Portal, open: open, portalHost: portalHost, onRequestClose: () => onOpenChange(false), children: _jsx(AlertDialogPrimitive.Overlay, { style: StyleSheet.flatten([
|
|
197
|
+
StyleSheet.absoluteFill,
|
|
198
|
+
{ backgroundColor: theme.colors.overlay },
|
|
199
|
+
Platform.OS === "web" && { zIndex: 52 },
|
|
200
|
+
]), children: _jsx(AnimatedView, { type: "fade", enterDuration: 200, style: StyleSheet.absoluteFill, children: _jsx(DialogKeyboardAvoidance, { children: _jsx(View, { style: overlayStyles.centeredContainer, children: _jsx(AnimatedView, { type: "scale", enterDuration: 250, style: overlayStyles.sizer, children: _jsx(TextColorContext.Provider, { value: textColor, children: _jsx(TextClassContext.Provider, { value: "", children: _jsx(AlertDialogPrimitive.Content, { style: StyleSheet.flatten([
|
|
128
201
|
{
|
|
129
202
|
backgroundColor: theme.colors.popover,
|
|
130
203
|
borderColor: theme.colors.border,
|
|
@@ -175,6 +248,11 @@ const AlertDialog = Object.assign(AlertDialogRoot, {
|
|
|
175
248
|
// Shared styles
|
|
176
249
|
// ============================================================================
|
|
177
250
|
const overlayStyles = StyleSheet.create({
|
|
251
|
+
// The keyboard-avoiding wrapper fills the overlay so its bottom padding
|
|
252
|
+
// shrinks the centered container instead of the card.
|
|
253
|
+
fill: {
|
|
254
|
+
flex: 1,
|
|
255
|
+
},
|
|
178
256
|
centeredContainer: {
|
|
179
257
|
flex: 1,
|
|
180
258
|
justifyContent: "center",
|
|
@@ -5,6 +5,7 @@ import { useTheme } from "../hooks/useTheme.js";
|
|
|
5
5
|
import { spacing } from "../constants/spacing.js";
|
|
6
6
|
import { StyledText } from "./StyledText.js";
|
|
7
7
|
import { createThemedStyles } from "../lib/themedStyles.js";
|
|
8
|
+
import { stateSurfaceProps } from "../lib/stateSurface.js";
|
|
8
9
|
const CELL_WIDTH = 36;
|
|
9
10
|
const CELL_HEIGHT = 40;
|
|
10
11
|
const CELL_FONT_SIZE = 20;
|
|
@@ -89,7 +90,7 @@ function OTPCell({ index, total, char, isActive, hasError, disabled, theme, onPr
|
|
|
89
90
|
: isActive
|
|
90
91
|
? theme.colors.primary
|
|
91
92
|
: theme.colors.border;
|
|
92
|
-
return (_jsx(Pressable, { onPress: onPress, disabled: disabled, accessibilityRole: "button", accessibilityLabel: `Digit ${index + 1} of ${total}`, accessibilityState: { disabled }, children: _jsx(View, { style: [
|
|
93
|
+
return (_jsx(Pressable, { onPress: onPress, disabled: disabled, accessibilityRole: "button", accessibilityLabel: `Digit ${index + 1} of ${total}`, accessibilityState: { disabled }, children: _jsx(View, { ...stateSurfaceProps(), style: [
|
|
93
94
|
{
|
|
94
95
|
width: CELL_WIDTH,
|
|
95
96
|
height: CELL_HEIGHT,
|
|
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { palette } from "../constants/colors.js";
|
|
3
3
|
import { useTheme } from "../hooks/useTheme.js";
|
|
4
4
|
import { hapticLight } from "../lib/haptics.js";
|
|
5
|
+
import { stateSurfaceProps } from "../lib/stateSurface.js";
|
|
5
6
|
import { useCallback, useRef } from "react";
|
|
6
7
|
import { View } from "react-native";
|
|
7
8
|
import { Slider as NativeSlider } from "@expo/ui/community/slider";
|
|
@@ -31,7 +32,7 @@ size: _size = "md", disabled = false, showValue = false, style: styleOverride, }
|
|
|
31
32
|
}
|
|
32
33
|
onValueChange?.(next);
|
|
33
34
|
}, [onValueChange]);
|
|
34
|
-
return (_jsxs(View, { style: [{ opacity: disabled ? 0.5 : 1, alignSelf: "stretch" }, styleOverride], children: [showValue && (_jsx(StyledText, { selectable: false, style: {
|
|
35
|
+
return (_jsxs(View, { ...stateSurfaceProps(), style: [{ opacity: disabled ? 0.5 : 1, alignSelf: "stretch" }, styleOverride], children: [showValue && (_jsx(StyledText, { selectable: false, style: {
|
|
35
36
|
fontSize: 12,
|
|
36
37
|
color: theme.colors.textDim,
|
|
37
38
|
marginBottom: 4,
|
|
@@ -74,8 +74,12 @@ export type TextProps = RNTextProps & {
|
|
|
74
74
|
* real Inter font files on native, and family + numeric fontWeight on web
|
|
75
75
|
* - Per-size letter-spacing scale (tight on large headings, positive on micro
|
|
76
76
|
* labels); explicit `style` letterSpacing always wins
|
|
77
|
-
* - Text selection
|
|
78
|
-
*
|
|
77
|
+
* - Text selection on by default on iOS and web, off by default on Android:
|
|
78
|
+
* a selectable Android `Text` is focusable-in-touch-mode, so a tap on a label
|
|
79
|
+
* next to a focused input moved view focus to the label and hid the keyboard.
|
|
80
|
+
* Pass `selectable` to opt copyable content in (codes, addresses, chat
|
|
81
|
+
* bubbles); pass `selectable={false}` on every platform for control chrome
|
|
82
|
+
* such as button labels, tabs, badges, and field labels
|
|
79
83
|
* - numberOfLines and ellipsizeMode support from RN TextProps
|
|
80
84
|
*/
|
|
81
85
|
export declare function StyledText(props: TextProps): import("react").JSX.Element;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { use } from "react";
|
|
3
|
-
import { Text as RNText, StyleSheet } from "react-native";
|
|
3
|
+
import { Platform, Text as RNText, StyleSheet } from "react-native";
|
|
4
4
|
import { useTheme } from "../hooks/useTheme.js";
|
|
5
5
|
import { resolveFontStyle } from "../constants/fonts.js";
|
|
6
6
|
import { useThemeStore } from "../state/themeStore.js";
|
|
@@ -67,8 +67,12 @@ const SEMANTIC_CONFIGS = {
|
|
|
67
67
|
* real Inter font files on native, and family + numeric fontWeight on web
|
|
68
68
|
* - Per-size letter-spacing scale (tight on large headings, positive on micro
|
|
69
69
|
* labels); explicit `style` letterSpacing always wins
|
|
70
|
-
* - Text selection
|
|
71
|
-
*
|
|
70
|
+
* - Text selection on by default on iOS and web, off by default on Android:
|
|
71
|
+
* a selectable Android `Text` is focusable-in-touch-mode, so a tap on a label
|
|
72
|
+
* next to a focused input moved view focus to the label and hid the keyboard.
|
|
73
|
+
* Pass `selectable` to opt copyable content in (codes, addresses, chat
|
|
74
|
+
* bubbles); pass `selectable={false}` on every platform for control chrome
|
|
75
|
+
* such as button labels, tabs, badges, and field labels
|
|
72
76
|
* - numberOfLines and ellipsizeMode support from RN TextProps
|
|
73
77
|
*/
|
|
74
78
|
export function StyledText(props) {
|
|
@@ -81,7 +85,9 @@ export function StyledText(props) {
|
|
|
81
85
|
const contextColor = use(TextColorContext);
|
|
82
86
|
const contextTextStyle = use(TextStyleContext);
|
|
83
87
|
const contextSelectable = use(TextSelectabilityContext);
|
|
84
|
-
|
|
88
|
+
// Read at render time (not module load) so tests can flip `Platform.OS`.
|
|
89
|
+
const platformDefaultSelectable = Platform.OS !== "android";
|
|
90
|
+
const resolvedSelectable = selectable ?? contextSelectable ?? platformDefaultSelectable;
|
|
85
91
|
// Use context color if provided, otherwise use theme default
|
|
86
92
|
const color = contextColor ?? theme.colors.text;
|
|
87
93
|
// If semantic variant is provided, use its config
|
|
@@ -3,6 +3,7 @@ import { palette } from "../constants/colors.js";
|
|
|
3
3
|
import { spacing } from "../constants/spacing.js";
|
|
4
4
|
import { useTheme } from "../hooks/useTheme.js";
|
|
5
5
|
import { hapticLight } from "../lib/haptics.js";
|
|
6
|
+
import { stateSurfaceProps } from "../lib/stateSurface.js";
|
|
6
7
|
import * as SwitchPrimitives from "@rn-primitives/switch";
|
|
7
8
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
8
9
|
import { ActivityIndicator, Platform, StyleSheet, View, Animated } from "react-native";
|
|
@@ -113,7 +114,7 @@ function Switch({ variant = "default", labelOn, labelOff, size = { width: 44, he
|
|
|
113
114
|
borderWidth: 1,
|
|
114
115
|
borderColor: trackBorderColor,
|
|
115
116
|
pointerEvents: "none",
|
|
116
|
-
} }), labelOn && !isIOS && (_jsx(View, { style: {
|
|
117
|
+
} }), labelOn && !isIOS && (_jsx(View, { ...stateSurfaceProps(), style: {
|
|
117
118
|
...styles.label,
|
|
118
119
|
left: labelHorizontalInset,
|
|
119
120
|
right: thumbInset + thumbSize + labelGap,
|
|
@@ -136,7 +137,7 @@ function Switch({ variant = "default", labelOn, labelOff, size = { width: 44, he
|
|
|
136
137
|
...getShadowStyle("subtle"),
|
|
137
138
|
},
|
|
138
139
|
thumbAnimatedStyle,
|
|
139
|
-
], children: loading && (_jsx(ActivityIndicator, { size: "small", color: thumbIndicatorColor })) }) }), labelOff && !isIOS && (_jsx(View, { style: {
|
|
140
|
+
], children: loading && (_jsx(ActivityIndicator, { size: "small", color: thumbIndicatorColor })) }) }), labelOff && !isIOS && (_jsx(View, { ...stateSurfaceProps(), style: {
|
|
140
141
|
...styles.label,
|
|
141
142
|
left: thumbInset + thumbSize + labelGap,
|
|
142
143
|
right: labelHorizontalInset,
|
|
@@ -10,6 +10,7 @@ import { StyledText } from "./StyledText.js";
|
|
|
10
10
|
import { Icon } from "./Icon.js";
|
|
11
11
|
import { hapticLight } from "../lib/haptics.js";
|
|
12
12
|
import { createThemedStyles } from "../lib/themedStyles.js";
|
|
13
|
+
import { stateSurfaceProps } from "../lib/stateSurface.js";
|
|
13
14
|
import { palette } from "../constants/colors.js";
|
|
14
15
|
import { clearKeyboardFocusedInput, setKeyboardFocusedInput, } from "./keyboardFocusRegistry.js";
|
|
15
16
|
import { useTextInputSurfaceResponder } from "./keyboardDismiss.js";
|
|
@@ -207,6 +208,10 @@ function WebTextInput({ variant = "outline", size = "md", label, helperText, err
|
|
|
207
208
|
* toggle laid out beside the native Host. Clear buttons, left/right elements,
|
|
208
209
|
* and the error icon remain web-only; RN overlays are not layered over the
|
|
209
210
|
* native host view.
|
|
211
|
+
*
|
|
212
|
+
* On Android `onBlur` is reported only after a forwarded `onFocus`: Compose
|
|
213
|
+
* reports `isFocused=false` on first composition and the field forwards it,
|
|
214
|
+
* so an unguarded consumer saw a blur before any interaction.
|
|
210
215
|
*/
|
|
211
216
|
function NativeTextInput({ variant = "outline", size = "md", label, helperText, errorText, error, required, rows, forceLight, inputMode, onChangeText, onFocus, onBlur, value, defaultValue, editable = true, multiline, secureTextEntry, showSecureEntryToggle, ref, wrapperStyle,
|
|
212
217
|
// Web-only affordances. Destructured out of `rest` so they're NOT forwarded to
|
|
@@ -321,9 +326,16 @@ leftElement, rightElement, clearable, focusedStyle, style, ...rest }) {
|
|
|
321
326
|
const handleBlur = useCallback((secure) => {
|
|
322
327
|
if (Platform.OS === "ios" && secure !== activeSecureRef.current)
|
|
323
328
|
return;
|
|
329
|
+
// Compose reports `isFocused=false` when the field is first composed and
|
|
330
|
+
// the Android field forwards it as a blur. A blur the field never
|
|
331
|
+
// preceded with a focus is not the user leaving the field: keep it from
|
|
332
|
+
// the consumer so blur validation does not flag untouched fields.
|
|
333
|
+
const wasFocused = isFocusedRef.current;
|
|
324
334
|
isFocusedRef.current = false;
|
|
325
335
|
restoreAfterSecureHandoffRef.current = false;
|
|
326
336
|
clearKeyboardFocusedInput(focusRegistryToken);
|
|
337
|
+
if (Platform.OS === "android" && !wasFocused)
|
|
338
|
+
return;
|
|
327
339
|
parentOnBlur?.();
|
|
328
340
|
}, [focusRegistryToken, parentOnBlur]);
|
|
329
341
|
const togglePasswordVisible = useCallback(() => {
|
|
@@ -476,7 +488,7 @@ leftElement, rightElement, clearable, focusedStyle, style, ...rest }) {
|
|
|
476
488
|
fontSize: sizeConfig.fontSize,
|
|
477
489
|
...(nativeFontFamily ? { fontFamily: nativeFontFamily } : null),
|
|
478
490
|
};
|
|
479
|
-
return (_jsxs(View, { style: wrapperStyle, children: [!!label && (_jsx(View, { style: styles.labelContainer, children: _jsxs(StyledText, { selectable: false, size: "base", fontWeight: "medium", style: styles.label, children: [label, required && _jsx(StyledText, { selectable: false, fontWeight: "bold", style: styles.required, children: " *" })] }) })), _jsxs(View, { style: [surfaceStyle, hasSecureToggle && styles.nativeRow], hitSlop: { top: surfaceHitSlop, bottom: surfaceHitSlop }, ...surfaceResponderProps, children: [(Platform.OS === "android" ? [effectiveSecureTextEntry] : [true, false]).map((secure) => {
|
|
491
|
+
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, { ...stateSurfaceProps(), style: [surfaceStyle, hasSecureToggle && styles.nativeRow], hitSlop: { top: surfaceHitSlop, bottom: surfaceHitSlop }, ...surfaceResponderProps, children: [(Platform.OS === "android" ? [effectiveSecureTextEntry] : [true, false]).map((secure) => {
|
|
480
492
|
const active = secure === effectiveSecureTextEntry;
|
|
481
493
|
const outgoing = handoff != null && secure === handoff.outgoingSecure;
|
|
482
494
|
if (!active && !outgoing)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Android `BottomSheet` keyboard avoidance is platform-owned, and the part the
|
|
3
|
+
* package depends on lives in the host: Material3's `ModalBottomSheet` shrinks
|
|
4
|
+
* the sheet with `imePadding()`, `@expo/ui`'s `RNHostView` re-reports its
|
|
5
|
+
* Compose size to the shadow tree (`onSizeChanged` → `ShadowNodeProxy.setViewSize`),
|
|
6
|
+
* and the `flexGrow: 1, height: 0` column the host wraps our children in
|
|
7
|
+
* follows. `expo-modules-core` <= 57.0.3 flushed that report only from a
|
|
8
|
+
* one-shot pre-draw listener on the activity window, which does not draw while
|
|
9
|
+
* the sheet's own dialog window animates the IME, so the column kept its
|
|
10
|
+
* detent height and `Footer` sat under the keyboard until the activity redrew
|
|
11
|
+
* (expo/expo#47778, fixed by expo/expo#47810 in 57.0.4 and 58.0.0; no 56.x
|
|
12
|
+
* release has it). Nothing in JS can observe a size the shadow tree never
|
|
13
|
+
* received, so the package names the floor instead: once per session, in dev,
|
|
14
|
+
* on Android, when the *native* core compiled into the app is older.
|
|
15
|
+
*/
|
|
16
|
+
export interface ExpoModulesCoreVersion {
|
|
17
|
+
version: string;
|
|
18
|
+
major: number;
|
|
19
|
+
minor: number;
|
|
20
|
+
patch: number;
|
|
21
|
+
}
|
|
22
|
+
/** First `expo-modules-core` release whose `RNHostView` size flush always lands. */
|
|
23
|
+
export declare const MIN_EXPO_MODULES_CORE: Readonly<Omit<ExpoModulesCoreVersion, "version">>;
|
|
24
|
+
/**
|
|
25
|
+
* The native `expo-modules-core` version compiled into the running app, as the
|
|
26
|
+
* core's own `CoreModule` publishes it on `globalThis.expo` (Android and iOS,
|
|
27
|
+
* present on every Expo release the package supports, 56 through 58).
|
|
28
|
+
* `undefined` on web, in Jest, or when the core is not loaded.
|
|
29
|
+
*/
|
|
30
|
+
export declare function readExpoModulesCoreVersion(): ExpoModulesCoreVersion | undefined;
|
|
31
|
+
/**
|
|
32
|
+
* Whether this core drops the sheet host's size update when the keyboard opens:
|
|
33
|
+
* a defined version lexicographically below {@link MIN_EXPO_MODULES_CORE}.
|
|
34
|
+
* An unknown version is not reported as broken.
|
|
35
|
+
*/
|
|
36
|
+
export declare function bottomSheetHostDropsResize(version: ExpoModulesCoreVersion | undefined): boolean;
|
|
37
|
+
export declare function bottomSheetHostWarning(version: ExpoModulesCoreVersion): string;
|
|
38
|
+
/**
|
|
39
|
+
* Warn once per JS session, in development, on Android, when the native
|
|
40
|
+
* `expo-modules-core` is older than {@link MIN_EXPO_MODULES_CORE}. Called when a
|
|
41
|
+
* `BottomSheet.Content` mounts. Silent on iOS and web, where the sheet's
|
|
42
|
+
* avoidance does not go through `RNHostView`'s size state.
|
|
43
|
+
*/
|
|
44
|
+
export declare function warnIfBottomSheetHostDropsResize(): void;
|
|
45
|
+
/** Test hook: forget that the warning was already shown. */
|
|
46
|
+
export declare function resetBottomSheetHostWarningForTests(): void;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { Platform } from "react-native";
|
|
2
|
+
/** First `expo-modules-core` release whose `RNHostView` size flush always lands. */
|
|
3
|
+
export const MIN_EXPO_MODULES_CORE = {
|
|
4
|
+
major: 57,
|
|
5
|
+
minor: 0,
|
|
6
|
+
patch: 4,
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* The native `expo-modules-core` version compiled into the running app, as the
|
|
10
|
+
* core's own `CoreModule` publishes it on `globalThis.expo` (Android and iOS,
|
|
11
|
+
* present on every Expo release the package supports, 56 through 58).
|
|
12
|
+
* `undefined` on web, in Jest, or when the core is not loaded.
|
|
13
|
+
*/
|
|
14
|
+
export function readExpoModulesCoreVersion() {
|
|
15
|
+
const expo = globalThis.expo;
|
|
16
|
+
const candidate = expo?.expoModulesCoreVersion;
|
|
17
|
+
if (typeof candidate !== "object" ||
|
|
18
|
+
candidate === null ||
|
|
19
|
+
typeof candidate.major !== "number" ||
|
|
20
|
+
typeof candidate.minor !== "number" ||
|
|
21
|
+
typeof candidate.patch !== "number") {
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
return candidate;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Whether this core drops the sheet host's size update when the keyboard opens:
|
|
28
|
+
* a defined version lexicographically below {@link MIN_EXPO_MODULES_CORE}.
|
|
29
|
+
* An unknown version is not reported as broken.
|
|
30
|
+
*/
|
|
31
|
+
export function bottomSheetHostDropsResize(version) {
|
|
32
|
+
if (!version)
|
|
33
|
+
return false;
|
|
34
|
+
const { major, minor, patch } = MIN_EXPO_MODULES_CORE;
|
|
35
|
+
if (version.major !== major)
|
|
36
|
+
return version.major < major;
|
|
37
|
+
if (version.minor !== minor)
|
|
38
|
+
return version.minor < minor;
|
|
39
|
+
return version.patch < patch;
|
|
40
|
+
}
|
|
41
|
+
export function bottomSheetHostWarning(version) {
|
|
42
|
+
const { major, minor, patch } = MIN_EXPO_MODULES_CORE;
|
|
43
|
+
return (`@mrmeg/expo-ui BottomSheet: expo-modules-core ${version.version} drops the sheet's ` +
|
|
44
|
+
"size update when the keyboard opens on Android, so BottomSheet.Footer and the tail " +
|
|
45
|
+
"of Body end up under the keyboard (expo/expo#47778). Update expo-modules-core to " +
|
|
46
|
+
`>= ${major}.${minor}.${patch} (fixed in 57.0.4 / 58.0.0) and rebuild the app.`);
|
|
47
|
+
}
|
|
48
|
+
let warned = false;
|
|
49
|
+
/**
|
|
50
|
+
* Warn once per JS session, in development, on Android, when the native
|
|
51
|
+
* `expo-modules-core` is older than {@link MIN_EXPO_MODULES_CORE}. Called when a
|
|
52
|
+
* `BottomSheet.Content` mounts. Silent on iOS and web, where the sheet's
|
|
53
|
+
* avoidance does not go through `RNHostView`'s size state.
|
|
54
|
+
*/
|
|
55
|
+
export function warnIfBottomSheetHostDropsResize() {
|
|
56
|
+
if (warned || !__DEV__ || Platform.OS !== "android")
|
|
57
|
+
return;
|
|
58
|
+
const version = readExpoModulesCoreVersion();
|
|
59
|
+
if (!version || !bottomSheetHostDropsResize(version))
|
|
60
|
+
return;
|
|
61
|
+
warned = true;
|
|
62
|
+
console.warn(bottomSheetHostWarning(version));
|
|
63
|
+
}
|
|
64
|
+
/** Test hook: forget that the warning was already shown. */
|
|
65
|
+
export function resetBottomSheetHostWarningForTests() {
|
|
66
|
+
warned = false;
|
|
67
|
+
}
|
|
@@ -27,6 +27,31 @@ export type TextInputSurfaceResponderProps = Pick<ViewProps, "onStartShouldSetRe
|
|
|
27
27
|
* for that.
|
|
28
28
|
*/
|
|
29
29
|
export declare function useTextInputSurfaceResponder(focus: () => void): TextInputSurfaceResponderProps;
|
|
30
|
+
export type AncestorClaimWarningProps = Pick<ViewProps, "onStartShouldSetResponderCapture" | "onTouchStart">;
|
|
31
|
+
/** Test hook: let the once-per-session ancestor-claim warning fire again. */
|
|
32
|
+
export declare function resetAncestorClaimWarningForTests(): void;
|
|
33
|
+
/**
|
|
34
|
+
* Dev-only diagnostic for a boundary whose subtree is drawn in another native
|
|
35
|
+
* window but still lives in the screen's React tree (`BottomSheet.Content`).
|
|
36
|
+
*
|
|
37
|
+
* React Native's responder negotiation walks the React tree, so every ancestor
|
|
38
|
+
* of the sheet takes part in the capture phase for a tap inside it, even though
|
|
39
|
+
* none of them is under the finger. An ancestor `ScrollView` left on the default
|
|
40
|
+
* `keyboardShouldPersistTaps="never"` claims the tap there as soon as a text
|
|
41
|
+
* input holds focus and RN's `Keyboard` has reported the IME, and blurs the
|
|
42
|
+
* field on release: the keyboard closes and the tapped control never fires.
|
|
43
|
+
* Nothing inside the tree can preempt a capture-phase claim, so the boundary
|
|
44
|
+
* names it instead.
|
|
45
|
+
*
|
|
46
|
+
* The signal needs no heuristics: `onStartShouldSetResponderCapture` runs on
|
|
47
|
+
* this view for every touch that reaches the negotiation without an ancestor
|
|
48
|
+
* claiming it, while `onTouchStart` bubbles regardless. A touch start with no
|
|
49
|
+
* capture call for the same native event, while a package field is focused,
|
|
50
|
+
* is exactly a tap an ancestor took. Warns once per session, Android only,
|
|
51
|
+
* `__DEV__` only; elsewhere the inner props are returned untouched. The wrapped
|
|
52
|
+
* `onTouchStart` still runs, so the boundary's own behavior is unchanged.
|
|
53
|
+
*/
|
|
54
|
+
export declare function useAncestorClaimWarning<P extends Pick<ViewProps, "onTouchStart">>(inner: P, message: string): P & AncestorClaimWarningProps;
|
|
30
55
|
export type KeyboardDismissResponderProps = Pick<ViewProps, "onStartShouldSetResponder" | "onTouchStart" | "onTouchMove" | "onTouchEnd" | "onTouchCancel">;
|
|
31
56
|
/**
|
|
32
57
|
* View props for a tap-away keyboard-dismiss boundary. Spread onto the wrapper
|
|
@@ -191,6 +191,55 @@ export function useTextInputSurfaceResponder(focus) {
|
|
|
191
191
|
};
|
|
192
192
|
}, []);
|
|
193
193
|
}
|
|
194
|
+
let warnedAncestorClaim = false;
|
|
195
|
+
/** Test hook: let the once-per-session ancestor-claim warning fire again. */
|
|
196
|
+
export function resetAncestorClaimWarningForTests() {
|
|
197
|
+
warnedAncestorClaim = false;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Dev-only diagnostic for a boundary whose subtree is drawn in another native
|
|
201
|
+
* window but still lives in the screen's React tree (`BottomSheet.Content`).
|
|
202
|
+
*
|
|
203
|
+
* React Native's responder negotiation walks the React tree, so every ancestor
|
|
204
|
+
* of the sheet takes part in the capture phase for a tap inside it, even though
|
|
205
|
+
* none of them is under the finger. An ancestor `ScrollView` left on the default
|
|
206
|
+
* `keyboardShouldPersistTaps="never"` claims the tap there as soon as a text
|
|
207
|
+
* input holds focus and RN's `Keyboard` has reported the IME, and blurs the
|
|
208
|
+
* field on release: the keyboard closes and the tapped control never fires.
|
|
209
|
+
* Nothing inside the tree can preempt a capture-phase claim, so the boundary
|
|
210
|
+
* names it instead.
|
|
211
|
+
*
|
|
212
|
+
* The signal needs no heuristics: `onStartShouldSetResponderCapture` runs on
|
|
213
|
+
* this view for every touch that reaches the negotiation without an ancestor
|
|
214
|
+
* claiming it, while `onTouchStart` bubbles regardless. A touch start with no
|
|
215
|
+
* capture call for the same native event, while a package field is focused,
|
|
216
|
+
* is exactly a tap an ancestor took. Warns once per session, Android only,
|
|
217
|
+
* `__DEV__` only; elsewhere the inner props are returned untouched. The wrapped
|
|
218
|
+
* `onTouchStart` still runs, so the boundary's own behavior is unchanged.
|
|
219
|
+
*/
|
|
220
|
+
export function useAncestorClaimWarning(inner, message) {
|
|
221
|
+
const messageRef = useRef(message);
|
|
222
|
+
messageRef.current = message;
|
|
223
|
+
return useMemo(() => {
|
|
224
|
+
if (!__DEV__ || Platform.OS !== "android")
|
|
225
|
+
return inner;
|
|
226
|
+
let captured = null;
|
|
227
|
+
return {
|
|
228
|
+
...inner,
|
|
229
|
+
onStartShouldSetResponderCapture: (event) => {
|
|
230
|
+
captured = keyOf(event);
|
|
231
|
+
return false;
|
|
232
|
+
},
|
|
233
|
+
onTouchStart: (event) => {
|
|
234
|
+
if (!warnedAncestorClaim && !isSameEvent(captured, event) && hasKeyboardFocusedInput()) {
|
|
235
|
+
warnedAncestorClaim = true;
|
|
236
|
+
console.warn(messageRef.current);
|
|
237
|
+
}
|
|
238
|
+
inner.onTouchStart?.(event);
|
|
239
|
+
},
|
|
240
|
+
};
|
|
241
|
+
}, [inner]);
|
|
242
|
+
}
|
|
194
243
|
/**
|
|
195
244
|
* View props for a tap-away keyboard-dismiss boundary. Spread onto the wrapper
|
|
196
245
|
* `View` that encloses the screen (or a form). Nesting is safe: the innermost
|
package/dist/design-system.json
CHANGED
package/dist/lib/index.d.ts
CHANGED
package/dist/lib/index.js
CHANGED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type ViewProps } from "react-native";
|
|
2
|
+
/**
|
|
3
|
+
* Props for a "state surface": a plain `View` whose `opacity`, `pointerEvents`
|
|
4
|
+
* or `display` is computed from component state (`disabled`, `loading`,
|
|
5
|
+
* `checked`, `editable`, …).
|
|
6
|
+
*
|
|
7
|
+
* On Android (Fabric) a View forms a stacking context only while one of those
|
|
8
|
+
* props says so. A filled or bordered View at opacity 1 still gets a native
|
|
9
|
+
* view, but its children are hoisted into the nearest stacking-context
|
|
10
|
+
* ancestor; the moment its opacity drops below 1 the children are pulled back
|
|
11
|
+
* under it. A state flip that races a navigation pop therefore re-parents
|
|
12
|
+
* views mid-mutation and crashed with `addViewAt: cannot insert view … View
|
|
13
|
+
* already has a parent` (doglog #57, Pixel 6a). `collapsable={false}` makes
|
|
14
|
+
* the View a permanent stacking context, so the native tree shape never
|
|
15
|
+
* changes with state. iOS and web return nothing: their mounting layers
|
|
16
|
+
* re-parent safely and the extra native view would only cost.
|
|
17
|
+
*
|
|
18
|
+
* RN's `Pressable` already sets `collapsable={false}` on its own View, so only
|
|
19
|
+
* plain `View` surfaces need this. Spread it before any consumer prop spread so
|
|
20
|
+
* a caller can still override it.
|
|
21
|
+
*
|
|
22
|
+
* ```tsx
|
|
23
|
+
* <View {...stateSurfaceProps()} style={{ opacity: disabled ? 0.5 : 1 }} />
|
|
24
|
+
* ```
|
|
25
|
+
*
|
|
26
|
+
* Reads `Platform.OS` at call time so per-test platform flips apply.
|
|
27
|
+
*/
|
|
28
|
+
export declare function stateSurfaceProps(): Pick<ViewProps, "collapsable">;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Platform } from "react-native";
|
|
2
|
+
/**
|
|
3
|
+
* Props for a "state surface": a plain `View` whose `opacity`, `pointerEvents`
|
|
4
|
+
* or `display` is computed from component state (`disabled`, `loading`,
|
|
5
|
+
* `checked`, `editable`, …).
|
|
6
|
+
*
|
|
7
|
+
* On Android (Fabric) a View forms a stacking context only while one of those
|
|
8
|
+
* props says so. A filled or bordered View at opacity 1 still gets a native
|
|
9
|
+
* view, but its children are hoisted into the nearest stacking-context
|
|
10
|
+
* ancestor; the moment its opacity drops below 1 the children are pulled back
|
|
11
|
+
* under it. A state flip that races a navigation pop therefore re-parents
|
|
12
|
+
* views mid-mutation and crashed with `addViewAt: cannot insert view … View
|
|
13
|
+
* already has a parent` (doglog #57, Pixel 6a). `collapsable={false}` makes
|
|
14
|
+
* the View a permanent stacking context, so the native tree shape never
|
|
15
|
+
* changes with state. iOS and web return nothing: their mounting layers
|
|
16
|
+
* re-parent safely and the extra native view would only cost.
|
|
17
|
+
*
|
|
18
|
+
* RN's `Pressable` already sets `collapsable={false}` on its own View, so only
|
|
19
|
+
* plain `View` surfaces need this. Spread it before any consumer prop spread so
|
|
20
|
+
* a caller can still override it.
|
|
21
|
+
*
|
|
22
|
+
* ```tsx
|
|
23
|
+
* <View {...stateSurfaceProps()} style={{ opacity: disabled ? 0.5 : 1 }} />
|
|
24
|
+
* ```
|
|
25
|
+
*
|
|
26
|
+
* Reads `Platform.OS` at call time so per-test platform flips apply.
|
|
27
|
+
*/
|
|
28
|
+
export function stateSurfaceProps() {
|
|
29
|
+
return Platform.OS === "android" ? { collapsable: false } : {};
|
|
30
|
+
}
|
package/llms-full.md
CHANGED
|
@@ -103,7 +103,7 @@ before creating a new app-local primitive.
|
|
|
103
103
|
| `AnimatedView` | Entrance and visibility animation | Keep simple reveal effects in this wrapper. |
|
|
104
104
|
| `Avatar`, `AvatarGroup` | Profile images with initials/icon fallback | Pass both `source` and `name`; set `size`/`shape` on the group, not per child. |
|
|
105
105
|
| `Badge` | Short status labels | Prefer over custom pill views. |
|
|
106
|
-
| `BottomSheet` | Mobile-first modal sheets | Requires root `UIProvider`. Native sheet via `@expo/ui`; `swipeEnabled`, `avoidKeyboard`, `dismissKeyboardOnDrag` are accepted but ignored. |
|
|
106
|
+
| `BottomSheet` | Mobile-first modal sheets | Requires root `UIProvider`. Native sheet via `@expo/ui`; `swipeEnabled`, `avoidKeyboard`, `dismissKeyboardOnDrag` are accepted but ignored (the iOS sheet presentation and Android Material3 keep content above the keyboard; never nest a `KeyboardAvoidingView` in a sheet). |
|
|
107
107
|
| `Button` | Commands and CTAs | Use `preset`, not `variant`; heights are 28/32/40. |
|
|
108
108
|
| `Card` | Individual framed content groups | Do not wrap whole page sections in cards. |
|
|
109
109
|
| `Carousel` | Small known set of horizontally snapping slides | Renders every child (no virtualization); fractional `itemWidth` measures the viewport until first layout. |
|