@mrmeg/expo-ui 0.11.0 → 0.12.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 +36 -0
- package/LLM_USAGE.md +1 -0
- package/README.md +2 -1
- package/dist/components/BottomSheet.js +99 -13
- package/dist/components/DismissKeyboard.d.ts +1 -1
- package/dist/components/DismissKeyboard.js +41 -21
- package/dist/components/KeyboardAvoidingView.d.ts +20 -0
- package/dist/components/KeyboardAvoidingView.js +20 -0
- package/dist/components/TextInput.js +41 -3
- 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/dist/components/keyboardFocusRegistry.d.ts +29 -0
- package/dist/components/keyboardFocusRegistry.js +64 -0
- package/llms-full.md +9 -4
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,42 @@
|
|
|
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.1]
|
|
7
|
+
|
|
8
|
+
### Fixed
|
|
9
|
+
|
|
10
|
+
- **`BottomSheet` now dismisses the keyboard when tapping outside a focused
|
|
11
|
+
field on Android.** The native sheet hosts its content in a separate window
|
|
12
|
+
(via `@expo/ui`'s `RNHostView`), outside the app's `KeyboardProvider` — so the
|
|
13
|
+
app-wide tap-away never reached sheet content, and `useKeyboardState()` /
|
|
14
|
+
`KeyboardController.dismiss()` are blind to that window. iOS papered over this
|
|
15
|
+
(SwiftUI resigns first-responder on outside taps for free); Compose did not,
|
|
16
|
+
leaving a focused field stuck open. `BottomSheet.Content` now mounts a
|
|
17
|
+
transparent dismiss overlay while a field is focused, detecting focus via the
|
|
18
|
+
window-independent `keyboardFocusRegistry` and resigning the field through its
|
|
19
|
+
own native `blur()` ref. The registry gained `subscribeKeyboardFocus`,
|
|
20
|
+
`hasKeyboardFocusedInput`, and `dismissKeyboardFocusedInput`, and `TextInput`
|
|
21
|
+
now registers a `blur` handle on focus.
|
|
22
|
+
|
|
23
|
+
## [0.12.0]
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
|
|
27
|
+
- **`KeyboardAvoidingView` is now a public package component.** Native uses
|
|
28
|
+
`react-native-keyboard-controller` with `automaticOffset` enabled by default,
|
|
29
|
+
while web renders a plain `View`.
|
|
30
|
+
- **`UIProvider` now owns app-wide native keyboard avoidance by default.** Apps
|
|
31
|
+
that mount `KeyboardProvider` above `UIProvider` get root-level keyboard
|
|
32
|
+
avoiding behavior without adding per-screen `KeyboardAvoidingView` wrappers.
|
|
33
|
+
Pass `keyboardAvoiding={false}` to opt out, or `keyboardAvoidingProps` to tune
|
|
34
|
+
the root wrapper. Web skips the root keyboard wrapper unless explicitly
|
|
35
|
+
enabled.
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
|
|
39
|
+
- **`DismissKeyboard` no longer nests keyboard-avoiding wrappers when the root
|
|
40
|
+
provider already owns keyboard avoidance.**
|
|
41
|
+
|
|
6
42
|
## [0.11.0]
|
|
7
43
|
|
|
8
44
|
### Added
|
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
|
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import React, { createContext, use, useCallback, useEffect, useReducer, useRef } from "react";
|
|
3
|
-
import { View, Pressable, ScrollView, Platform, useWindowDimensions, } from "react-native";
|
|
2
|
+
import React, { createContext, use, useCallback, useEffect, useMemo, useReducer, useRef, useSyncExternalStore } from "react";
|
|
3
|
+
import { View, Pressable, StyleSheet, ScrollView, Platform, useWindowDimensions, } from "react-native";
|
|
4
4
|
import { BottomSheet as NativeBottomSheet } from "@expo/ui/community/bottom-sheet";
|
|
5
|
+
import { KeyboardController } from "react-native-keyboard-controller";
|
|
5
6
|
import { useSafeAreaInsets, initialWindowMetrics } from "react-native-safe-area-context";
|
|
6
7
|
import { useTheme } from "../hooks/useTheme.js";
|
|
7
8
|
import { spacing } from "../constants/spacing.js";
|
|
8
9
|
import { TextColorContext, TextClassContext } from "./StyledText.context";
|
|
9
10
|
import { Icon } from "./Icon.js";
|
|
11
|
+
import { dismissKeyboardFocusedInput, hasKeyboardFocusedInput, subscribeKeyboardFocus, } from "./keyboardFocusRegistry.js";
|
|
12
|
+
const DEFAULT_SNAP_POINTS = ["50%"];
|
|
10
13
|
function clampSnapIndex(index, maxSnapIndex) {
|
|
11
14
|
return Math.min(Math.max(index, 0), maxSnapIndex);
|
|
12
15
|
}
|
|
@@ -22,25 +25,42 @@ function createInitialRootState(defaultOpen, maxSnapIndex) {
|
|
|
22
25
|
}
|
|
23
26
|
function bottomSheetRootReducer(state, action) {
|
|
24
27
|
switch (action.type) {
|
|
25
|
-
case "setInternalOpen":
|
|
28
|
+
case "setInternalOpen": {
|
|
29
|
+
const snapIndex = action.open ? state.snapIndex : action.maxSnapIndex;
|
|
30
|
+
const snapDirection = action.open ? state.snapDirection : -1;
|
|
31
|
+
if (state.internalOpen === action.open &&
|
|
32
|
+
state.snapIndex === snapIndex &&
|
|
33
|
+
state.snapDirection === snapDirection) {
|
|
34
|
+
return state;
|
|
35
|
+
}
|
|
26
36
|
return {
|
|
27
37
|
...state,
|
|
28
38
|
internalOpen: action.open,
|
|
29
|
-
snapIndex
|
|
30
|
-
snapDirection
|
|
39
|
+
snapIndex,
|
|
40
|
+
snapDirection,
|
|
31
41
|
};
|
|
42
|
+
}
|
|
32
43
|
case "setScrollable":
|
|
44
|
+
if (state.scrollable === action.scrollable)
|
|
45
|
+
return state;
|
|
33
46
|
return { ...state, scrollable: action.scrollable };
|
|
34
47
|
case "setHasHeader":
|
|
48
|
+
if (state.hasHeader === action.present)
|
|
49
|
+
return state;
|
|
35
50
|
return { ...state, hasHeader: action.present };
|
|
36
51
|
case "setHasFooter":
|
|
52
|
+
if (state.hasFooter === action.present)
|
|
53
|
+
return state;
|
|
37
54
|
return { ...state, hasFooter: action.present };
|
|
38
55
|
case "setSnapIndex": {
|
|
39
56
|
const snapIndex = clampSnapIndex(action.index, action.maxSnapIndex);
|
|
57
|
+
const snapDirection = snapIndex === 0 ? 1 : snapIndex === action.maxSnapIndex ? -1 : state.snapDirection;
|
|
58
|
+
if (state.snapIndex === snapIndex && state.snapDirection === snapDirection)
|
|
59
|
+
return state;
|
|
40
60
|
return {
|
|
41
61
|
...state,
|
|
42
62
|
snapIndex,
|
|
43
|
-
snapDirection
|
|
63
|
+
snapDirection,
|
|
44
64
|
};
|
|
45
65
|
}
|
|
46
66
|
case "cycleSnapPoint": {
|
|
@@ -140,9 +160,51 @@ function SheetCloseButton({ style }) {
|
|
|
140
160
|
], children: _jsx(Icon, { name: "x", size: 22, color: "text" }) }));
|
|
141
161
|
}
|
|
142
162
|
// ============================================================================
|
|
163
|
+
// Keyboard dismiss overlay — tap-away dismissal inside the sheet
|
|
164
|
+
// ============================================================================
|
|
165
|
+
/**
|
|
166
|
+
* Tap-away keyboard dismissal for sheet content.
|
|
167
|
+
*
|
|
168
|
+
* The native sheet (SwiftUI `.sheet()` / Material `ModalBottomSheet`) hosts its
|
|
169
|
+
* RN children in a separate native window via @expo/ui's `RNHostView`, OUTSIDE
|
|
170
|
+
* the app's `KeyboardProvider` / `KeyboardDismissBoundary`. Two consequences,
|
|
171
|
+
* both worked around here:
|
|
172
|
+
*
|
|
173
|
+
* 1. Detection: `react-native-keyboard-controller`'s `useKeyboardState` watches
|
|
174
|
+
* the main window and does NOT see a keyboard raised inside the sheet's
|
|
175
|
+
* window (the same isolation that makes `useSafeAreaInsets()` read zero in
|
|
176
|
+
* here). So presence is read from the `keyboardFocusRegistry` instead, which
|
|
177
|
+
* is fed by @expo/ui's own native focus events and is window-independent.
|
|
178
|
+
* 2. Dispatch: RN's JS responder chain (onStartShouldSetResponder*) is NOT
|
|
179
|
+
* dispatched across the `RNHostView` boundary on Android — only `Pressable`
|
|
180
|
+
* hit-testing (native `measure()`) works inside the host. So this is a
|
|
181
|
+
* transparent `Pressable` mounted ONLY while a field is focused, mirroring
|
|
182
|
+
* the app's `DismissKeyboardOverlay`. The tap blurs the focused field via
|
|
183
|
+
* its own native ref (`dismissKeyboardFocusedInput`), which resigns the
|
|
184
|
+
* responder regardless of window; `KeyboardController.dismiss()` is a
|
|
185
|
+
* best-effort fallback for the (rare) case with no registered blur handle.
|
|
186
|
+
*
|
|
187
|
+
* iOS doesn't strictly need this (SwiftUI resigns first-responder on outside
|
|
188
|
+
* taps for free), but the same path is harmless and keeps behavior identical.
|
|
189
|
+
*/
|
|
190
|
+
function SheetKeyboardDismissOverlay() {
|
|
191
|
+
const hasFocus = useSyncExternalStore(subscribeKeyboardFocus, hasKeyboardFocusedInput, () => false);
|
|
192
|
+
if (Platform.OS === "web" || !hasFocus)
|
|
193
|
+
return null;
|
|
194
|
+
return (_jsx(Pressable, { style: [StyleSheet.absoluteFill, styles.keyboardDismissOverlay], onPressIn: () => {
|
|
195
|
+
if (!dismissKeyboardFocusedInput())
|
|
196
|
+
KeyboardController.dismiss();
|
|
197
|
+
}, accessibilityLabel: "Dismiss keyboard", accessibilityRole: "button" }));
|
|
198
|
+
}
|
|
199
|
+
const styles = StyleSheet.create({
|
|
200
|
+
keyboardDismissOverlay: {
|
|
201
|
+
zIndex: 999,
|
|
202
|
+
},
|
|
203
|
+
});
|
|
204
|
+
// ============================================================================
|
|
143
205
|
// Root
|
|
144
206
|
// ============================================================================
|
|
145
|
-
function BottomSheetRoot({ open: controlledOpen, onOpenChange: controlledOnOpenChange, defaultOpen = false, snapPoints =
|
|
207
|
+
function BottomSheetRoot({ open: controlledOpen, onOpenChange: controlledOnOpenChange, defaultOpen = false, snapPoints = DEFAULT_SNAP_POINTS, closeOnBackdropPress = true, children, }) {
|
|
146
208
|
const maxSnapIndex = Math.max(snapPoints.length - 1, 0);
|
|
147
209
|
const [state, dispatch] = useReducer(bottomSheetRootReducer, undefined, () => createInitialRootState(defaultOpen, maxSnapIndex));
|
|
148
210
|
const isControlled = controlledOpen !== undefined;
|
|
@@ -167,7 +229,16 @@ function BottomSheetRoot({ open: controlledOpen, onOpenChange: controlledOnOpenC
|
|
|
167
229
|
}
|
|
168
230
|
}, [controlledOnOpenChange, isControlled, maxSnapIndex]);
|
|
169
231
|
const toggle = useCallback(() => onOpenChange(!open), [onOpenChange, open]);
|
|
170
|
-
const
|
|
232
|
+
const setScrollable = useCallback((scrollable) => {
|
|
233
|
+
dispatch({ type: "setScrollable", scrollable });
|
|
234
|
+
}, []);
|
|
235
|
+
const setHasHeader = useCallback((present) => {
|
|
236
|
+
dispatch({ type: "setHasHeader", present });
|
|
237
|
+
}, []);
|
|
238
|
+
const setHasFooter = useCallback((present) => {
|
|
239
|
+
dispatch({ type: "setHasFooter", present });
|
|
240
|
+
}, []);
|
|
241
|
+
const contextValue = useMemo(() => ({
|
|
171
242
|
open,
|
|
172
243
|
onOpenChange,
|
|
173
244
|
toggle,
|
|
@@ -177,12 +248,27 @@ function BottomSheetRoot({ open: controlledOpen, onOpenChange: controlledOnOpenC
|
|
|
177
248
|
cycleSnapPoint,
|
|
178
249
|
closeOnBackdropPress,
|
|
179
250
|
scrollable: state.scrollable,
|
|
180
|
-
setScrollable
|
|
251
|
+
setScrollable,
|
|
181
252
|
hasHeader: state.hasHeader,
|
|
182
|
-
setHasHeader
|
|
253
|
+
setHasHeader,
|
|
183
254
|
hasFooter: state.hasFooter,
|
|
184
|
-
setHasFooter
|
|
185
|
-
}
|
|
255
|
+
setHasFooter,
|
|
256
|
+
}), [
|
|
257
|
+
open,
|
|
258
|
+
onOpenChange,
|
|
259
|
+
toggle,
|
|
260
|
+
snapPoints,
|
|
261
|
+
snapIndex,
|
|
262
|
+
setSnapIndex,
|
|
263
|
+
cycleSnapPoint,
|
|
264
|
+
closeOnBackdropPress,
|
|
265
|
+
state.scrollable,
|
|
266
|
+
setScrollable,
|
|
267
|
+
state.hasHeader,
|
|
268
|
+
setHasHeader,
|
|
269
|
+
state.hasFooter,
|
|
270
|
+
setHasFooter,
|
|
271
|
+
]);
|
|
186
272
|
return (_jsx(BottomSheetContext.Provider, { value: contextValue, children: children }));
|
|
187
273
|
}
|
|
188
274
|
// ============================================================================
|
|
@@ -259,7 +345,7 @@ swipeEnabled: _swipeEnabled, avoidKeyboard: _avoidKeyboard, dismissKeyboardOnDra
|
|
|
259
345
|
backgroundColor: theme.colors.card,
|
|
260
346
|
},
|
|
261
347
|
styleOverride,
|
|
262
|
-
], children: [children, showFloatingClose && (_jsx(SheetCloseButton, { style: {
|
|
348
|
+
], children: [children, _jsx(SheetKeyboardDismissOverlay, {}), showFloatingClose && (_jsx(SheetCloseButton, { style: {
|
|
263
349
|
// The sheet card sits below the status bar, so no top inset
|
|
264
350
|
// is needed — just clear the sheet's own top edge / handle.
|
|
265
351
|
position: "absolute",
|
|
@@ -11,5 +11,5 @@ type Props = {
|
|
|
11
11
|
/**
|
|
12
12
|
* @returns Wrapper for a view that dismisses the keyboard when tapped outside of a text input
|
|
13
13
|
*/
|
|
14
|
-
export declare const DismissKeyboard: (
|
|
14
|
+
export declare const DismissKeyboard: (props: Props) => React.JSX.Element;
|
|
15
15
|
export {};
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import { jsx as _jsx
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import React from "react";
|
|
3
|
+
import { Platform, ScrollView, View, } from "react-native";
|
|
4
|
+
import { KeyboardController, useKeyboardContext, useKeyboardState, } from "react-native-keyboard-controller";
|
|
5
|
+
import { KeyboardAvoidingView, useKeyboardAvoidance } from "./KeyboardAvoidingView.js";
|
|
6
|
+
import { isTouchInsideKeyboardFocusedInput } from "./keyboardFocusRegistry.js";
|
|
4
7
|
/**
|
|
5
|
-
*
|
|
6
|
-
* keyboard is visible.
|
|
8
|
+
* Native tap-away keyboard dismissal.
|
|
7
9
|
*
|
|
8
10
|
* Why window-level dismissal: the native `@expo/ui` TextInput is a SwiftUI /
|
|
9
11
|
* Compose field that never registers with React Native's `TextInputState`, so
|
|
@@ -11,28 +13,46 @@ import { KeyboardController, useKeyboardState } from "react-native-keyboard-cont
|
|
|
11
13
|
* `KeyboardController.dismiss()` resigns the focused responder at the IME level,
|
|
12
14
|
* which works for both native and RN fields.
|
|
13
15
|
*
|
|
14
|
-
* Why
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* off the field dismisses.
|
|
16
|
+
* Why responder capture: a full-screen overlay also catches taps on the focused
|
|
17
|
+
* input, which breaks double-tap text selection. Capture lets the tap continue
|
|
18
|
+
* to children while still dismissing when the touch starts outside the focused
|
|
19
|
+
* input bounds.
|
|
19
20
|
*/
|
|
20
|
-
function
|
|
21
|
+
function useDismissKeyboardOnOutsideTouch() {
|
|
21
22
|
const isVisible = useKeyboardState((state) => state.isVisible);
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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]);
|
|
25
34
|
}
|
|
26
35
|
/**
|
|
27
36
|
* @returns Wrapper for a view that dismisses the keyboard when tapped outside of a text input
|
|
28
37
|
*/
|
|
29
|
-
|
|
38
|
+
function DismissKeyboardLayout({ children, style, avoidKeyboard = true, scrollable = true, dismissOnTouchStart, }) {
|
|
39
|
+
const hasKeyboardAvoidance = useKeyboardAvoidance();
|
|
30
40
|
const content = scrollable ? (_jsx(ScrollView, { style: { flex: 1 }, contentContainerStyle: { flexGrow: 1, justifyContent: "center" }, keyboardShouldPersistTaps: "handled", showsVerticalScrollIndicator: false, children: children })) : (children);
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
const overlay = Platform.OS !== "web" ? _jsx(KeyboardDismissOverlay, {}) : null;
|
|
34
|
-
if (!avoidKeyboard) {
|
|
35
|
-
return (_jsxs(View, { style: { flex: 1 }, children: [content, overlay] }));
|
|
41
|
+
if (!avoidKeyboard || hasKeyboardAvoidance) {
|
|
42
|
+
return (_jsx(View, { style: { flex: 1 }, onStartShouldSetResponderCapture: dismissOnTouchStart, children: content }));
|
|
36
43
|
}
|
|
37
|
-
return (
|
|
44
|
+
return (_jsx(KeyboardAvoidingView, { style: [{ flex: 1, width: "100%" }, style], keyboardVerticalOffset: Platform.OS === "ios" ? 0 : 0, onStartShouldSetResponderCapture: dismissOnTouchStart, children: content }));
|
|
45
|
+
}
|
|
46
|
+
function NativeDismissKeyboard(props) {
|
|
47
|
+
const dismissOnTouchStart = useDismissKeyboardOnOutsideTouch();
|
|
48
|
+
return (_jsx(DismissKeyboardLayout, { ...props, dismissOnTouchStart: dismissOnTouchStart }));
|
|
49
|
+
}
|
|
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 });
|
|
38
58
|
};
|
|
@@ -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
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useEffect, useImperativeHandle, useMemo, useRef, useState, } from "react";
|
|
2
|
+
import { useEffect, useCallback, useImperativeHandle, useMemo, useRef, useState, } from "react";
|
|
3
3
|
import { StyleSheet, TextInput as RNTextInput, Platform, View, Pressable, } from "react-native";
|
|
4
4
|
import { Host, TextInput as ExpoTextInput, useNativeState, } from "@expo/ui";
|
|
5
5
|
import { useTheme } from "../hooks/useTheme.js";
|
|
@@ -9,6 +9,7 @@ import { StyledText } from "./StyledText.js";
|
|
|
9
9
|
import { Icon } from "./Icon.js";
|
|
10
10
|
import { hapticLight } from "../lib/haptics.js";
|
|
11
11
|
import { palette } from "../constants/colors.js";
|
|
12
|
+
import { clearKeyboardFocusedInputLayout, setKeyboardFocusedInputLayout, } from "./keyboardFocusRegistry.js";
|
|
12
13
|
const NUMERIC_REGEX = /^[0-9]*$/;
|
|
13
14
|
const SIZE_CONFIGS = {
|
|
14
15
|
sm: {
|
|
@@ -188,7 +189,7 @@ function WebTextInput({ variant = "outline", size = "md", label, helperText, err
|
|
|
188
189
|
* elements, and error icon — are intentionally omitted on native to avoid
|
|
189
190
|
* layering RN views over the native host view.
|
|
190
191
|
*/
|
|
191
|
-
function NativeTextInput({ variant = "outline", size = "md", label, helperText, errorText, error, required, rows, forceLight, inputMode, onChangeText, value, defaultValue, editable = true, multiline, secureTextEntry, showSecureEntryToggle, ref, wrapperStyle,
|
|
192
|
+
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,
|
|
192
193
|
// Web-only affordances. Destructured out of `rest` so they're NOT forwarded to
|
|
193
194
|
// the native field; intentionally unused on this path (see doc comment above).
|
|
194
195
|
// `style` (an RN TextStyle) is likewise dropped — it doesn't map to
|
|
@@ -220,6 +221,43 @@ leftElement, rightElement, clearable, focusedStyle, style, ...rest }) {
|
|
|
220
221
|
// We expose the subset consumers use, plus a `setNativeProps` shim so the
|
|
221
222
|
// uncontrolled AuthTextField can push corrected text into the native buffer.
|
|
222
223
|
const innerRef = useRef(null);
|
|
224
|
+
const surfaceRef = useRef(null);
|
|
225
|
+
const isFocusedRef = useRef(false);
|
|
226
|
+
const focusRegistryToken = useMemo(() => ({}), []);
|
|
227
|
+
const syncFocusedInputLayout = useCallback(() => {
|
|
228
|
+
if (!isFocusedRef.current)
|
|
229
|
+
return;
|
|
230
|
+
surfaceRef.current?.measureInWindow((absoluteX, absoluteY, width, height) => {
|
|
231
|
+
if (!isFocusedRef.current)
|
|
232
|
+
return;
|
|
233
|
+
setKeyboardFocusedInputLayout(focusRegistryToken, {
|
|
234
|
+
x: 0,
|
|
235
|
+
y: 0,
|
|
236
|
+
width,
|
|
237
|
+
height,
|
|
238
|
+
absoluteX,
|
|
239
|
+
absoluteY,
|
|
240
|
+
},
|
|
241
|
+
// Window-independent dismissal: blur the native field directly. Inside a
|
|
242
|
+
// native bottom sheet the field lives in its own window, where
|
|
243
|
+
// `KeyboardController.dismiss()` can miss; calling the field's own ref
|
|
244
|
+
// always resigns it.
|
|
245
|
+
() => innerRef.current?.blur());
|
|
246
|
+
});
|
|
247
|
+
}, [focusRegistryToken]);
|
|
248
|
+
const handleFocus = useCallback(() => {
|
|
249
|
+
isFocusedRef.current = true;
|
|
250
|
+
syncFocusedInputLayout();
|
|
251
|
+
onFocus?.();
|
|
252
|
+
}, [onFocus, syncFocusedInputLayout]);
|
|
253
|
+
const handleBlur = useCallback(() => {
|
|
254
|
+
isFocusedRef.current = false;
|
|
255
|
+
clearKeyboardFocusedInputLayout(focusRegistryToken);
|
|
256
|
+
onBlur?.();
|
|
257
|
+
}, [focusRegistryToken, onBlur]);
|
|
258
|
+
useEffect(() => {
|
|
259
|
+
return () => clearKeyboardFocusedInputLayout(focusRegistryToken);
|
|
260
|
+
}, [focusRegistryToken]);
|
|
223
261
|
useImperativeHandle(ref, () => ({
|
|
224
262
|
focus: () => innerRef.current?.focus(),
|
|
225
263
|
blur: () => innerRef.current?.blur(),
|
|
@@ -289,7 +327,7 @@ leftElement, rightElement, clearable, focusedStyle, style, ...rest }) {
|
|
|
289
327
|
fontSize: sizeConfig.fontSize,
|
|
290
328
|
...(nativeFontFamily ? { fontFamily: nativeFontFamily } : null),
|
|
291
329
|
};
|
|
292
|
-
return (_jsxs(View, { style: wrapperStyle, children: [!!label && (_jsx(View, { style: styles.labelContainer, children: _jsxs(StyledText, { selectable: false, style: styles.label, children: [label, required && _jsx(StyledText, { selectable: false, style: styles.required, children: " *" })] }) })), _jsxs(View, { style: [surfaceStyle, hasSecureToggle && styles.nativeRow], children: [_jsx(Host, { matchContents: { vertical: true }, style: hasSecureToggle ? styles.nativeHostFlex : styles.nativeHost, children: _jsx(ExpoTextInput, { ...rest, ref: innerRef, value: state, defaultValue: defaultValue, onChangeText: onChangeText, editable: editable, multiline: multiline, rows: rows, inputMode: inputMode, secureTextEntry: effectiveSecureTextEntry, placeholderTextColor: theme.colors.textDim, style: boxStyle, textStyle: textStyle }) }), hasSecureToggle && (_jsx(Pressable, { style: styles.nativePasswordToggle, onPress: () => setPasswordVisible((v) => !v), accessibilityLabel: passwordVisible ? "Hide password" : "Show password", accessibilityRole: "button", children: _jsx(Icon, { name: passwordVisible ? "eye-off" : "eye", size: spacing.iconSm + 4, color: "textDim" }) }))] }), !!(helperText || errorText) && (_jsx(StyledText, { selectable: false, style: [styles.helperText, hasError && styles.errorText], children: errorText || helperText }))] }));
|
|
330
|
+
return (_jsxs(View, { style: wrapperStyle, children: [!!label && (_jsx(View, { style: styles.labelContainer, children: _jsxs(StyledText, { selectable: false, style: styles.label, children: [label, required && _jsx(StyledText, { selectable: false, style: styles.required, children: " *" })] }) })), _jsxs(View, { ref: surfaceRef, style: [surfaceStyle, hasSecureToggle && styles.nativeRow], onLayout: syncFocusedInputLayout, children: [_jsx(Host, { matchContents: { vertical: true }, style: hasSecureToggle ? styles.nativeHostFlex : styles.nativeHost, children: _jsx(ExpoTextInput, { ...rest, ref: innerRef, value: state, defaultValue: defaultValue, onChangeText: onChangeText, onFocus: handleFocus, onBlur: handleBlur, editable: editable, multiline: multiline, rows: rows, inputMode: inputMode, secureTextEntry: effectiveSecureTextEntry, placeholderTextColor: theme.colors.textDim, style: boxStyle, textStyle: textStyle }) }), hasSecureToggle && (_jsx(Pressable, { style: styles.nativePasswordToggle, onPress: () => setPasswordVisible((v) => !v), accessibilityLabel: passwordVisible ? "Hide password" : "Show password", accessibilityRole: "button", children: _jsx(Icon, { name: passwordVisible ? "eye-off" : "eye", size: spacing.iconSm + 4, color: "textDim" }) }))] }), !!(helperText || errorText) && (_jsx(StyledText, { selectable: false, style: [styles.helperText, hasError && styles.errorText], children: errorText || helperText }))] }));
|
|
293
331
|
}
|
|
294
332
|
const createStyles = (theme, variant, size) => StyleSheet.create({
|
|
295
333
|
nativeHost: {
|
|
@@ -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";
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { GestureResponderEvent } from "react-native";
|
|
2
|
+
import type { FocusedInputLayoutChangedEvent } from "react-native-keyboard-controller";
|
|
3
|
+
export type KeyboardFocusedInputLayout = FocusedInputLayoutChangedEvent["layout"];
|
|
4
|
+
export type KeyboardFocusedInputToken = object;
|
|
5
|
+
/**
|
|
6
|
+
* Subscribe to focus presence changes. Pairs with {@link hasKeyboardFocusedInput}
|
|
7
|
+
* via `useSyncExternalStore` so a component can mount/unmount with focus WITHOUT
|
|
8
|
+
* `react-native-keyboard-controller`'s `useKeyboardState` — which doesn't observe
|
|
9
|
+
* keyboards raised inside a native sheet's isolated window.
|
|
10
|
+
*/
|
|
11
|
+
export declare function subscribeKeyboardFocus(listener: () => void): () => void;
|
|
12
|
+
export declare function setKeyboardFocusedInputLayout(token: KeyboardFocusedInputToken, layout: KeyboardFocusedInputLayout, blur?: () => void): void;
|
|
13
|
+
export declare function clearKeyboardFocusedInputLayout(token: KeyboardFocusedInputToken): void;
|
|
14
|
+
export declare function getKeyboardFocusedInputLayout(): {
|
|
15
|
+
x: number;
|
|
16
|
+
y: number;
|
|
17
|
+
width: number;
|
|
18
|
+
height: number;
|
|
19
|
+
absoluteX: number;
|
|
20
|
+
absoluteY: number;
|
|
21
|
+
} | undefined;
|
|
22
|
+
/** Whether any native field currently holds focus. Stable boolean for snapshots. */
|
|
23
|
+
export declare function hasKeyboardFocusedInput(): boolean;
|
|
24
|
+
/**
|
|
25
|
+
* Resign the currently-focused native field, dismissing its keyboard. Returns
|
|
26
|
+
* `true` if a field was focused and a blur handle was available.
|
|
27
|
+
*/
|
|
28
|
+
export declare function dismissKeyboardFocusedInput(): boolean;
|
|
29
|
+
export declare function isTouchInsideKeyboardFocusedInput(event: GestureResponderEvent, nativeLayout?: KeyboardFocusedInputLayout | null): boolean;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
const FOCUSED_INPUT_TOUCH_SLOP = 16;
|
|
2
|
+
let focusedInput = null;
|
|
3
|
+
const listeners = new Set();
|
|
4
|
+
function emit() {
|
|
5
|
+
listeners.forEach((listener) => listener());
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Subscribe to focus presence changes. Pairs with {@link hasKeyboardFocusedInput}
|
|
9
|
+
* via `useSyncExternalStore` so a component can mount/unmount with focus WITHOUT
|
|
10
|
+
* `react-native-keyboard-controller`'s `useKeyboardState` — which doesn't observe
|
|
11
|
+
* keyboards raised inside a native sheet's isolated window.
|
|
12
|
+
*/
|
|
13
|
+
export function subscribeKeyboardFocus(listener) {
|
|
14
|
+
listeners.add(listener);
|
|
15
|
+
return () => {
|
|
16
|
+
listeners.delete(listener);
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
export function setKeyboardFocusedInputLayout(token, layout, blur) {
|
|
20
|
+
const wasPresent = focusedInput != null;
|
|
21
|
+
focusedInput = { token, layout, blur };
|
|
22
|
+
// Only notify subscribers on a presence transition; re-measures (scroll,
|
|
23
|
+
// layout) update the layout silently so the overlay doesn't churn.
|
|
24
|
+
if (!wasPresent)
|
|
25
|
+
emit();
|
|
26
|
+
}
|
|
27
|
+
export function clearKeyboardFocusedInputLayout(token) {
|
|
28
|
+
if (focusedInput?.token === token) {
|
|
29
|
+
focusedInput = null;
|
|
30
|
+
emit();
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export function getKeyboardFocusedInputLayout() {
|
|
34
|
+
return focusedInput?.layout;
|
|
35
|
+
}
|
|
36
|
+
/** Whether any native field currently holds focus. Stable boolean for snapshots. */
|
|
37
|
+
export function hasKeyboardFocusedInput() {
|
|
38
|
+
return focusedInput != null;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Resign the currently-focused native field, dismissing its keyboard. Returns
|
|
42
|
+
* `true` if a field was focused and a blur handle was available.
|
|
43
|
+
*/
|
|
44
|
+
export function dismissKeyboardFocusedInput() {
|
|
45
|
+
if (focusedInput?.blur) {
|
|
46
|
+
focusedInput.blur();
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
function isTouchInsideLayout(event, layout) {
|
|
52
|
+
if (!layout)
|
|
53
|
+
return false;
|
|
54
|
+
const { pageX, pageY } = event.nativeEvent;
|
|
55
|
+
const left = layout.absoluteX - FOCUSED_INPUT_TOUCH_SLOP;
|
|
56
|
+
const right = layout.absoluteX + layout.width + FOCUSED_INPUT_TOUCH_SLOP;
|
|
57
|
+
const top = layout.absoluteY - FOCUSED_INPUT_TOUCH_SLOP;
|
|
58
|
+
const bottom = layout.absoluteY + layout.height + FOCUSED_INPUT_TOUCH_SLOP;
|
|
59
|
+
return pageX >= left && pageX <= right && pageY >= top && pageY <= bottom;
|
|
60
|
+
}
|
|
61
|
+
export function isTouchInsideKeyboardFocusedInput(event, nativeLayout) {
|
|
62
|
+
return (isTouchInsideLayout(event, getKeyboardFocusedInputLayout()) ||
|
|
63
|
+
isTouchInsideLayout(event, nativeLayout));
|
|
64
|
+
}
|
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. |
|