@mrmeg/expo-ui 0.10.0 → 0.11.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 ADDED
@@ -0,0 +1,53 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@mrmeg/expo-ui` are documented here. This project
4
+ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
5
+
6
+ ## [0.11.0]
7
+
8
+ ### Added
9
+
10
+ - **New peer dependency: `react-native-keyboard-controller` (>=1.21.0 <2.0.0).**
11
+ Required by `DismissKeyboard` to dismiss the software keyboard for the native
12
+ `@expo/ui` TextInput (see fix below). It ships a no-op web fallback, so web
13
+ bundles are unaffected.
14
+
15
+ ### Fixed
16
+
17
+ - **`DismissKeyboard` now dismisses the keyboard for native `@expo/ui` fields.**
18
+ The native TextInput is a SwiftUI / Compose field that never registers with
19
+ React Native's `TextInputState`, so the previous `Keyboard.dismiss()` (which
20
+ only blurs RN-tracked inputs) did nothing — tapping outside never closed the
21
+ keyboard on iOS or Android. `DismissKeyboard` now mounts a full-screen
22
+ tap-catcher *only while the keyboard is visible* (`useKeyboardState`) that calls
23
+ `KeyboardController.dismiss()` at the IME level. Mounting it only while visible
24
+ avoids stealing the focus tap and fighting bottom sheets / modals. Web remains a
25
+ no-op (no software keyboard); all existing props (`children`, `style`,
26
+ `avoidKeyboard`, `scrollable`) and the `KeyboardAvoidingView` / `ScrollView`
27
+ wrapping behavior are preserved.
28
+ - **Android: TextInput text is now vertically centered.** The native field set a
29
+ fixed `height`, but Android's Compose `BasicTextField` decoration box defaults to
30
+ `contentAlignment = topStart`, so text pinned to the top with the slack falling to
31
+ the bottom (iOS centered fine). The single-line field is now sized by symmetric
32
+ vertical padding instead of a fixed height, centering the text on Android with no
33
+ change to iOS sizing or centering. Multiline is unchanged.
34
+
35
+ ## [0.10.1]
36
+
37
+ ### Fixed
38
+
39
+ - Fix TextInput rounded-corner fill leak on New Architecture. On Fabric, the
40
+ `outline`/`filled` variants stroked a rounded border but painted the
41
+ background fill as an un-clipped rect, so the fill's square corners poked past
42
+ the rounded stroke (most visible on dark themes and the `filled` variant). The
43
+ fill, border, and radius now live on the RN wrapper `View` with
44
+ `overflow: "hidden"`, and the native `@expo/ui` host renders transparent inside
45
+ that clipped rounded surface. The `underlined` variant (bottom border only),
46
+ error state, `forceLight` mode, and the secure-entry eye toggle are unchanged.
47
+
48
+ ## [0.10.0]
49
+
50
+ ### Added
51
+
52
+ - Drawer collapsible rail mode (`variant="rail"`, `Drawer.ToggleCollapse`).
53
+ - Theme-aware Icon color resolution.
@@ -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` is a no-op: the platform draws the drag indicator.
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(_props: BottomSheetHandleProps): null;
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, useRef, useState } from "react";
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 [internalOpen, setInternalOpen] = useState(defaultOpen);
91
- const [scrollable, setScrollable] = useState(false);
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 onOpenChange = (newOpen) => {
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(newOpen);
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. Open at the highest snap point so
150
- // the sheet starts fully expanded; -1 keeps it closed.
151
- const index = open ? snapPoints.length - 1 : -1;
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 && open)
169
- onOpenChange(false);
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, enablePanDownToClose: !dismissDisabled, onChange: handleChange, onClose: () => {
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 — no-op (the platform draws the drag indicator)
271
+ // Handle — pressable snap-point control; native drag gestures remain active
195
272
  // ============================================================================
196
- function BottomSheetHandle(_props) {
197
- return null;
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,38 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
2
- import { Pressable, Keyboard, Platform, KeyboardAvoidingView, ScrollView } from "react-native";
3
- const handleDismissKeyboard = () => Platform.OS !== "web" && Keyboard.dismiss();
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Pressable, StyleSheet, Platform, KeyboardAvoidingView, ScrollView, View, } from "react-native";
3
+ import { KeyboardController, useKeyboardState } from "react-native-keyboard-controller";
4
+ /**
5
+ * Full-screen tap-catcher that dismisses the keyboard, mounted ONLY while the
6
+ * keyboard is visible.
7
+ *
8
+ * Why window-level dismissal: the native `@expo/ui` TextInput is a SwiftUI /
9
+ * Compose field that never registers with React Native's `TextInputState`, so
10
+ * `Keyboard.dismiss()` (which only blurs RN-tracked inputs) does nothing for it.
11
+ * `KeyboardController.dismiss()` resigns the focused responder at the IME level,
12
+ * which works for both native and RN fields.
13
+ *
14
+ * Why only-while-visible: an always-mounted catcher would intercept the very tap
15
+ * that focuses a field and close the keyboard before it opens, and would fight
16
+ * bottom sheets / modals. Rendering it solely while the keyboard is up means
17
+ * nothing intercepts taps when no field is focused; once one is, a tap anywhere
18
+ * off the field dismisses.
19
+ */
20
+ function KeyboardDismissOverlay() {
21
+ const isVisible = useKeyboardState((state) => state.isVisible);
22
+ if (!isVisible)
23
+ return null;
24
+ return (_jsx(Pressable, { style: StyleSheet.absoluteFill, onPress: () => KeyboardController.dismiss(), accessibilityLabel: "Dismiss keyboard", accessibilityRole: "button" }));
25
+ }
4
26
  /**
5
27
  * @returns Wrapper for a view that dismisses the keyboard when tapped outside of a text input
6
28
  */
7
29
  export const DismissKeyboard = ({ children, style, avoidKeyboard = true, scrollable = true }) => {
8
30
  const content = scrollable ? (_jsx(ScrollView, { style: { flex: 1 }, contentContainerStyle: { flexGrow: 1, justifyContent: "center" }, keyboardShouldPersistTaps: "handled", showsVerticalScrollIndicator: false, children: children })) : (children);
31
+ // Web has no software keyboard, so the tap-catcher is native-only. The overlay
32
+ // is never created on web, keeping `useKeyboardState` off that platform.
33
+ const overlay = Platform.OS !== "web" ? _jsx(KeyboardDismissOverlay, {}) : null;
9
34
  if (!avoidKeyboard) {
10
- return (_jsx(Pressable, { onPress: handleDismissKeyboard, accessible: false, style: { flex: 1 }, children: content }));
35
+ return (_jsxs(View, { style: { flex: 1 }, children: [content, overlay] }));
11
36
  }
12
- return (_jsx(KeyboardAvoidingView, { style: [{ flex: 1, width: "100%" }, style], behavior: Platform.OS === "ios" ? "padding" : "height", keyboardVerticalOffset: Platform.OS === "ios" ? 0 : 0, children: _jsx(Pressable, { onPress: handleDismissKeyboard, accessible: false, style: { flex: 1 }, children: content }) }));
37
+ return (_jsxs(KeyboardAvoidingView, { style: [{ flex: 1, width: "100%" }, style], behavior: Platform.OS === "ios" ? "padding" : "height", keyboardVerticalOffset: Platform.OS === "ios" ? 0 : 0, children: [content, overlay] }));
13
38
  };
@@ -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
  /**
@@ -243,17 +246,35 @@ leftElement, rightElement, clearable, focusedStyle, style, ...rest }) {
243
246
  const textColor = forceLight
244
247
  ? "#1f2937"
245
248
  : getContrastingColor(backgroundColor === "transparent" ? theme.colors.background : backgroundColor, theme.colors.text, palette.white);
246
- // Map variant/size to @expo/ui's UniversalStyle (translated to SwiftUI /
247
- // Compose modifiers natively).
248
- const boxStyle = {
249
+ // The rounded surface (fill + border + radius) lives on the RN wrapper View,
250
+ // NOT on the native field. On the New Architecture (Fabric), @expo/ui's host
251
+ // paints `backgroundColor` as an un-clipped rect and strokes the rounded border
252
+ // on top, so a fill handed to the host leaks square corners past the stroke.
253
+ // Letting the RN View own the surface (with `overflow: "hidden"`) keeps the
254
+ // fill clipped to `borderRadius`; the native host sits transparent inside it.
255
+ const surfaceStyle = {
249
256
  backgroundColor,
250
257
  borderColor,
251
258
  borderRadius: variant === "underlined" ? 0 : spacing.radiusMd,
252
259
  borderWidth: variant === "outline" ? 1 : 0,
253
- paddingHorizontal: sizeConfig.paddingHorizontal,
254
- paddingVertical: sizeConfig.paddingVertical,
255
260
  opacity: editable === false ? 0.6 : 1,
256
- ...(multiline ? null : { height: sizeConfig.height }),
261
+ overflow: "hidden",
262
+ };
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.
272
+ const boxStyle = {
273
+ backgroundColor: "transparent",
274
+ paddingHorizontal: sizeConfig.paddingHorizontal,
275
+ paddingVertical: multiline
276
+ ? sizeConfig.paddingVertical
277
+ : sizeConfig.nativePaddingVertical,
257
278
  };
258
279
  // "System" is an RN-only sentinel (RCTFont resolves it to the system font).
259
280
  // @expo/ui passes the family verbatim to SwiftUI's Font.custom / Compose,
@@ -268,7 +289,7 @@ leftElement, rightElement, clearable, focusedStyle, style, ...rest }) {
268
289
  fontSize: sizeConfig.fontSize,
269
290
  ...(nativeFontFamily ? { fontFamily: nativeFontFamily } : null),
270
291
  };
271
- 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: hasSecureToggle ? styles.nativeRow : undefined, 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 }))] }));
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 }))] }));
272
293
  }
273
294
  const createStyles = (theme, variant, size) => StyleSheet.create({
274
295
  nativeHost: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrmeg/expo-ui",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "private": false,
5
5
  "description": "Reusable Expo and React Native UI primitives for MrMeg projects.",
6
6
  "keywords": [
@@ -33,6 +33,7 @@
33
33
  "dist",
34
34
  "package.json",
35
35
  "README.md",
36
+ "CHANGELOG.md",
36
37
  "LLM_USAGE.md",
37
38
  "llms.txt",
38
39
  "llms-full.md"
@@ -116,6 +117,7 @@
116
117
  "react": ">=19.2.0 <20.0.0",
117
118
  "react-native": ">=0.83.0 <0.86.0",
118
119
  "react-native-gesture-handler": ">=2.30.0 <2.32.0",
120
+ "react-native-keyboard-controller": ">=1.21.0 <2.0.0",
119
121
  "react-native-safe-area-context": ">=5.6.0 <6.0.0",
120
122
  "react-native-screens": ">=4.23.0 <5.0.0",
121
123
  "react-native-web": ">=0.21.0 <0.22.0",
@@ -123,6 +125,7 @@
123
125
  },
124
126
  "devDependencies": {
125
127
  "@types/react": "~19.2.17",
128
+ "react-native-keyboard-controller": "1.21.6",
126
129
  "typescript": "~6.0.3"
127
130
  }
128
131
  }