@mrmeg/expo-ui 0.10.1 → 0.12.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 +48 -0
- package/LLM_USAGE.md +1 -0
- package/README.md +2 -1
- package/dist/components/BottomSheet.d.ts +7 -2
- package/dist/components/BottomSheet.js +130 -26
- package/dist/components/DismissKeyboard.js +38 -6
- package/dist/components/KeyboardAvoidingView.d.ts +20 -0
- package/dist/components/KeyboardAvoidingView.js +20 -0
- package/dist/components/TextInput.js +15 -4
- package/dist/components/UIProvider.d.ts +12 -1
- package/dist/components/UIProvider.js +6 -2
- package/dist/components/index.d.ts +1 -0
- package/dist/components/index.js +1 -0
- package/llms-full.md +9 -4
- package/package.json +3 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,54 @@
|
|
|
3
3
|
All notable changes to `@mrmeg/expo-ui` are documented here. This project
|
|
4
4
|
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
5
5
|
|
|
6
|
+
## [0.12.0]
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- **`KeyboardAvoidingView` is now a public package component.** Native uses
|
|
11
|
+
`react-native-keyboard-controller` with `automaticOffset` enabled by default,
|
|
12
|
+
while web renders a plain `View`.
|
|
13
|
+
- **`UIProvider` now owns app-wide native keyboard avoidance by default.** Apps
|
|
14
|
+
that mount `KeyboardProvider` above `UIProvider` get root-level keyboard
|
|
15
|
+
avoiding behavior without adding per-screen `KeyboardAvoidingView` wrappers.
|
|
16
|
+
Pass `keyboardAvoiding={false}` to opt out, or `keyboardAvoidingProps` to tune
|
|
17
|
+
the root wrapper. Web skips the root keyboard wrapper unless explicitly
|
|
18
|
+
enabled.
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- **`DismissKeyboard` no longer nests keyboard-avoiding wrappers when the root
|
|
23
|
+
provider already owns keyboard avoidance.**
|
|
24
|
+
|
|
25
|
+
## [0.11.0]
|
|
26
|
+
|
|
27
|
+
### Added
|
|
28
|
+
|
|
29
|
+
- **New peer dependency: `react-native-keyboard-controller` (>=1.21.0 <2.0.0).**
|
|
30
|
+
Required by `DismissKeyboard` to dismiss the software keyboard for the native
|
|
31
|
+
`@expo/ui` TextInput (see fix below). It ships a no-op web fallback, so web
|
|
32
|
+
bundles are unaffected.
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
|
|
36
|
+
- **`DismissKeyboard` now dismisses the keyboard for native `@expo/ui` fields.**
|
|
37
|
+
The native TextInput is a SwiftUI / Compose field that never registers with
|
|
38
|
+
React Native's `TextInputState`, so the previous `Keyboard.dismiss()` (which
|
|
39
|
+
only blurs RN-tracked inputs) did nothing — tapping outside never closed the
|
|
40
|
+
keyboard on iOS or Android. `DismissKeyboard` now mounts a full-screen
|
|
41
|
+
tap-catcher *only while the keyboard is visible* (`useKeyboardState`) that calls
|
|
42
|
+
`KeyboardController.dismiss()` at the IME level. Mounting it only while visible
|
|
43
|
+
avoids stealing the focus tap and fighting bottom sheets / modals. Web remains a
|
|
44
|
+
no-op (no software keyboard); all existing props (`children`, `style`,
|
|
45
|
+
`avoidKeyboard`, `scrollable`) and the `KeyboardAvoidingView` / `ScrollView`
|
|
46
|
+
wrapping behavior are preserved.
|
|
47
|
+
- **Android: TextInput text is now vertically centered.** The native field set a
|
|
48
|
+
fixed `height`, but Android's Compose `BasicTextField` decoration box defaults to
|
|
49
|
+
`contentAlignment = topStart`, so text pinned to the top with the slack falling to
|
|
50
|
+
the bottom (iOS centered fine). The single-line field is now sized by symmetric
|
|
51
|
+
vertical padding instead of a fixed height, centering the text on Android with no
|
|
52
|
+
change to iOS sizing or centering. Multiline is unchanged.
|
|
53
|
+
|
|
6
54
|
## [0.10.1]
|
|
7
55
|
|
|
8
56
|
### Fixed
|
package/LLM_USAGE.md
CHANGED
|
@@ -177,6 +177,7 @@ Use this table before creating a new app-local primitive.
|
|
|
177
177
|
| `Collapsible`, `CollapsibleTrigger`, `CollapsibleContent` | One-off disclosure | Local animated height wrappers | Advanced settings, hidden helper text |
|
|
178
178
|
| `Dialog`, `AlertDialog` | Modal decisions and custom modal content | Custom modal overlays | Confirm delete, edit profile, invite user |
|
|
179
179
|
| `DismissKeyboard` | Tap-away keyboard dismissal | Screen-level keyboard handling | Forms, search screens, sign-in screens |
|
|
180
|
+
| `KeyboardAvoidingView` | Native keyboard-aware layout root | Repeated app-local keyboard wrappers | Screen roots, composer footers, form-heavy subtrees |
|
|
180
181
|
| `Drawer` | Side panels and drawer navigation | Custom sliding panels | Filter drawer, app navigation drawer, inspector panel |
|
|
181
182
|
| `DropdownMenu` | Menus and command lists | Homemade popover menus | Row actions, account menu, sort menu |
|
|
182
183
|
| `EmptyState` | No-data or recoverable error regions | One-off empty placeholders | Empty inbox, no search results, failed list load |
|
package/README.md
CHANGED
|
@@ -222,6 +222,7 @@ All components are exported from `@mrmeg/expo-ui/components`; direct imports suc
|
|
|
222
222
|
| `ErrorBoundary` | React render error fallback | Unhandled screen crashes | Route-level fallback, feature boundary, recoverable widget crashes |
|
|
223
223
|
| `Icon` | Feather or custom icons with theme tokens | Raw vector icons with hardcoded colors | Button accessories, empty-state icons, menu icons, status glyphs |
|
|
224
224
|
| `InputOTP` | Verification code entry | Multiple manually managed text inputs | Email codes, SMS codes, MFA, invite codes |
|
|
225
|
+
| `KeyboardAvoidingView` | Native keyboard-aware layout root | Repeated app-local keyboard wrappers | Screen roots, composer footers, form-heavy subtrees |
|
|
225
226
|
| `Label` | Accessible form labels | Plain styled text labels | Required labels, disabled labels, field group labels |
|
|
226
227
|
| `MaxWidthContainer` | Centered responsive width | Per-screen max-width wrappers | Web pages, tablet layouts, settings forms, auth panels |
|
|
227
228
|
| `Notification` | Global toast surface | Screen-local toast state | Saved/error/sync notifications, action toasts, loading toast, bottom-position alerts |
|
|
@@ -272,7 +273,7 @@ Use `Button.preset`, not `variant`. `default` is the neutral primary action, `se
|
|
|
272
273
|
|
|
273
274
|
Use `StyledText` or its aliases instead of raw `Text` whenever the text is part of app UI. Use `TextInput` for labeled fields because it already owns label, helper text, error text, clear buttons, password visibility, numeric filtering, and left/right elements.
|
|
274
275
|
|
|
275
|
-
Mount `UIProvider` once near the root before using `Dialog`, `AlertDialog`, `BottomSheet`, `Drawer`, `DropdownMenu`, `Popover`, `SelectContent`, `Tooltip`, or package notifications. On native, `BottomSheet.Content` listens to React Native keyboard events when `avoidKeyboard` is enabled; it defaults to `true` and can be disabled per sheet. Trigger transient feedback with `notify`.
|
|
276
|
+
Mount `UIProvider` once near the root before using `Dialog`, `AlertDialog`, `BottomSheet`, `Drawer`, `DropdownMenu`, `Popover`, `SelectContent`, `Tooltip`, or package notifications. On native, `UIProvider` also wraps app content in the package keyboard-avoiding root by default, so ordinary screens and fixed footers stay above the soft keyboard without repeated app-local wrappers; pass `keyboardAvoiding={false}` to opt out, or use `KeyboardAvoidingView` directly for a subtree with custom behavior. Web skips the root keyboard wrapper unless `keyboardAvoiding` is explicitly enabled. `BottomSheet.Content` listens to React Native keyboard events when `avoidKeyboard` is enabled; it defaults to `true` and can be disabled per sheet. Trigger transient feedback with `notify`.
|
|
276
277
|
|
|
277
278
|
Use `Skeleton` components for loading content with stable dimensions, `EmptyState` for no-data/recoverable errors, `Alert` for blocking confirm/alert dialogs, and `Notification` for transient global feedback.
|
|
278
279
|
|
|
@@ -15,7 +15,9 @@ import { ViewProps, StyleProp, ViewStyle, ScrollViewProps } from "react-native";
|
|
|
15
15
|
*
|
|
16
16
|
* Platform-owned behaviors (props accepted for ergonomics, but the platform
|
|
17
17
|
* decides):
|
|
18
|
-
* - `.Handle`
|
|
18
|
+
* - `.Handle` replaces the native drag indicator with a pressable equivalent.
|
|
19
|
+
* Pressing it walks through the configured snap points, reversing direction
|
|
20
|
+
* at either end. Dragging the sheet continues to use the platform gesture.
|
|
19
21
|
* - `swipeEnabled` / `avoidKeyboard` / `dismissKeyboardOnDrag` are accepted
|
|
20
22
|
* for call-site ergonomics but have no effect — the platform handles them.
|
|
21
23
|
* - Sheet *chrome* (corner radius, system background, safe area) is the
|
|
@@ -59,6 +61,9 @@ interface BottomSheetContextValue {
|
|
|
59
61
|
onOpenChange: (open: boolean) => void;
|
|
60
62
|
toggle: () => void;
|
|
61
63
|
snapPoints: SnapPoint[];
|
|
64
|
+
snapIndex: number;
|
|
65
|
+
setSnapIndex: (index: number) => void;
|
|
66
|
+
cycleSnapPoint: () => void;
|
|
62
67
|
closeOnBackdropPress: boolean;
|
|
63
68
|
/**
|
|
64
69
|
* True when a `Body`'s content overflows its viewport (is genuinely
|
|
@@ -136,7 +141,7 @@ declare function useBottomSheetContext(): BottomSheetContextValue;
|
|
|
136
141
|
declare function BottomSheetRoot({ open: controlledOpen, onOpenChange: controlledOnOpenChange, defaultOpen, snapPoints, closeOnBackdropPress, children, }: BottomSheetProps): React.JSX.Element;
|
|
137
142
|
declare function BottomSheetTrigger({ asChild, children, style: styleOverride }: BottomSheetTriggerProps): React.JSX.Element;
|
|
138
143
|
declare function BottomSheetContent({ swipeEnabled: _swipeEnabled, avoidKeyboard: _avoidKeyboard, dismissKeyboardOnDrag: _dismissKeyboardOnDrag, style: styleOverride, children, }: BottomSheetContentProps): React.JSX.Element;
|
|
139
|
-
declare function BottomSheetHandle(
|
|
144
|
+
declare function BottomSheetHandle({ style }: BottomSheetHandleProps): React.JSX.Element;
|
|
140
145
|
declare function BottomSheetHeader({ children, style, ...props }: BottomSheetHeaderProps): React.JSX.Element;
|
|
141
146
|
declare function BottomSheetBody({ children, style, contentContainerStyle, onContentSizeChange, onLayout, ...props }: BottomSheetBodyProps): React.JSX.Element;
|
|
142
147
|
declare function BottomSheetFooter({ children, style, ...props }: BottomSheetFooterProps): React.JSX.Element;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import React, { createContext, use, useCallback, useEffect,
|
|
2
|
+
import React, { createContext, use, useCallback, useEffect, useReducer, useRef } from "react";
|
|
3
3
|
import { View, Pressable, ScrollView, Platform, useWindowDimensions, } from "react-native";
|
|
4
4
|
import { BottomSheet as NativeBottomSheet } from "@expo/ui/community/bottom-sheet";
|
|
5
5
|
import { useSafeAreaInsets, initialWindowMetrics } from "react-native-safe-area-context";
|
|
@@ -7,6 +7,62 @@ import { useTheme } from "../hooks/useTheme.js";
|
|
|
7
7
|
import { spacing } from "../constants/spacing.js";
|
|
8
8
|
import { TextColorContext, TextClassContext } from "./StyledText.context";
|
|
9
9
|
import { Icon } from "./Icon.js";
|
|
10
|
+
function clampSnapIndex(index, maxSnapIndex) {
|
|
11
|
+
return Math.min(Math.max(index, 0), maxSnapIndex);
|
|
12
|
+
}
|
|
13
|
+
function createInitialRootState(defaultOpen, maxSnapIndex) {
|
|
14
|
+
return {
|
|
15
|
+
internalOpen: defaultOpen,
|
|
16
|
+
scrollable: false,
|
|
17
|
+
hasHeader: false,
|
|
18
|
+
hasFooter: false,
|
|
19
|
+
snapIndex: maxSnapIndex,
|
|
20
|
+
snapDirection: -1,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
function bottomSheetRootReducer(state, action) {
|
|
24
|
+
switch (action.type) {
|
|
25
|
+
case "setInternalOpen":
|
|
26
|
+
return {
|
|
27
|
+
...state,
|
|
28
|
+
internalOpen: action.open,
|
|
29
|
+
snapIndex: action.open ? state.snapIndex : action.maxSnapIndex,
|
|
30
|
+
snapDirection: action.open ? state.snapDirection : -1,
|
|
31
|
+
};
|
|
32
|
+
case "setScrollable":
|
|
33
|
+
return { ...state, scrollable: action.scrollable };
|
|
34
|
+
case "setHasHeader":
|
|
35
|
+
return { ...state, hasHeader: action.present };
|
|
36
|
+
case "setHasFooter":
|
|
37
|
+
return { ...state, hasFooter: action.present };
|
|
38
|
+
case "setSnapIndex": {
|
|
39
|
+
const snapIndex = clampSnapIndex(action.index, action.maxSnapIndex);
|
|
40
|
+
return {
|
|
41
|
+
...state,
|
|
42
|
+
snapIndex,
|
|
43
|
+
snapDirection: snapIndex === 0 ? 1 : snapIndex === action.maxSnapIndex ? -1 : state.snapDirection,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
case "cycleSnapPoint": {
|
|
47
|
+
if (action.maxSnapIndex === 0)
|
|
48
|
+
return state;
|
|
49
|
+
if (action.android) {
|
|
50
|
+
const snapIndex = state.snapIndex === action.maxSnapIndex ? 0 : action.maxSnapIndex;
|
|
51
|
+
return { ...state, snapIndex, snapDirection: snapIndex === 0 ? 1 : -1 };
|
|
52
|
+
}
|
|
53
|
+
let snapDirection = state.snapDirection;
|
|
54
|
+
if (state.snapIndex <= 0)
|
|
55
|
+
snapDirection = 1;
|
|
56
|
+
if (state.snapIndex >= action.maxSnapIndex)
|
|
57
|
+
snapDirection = -1;
|
|
58
|
+
return {
|
|
59
|
+
...state,
|
|
60
|
+
snapIndex: state.snapIndex + snapDirection,
|
|
61
|
+
snapDirection,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
10
66
|
// ============================================================================
|
|
11
67
|
// Context
|
|
12
68
|
// ============================================================================
|
|
@@ -87,33 +143,45 @@ function SheetCloseButton({ style }) {
|
|
|
87
143
|
// Root
|
|
88
144
|
// ============================================================================
|
|
89
145
|
function BottomSheetRoot({ open: controlledOpen, onOpenChange: controlledOnOpenChange, defaultOpen = false, snapPoints = ["50%"], closeOnBackdropPress = true, children, }) {
|
|
90
|
-
const
|
|
91
|
-
const [
|
|
92
|
-
const [hasHeader, setHasHeader] = useState(false);
|
|
93
|
-
const [hasFooter, setHasFooter] = useState(false);
|
|
146
|
+
const maxSnapIndex = Math.max(snapPoints.length - 1, 0);
|
|
147
|
+
const [state, dispatch] = useReducer(bottomSheetRootReducer, undefined, () => createInitialRootState(defaultOpen, maxSnapIndex));
|
|
94
148
|
const isControlled = controlledOpen !== undefined;
|
|
95
|
-
const open = isControlled ? controlledOpen : internalOpen;
|
|
96
|
-
const
|
|
149
|
+
const open = isControlled ? controlledOpen : state.internalOpen;
|
|
150
|
+
const snapIndex = clampSnapIndex(state.snapIndex, maxSnapIndex);
|
|
151
|
+
const setSnapIndex = useCallback((index) => {
|
|
152
|
+
dispatch({ type: "setSnapIndex", index, maxSnapIndex });
|
|
153
|
+
}, [maxSnapIndex]);
|
|
154
|
+
const cycleSnapPoint = useCallback(() => {
|
|
155
|
+
dispatch({
|
|
156
|
+
type: "cycleSnapPoint",
|
|
157
|
+
maxSnapIndex,
|
|
158
|
+
android: Platform.OS === "android",
|
|
159
|
+
});
|
|
160
|
+
}, [maxSnapIndex]);
|
|
161
|
+
const onOpenChange = useCallback((newOpen) => {
|
|
97
162
|
if (isControlled) {
|
|
98
163
|
controlledOnOpenChange?.(newOpen);
|
|
99
164
|
}
|
|
100
165
|
else {
|
|
101
|
-
setInternalOpen
|
|
166
|
+
dispatch({ type: "setInternalOpen", open: newOpen, maxSnapIndex });
|
|
102
167
|
}
|
|
103
|
-
};
|
|
104
|
-
const toggle = () => onOpenChange(!open);
|
|
168
|
+
}, [controlledOnOpenChange, isControlled, maxSnapIndex]);
|
|
169
|
+
const toggle = useCallback(() => onOpenChange(!open), [onOpenChange, open]);
|
|
105
170
|
const contextValue = {
|
|
106
171
|
open,
|
|
107
172
|
onOpenChange,
|
|
108
173
|
toggle,
|
|
109
174
|
snapPoints,
|
|
175
|
+
snapIndex,
|
|
176
|
+
setSnapIndex,
|
|
177
|
+
cycleSnapPoint,
|
|
110
178
|
closeOnBackdropPress,
|
|
111
|
-
scrollable,
|
|
112
|
-
setScrollable,
|
|
113
|
-
hasHeader,
|
|
114
|
-
setHasHeader,
|
|
115
|
-
hasFooter,
|
|
116
|
-
setHasFooter,
|
|
179
|
+
scrollable: state.scrollable,
|
|
180
|
+
setScrollable: (scrollable) => dispatch({ type: "setScrollable", scrollable }),
|
|
181
|
+
hasHeader: state.hasHeader,
|
|
182
|
+
setHasHeader: (present) => dispatch({ type: "setHasHeader", present }),
|
|
183
|
+
hasFooter: state.hasFooter,
|
|
184
|
+
setHasFooter: (present) => dispatch({ type: "setHasFooter", present }),
|
|
117
185
|
};
|
|
118
186
|
return (_jsx(BottomSheetContext.Provider, { value: contextValue, children: children }));
|
|
119
187
|
}
|
|
@@ -141,14 +209,14 @@ function BottomSheetTrigger({ asChild, children, style: styleOverride }) {
|
|
|
141
209
|
function BottomSheetContent({
|
|
142
210
|
// Accepted-but-ignored ergonomics props (platform owns these behaviors):
|
|
143
211
|
swipeEnabled: _swipeEnabled, avoidKeyboard: _avoidKeyboard, dismissKeyboardOnDrag: _dismissKeyboardOnDrag, style: styleOverride, children, }) {
|
|
144
|
-
const { open, onOpenChange, snapPoints, hasHeader } = useBottomSheetContext();
|
|
212
|
+
const { open, onOpenChange, snapPoints, snapIndex, setSnapIndex, hasHeader } = useBottomSheetContext();
|
|
145
213
|
const { theme } = useTheme();
|
|
146
214
|
const dismissDisabled = useDismissDisabled();
|
|
147
215
|
const showClose = useShowClose();
|
|
148
216
|
const { height: winH } = useWindowDimensions();
|
|
149
|
-
// Boolean open → native imperative index.
|
|
150
|
-
//
|
|
151
|
-
const index = open ?
|
|
217
|
+
// Boolean open → native imperative index. The root resets snapIndex to the
|
|
218
|
+
// highest point while closed; -1 keeps the native sheet closed.
|
|
219
|
+
const index = open ? snapIndex : -1;
|
|
152
220
|
// The native RN-in-SwiftUI host does NOT clamp our RN column to the sheet's
|
|
153
221
|
// detent height — it lays the column out at its full intrinsic content height,
|
|
154
222
|
// SwiftUI then clips it at the sheet edge (footer/tail fall off-screen), and
|
|
@@ -163,12 +231,21 @@ swipeEnabled: _swipeEnabled, avoidKeyboard: _avoidKeyboard, dismissKeyboardOnDra
|
|
|
163
231
|
// dismiss is off, or the body scrolls), float one over the top-right corner.
|
|
164
232
|
// A Header, when present, renders its own (see BottomSheetHeader).
|
|
165
233
|
const showFloatingClose = showClose && !hasHeader;
|
|
234
|
+
const hasInteractiveHandle = containsHandle(children);
|
|
166
235
|
const handleChange = (newIndex) => {
|
|
167
236
|
// Native fires onChange(-1) on dismiss (swipe / backdrop / back button).
|
|
168
|
-
if (newIndex < 0
|
|
169
|
-
|
|
237
|
+
if (newIndex < 0) {
|
|
238
|
+
if (open)
|
|
239
|
+
onOpenChange(false);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
setSnapIndex(newIndex);
|
|
170
243
|
};
|
|
171
|
-
return (_jsx(NativeBottomSheet, { index: index, snapPoints: snapPoints,
|
|
244
|
+
return (_jsx(NativeBottomSheet, { index: index, snapPoints: snapPoints,
|
|
245
|
+
// The native indicator lives outside the hosted RN tree and cannot
|
|
246
|
+
// receive JS presses. Hide it only when the compound Handle supplies the
|
|
247
|
+
// interactive replacement; sheets without Handle retain native chrome.
|
|
248
|
+
handleComponent: hasInteractiveHandle ? null : undefined, enablePanDownToClose: !dismissDisabled, onChange: handleChange, onClose: () => {
|
|
172
249
|
if (open)
|
|
173
250
|
onOpenChange(false);
|
|
174
251
|
},
|
|
@@ -191,10 +268,37 @@ swipeEnabled: _swipeEnabled, avoidKeyboard: _avoidKeyboard, dismissKeyboardOnDra
|
|
|
191
268
|
} }))] }) }) }) }));
|
|
192
269
|
}
|
|
193
270
|
// ============================================================================
|
|
194
|
-
// Handle —
|
|
271
|
+
// Handle — pressable snap-point control; native drag gestures remain active
|
|
195
272
|
// ============================================================================
|
|
196
|
-
|
|
197
|
-
|
|
273
|
+
/** Walk a React children tree looking for a `BottomSheet.Handle` element. */
|
|
274
|
+
function containsHandle(node) {
|
|
275
|
+
return React.Children.toArray(node).some((child) => {
|
|
276
|
+
if (!React.isValidElement(child))
|
|
277
|
+
return false;
|
|
278
|
+
if (child.type === BottomSheetHandle)
|
|
279
|
+
return true;
|
|
280
|
+
return containsHandle(child.props.children);
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
function BottomSheetHandle({ style }) {
|
|
284
|
+
const { cycleSnapPoint, snapPoints } = useBottomSheetContext();
|
|
285
|
+
const { theme } = useTheme();
|
|
286
|
+
const disabled = snapPoints.length < 2;
|
|
287
|
+
return (_jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Change sheet height", accessibilityHint: "Moves the sheet to the next snap point", accessibilityState: { disabled }, disabled: disabled, onPress: cycleSnapPoint, style: ({ pressed }) => [
|
|
288
|
+
{
|
|
289
|
+
minHeight: spacing.touchTarget,
|
|
290
|
+
alignItems: "center",
|
|
291
|
+
justifyContent: "center",
|
|
292
|
+
opacity: pressed ? 0.65 : 1,
|
|
293
|
+
},
|
|
294
|
+
Platform.OS === "web" && !disabled && { cursor: "pointer" },
|
|
295
|
+
style,
|
|
296
|
+
], children: _jsx(View, { style: {
|
|
297
|
+
width: spacing.xl,
|
|
298
|
+
height: spacing.xs,
|
|
299
|
+
borderRadius: spacing.radiusFull,
|
|
300
|
+
backgroundColor: theme.colors.mutedForeground,
|
|
301
|
+
} }) }));
|
|
198
302
|
}
|
|
199
303
|
// ============================================================================
|
|
200
304
|
// Header
|
|
@@ -1,13 +1,45 @@
|
|
|
1
|
-
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
import { Pressable,
|
|
3
|
-
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Pressable, StyleSheet, Platform, ScrollView, View, } from "react-native";
|
|
3
|
+
import { KeyboardController, useKeyboardState } from "react-native-keyboard-controller";
|
|
4
|
+
import { KeyboardAvoidingView, useKeyboardAvoidance } from "./KeyboardAvoidingView.js";
|
|
5
|
+
/**
|
|
6
|
+
* Full-screen tap-catcher that dismisses the keyboard, mounted ONLY while the
|
|
7
|
+
* keyboard is visible.
|
|
8
|
+
*
|
|
9
|
+
* Why window-level dismissal: the native `@expo/ui` TextInput is a SwiftUI /
|
|
10
|
+
* Compose field that never registers with React Native's `TextInputState`, so
|
|
11
|
+
* `Keyboard.dismiss()` (which only blurs RN-tracked inputs) does nothing for it.
|
|
12
|
+
* `KeyboardController.dismiss()` resigns the focused responder at the IME level,
|
|
13
|
+
* which works for both native and RN fields.
|
|
14
|
+
*
|
|
15
|
+
* Why only-while-visible: an always-mounted catcher would intercept the very tap
|
|
16
|
+
* that focuses a field and close the keyboard before it opens, and would fight
|
|
17
|
+
* bottom sheets / modals. Rendering it solely while the keyboard is up means
|
|
18
|
+
* nothing intercepts taps when no field is focused; once one is, a tap anywhere
|
|
19
|
+
* off the field dismisses.
|
|
20
|
+
*/
|
|
21
|
+
function KeyboardDismissOverlay() {
|
|
22
|
+
const isVisible = useKeyboardState((state) => state.isVisible);
|
|
23
|
+
if (!isVisible)
|
|
24
|
+
return null;
|
|
25
|
+
return (_jsx(Pressable, { style: [StyleSheet.absoluteFill, styles.overlay], onPressIn: () => KeyboardController.dismiss(), accessibilityLabel: "Dismiss keyboard", accessibilityRole: "button" }));
|
|
26
|
+
}
|
|
4
27
|
/**
|
|
5
28
|
* @returns Wrapper for a view that dismisses the keyboard when tapped outside of a text input
|
|
6
29
|
*/
|
|
7
30
|
export const DismissKeyboard = ({ children, style, avoidKeyboard = true, scrollable = true }) => {
|
|
31
|
+
const hasKeyboardAvoidance = useKeyboardAvoidance();
|
|
8
32
|
const content = scrollable ? (_jsx(ScrollView, { style: { flex: 1 }, contentContainerStyle: { flexGrow: 1, justifyContent: "center" }, keyboardShouldPersistTaps: "handled", showsVerticalScrollIndicator: false, children: children })) : (children);
|
|
9
|
-
|
|
10
|
-
|
|
33
|
+
// Web has no software keyboard, so the tap-catcher is native-only. The overlay
|
|
34
|
+
// is never created on web, keeping `useKeyboardState` off that platform.
|
|
35
|
+
const overlay = Platform.OS !== "web" ? _jsx(KeyboardDismissOverlay, {}) : null;
|
|
36
|
+
if (!avoidKeyboard || hasKeyboardAvoidance) {
|
|
37
|
+
return (_jsxs(View, { style: { flex: 1 }, children: [content, overlay] }));
|
|
11
38
|
}
|
|
12
|
-
return (
|
|
39
|
+
return (_jsxs(KeyboardAvoidingView, { style: [{ flex: 1, width: "100%" }, style], keyboardVerticalOffset: Platform.OS === "ios" ? 0 : 0, children: [content, overlay] }));
|
|
13
40
|
};
|
|
41
|
+
const styles = StyleSheet.create({
|
|
42
|
+
overlay: {
|
|
43
|
+
zIndex: 999,
|
|
44
|
+
},
|
|
45
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { type StyleProp, type ViewProps, type ViewStyle } from "react-native";
|
|
3
|
+
type KeyboardAvoidingBehavior = "height" | "padding" | "position" | "translate-with-padding";
|
|
4
|
+
export interface KeyboardAvoidingViewProps extends ViewProps {
|
|
5
|
+
children: React.ReactNode;
|
|
6
|
+
style?: StyleProp<ViewStyle>;
|
|
7
|
+
behavior?: KeyboardAvoidingBehavior;
|
|
8
|
+
contentContainerStyle?: ViewProps["style"];
|
|
9
|
+
keyboardVerticalOffset?: number;
|
|
10
|
+
automaticOffset?: boolean;
|
|
11
|
+
}
|
|
12
|
+
export declare function useKeyboardAvoidance(): boolean;
|
|
13
|
+
/**
|
|
14
|
+
* Package-level keyboard avoiding wrapper.
|
|
15
|
+
*
|
|
16
|
+
* Native uses `react-native-keyboard-controller` so screens can avoid the soft
|
|
17
|
+
* keyboard with `automaticOffset`; web renders a plain `View`.
|
|
18
|
+
*/
|
|
19
|
+
export declare function KeyboardAvoidingView({ children, style, behavior, automaticOffset, contentContainerStyle, keyboardVerticalOffset, ...props }: KeyboardAvoidingViewProps): React.JSX.Element;
|
|
20
|
+
export {};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { createContext, useContext } from "react";
|
|
3
|
+
import { Platform, View, } from "react-native";
|
|
4
|
+
import { KeyboardAvoidingView as NativeKeyboardAvoidingView, } from "react-native-keyboard-controller";
|
|
5
|
+
const KeyboardAvoidanceContext = createContext(false);
|
|
6
|
+
export function useKeyboardAvoidance() {
|
|
7
|
+
return useContext(KeyboardAvoidanceContext);
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Package-level keyboard avoiding wrapper.
|
|
11
|
+
*
|
|
12
|
+
* Native uses `react-native-keyboard-controller` so screens can avoid the soft
|
|
13
|
+
* keyboard with `automaticOffset`; web renders a plain `View`.
|
|
14
|
+
*/
|
|
15
|
+
export function KeyboardAvoidingView({ children, style, behavior = Platform.OS === "ios" ? "padding" : "height", automaticOffset = true, contentContainerStyle, keyboardVerticalOffset, ...props }) {
|
|
16
|
+
if (Platform.OS === "web") {
|
|
17
|
+
return (_jsx(KeyboardAvoidanceContext.Provider, { value: true, children: _jsx(View, { style: style, ...props, children: children }) }));
|
|
18
|
+
}
|
|
19
|
+
return (_jsx(KeyboardAvoidanceContext.Provider, { value: true, children: _jsx(NativeKeyboardAvoidingView, { style: style, behavior: behavior, automaticOffset: automaticOffset, contentContainerStyle: contentContainerStyle, keyboardVerticalOffset: keyboardVerticalOffset, ...props, children: children }) }));
|
|
20
|
+
}
|
|
@@ -16,18 +16,21 @@ const SIZE_CONFIGS = {
|
|
|
16
16
|
fontSize: 13,
|
|
17
17
|
paddingVertical: spacing.xs,
|
|
18
18
|
paddingHorizontal: spacing.sm,
|
|
19
|
+
nativePaddingVertical: 7,
|
|
19
20
|
},
|
|
20
21
|
md: {
|
|
21
22
|
height: 36,
|
|
22
23
|
fontSize: 14,
|
|
23
24
|
paddingVertical: spacing.xs,
|
|
24
25
|
paddingHorizontal: spacing.sm,
|
|
26
|
+
nativePaddingVertical: 8,
|
|
25
27
|
},
|
|
26
28
|
lg: {
|
|
27
29
|
height: 40,
|
|
28
30
|
fontSize: 15,
|
|
29
31
|
paddingVertical: spacing.sm,
|
|
30
32
|
paddingHorizontal: spacing.md,
|
|
33
|
+
nativePaddingVertical: 10,
|
|
31
34
|
},
|
|
32
35
|
};
|
|
33
36
|
/**
|
|
@@ -257,13 +260,21 @@ leftElement, rightElement, clearable, focusedStyle, style, ...rest }) {
|
|
|
257
260
|
opacity: editable === false ? 0.6 : 1,
|
|
258
261
|
overflow: "hidden",
|
|
259
262
|
};
|
|
260
|
-
// Native field: transparent, padding
|
|
261
|
-
//
|
|
263
|
+
// Native field: transparent, padding only. The visible surface is drawn by
|
|
264
|
+
// `surfaceStyle` on the wrapper above.
|
|
265
|
+
//
|
|
266
|
+
// Single-line height comes from symmetric vertical padding, NOT a fixed
|
|
267
|
+
// `height`. Forcing a height made Android pin the text to the top of the box
|
|
268
|
+
// (Compose's decoration box uses `contentAlignment = topStart`), leaving a
|
|
269
|
+
// bottom-heavy gap; iOS centered fine. Letting padding define the height keeps
|
|
270
|
+
// the text vertically centered on both platforms while matching the previous
|
|
271
|
+
// visual size (≈ `sizeConfig.height`). Multiline is left to grow naturally.
|
|
262
272
|
const boxStyle = {
|
|
263
273
|
backgroundColor: "transparent",
|
|
264
274
|
paddingHorizontal: sizeConfig.paddingHorizontal,
|
|
265
|
-
paddingVertical:
|
|
266
|
-
|
|
275
|
+
paddingVertical: multiline
|
|
276
|
+
? sizeConfig.paddingVertical
|
|
277
|
+
: sizeConfig.nativePaddingVertical,
|
|
267
278
|
};
|
|
268
279
|
// "System" is an RN-only sentinel (RCTFont resolves it to the system font).
|
|
269
280
|
// @expo/ui passes the family verbatim to SwiftUI's Font.custom / Compose,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as React from "react";
|
|
2
|
+
import { type KeyboardAvoidingViewProps } from "./KeyboardAvoidingView";
|
|
2
3
|
export interface UIProviderProps {
|
|
3
4
|
children: React.ReactNode;
|
|
4
5
|
/**
|
|
@@ -19,5 +20,15 @@ export interface UIProviderProps {
|
|
|
19
20
|
* @default true
|
|
20
21
|
*/
|
|
21
22
|
statusBar?: boolean;
|
|
23
|
+
/**
|
|
24
|
+
* Wrap app content in the package keyboard-avoiding root.
|
|
25
|
+
*
|
|
26
|
+
* @default true on native, false on web
|
|
27
|
+
*/
|
|
28
|
+
keyboardAvoiding?: boolean;
|
|
29
|
+
/**
|
|
30
|
+
* Props forwarded to the keyboard-avoiding root when enabled.
|
|
31
|
+
*/
|
|
32
|
+
keyboardAvoidingProps?: Omit<KeyboardAvoidingViewProps, "children">;
|
|
22
33
|
}
|
|
23
|
-
export declare function UIProvider({ children, notification, portalHost, statusBar, }: UIProviderProps): React.JSX.Element;
|
|
34
|
+
export declare function UIProvider({ children, notification, portalHost, statusBar, keyboardAvoiding, keyboardAvoidingProps, }: UIProviderProps): React.JSX.Element;
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Platform } from "react-native";
|
|
2
3
|
import { PortalHost } from "@rn-primitives/portal";
|
|
3
4
|
import { Notification } from "./Notification.js";
|
|
4
5
|
import { StatusBar } from "./StatusBar.js";
|
|
5
|
-
|
|
6
|
-
|
|
6
|
+
import { KeyboardAvoidingView, } from "./KeyboardAvoidingView.js";
|
|
7
|
+
export function UIProvider({ children, notification = true, portalHost = true, statusBar = true, keyboardAvoiding = Platform.OS !== "web", keyboardAvoidingProps, }) {
|
|
8
|
+
const { style: keyboardAvoidingStyle, ...restKeyboardAvoidingProps } = keyboardAvoidingProps ?? {};
|
|
9
|
+
const content = keyboardAvoiding ? (_jsx(KeyboardAvoidingView, { style: [{ flex: 1 }, keyboardAvoidingStyle], ...restKeyboardAvoidingProps, children: children })) : (children);
|
|
10
|
+
return (_jsxs(_Fragment, { children: [content, notification ? _jsx(Notification, {}) : null, portalHost ? _jsx(PortalHost, {}) : null, statusBar ? _jsx(StatusBar, {}) : null] }));
|
|
7
11
|
}
|
|
@@ -15,6 +15,7 @@ export * from "./EmptyState";
|
|
|
15
15
|
export * from "./ErrorBoundary";
|
|
16
16
|
export * from "./Icon";
|
|
17
17
|
export * from "./InputOTP";
|
|
18
|
+
export * from "./KeyboardAvoidingView";
|
|
18
19
|
export * from "./Label";
|
|
19
20
|
export * from "./MaxWidthContainer";
|
|
20
21
|
export * from "./Notification";
|
package/dist/components/index.js
CHANGED
|
@@ -15,6 +15,7 @@ export * from "./EmptyState.js";
|
|
|
15
15
|
export * from "./ErrorBoundary.js";
|
|
16
16
|
export * from "./Icon.js";
|
|
17
17
|
export * from "./InputOTP.js";
|
|
18
|
+
export * from "./KeyboardAvoidingView.js";
|
|
18
19
|
export * from "./Label.js";
|
|
19
20
|
export * from "./MaxWidthContainer.js";
|
|
20
21
|
export * from "./Notification.js";
|
package/llms-full.md
CHANGED
|
@@ -31,10 +31,14 @@ app-local component files.
|
|
|
31
31
|
Call `useResources()` once near the Expo app root. Mount `UIProvider` once near
|
|
32
32
|
the root when the app uses package feedback or overlay components.
|
|
33
33
|
|
|
34
|
-
`UIProvider` owns the package `Notification`, `StatusBar`,
|
|
35
|
-
`@rn-primitives` portal host
|
|
36
|
-
`
|
|
37
|
-
`Tooltip`, or `notify` / `globalUIStore`
|
|
34
|
+
`UIProvider` owns the package `Notification`, `StatusBar`, default
|
|
35
|
+
`@rn-primitives` portal host, and native keyboard-avoiding root. Mount it
|
|
36
|
+
before using `Dialog`, `AlertDialog`, `BottomSheet`, `Drawer`, `DropdownMenu`,
|
|
37
|
+
`Popover`, `SelectContent`, `Tooltip`, or `notify` / `globalUIStore`
|
|
38
|
+
notifications. Pass `keyboardAvoiding={false}` to opt out of the native root
|
|
39
|
+
keyboard avoidance, or use `KeyboardAvoidingView` directly for a subtree with
|
|
40
|
+
custom behavior. Web skips the root keyboard wrapper unless `keyboardAvoiding`
|
|
41
|
+
is explicitly enabled.
|
|
38
42
|
|
|
39
43
|
On native, `BottomSheet.Content` composes its sheet transform with React Native
|
|
40
44
|
keyboard event values. Pass `avoidKeyboard={false}` for sheets that should not
|
|
@@ -99,6 +103,7 @@ Use this catalog before creating a new app-local primitive.
|
|
|
99
103
|
| `ErrorBoundary` | `@mrmeg/expo-ui/components` | React render error fallback | Use for route or feature boundaries. |
|
|
100
104
|
| `Icon` | `@mrmeg/expo-ui/components` | Feather or custom icons with theme tokens | Avoid raw vector icons with hardcoded colors. |
|
|
101
105
|
| `InputOTP` | `@mrmeg/expo-ui/components` | Verification code entry | Prefer over manually managed text input groups. |
|
|
106
|
+
| `KeyboardAvoidingView` | `@mrmeg/expo-ui/components` | Native keyboard-aware layout roots, composer footers, and form-heavy subtrees | `UIProvider` already mounts one root by default; use this directly only for custom subtrees. |
|
|
102
107
|
| `Label` | `@mrmeg/expo-ui/components` | Accessible form labels | Use with package form controls. |
|
|
103
108
|
| `MaxWidthContainer` | `@mrmeg/expo-ui/components` | Centered responsive width | Use for web and tablet constrained layouts. |
|
|
104
109
|
| `Notification` | `@mrmeg/expo-ui/components` | Global toast surface | Trigger through `notify` (or `globalUIStore` for subscriptions/tests) with root `UIProvider`; optional actions dismiss after press. |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mrmeg/expo-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Reusable Expo and React Native UI primitives for MrMeg projects.",
|
|
6
6
|
"keywords": [
|
|
@@ -117,6 +117,7 @@
|
|
|
117
117
|
"react": ">=19.2.0 <20.0.0",
|
|
118
118
|
"react-native": ">=0.83.0 <0.86.0",
|
|
119
119
|
"react-native-gesture-handler": ">=2.30.0 <2.32.0",
|
|
120
|
+
"react-native-keyboard-controller": ">=1.21.0 <2.0.0",
|
|
120
121
|
"react-native-safe-area-context": ">=5.6.0 <6.0.0",
|
|
121
122
|
"react-native-screens": ">=4.23.0 <5.0.0",
|
|
122
123
|
"react-native-web": ">=0.21.0 <0.22.0",
|
|
@@ -124,6 +125,7 @@
|
|
|
124
125
|
},
|
|
125
126
|
"devDependencies": {
|
|
126
127
|
"@types/react": "~19.2.17",
|
|
128
|
+
"react-native-keyboard-controller": "1.21.6",
|
|
127
129
|
"typescript": "~6.0.3"
|
|
128
130
|
}
|
|
129
131
|
}
|