@mrmeg/expo-ui 0.6.1 → 0.7.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.
Files changed (54) hide show
  1. package/LLM_USAGE.md +4 -5
  2. package/README.md +6 -6
  3. package/dist/components/Accordion.js +21 -16
  4. package/dist/components/AnimatedView.d.ts +1 -1
  5. package/dist/components/AnimatedView.js +2 -2
  6. package/dist/components/Badge.d.ts +3 -2
  7. package/dist/components/Badge.js +4 -3
  8. package/dist/components/BottomSheet.js +31 -29
  9. package/dist/components/BottomSheetKeyboard.d.ts +7 -0
  10. package/dist/components/BottomSheetKeyboard.js +35 -0
  11. package/dist/components/Button.d.ts +55 -13
  12. package/dist/components/Button.js +72 -28
  13. package/dist/components/Card.js +8 -10
  14. package/dist/components/Checkbox.js +22 -25
  15. package/dist/components/Collapsible.js +3 -7
  16. package/dist/components/Dialog.js +1 -1
  17. package/dist/components/DismissKeyboard.js +3 -3
  18. package/dist/components/Drawer.js +21 -10
  19. package/dist/components/DropdownMenu.d.ts +3 -2
  20. package/dist/components/DropdownMenu.js +29 -29
  21. package/dist/components/EmptyState.js +1 -1
  22. package/dist/components/InputOTP.js +16 -40
  23. package/dist/components/Notification.js +50 -25
  24. package/dist/components/Popover.js +1 -1
  25. package/dist/components/Progress.d.ts +2 -2
  26. package/dist/components/Progress.js +36 -34
  27. package/dist/components/RadioGroup.js +22 -20
  28. package/dist/components/Select.js +30 -20
  29. package/dist/components/Skeleton.js +6 -6
  30. package/dist/components/Slider.js +90 -97
  31. package/dist/components/StyledText.context.d.ts +6 -0
  32. package/dist/components/StyledText.context.js +5 -0
  33. package/dist/components/StyledText.d.ts +7 -58
  34. package/dist/components/StyledText.js +8 -28
  35. package/dist/components/Switch.js +30 -26
  36. package/dist/components/Tabs.d.ts +23 -3
  37. package/dist/components/Tabs.js +39 -17
  38. package/dist/components/TextInput.d.ts +6 -2
  39. package/dist/components/TextInput.js +6 -7
  40. package/dist/components/Toggle.js +12 -7
  41. package/dist/components/ToggleGroup.js +17 -11
  42. package/dist/components/Tooltip.js +1 -1
  43. package/dist/hooks/useDimensions.js +25 -26
  44. package/dist/hooks/useReduceMotion.d.ts +5 -1
  45. package/dist/hooks/useReduceMotion.js +46 -41
  46. package/dist/hooks/useResources.js +6 -1
  47. package/dist/hooks/useScalePress.d.ts +6 -5
  48. package/dist/hooks/useScalePress.js +25 -21
  49. package/dist/hooks/useStaggeredEntrance.d.ts +9 -8
  50. package/dist/hooks/useStaggeredEntrance.js +48 -21
  51. package/dist/state/themeColorScope.js +3 -3
  52. package/llms-full.md +4 -5
  53. package/llms.txt +2 -2
  54. package/package.json +1 -4
package/LLM_USAGE.md CHANGED
@@ -68,10 +68,9 @@ export function RootLayout() {
68
68
  `AlertDialog`, `BottomSheet`, `Drawer`, `DropdownMenu`, `Popover`,
69
69
  `SelectContent`, or `Tooltip`.
70
70
 
71
- On native, `BottomSheet.Content` avoids the soft keyboard by default through
72
- `react-native-keyboard-controller`. Mount that library's `KeyboardProvider`
73
- near the app root before using bottom sheets with text inputs, or pass
74
- `avoidKeyboard={false}` to opt out for a specific sheet.
71
+ On native, `BottomSheet.Content` avoids the soft keyboard by default with
72
+ React Native keyboard events. Pass `avoidKeyboard={false}` to opt out for a
73
+ specific sheet.
75
74
 
76
75
  i18n is optional. Do not add app-level i18n setup just to use this package.
77
76
  Plain children and `text` props work without `i18next` or `react-i18next`.
@@ -168,7 +167,7 @@ Use this table before creating a new app-local primitive.
168
167
  |-----------|---------|----------------------|--------------------------|
169
168
  | `Accordion`, `AccordionItem`, `AccordionTrigger`, `AccordionContent` | Multi-section disclosure | Custom FAQ/settings expanders | FAQ lists, grouped settings, help sections |
170
169
  | `Alert` | Cross-platform imperative alerts | Direct `window.alert` or duplicated RN/web branching | Confirm destructive actions, native alert dialogs |
171
- | `AnimatedView` | Entrance and visibility animation | Hand-rolled Reanimated wrappers | Staggered list rows, revealed panels, animated empty states |
170
+ | `AnimatedView` | Entrance and visibility animation | Hand-rolled one-off Animated wrappers | Staggered list rows, revealed panels, animated empty states |
172
171
  | `Badge` | Short status labels | Custom pill `View` + `Text` | Draft/active states, counts, plan labels, role tags |
173
172
  | `BottomSheet` | Mobile-first modal sheets | Custom absolute-position sheets | Action pickers, mobile filters, keyboard-aware quick edit forms |
174
173
  | `Button` | Commands and CTAs | Pressable plus custom text styling | Submit, save, cancel, delete, navigation CTAs |
package/README.md CHANGED
@@ -29,12 +29,12 @@ bun add @mrmeg/expo-ui
29
29
 
30
30
  Consumers must also install the native and Expo peer dependencies listed in
31
31
  `package.json`. The tested baseline is Expo SDK 55 with React 19.2, React
32
- Native 0.83, React Native Web 0.21, Reanimated 4.2, and Worklets 0.7.
32
+ Native 0.83, and React Native Web 0.21. UI animations and keyboard-aware
33
+ sheet offsets use React Native Animated by default.
33
34
  `@rn-primitives/*` packages are managed by `@mrmeg/expo-ui` because they are
34
35
  implementation details of the exported UI components. Native bottom sheet
35
- keyboard avoidance uses `react-native-keyboard-controller`; mount that
36
- library's `KeyboardProvider` near the app root when sheets contain text
37
- inputs. i18n setup is optional; plain text and children render without
36
+ keyboard avoidance uses React Native keyboard events. i18n setup is optional;
37
+ plain text and children render without
38
38
  `i18next` or `react-i18next`. Start
39
39
  consumer apps from the same Expo SDK family or update the package and peer
40
40
  ranges deliberately. Keep npm auth tokens in developer or CI configuration,
@@ -207,7 +207,7 @@ All components are exported from `@mrmeg/expo-ui/components`; direct imports suc
207
207
  |-----------|---------|----------------------|--------------------------|
208
208
  | `Accordion`, `AccordionItem`, `AccordionTrigger`, `AccordionContent` | Multi-section disclosure | Custom FAQ/settings expanders | FAQ lists, grouped settings, help sections, dense detail pages |
209
209
  | `Alert` | Cross-platform imperative alerts | Direct `window.alert` or duplicated RN/web branching | Confirm destructive actions, native alert dialogs, simple blocking messages |
210
- | `AnimatedView` | Entrance and visibility animation | Hand-rolled Reanimated wrappers | Staggered list rows, revealed panels, animated empty states |
210
+ | `AnimatedView` | Entrance and visibility animation | Hand-rolled one-off Animated wrappers | Staggered list rows, revealed panels, animated empty states |
211
211
  | `Badge` | Short status labels | Custom pill `View` + `Text` | Draft/active states, counts, plan labels, role tags |
212
212
  | `BottomSheet` | Mobile-first modal sheets | Custom absolute-position sheets | Action pickers, mobile filters, keyboard-aware quick edit forms, contextual details |
213
213
  | `Button` | Commands and CTAs | Pressable plus custom text styling | Submit, save, cancel, delete, navigation CTAs, icon-accessory buttons; loading state preserves resting width |
@@ -272,7 +272,7 @@ Use `Button.preset`, not `variant`. `default` is the neutral primary action, `se
272
272
 
273
273
  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
274
 
275
- Mount `UIProvider` once near the root before using `Dialog`, `AlertDialog`, `BottomSheet`, `Drawer`, `DropdownMenu`, `Popover`, `SelectContent`, `Tooltip`, or package notifications. On native, mount `KeyboardProvider` from `react-native-keyboard-controller` near the root before using `BottomSheet.Content` with text inputs; `avoidKeyboard` defaults to `true` and can be disabled per sheet. Trigger transient feedback from `globalUIStore`.
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 from `globalUIStore`.
276
276
 
277
277
  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
278
 
@@ -1,10 +1,10 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useEffect, useState } from "react";
3
- import { Platform, Pressable, View } from "react-native";
4
- import Animated, { useSharedValue, useAnimatedStyle, withTiming, useReducedMotion, } from "react-native-reanimated";
2
+ import { useEffect, useRef, useState } from "react";
3
+ import { Animated, Platform, Pressable, View } from "react-native";
5
4
  import { Icon } from "./Icon.js";
6
- import { TextClassContext, TextSelectabilityContext } from "./StyledText.js";
5
+ import { TextClassContext, TextSelectabilityContext } from "./StyledText.context";
7
6
  import { useTheme } from "../hooks/useTheme.js";
7
+ import { useReducedMotion } from "../hooks/useReduceMotion.js";
8
8
  import { spacing } from "../constants/spacing.js";
9
9
  import * as AccordionPrimitive from "@rn-primitives/accordion";
10
10
  function normalizeSingleValue(value) {
@@ -100,20 +100,25 @@ function AccordionTrigger({ children, style: styleOverride, ...props }) {
100
100
  const { theme } = useTheme();
101
101
  const reduceMotion = useReducedMotion();
102
102
  const { isExpanded } = AccordionPrimitive.useItemContext();
103
- const rotation = useSharedValue(isExpanded ? 1 : 0);
103
+ const rotation = useRef(new Animated.Value(isExpanded ? 1 : 0)).current;
104
104
  useEffect(() => {
105
105
  const target = isExpanded ? 1 : 0;
106
- if (reduceMotion) {
107
- rotation.value = target;
108
- return;
109
- }
110
- rotation.value = withTiming(target, {
111
- duration: isExpanded ? 200 : 150,
112
- });
113
- }, [isExpanded, reduceMotion]);
114
- const chevronStyle = useAnimatedStyle(() => ({
115
- transform: [{ rotate: `${rotation.value * 180}deg` }],
116
- }));
106
+ Animated.timing(rotation, {
107
+ toValue: target,
108
+ duration: reduceMotion ? 0 : isExpanded ? 200 : 150,
109
+ useNativeDriver: true,
110
+ }).start();
111
+ }, [isExpanded, reduceMotion, rotation]);
112
+ const chevronStyle = {
113
+ transform: [
114
+ {
115
+ rotate: rotation.interpolate({
116
+ inputRange: [0, 1],
117
+ outputRange: ["0deg", "180deg"],
118
+ }),
119
+ },
120
+ ],
121
+ };
117
122
  return (_jsx(TextClassContext.Provider, { value: "", children: _jsx(TextSelectabilityContext.Provider, { value: false, children: _jsx(AccordionPrimitive.Header, { children: _jsx(AccordionPrimitive.Trigger, { ...props, asChild: true, children: _jsxs(Trigger, { style: [
118
123
  {
119
124
  flexDirection: "row",
@@ -25,7 +25,7 @@ interface AnimatedViewProps extends ViewProps {
25
25
  }
26
26
  /**
27
27
  * Cross-Platform Animated View Component
28
- * Uses Reanimated for smooth 60fps animations on all platforms
28
+ * Uses React Native Animated for lightweight cross-platform animations
29
29
  *
30
30
  * Features:
31
31
  * - Multiple animation types (fade, fadeSlideUp, fadeSlideDown, scale)
@@ -1,9 +1,9 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import Animated from "react-native-reanimated";
2
+ import { Animated } from "react-native";
3
3
  import { useStaggeredEntrance } from "../hooks/useStaggeredEntrance.js";
4
4
  /**
5
5
  * Cross-Platform Animated View Component
6
- * Uses Reanimated for smooth 60fps animations on all platforms
6
+ * Uses React Native Animated for lightweight cross-platform animations
7
7
  *
8
8
  * Features:
9
9
  * - Multiple animation types (fade, fadeSlideUp, fadeSlideDown, scale)
@@ -2,7 +2,8 @@ import React from "react";
2
2
  import { StyleProp, ViewStyle } from "react-native";
3
3
  export type BadgeVariant = "default" | "secondary" | "outline" | "destructive";
4
4
  export interface BadgeProps {
5
- children: React.ReactNode;
5
+ children?: React.ReactNode;
6
+ text?: string;
6
7
  variant?: BadgeVariant;
7
8
  style?: StyleProp<ViewStyle>;
8
9
  }
@@ -19,5 +20,5 @@ export interface BadgeProps {
19
20
  * <Badge variant="destructive">Error</Badge>
20
21
  * ```
21
22
  */
22
- declare function Badge({ children, variant, style: styleOverride }: BadgeProps): import("react/jsx-runtime").JSX.Element;
23
+ declare function Badge({ children, text, variant, style: styleOverride }: BadgeProps): import("react/jsx-runtime").JSX.Element;
23
24
  export { Badge };
@@ -17,9 +17,10 @@ import { StyledText } from "./StyledText.js";
17
17
  * <Badge variant="destructive">Error</Badge>
18
18
  * ```
19
19
  */
20
- function Badge({ children, variant = "default", style: styleOverride }) {
20
+ function Badge({ children, text, variant = "default", style: styleOverride }) {
21
21
  const { theme } = useTheme();
22
22
  const styles = useMemo(() => createStyles(theme), [theme]);
23
+ const badgeContent = text ?? children;
23
24
  const textStyle = [
24
25
  styles.text,
25
26
  variant === "default" && { color: theme.colors.primaryForeground },
@@ -27,9 +28,9 @@ function Badge({ children, variant = "default", style: styleOverride }) {
27
28
  variant === "outline" && { color: theme.colors.foreground },
28
29
  variant === "destructive" && { color: theme.colors.destructiveForeground },
29
30
  ];
30
- const normalizedChildren = React.Children.toArray(children);
31
+ const normalizedChildren = React.Children.toArray(badgeContent);
31
32
  const hasOnlyTextChildren = normalizedChildren.every((child) => typeof child === "string" || typeof child === "number");
32
- const content = hasOnlyTextChildren ? (_jsx(StyledText, { selectable: false, style: textStyle, children: normalizedChildren.join("") })) : (React.Children.map(children, (child) => {
33
+ const content = hasOnlyTextChildren ? (_jsx(StyledText, { selectable: false, style: textStyle, children: normalizedChildren.join("") })) : (React.Children.map(badgeContent, (child) => {
33
34
  if (typeof child === "string" || typeof child === "number") {
34
35
  return _jsx(StyledText, { selectable: false, style: textStyle, children: child });
35
36
  }
@@ -1,15 +1,16 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import React, { createContext, useCallback, useContext, useEffect, useMemo, useReducer, useRef, useState } from "react";
3
- import { View, Pressable, Animated, StyleSheet, Platform, Dimensions, PanResponder, ScrollView, } from "react-native";
2
+ import React, { createContext, use, useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
3
+ import { View, Pressable, Animated, StyleSheet, Platform, PanResponder, ScrollView, } from "react-native";
4
4
  import { Portal } from "@rn-primitives/portal";
5
5
  import { FullWindowOverlay as RNFullWindowOverlay } from "react-native-screens";
6
6
  import { Pressable as SlotPressable } from "@rn-primitives/slot";
7
- import { KeyboardController, useKeyboardAnimation } from "react-native-keyboard-controller";
8
7
  import { useTheme } from "../hooks/useTheme.js";
8
+ import { useDimensions } from "../hooks/useDimensions.js";
9
9
  import { spacing } from "../constants/spacing.js";
10
10
  import { shouldUseNativeDriver } from "../lib/animations.js";
11
- import { TextColorContext, TextClassContext } from "./StyledText.js";
11
+ import { TextColorContext, TextClassContext } from "./StyledText.context";
12
12
  import { useSafeAreaInsets } from "react-native-safe-area-context";
13
+ import { BottomSheetKeyboardController, useBottomSheetKeyboardAnimation, } from "./BottomSheetKeyboard.js";
13
14
  /**
14
15
  * BottomSheet Component with Sub-components
15
16
  *
@@ -44,7 +45,7 @@ const FullWindowOverlay = Platform.OS === "ios" ? RNFullWindowOverlay : React.Fr
44
45
  // ============================================================================
45
46
  const BottomSheetContext = createContext(null);
46
47
  function useBottomSheetContext() {
47
- const context = useContext(BottomSheetContext);
48
+ const context = use(BottomSheetContext);
48
49
  if (!context) {
49
50
  throw new Error("BottomSheet components must be used within a BottomSheet");
50
51
  }
@@ -61,15 +62,14 @@ function BottomSheetPanel({ accessibilityViewIsModal, children, panHandlers, she
61
62
  ], accessibilityViewIsModal: accessibilityViewIsModal, ...panHandlers, ...props, children: children }));
62
63
  }
63
64
  function KeyboardAvoidingBottomSheetPanel(props) {
64
- const { height: keyboardHeight } = useKeyboardAnimation();
65
+ const { height: keyboardHeight } = useBottomSheetKeyboardAnimation();
65
66
  const composedTranslateY = useMemo(() => Animated.add(props.translateY, keyboardHeight), [keyboardHeight, props.translateY]);
66
67
  return _jsx(BottomSheetPanel, { ...props, translateY: composedTranslateY });
67
68
  }
68
69
  // ============================================================================
69
70
  // Utility Functions
70
71
  // ============================================================================
71
- function resolveSnapPoints(points) {
72
- const screenHeight = Dimensions.get("window").height;
72
+ function resolveSnapPoints(points, screenHeight) {
73
73
  return points.map((p) => {
74
74
  if (typeof p === "number")
75
75
  return p;
@@ -90,7 +90,9 @@ function BottomSheetRoot({ open: controlledOpen, onOpenChange: controlledOnOpenC
90
90
  const [internalOpen, dispatch] = useReducer(sheetReducer, defaultOpen);
91
91
  const isControlled = controlledOpen !== undefined;
92
92
  const open = isControlled ? controlledOpen : internalOpen;
93
- const snapPoints = resolveSnapPoints(rawSnapPoints);
93
+ // useDimensions reacts to rotation / split-screen, unlike Dimensions.get.
94
+ const { height: screenHeight } = useDimensions();
95
+ const snapPoints = resolveSnapPoints(rawSnapPoints, screenHeight);
94
96
  const toggle = () => {
95
97
  if (isControlled) {
96
98
  controlledOnOpenChange?.(!controlledOpen);
@@ -149,8 +151,18 @@ function BottomSheetContent({ swipeEnabled = true, velocityThreshold = 500, avoi
149
151
  const maxHeight = Math.max(...snapPoints);
150
152
  // With bottom:0 positioning, translateY=0 means visible, translateY=maxHeight means hidden below
151
153
  const closedPosition = maxHeight;
152
- const translateY = useRef(new Animated.Value(open ? 0 : closedPosition)).current;
153
- const backdropOpacity = useRef(new Animated.Value(open ? 1 : 0)).current;
154
+ // Initialize lazily so each Animated.Value is allocated once on first render
155
+ // instead of being rebuilt and discarded on every render.
156
+ const translateYRef = useRef(null);
157
+ if (translateYRef.current === null) {
158
+ translateYRef.current = new Animated.Value(open ? 0 : closedPosition);
159
+ }
160
+ const translateY = translateYRef.current;
161
+ const backdropOpacityRef = useRef(null);
162
+ if (backdropOpacityRef.current === null) {
163
+ backdropOpacityRef.current = new Animated.Value(open ? 1 : 0);
164
+ }
165
+ const backdropOpacity = backdropOpacityRef.current;
154
166
  const [isVisible, setIsVisible] = useState(open);
155
167
  const lastOpenRef = useRef(null);
156
168
  const runningAnimationRef = useRef(null);
@@ -158,9 +170,6 @@ function BottomSheetContent({ swipeEnabled = true, velocityThreshold = 500, avoi
158
170
  // Track which snap we're at
159
171
  const currentSnapRef = useRef(snapPoints.length - 1);
160
172
  const textColor = theme.colors.foreground;
161
- // ------------------------------------------------------------------
162
- // Shared snap/close logic used by both native PanResponder and web drag
163
- // ------------------------------------------------------------------
164
173
  const handleDragRelease = useCallback((dragDistance, velocity) => {
165
174
  const visibleHeight = currentHeightRef.current - dragDistance;
166
175
  if (velocity > velocityThreshold / 1000 || dragDistance > currentHeightRef.current * 0.4) {
@@ -241,12 +250,9 @@ function BottomSheetContent({ swipeEnabled = true, velocityThreshold = 500, avoi
241
250
  }, [translateY, backdropOpacity, maxHeight]);
242
251
  const dismissKeyboardForDrag = useCallback(() => {
243
252
  if (Platform.OS !== "web" && dismissKeyboardOnDrag) {
244
- void KeyboardController.dismiss();
253
+ void BottomSheetKeyboardController.dismiss();
245
254
  }
246
255
  }, [dismissKeyboardOnDrag]);
247
- // ------------------------------------------------------------------
248
- // Trigger animation during render if open changed
249
- // ------------------------------------------------------------------
250
256
  if (open !== lastOpenRef.current) {
251
257
  const previousOpen = lastOpenRef.current;
252
258
  lastOpenRef.current = open;
@@ -304,9 +310,6 @@ function BottomSheetContent({ swipeEnabled = true, velocityThreshold = 500, avoi
304
310
  });
305
311
  }
306
312
  }
307
- // ------------------------------------------------------------------
308
- // Native: PanResponder for swipe gestures on the whole sheet
309
- // ------------------------------------------------------------------
310
313
  const panResponder = useMemo(() => Platform.OS !== "web" && swipeEnabled
311
314
  ? PanResponder.create({
312
315
  onStartShouldSetPanResponder: () => false,
@@ -325,9 +328,6 @@ function BottomSheetContent({ swipeEnabled = true, velocityThreshold = 500, avoi
325
328
  },
326
329
  })
327
330
  : null, [dismissKeyboardForDrag, handleDragMove, handleDragRelease, swipeEnabled]);
328
- // ------------------------------------------------------------------
329
- // Web: drag context provides callbacks for Handle's pointer events
330
- // ------------------------------------------------------------------
331
331
  const dragContextValue = Platform.OS === "web" && swipeEnabled
332
332
  ? {
333
333
  onDragMove: handleDragMove,
@@ -363,25 +363,27 @@ function BottomSheetContent({ swipeEnabled = true, velocityThreshold = 500, avoi
363
363
  const PanelComponent = Platform.OS !== "web" && avoidKeyboard
364
364
  ? KeyboardAvoidingBottomSheetPanel
365
365
  : BottomSheetPanel;
366
- const contentElement = (_jsx(Portal, { name: "bottom-sheet-portal", children: _jsx(FullWindowOverlay, { children: _jsx(BottomSheetContext.Provider, { value: sheetContext, children: _jsxs(View, { style: StyleSheet.absoluteFill, children: [_jsx(Animated.View, { style: [
366
+ return (_jsx(BottomSheetContentPortal, { sheetContext: sheetContext, theme: theme, backdropOpacity: backdropOpacity, onBackdropPress: handleBackdropPress, PanelComponent: PanelComponent, sheetStyle: sheetStyle, styleOverride: styleOverride, translateY: translateY, panHandlers: panResponder ? panResponder.panHandlers : undefined, panelProps: props, dragContextValue: dragContextValue, sheetContent: sheetContent }));
367
+ }
368
+ function BottomSheetContentPortal({ sheetContext, theme, backdropOpacity, onBackdropPress, PanelComponent, sheetStyle, styleOverride, translateY, panHandlers, panelProps, dragContextValue, sheetContent, }) {
369
+ return (_jsx(Portal, { name: "bottom-sheet-portal", children: _jsx(FullWindowOverlay, { children: _jsx(BottomSheetContext.Provider, { value: sheetContext, children: _jsxs(View, { style: StyleSheet.absoluteFill, children: [_jsx(Animated.View, { style: [
367
370
  StyleSheet.absoluteFill,
368
371
  {
369
372
  backgroundColor: theme.colors.overlay,
370
373
  opacity: backdropOpacity,
371
374
  },
372
375
  Platform.OS === "web" && { zIndex: 50 },
373
- ], children: _jsx(Pressable, { style: StyleSheet.absoluteFill, onPress: handleBackdropPress }) }), _jsx(PanelComponent, { sheetStyle: sheetStyle, styleOverride: styleOverride, translateY: translateY, accessibilityViewIsModal: true, ...(Platform.OS === "web" && {
376
+ ], children: _jsx(Pressable, { style: StyleSheet.absoluteFill, onPress: onBackdropPress }) }), _jsx(PanelComponent, { sheetStyle: sheetStyle, styleOverride: styleOverride, translateY: translateY, accessibilityViewIsModal: true, ...(Platform.OS === "web" && {
374
377
  role: "dialog",
375
378
  "aria-modal": true,
376
- }), panHandlers: panResponder ? panResponder.panHandlers : undefined, ...props, children: dragContextValue ? (_jsx(DragContext.Provider, { value: dragContextValue, children: sheetContent })) : (sheetContent) })] }) }) }) }));
377
- return contentElement;
379
+ }), panHandlers: panHandlers, ...panelProps, children: dragContextValue ? (_jsx(DragContext.Provider, { value: dragContextValue, children: sheetContent })) : (sheetContent) })] }) }) }) }));
378
380
  }
379
381
  // ============================================================================
380
382
  // Handle
381
383
  // ============================================================================
382
384
  function BottomSheetHandle({ style }) {
383
385
  const { theme } = useTheme();
384
- const dragCtx = useContext(DragContext);
386
+ const dragCtx = use(DragContext);
385
387
  // Web pointer-event drag — attaches move/up listeners on document
386
388
  const dragStartY = useRef(0);
387
389
  const lastTimestamp = useRef(0);
@@ -0,0 +1,7 @@
1
+ import { Animated } from "react-native";
2
+ export declare function useBottomSheetKeyboardAnimation(): {
3
+ height: Animated.Value;
4
+ };
5
+ export declare const BottomSheetKeyboardController: {
6
+ dismiss(): void;
7
+ };
@@ -0,0 +1,35 @@
1
+ import { useEffect, useRef } from "react";
2
+ import { Animated, Keyboard, Platform } from "react-native";
3
+ function animateKeyboardOffset(value, toValue, duration = 180) {
4
+ Animated.timing(value, {
5
+ toValue,
6
+ duration,
7
+ useNativeDriver: true,
8
+ }).start();
9
+ }
10
+ export function useBottomSheetKeyboardAnimation() {
11
+ const keyboardHeight = useRef(new Animated.Value(0)).current;
12
+ useEffect(() => {
13
+ if (Platform.OS === "web") {
14
+ return;
15
+ }
16
+ const showEvent = Platform.OS === "ios" ? "keyboardWillShow" : "keyboardDidShow";
17
+ const hideEvent = Platform.OS === "ios" ? "keyboardWillHide" : "keyboardDidHide";
18
+ const showSubscription = Keyboard.addListener(showEvent, (event) => {
19
+ animateKeyboardOffset(keyboardHeight, -event.endCoordinates.height, event.duration || 180);
20
+ });
21
+ const hideSubscription = Keyboard.addListener(hideEvent, (event) => {
22
+ animateKeyboardOffset(keyboardHeight, 0, event.duration || 160);
23
+ });
24
+ return () => {
25
+ showSubscription.remove();
26
+ hideSubscription.remove();
27
+ };
28
+ }, [keyboardHeight]);
29
+ return { height: keyboardHeight };
30
+ }
31
+ export const BottomSheetKeyboardController = {
32
+ dismiss() {
33
+ Keyboard.dismiss();
34
+ },
35
+ };
@@ -1,6 +1,7 @@
1
1
  import React, { ComponentType } from "react";
2
2
  import { PressableProps, PressableStateCallbackType, DimensionValue, StyleProp, TextStyle, ViewStyle } from "react-native";
3
3
  import { TextProps } from "./StyledText";
4
+ import { type IconProps } from "./Icon";
4
5
  /**
5
6
  * Button variants
6
7
  */
@@ -115,26 +116,67 @@ export interface ButtonProps extends PressableProps {
115
116
  *
116
117
  * Usage:
117
118
  * ```tsx
118
- * // Default button
119
- * <Button onPress={handler}>
120
- * <SansSerifBoldText>Click Me</SansSerifBoldText>
121
- * </Button>
119
+ * // Simplest path — plain label via the `text` prop
120
+ * <Button onPress={handler} text="Click Me" />
122
121
  *
123
122
  * // Different variants
124
- * <Button preset="outline" onPress={handler}>Outline</Button>
125
- * <Button preset="ghost" onPress={handler}>Ghost</Button>
126
- * <Button preset="destructive" onPress={handler}>Delete</Button>
123
+ * <Button preset="outline" onPress={handler} text="Outline" />
124
+ * <Button preset="ghost" onPress={handler} text="Ghost" />
125
+ * <Button preset="destructive" onPress={handler} text="Delete" />
127
126
  *
128
127
  * // Different sizes
129
- * <Button size="sm" onPress={handler}>Small</Button>
130
- * <Button size="lg" onPress={handler}>Large</Button>
128
+ * <Button size="sm" onPress={handler} text="Small" />
129
+ * <Button size="lg" onPress={handler} text="Large" />
131
130
  *
132
131
  * // Loading state
133
- * <Button loading onPress={handler}>Processing...</Button>
132
+ * <Button loading onPress={handler} text="Processing..." />
134
133
  *
135
134
  * // Full width
136
- * <Button fullWidth onPress={handler}>Submit</Button>
135
+ * <Button fullWidth onPress={handler} text="Submit" />
136
+ *
137
+ * // Composed content — icon + label via subcomponents
138
+ * <Button onPress={handler}>
139
+ * <Button.Icon name="heart" />
140
+ * <Button.Text>Like</Button.Text>
141
+ * </Button>
142
+ * ```
143
+ */
144
+ declare function ButtonRoot(props: ButtonProps): import("react/jsx-runtime").JSX.Element;
145
+ /**
146
+ * Button.Text
147
+ * Text content for a Button. Inherits the button's control typography, color,
148
+ * and non-selectable behavior from context, so callers state their intent
149
+ * explicitly instead of relying on the component to inspect `typeof children`.
150
+ *
151
+ * ```tsx
152
+ * <Button onPress={save}>
153
+ * <Button.Text>Save</Button.Text>
154
+ * </Button>
137
155
  * ```
138
156
  */
139
- export declare function Button(props: ButtonProps): import("react/jsx-runtime").JSX.Element;
140
- export {};
157
+ declare function ButtonText(props: TextProps): import("react/jsx-runtime").JSX.Element;
158
+ /**
159
+ * Button.Icon
160
+ * Icon content for a Button. Defaults its color to the button's text color
161
+ * (from context) and is decorative by default, since the label conveys meaning.
162
+ *
163
+ * ```tsx
164
+ * <Button onPress={like}>
165
+ * <Button.Icon name="heart" />
166
+ * <Button.Text>Like</Button.Text>
167
+ * </Button>
168
+ * ```
169
+ */
170
+ declare function ButtonIcon(props: IconProps): import("react/jsx-runtime").JSX.Element;
171
+ /**
172
+ * Button with explicit subcomponents.
173
+ * - `Button.Text` for label text (inherits control typography)
174
+ * - `Button.Icon` for icons (inherits the button's text color)
175
+ *
176
+ * The `tx`/`text` props remain the simplest path for plain labels.
177
+ */
178
+ declare const Button: typeof ButtonRoot & {
179
+ Text: typeof ButtonText;
180
+ Icon: typeof ButtonIcon;
181
+ };
182
+ export { Button, ButtonText, ButtonIcon };
@@ -1,9 +1,10 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useCallback, useMemo, useState } from "react";
3
- import { Pressable, StyleSheet, View, Platform, ActivityIndicator, } from "react-native";
4
- import Animated from "react-native-reanimated";
2
+ import { use, useCallback, useMemo, useState } from "react";
3
+ import { Pressable, StyleSheet, View, Platform, ActivityIndicator, Animated, } from "react-native";
5
4
  import { spacing } from "../constants/spacing.js";
6
- import { StyledText, TextColorContext, TextSelectabilityContext, TextStyleContext } from "./StyledText.js";
5
+ import { StyledText } from "./StyledText.js";
6
+ import { Icon } from "./Icon.js";
7
+ import { TextColorContext, TextSelectabilityContext, TextStyleContext } from "./StyledText.context";
7
8
  import { fontFamilies } from "../constants/fonts.js";
8
9
  import { palette } from "../constants/colors.js";
9
10
  import { useTheme } from "../hooks/useTheme.js";
@@ -43,28 +44,32 @@ const getNativeHitSlop = (sizeConfig) => Math.ceil(Math.max(0, spacing.touchTarg
43
44
  *
44
45
  * Usage:
45
46
  * ```tsx
46
- * // Default button
47
- * <Button onPress={handler}>
48
- * <SansSerifBoldText>Click Me</SansSerifBoldText>
49
- * </Button>
47
+ * // Simplest path — plain label via the `text` prop
48
+ * <Button onPress={handler} text="Click Me" />
50
49
  *
51
50
  * // Different variants
52
- * <Button preset="outline" onPress={handler}>Outline</Button>
53
- * <Button preset="ghost" onPress={handler}>Ghost</Button>
54
- * <Button preset="destructive" onPress={handler}>Delete</Button>
51
+ * <Button preset="outline" onPress={handler} text="Outline" />
52
+ * <Button preset="ghost" onPress={handler} text="Ghost" />
53
+ * <Button preset="destructive" onPress={handler} text="Delete" />
55
54
  *
56
55
  * // Different sizes
57
- * <Button size="sm" onPress={handler}>Small</Button>
58
- * <Button size="lg" onPress={handler}>Large</Button>
56
+ * <Button size="sm" onPress={handler} text="Small" />
57
+ * <Button size="lg" onPress={handler} text="Large" />
59
58
  *
60
59
  * // Loading state
61
- * <Button loading onPress={handler}>Processing...</Button>
60
+ * <Button loading onPress={handler} text="Processing..." />
62
61
  *
63
62
  * // Full width
64
- * <Button fullWidth onPress={handler}>Submit</Button>
63
+ * <Button fullWidth onPress={handler} text="Submit" />
64
+ *
65
+ * // Composed content — icon + label via subcomponents
66
+ * <Button onPress={handler}>
67
+ * <Button.Icon name="heart" />
68
+ * <Button.Text>Like</Button.Text>
69
+ * </Button>
65
70
  * ```
66
71
  */
67
- export function Button(props) {
72
+ function ButtonRoot(props) {
68
73
  const { tx, text, txOptions, style: styleOverride, pressedStyle: pressedStyleOverride, textStyle: textStyleOverride, pressedTextStyle: pressedTextStyleOverride, disabledTextStyle: disabledTextStyleOverride, children, RightAccessory, LeftAccessory, disabled, disabledStyle: disabledStyleOverride, withShadow = false, preset = "default", size = "md", loading = false, fullWidth = false, onFocus, onBlur, onPressIn, onPressOut, ...rest } = props;
69
74
  const { theme, getContrastingColor, getFocusRingStyle, getShadowStyle } = useTheme();
70
75
  const styles = useMemo(() => createStyles(theme, size), [theme, size]);
@@ -115,11 +120,11 @@ export function Button(props) {
115
120
  haptic: false,
116
121
  scaleTo: preset === "link" ? 1 : 0.97,
117
122
  });
118
- const handleFocus = (event) => {
123
+ const showFocusRing = (event) => {
119
124
  setFocused(true);
120
125
  onFocus?.(event);
121
126
  };
122
- const handleBlur = (event) => {
127
+ const hideFocusRing = (event) => {
123
128
  setFocused(false);
124
129
  onBlur?.(event);
125
130
  };
@@ -144,7 +149,7 @@ export function Button(props) {
144
149
  return nextWidth;
145
150
  });
146
151
  }, [fullWidth, loading]);
147
- return (_jsx(TextColorContext.Provider, { value: textColor, children: _jsx(TextSelectabilityContext.Provider, { value: false, children: _jsx(TextStyleContext.Provider, { value: styles.text, children: _jsx(Pressable, { accessibilityRole: "button", accessibilityState: { disabled: !!isDisabled, busy: loading }, ...rest, onPressIn: handlePressIn, onPressOut: handlePressOut, onFocus: handleFocus, onBlur: handleBlur, style: { alignSelf: fullWidth ? "stretch" : flattenedStyle?.alignSelf ?? "flex-start" }, hitSlop: rest.hitSlop ?? (Platform.OS === "web" ? undefined : getNativeHitSlop(sizeConfig)), disabled: isDisabled, children: (state) => (_jsx(Animated.View, { style: scaleStyle, children: _jsxs(View, { style: [
152
+ return (_jsx(TextColorContext.Provider, { value: textColor, children: _jsx(TextSelectabilityContext.Provider, { value: false, children: _jsx(TextStyleContext.Provider, { value: styles.text, children: _jsx(Pressable, { accessibilityRole: "button", accessibilityState: { disabled: !!isDisabled, busy: loading }, ...rest, onPressIn: handlePressIn, onPressOut: handlePressOut, onFocus: showFocusRing, onBlur: hideFocusRing, style: { alignSelf: fullWidth ? "stretch" : flattenedStyle?.alignSelf ?? "flex-start" }, hitSlop: rest.hitSlop ?? (Platform.OS === "web" ? undefined : getNativeHitSlop(sizeConfig)), disabled: isDisabled, children: (state) => (_jsx(Animated.View, { style: scaleStyle, children: _jsxs(View, { style: [
148
153
  styles.button,
149
154
  preset === "default" && styles.buttonDefault,
150
155
  preset === "outline" && styles.buttonOutline,
@@ -170,15 +175,42 @@ export function Button(props) {
170
175
  state.pressed && pressedTextStyleOverride,
171
176
  isDisabled && disabledTextStyleOverride,
172
177
  textStyleOverride,
173
- ] })) : children ? (
174
- // Wrap string children in StyledText to apply control typography.
175
- typeof children === "string" ? (_jsx(StyledText, { style: [
176
- styles.text,
177
- state.pressed && styles.pressedText,
178
- state.pressed && pressedTextStyleOverride,
179
- isDisabled && disabledTextStyleOverride,
180
- textStyleOverride,
181
- ], children: children })) : (children)) : (null), !!RightAccessory && (_jsx(RightAccessory, { style: styles.rightAccessory, pressableState: state, disabled: isDisabled }))] })] }) })) }) }) }) }));
178
+ ] })) : (
179
+ // Children render as-is. For text content, use the explicit
180
+ // <Button.Text> subcomponent so it inherits control typography
181
+ // via context instead of relying on a runtime `typeof` check.
182
+ children ?? null), !!RightAccessory && (_jsx(RightAccessory, { style: styles.rightAccessory, pressableState: state, disabled: isDisabled }))] })] }) })) }) }) }) }));
183
+ }
184
+ /**
185
+ * Button.Text
186
+ * Text content for a Button. Inherits the button's control typography, color,
187
+ * and non-selectable behavior from context, so callers state their intent
188
+ * explicitly instead of relying on the component to inspect `typeof children`.
189
+ *
190
+ * ```tsx
191
+ * <Button onPress={save}>
192
+ * <Button.Text>Save</Button.Text>
193
+ * </Button>
194
+ * ```
195
+ */
196
+ function ButtonText(props) {
197
+ return _jsx(StyledText, { ...props });
198
+ }
199
+ /**
200
+ * Button.Icon
201
+ * Icon content for a Button. Defaults its color to the button's text color
202
+ * (from context) and is decorative by default, since the label conveys meaning.
203
+ *
204
+ * ```tsx
205
+ * <Button onPress={like}>
206
+ * <Button.Icon name="heart" />
207
+ * <Button.Text>Like</Button.Text>
208
+ * </Button>
209
+ * ```
210
+ */
211
+ function ButtonIcon(props) {
212
+ const contextColor = use(TextColorContext);
213
+ return _jsx(Icon, { decorative: true, color: contextColor, ...props });
182
214
  }
183
215
  const createStyles = (theme, size) => {
184
216
  const sizeConfig = SIZE_CONFIGS[size];
@@ -263,3 +295,15 @@ const createStyles = (theme, size) => {
263
295
  },
264
296
  });
265
297
  };
298
+ /**
299
+ * Button with explicit subcomponents.
300
+ * - `Button.Text` for label text (inherits control typography)
301
+ * - `Button.Icon` for icons (inherits the button's text color)
302
+ *
303
+ * The `tx`/`text` props remain the simplest path for plain labels.
304
+ */
305
+ const Button = Object.assign(ButtonRoot, {
306
+ Text: ButtonText,
307
+ Icon: ButtonIcon,
308
+ });
309
+ export { Button, ButtonText, ButtonIcon };