@mrmeg/expo-ui 0.1.4 → 0.1.6

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 (37) hide show
  1. package/LLM_USAGE.md +16 -2
  2. package/README.md +24 -20
  3. package/dist/components/Accordion.js +19 -16
  4. package/dist/components/Badge.js +5 -4
  5. package/dist/components/Button.d.ts +1 -1
  6. package/dist/components/Button.js +84 -51
  7. package/dist/components/Card.js +4 -3
  8. package/dist/components/Checkbox.js +6 -4
  9. package/dist/components/Collapsible.js +15 -14
  10. package/dist/components/Dialog.js +6 -6
  11. package/dist/components/Drawer.js +5 -5
  12. package/dist/components/DropdownMenu.js +119 -112
  13. package/dist/components/EmptyState.js +5 -3
  14. package/dist/components/InputOTP.js +3 -3
  15. package/dist/components/Label.js +5 -2
  16. package/dist/components/Notification.js +6 -6
  17. package/dist/components/Popover.js +2 -2
  18. package/dist/components/RadioGroup.js +6 -4
  19. package/dist/components/Select.js +35 -25
  20. package/dist/components/Slider.js +34 -24
  21. package/dist/components/StyledText.d.ts +15 -4
  22. package/dist/components/StyledText.js +28 -7
  23. package/dist/components/Switch.js +28 -28
  24. package/dist/components/Tabs.js +6 -3
  25. package/dist/components/TextInput.js +8 -10
  26. package/dist/components/Toggle.js +4 -2
  27. package/dist/components/ToggleGroup.js +3 -2
  28. package/dist/components/Tooltip.js +4 -4
  29. package/dist/constants/colors.d.ts +4 -0
  30. package/dist/constants/colors.js +9 -1
  31. package/dist/constants/spacing.d.ts +2 -1
  32. package/dist/constants/spacing.js +2 -1
  33. package/dist/hooks/useTheme.d.ts +3 -1
  34. package/dist/hooks/useTheme.js +46 -18
  35. package/dist/lib/i18n.d.ts +2 -2
  36. package/dist/lib/i18n.js +5 -5
  37. package/package.json +6 -7
package/LLM_USAGE.md CHANGED
@@ -70,8 +70,9 @@ export function RootLayout() {
70
70
 
71
71
  i18n is optional. Do not add app-level i18n setup just to use this package.
72
72
  Plain children and `text` props work without `i18next` or `react-i18next`.
73
- `tx` props render the key until the consumer opts in with a package-local
74
- translator:
73
+ `tx` props render fallback text when provided and otherwise render the key
74
+ until the consumer opts in with a package-local translator. Package-owned
75
+ defaults such as notification titles stay human-readable without app i18n:
75
76
 
76
77
  ```tsx
77
78
  import { configureExpoUiI18n } from "@mrmeg/expo-ui/lib";
@@ -85,6 +86,8 @@ configureExpoUiI18n((key, options) => i18n.t(key, options));
85
86
  - Use `useTheme()` and semantic tokens instead of hardcoded colors.
86
87
  - Use `StyledText` or its semantic aliases instead of raw `Text` for app UI.
87
88
  - Use `Button.preset`, not `variant`, for buttons.
89
+ - Button visible heights are compact: `sm` 28px, `md` 32px, and `lg` 40px.
90
+ - Use `Button size="sm"` for compact popover, tooltip, and toolbar triggers; nested `StyledText` inherits the selected Button size.
88
91
  - Use `globalUIStore` plus root-mounted `UIProvider` for transient global feedback.
89
92
  - Keep app monitoring, auth, API, and domain behavior outside this package.
90
93
 
@@ -94,7 +97,10 @@ Useful theme tokens include:
94
97
  theme.colors.background;
95
98
  theme.colors.foreground;
96
99
  theme.colors.card;
100
+ theme.colors.popover;
97
101
  theme.colors.border;
102
+ theme.colors.input;
103
+ theme.colors.ring;
98
104
  theme.colors.primary;
99
105
  theme.colors.secondary;
100
106
  theme.colors.accent;
@@ -109,6 +115,14 @@ Token intent:
109
115
  - `primary`: neutral action color
110
116
  - `secondary`: neutral secondary surface
111
117
  - `accent`: teal highlight color
118
+ - `input`: form-control border color
119
+ - `ring`: focus outline color
120
+ - `popover`: elevated overlay surface
121
+
122
+ Use `getShadowStyle()` for package surfaces that need elevation and
123
+ `getFocusRingStyle()` for web focus styling. Keep web controls compact, but
124
+ preserve mobile tap comfort with package controls that already provide native
125
+ hit slop or 44px touch rows.
112
126
 
113
127
  ## Component Use-Case Index
114
128
 
package/README.md CHANGED
@@ -24,15 +24,15 @@ Install from npm after publishing:
24
24
  bun add @mrmeg/expo-ui
25
25
  ```
26
26
 
27
- Consumers must also install the peer dependencies listed in `package.json`.
28
- The tested baseline is Expo SDK 55 with React 19.2, React Native 0.83,
29
- React Native Web 0.21, Reanimated 4.2, Worklets 0.7, and
30
- `@rn-primitives/*` 1.4. `@rn-primitives/portal` is package-managed because
31
- `UIProvider` mounts the portal host used by package overlays. i18n setup is
32
- optional; plain text and children render without `i18next` or
33
- `react-i18next`. Start consumer apps from the same Expo SDK family or update
34
- the package and peer ranges deliberately. Keep npm auth tokens in developer or
35
- CI configuration, not in this repository.
27
+ Consumers must also install the native and Expo peer dependencies listed in
28
+ `package.json`. The tested baseline is Expo SDK 55 with React 19.2, React
29
+ Native 0.83, React Native Web 0.21, Reanimated 4.2, and Worklets 0.7.
30
+ `@rn-primitives/*` packages are managed by `@mrmeg/expo-ui` because they are
31
+ implementation details of the exported UI components. i18n setup is optional;
32
+ plain text and children render without `i18next` or `react-i18next`. Start
33
+ consumer apps from the same Expo SDK family or update the package and peer
34
+ ranges deliberately. Keep npm auth tokens in developer or CI configuration,
35
+ not in this repository.
36
36
 
37
37
  ## Imports
38
38
 
@@ -95,7 +95,7 @@ const styles = StyleSheet.create({
95
95
  });
96
96
  ```
97
97
 
98
- `useTheme()` returns the active `theme`, resolved `scheme`, persisted `currentTheme`, `setTheme`, `toggleTheme`, shadow helpers, contrast helpers, and `withAlpha`. Use semantic tokens such as `theme.colors.background`, `foreground`, `card`, `border`, `primary`, `secondary`, `accent`, `mutedForeground`, `destructive`, `success`, and `warning`. `primary` is the neutral action color, `secondary` is a neutral secondary surface, and `accent` is the teal highlight color.
98
+ `useTheme()` returns the active `theme`, resolved `scheme`, persisted `currentTheme`, `setTheme`, `toggleTheme`, cross-platform shadow helpers, a web focus-ring helper, contrast helpers, and `withAlpha`. Use semantic tokens such as `theme.colors.background`, `foreground`, `card`, `popover`, `border`, `input`, `ring`, `primary`, `secondary`, `accent`, `mutedForeground`, `destructive`, `success`, and `warning`. `primary` is the neutral action color, `secondary` is a neutral secondary surface, `accent` is the teal highlight color, `input` is the default form-control border, and `ring` is the focus outline color.
99
99
 
100
100
  Use `StyledText` for theme-aware text:
101
101
 
@@ -115,10 +115,14 @@ Useful `StyledText` props:
115
115
  - `fontWeight`: `light`, `regular`, `medium`, `semibold`, `bold`
116
116
  - `variant`: `sansSerif`, `serif`
117
117
  - `align`, `tx`, `txOptions`
118
+ - `selectable`: defaults to `true` for readable copy; package controls disable
119
+ selection for labels and other interactive chrome where accidental drag
120
+ selection would feel broken.
118
121
 
119
- `tx` support is opt-in. Without a configured translator, `tx` renders the key
120
- and `text` renders as provided. Consumers that already use i18n can connect it
121
- once near app startup:
122
+ `tx` support is opt-in. Without a configured translator, `tx` renders its
123
+ fallback text when provided and otherwise renders the key; package-owned
124
+ defaults such as notification titles use readable fallback text. Consumers
125
+ that already use i18n can connect it once near app startup:
122
126
 
123
127
  ```tsx
124
128
  import { configureExpoUiI18n } from "@mrmeg/expo-ui/lib";
@@ -140,7 +144,7 @@ All components are exported from `@mrmeg/expo-ui/components`; direct imports suc
140
144
  | `AnimatedView` | Entrance and visibility animation | Hand-rolled Reanimated wrappers | Staggered list rows, revealed panels, animated empty states |
141
145
  | `Badge` | Short status labels | Custom pill `View` + `Text` | Draft/active states, counts, plan labels, role tags |
142
146
  | `BottomSheet` | Mobile-first modal sheets | Custom absolute-position sheets | Action pickers, mobile filters, quick edit forms, contextual details |
143
- | `Button` | Commands and CTAs | Pressable plus custom text styling | Submit, save, cancel, delete, navigation CTAs, icon-accessory buttons |
147
+ | `Button` | Commands and CTAs | Pressable plus custom text styling | Submit, save, cancel, delete, navigation CTAs, icon-accessory buttons; loading state preserves resting width |
144
148
  | `Card`, `CardHeader`, `CardTitle`, `CardDescription`, `CardContent`, `CardFooter` | Framed content groups | Ad hoc bordered panels | List items, pricing plans, settings sections, summaries, dashboards |
145
149
  | `Checkbox` | Boolean selection | Custom checkmark controls | Terms consent, checklist items, multi-select filters, notification opt-ins |
146
150
  | `Collapsible`, `CollapsibleTrigger`, `CollapsibleContent` | One-off disclosure | Local animated height wrappers | Advanced settings, hidden helper text, optional details |
@@ -158,13 +162,13 @@ All components are exported from `@mrmeg/expo-ui/components`; direct imports suc
158
162
  | `Popover` | Anchored contextual content | Custom anchored views | Inline help, quick previews, contextual controls, small forms |
159
163
  | `Progress` | Determinate or indeterminate progress | Layout-shifting spinners for progress regions | Upload progress, onboarding completion, long-running task state |
160
164
  | `RadioGroup`, `RadioGroupItem` | Mutually exclusive choices | Custom radio rows | Plan interval, visibility choice, survey answer, preference setting |
161
- | `Select` | Option menus | Custom dropdowns | Country picker, category selector, status selector, compact form choice |
165
+ | `Select` | Option menus | Custom dropdowns | Country picker, category selector, status selector, compact form choice; `label` drives default item text and overlay text uses popover foreground tokens |
162
166
  | `Separator` | Horizontal or vertical dividers | Border-only spacer views | Menu dividers, section dividers, card dividers |
163
167
  | `Skeleton`, `SkeletonText`, `SkeletonAvatar`, `SkeletonCard` | Loading placeholders | Blank space or generic spinners | List loading, profile card loading, dashboard placeholders |
164
- | `Slider` | Numeric value selection | Custom pan gesture track | Volume, percentage, rating, threshold, range-like settings |
168
+ | `Slider` | Numeric value selection | Custom pan gesture track | Volume, percentage, rating, threshold, range-like settings; accent active track and thumb affordance |
165
169
  | `StatusBar` | Theme-aware native status bar | Per-screen status-bar duplication | Root layout status styling, dark/light mode updates |
166
170
  | `StyledText` and text aliases | Theme-aware typography | Raw `Text` with hardcoded styles | Titles, headings, labels, body copy, captions, translated text |
167
- | `Switch` | Binary settings | Custom toggle switches | Enable notifications, privacy setting, feature toggles |
171
+ | `Switch` | Binary settings | Custom toggle switches | Enable notifications, privacy setting, feature toggles; accent checked track with softened thumb border |
168
172
  | `Tabs`, `TabsList`, `TabsTrigger`, `TabsContent` | In-page tabbed views | Custom segmented/tab controls | Profile sections, report views, settings categories |
169
173
  | `TextInput` | Text entry | Raw `TextInput` with repeated label/error code | Email/password, search, numeric input, multiline notes |
170
174
  | `Toggle`, `ToggleIcon` | Pressed/unpressed control | Button with local selected styling | Favorite, mute, bold/italic, view mode button |
@@ -194,11 +198,11 @@ Most compound components support both direct named imports and dot notation on t
194
198
  | `ToggleGroup` | `ToggleGroupItem`, `ToggleGroupIcon` |
195
199
  | `Tooltip` | `TooltipTrigger`, `TooltipContent`, `TooltipBody` |
196
200
 
197
- Text aliases are exported for common semantic typography: `SerifText`, `SansSerifText`, `SerifBoldText`, `SansSerifBoldText`, `DisplayText`, `TitleText`, `HeadingText`, `SubheadingText`, `BodyText`, `CaptionText`, and `LabelText`. `TextClassContext` and `TextColorContext` are advanced context exports used by package controls to pass nested text styling.
201
+ Text aliases are exported for common semantic typography: `SerifText`, `SansSerifText`, `SerifBoldText`, `SansSerifBoldText`, `DisplayText`, `TitleText`, `HeadingText`, `SubheadingText`, `BodyText`, `CaptionText`, and `LabelText`. `TextClassContext`, `TextColorContext`, `TextStyleContext`, and `TextSelectabilityContext` are advanced context exports used by package controls to pass nested text styling and control-label selectability.
198
202
 
199
203
  ### Common Patterns
200
204
 
201
- Use `Button.preset`, not `variant`. `default` is the neutral primary action, `secondary` is a neutral secondary surface, `outline` is for lower-emphasis actions, `ghost` is for compact toolbars, `link` is for text-like commands, and `destructive` is for dangerous actions.
205
+ Use `Button.preset`, not `variant`. `default` is the neutral primary action, `secondary` is a neutral secondary surface, `outline` is for lower-emphasis actions, `ghost` is for compact toolbars, `link` is for text-like commands, and `destructive` is for dangerous actions. Button visible heights are compact: `sm` 28px, `md` 32px, and `lg` 40px. Native Button targets preserve tap comfort with computed hit slop up to 44px. Nested `StyledText` children inherit the selected Button size, so use `size="sm"` for compact popover, tooltip, and toolbar triggers.
202
206
 
203
207
  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.
204
208
 
@@ -321,7 +325,7 @@ On web, `useResources()` injects the Google Fonts Lato stylesheet after hydratio
321
325
  />
322
326
  ```
323
327
 
324
- On native, the package uses platform sans-serif fallbacks. `useResources()` still loads `Feather.font` from the consumer app's `@expo/vector-icons` peer dependency for icon rendering.
328
+ On native, the package uses platform sans-serif fallbacks. `useResources()` still loads `Feather.font` from the package-managed `@expo/vector-icons` dependency for icon rendering.
325
329
 
326
330
  ## Package Checks
327
331
 
@@ -3,7 +3,7 @@ import { useEffect, useState } from "react";
3
3
  import { Platform, Pressable, View } from "react-native";
4
4
  import Animated, { useSharedValue, useAnimatedStyle, withTiming, useReducedMotion, } from "react-native-reanimated";
5
5
  import { Icon } from "./Icon.js";
6
- import { TextClassContext } from "./StyledText.js";
6
+ import { TextClassContext, TextSelectabilityContext } from "./StyledText.js";
7
7
  import { useTheme } from "../hooks/useTheme.js";
8
8
  import { spacing } from "../constants/spacing.js";
9
9
  import * as AccordionPrimitive from "@rn-primitives/accordion";
@@ -114,21 +114,24 @@ function AccordionTrigger({ children, style: styleOverride, ...props }) {
114
114
  const chevronStyle = useAnimatedStyle(() => ({
115
115
  transform: [{ rotate: `${rotation.value * 180}deg` }],
116
116
  }));
117
- return (_jsx(TextClassContext.Provider, { value: "", children: _jsx(AccordionPrimitive.Header, { children: _jsx(AccordionPrimitive.Trigger, { ...props, asChild: true, children: _jsxs(Trigger, { style: [
118
- {
119
- flexDirection: "row",
120
- alignItems: "center",
121
- justifyContent: "space-between",
122
- gap: spacing.md,
123
- borderRadius: spacing.radiusMd,
124
- paddingVertical: spacing.md,
125
- ...(Platform.OS === "web" && { cursor: "pointer" }),
126
- },
127
- // Spread array styles from primitives to prevent nested arrays on web
128
- ...(styleOverride && typeof styleOverride !== "function"
129
- ? (Array.isArray(styleOverride) ? styleOverride : [styleOverride])
130
- : []),
131
- ], children: [_jsx(_Fragment, { children: children }), _jsx(Animated.View, { style: chevronStyle, children: _jsx(Icon, { name: "chevron-down", size: 16, color: theme.colors.textDim, decorative: true }) })] }) }) }) }));
117
+ 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
+ {
119
+ flexDirection: "row",
120
+ alignItems: "center",
121
+ justifyContent: "space-between",
122
+ gap: spacing.md,
123
+ borderRadius: spacing.radiusMd,
124
+ paddingVertical: spacing.md,
125
+ ...(Platform.OS === "web" && {
126
+ cursor: "pointer",
127
+ userSelect: "none",
128
+ }),
129
+ },
130
+ // Spread array styles from primitives to prevent nested arrays on web
131
+ ...(styleOverride && typeof styleOverride !== "function"
132
+ ? (Array.isArray(styleOverride) ? styleOverride : [styleOverride])
133
+ : []),
134
+ ], children: [_jsx(_Fragment, { children: children }), _jsx(Animated.View, { style: chevronStyle, children: _jsx(Icon, { name: "chevron-down", size: 16, color: theme.colors.textDim, decorative: true }) })] }) }) }) }) }));
132
135
  }
133
136
  /**
134
137
  * Accordion Content Component
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import React from "react";
2
+ import React, { useMemo } from "react";
3
3
  import { View, StyleSheet } from "react-native";
4
4
  import { useTheme } from "../hooks/useTheme.js";
5
5
  import { spacing } from "../constants/spacing.js";
@@ -19,7 +19,7 @@ import { StyledText } from "./StyledText.js";
19
19
  */
20
20
  function Badge({ children, variant = "default", style: styleOverride }) {
21
21
  const { theme } = useTheme();
22
- const styles = createStyles(theme);
22
+ const styles = useMemo(() => createStyles(theme), [theme]);
23
23
  const textStyle = [
24
24
  styles.text,
25
25
  variant === "default" && { color: theme.colors.primaryForeground },
@@ -29,9 +29,9 @@ function Badge({ children, variant = "default", style: styleOverride }) {
29
29
  ];
30
30
  const normalizedChildren = React.Children.toArray(children);
31
31
  const hasOnlyTextChildren = normalizedChildren.every((child) => typeof child === "string" || typeof child === "number");
32
- const content = hasOnlyTextChildren ? (_jsx(StyledText, { style: textStyle, children: normalizedChildren.join("") })) : (React.Children.map(children, (child) => {
32
+ const content = hasOnlyTextChildren ? (_jsx(StyledText, { selectable: false, style: textStyle, children: normalizedChildren.join("") })) : (React.Children.map(children, (child) => {
33
33
  if (typeof child === "string" || typeof child === "number") {
34
- return _jsx(StyledText, { style: textStyle, children: child });
34
+ return _jsx(StyledText, { selectable: false, style: textStyle, children: child });
35
35
  }
36
36
  return child;
37
37
  }));
@@ -69,6 +69,7 @@ const createStyles = (theme) => StyleSheet.create({
69
69
  fontSize: 12,
70
70
  fontWeight: "500",
71
71
  lineHeight: 18,
72
+ userSelect: "none",
72
73
  },
73
74
  });
74
75
  export { Badge };
@@ -20,7 +20,7 @@ export interface ButtonProps extends PressableProps {
20
20
  */
21
21
  tx?: TextProps["tx"];
22
22
  /**
23
- * The text to display if not using `tx` or nested components.
23
+ * The text to display directly, or as fallback text when `tx` is provided.
24
24
  */
25
25
  text?: TextProps["text"];
26
26
  /**
@@ -1,33 +1,34 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useState } from "react";
2
+ import { useCallback, useMemo, useState } from "react";
3
3
  import { Pressable, StyleSheet, View, Platform, ActivityIndicator, } from "react-native";
4
4
  import Animated from "react-native-reanimated";
5
5
  import { spacing } from "../constants/spacing.js";
6
- import { StyledText, TextColorContext } from "./StyledText.js";
6
+ import { StyledText, TextColorContext, TextSelectabilityContext, TextStyleContext } from "./StyledText.js";
7
7
  import { fontFamilies } from "../constants/fonts.js";
8
8
  import { palette } from "../constants/colors.js";
9
9
  import { useTheme } from "../hooks/useTheme.js";
10
10
  import { useScalePress } from "../hooks/useScalePress.js";
11
11
  const SIZE_CONFIGS = {
12
12
  sm: {
13
- paddingVertical: spacing.xs,
14
- paddingHorizontal: 12,
13
+ paddingVertical: spacing.xxs,
14
+ paddingHorizontal: 10,
15
15
  fontSize: 12,
16
- height: 32,
16
+ height: 28,
17
17
  },
18
18
  md: {
19
- paddingVertical: spacing.sm,
20
- paddingHorizontal: spacing.md,
19
+ paddingVertical: spacing.xs,
20
+ paddingHorizontal: 12,
21
21
  fontSize: 14,
22
- height: 36,
22
+ height: 32,
23
23
  },
24
24
  lg: {
25
- paddingVertical: 10,
26
- paddingHorizontal: spacing.lg,
27
- fontSize: 16,
28
- height: 44,
25
+ paddingVertical: 6,
26
+ paddingHorizontal: spacing.md,
27
+ fontSize: 15,
28
+ height: 40,
29
29
  },
30
30
  };
31
+ const getNativeHitSlop = (sizeConfig) => Math.ceil(Math.max(0, spacing.touchTarget - sizeConfig.height) / 2);
31
32
  /**
32
33
  * Enhanced Button Component
33
34
  *
@@ -65,10 +66,11 @@ const SIZE_CONFIGS = {
65
66
  */
66
67
  export function Button(props) {
67
68
  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;
68
- const { theme, getContrastingColor, getShadowStyle } = useTheme();
69
- const styles = createStyles(theme, size);
69
+ const { theme, getContrastingColor, getFocusRingStyle, getShadowStyle } = useTheme();
70
+ const styles = useMemo(() => createStyles(theme, size), [theme, size]);
70
71
  const shadowStyle = getShadowStyle("base");
71
72
  const sizeConfig = SIZE_CONFIGS[size];
73
+ const focusRingStyle = getFocusRingStyle();
72
74
  // Pre-compute background color for contrast calculation
73
75
  // Always flatten to handle both array styles (from Slot) and RegisteredStyle
74
76
  const flattenedStyle = styleOverride ? StyleSheet.flatten(styleOverride) : undefined;
@@ -106,6 +108,7 @@ export function Button(props) {
106
108
  ? theme.colors.secondaryForeground
107
109
  : getContrastingColor(backgroundColor, palette.white, palette.black);
108
110
  const [focused, setFocused] = useState(false);
111
+ const [restingWidth, setRestingWidth] = useState();
109
112
  const isDisabled = disabled || loading;
110
113
  const { animatedStyle: scaleStyle, pressHandlers } = useScalePress({
111
114
  disabled: !!isDisabled,
@@ -128,45 +131,60 @@ export function Button(props) {
128
131
  pressHandlers.onPressOut();
129
132
  onPressOut?.(event);
130
133
  };
131
- return (_jsx(TextColorContext.Provider, { value: textColor, 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" }, disabled: isDisabled, children: (state) => (_jsx(Animated.View, { style: scaleStyle, children: _jsxs(View, { style: [
132
- styles.button,
133
- preset === "default" && styles.buttonDefault,
134
- preset === "outline" && styles.buttonOutline,
135
- preset === "ghost" && styles.buttonGhost,
136
- preset === "link" && styles.buttonLink,
137
- preset === "destructive" && styles.buttonDestructive,
138
- preset === "secondary" && styles.buttonSecondary,
139
- fullWidth && styles.fullWidth,
140
- withShadow && !isDisabled && shadowStyle,
141
- state.pressed && styles.pressed,
142
- state.pressed && pressedStyleOverride,
143
- isDisabled && styles.disabled,
144
- isDisabled && disabledStyleOverride,
145
- Platform.OS === "web" && focused && !isDisabled && {
146
- boxShadow: `0 0 0 2px ${theme.colors.background}, 0 0 0 4px ${theme.colors.accent}`,
147
- },
148
- // Spread array styles from Slot to prevent nested arrays on web
149
- ...(Array.isArray(styleOverride) ? styleOverride : [styleOverride]),
150
- ], children: [!!LeftAccessory && !loading && (_jsx(LeftAccessory, { style: styles.leftAccessory, pressableState: state, disabled: isDisabled })), loading && (_jsx(ActivityIndicator, { size: "small", color: textColor, style: styles.loader })), (tx || text) ? (_jsx(StyledText, { tx: tx, text: text, txOptions: txOptions, style: [
151
- styles.text,
152
- state.pressed && styles.pressedText,
153
- state.pressed && pressedTextStyleOverride,
154
- isDisabled && disabledTextStyleOverride,
155
- textStyleOverride,
156
- ] })) : !loading && children ? (
157
- // Wrap string children in StyledText to apply TextColorContext
158
- typeof children === "string" ? (_jsx(StyledText, { style: [
159
- styles.text,
160
- state.pressed && styles.pressedText,
161
- state.pressed && pressedTextStyleOverride,
162
- isDisabled && disabledTextStyleOverride,
163
- textStyleOverride,
164
- ], children: children })) : (children)) : null, !!RightAccessory && !loading && (_jsx(RightAccessory, { style: styles.rightAccessory, pressableState: state, disabled: isDisabled }))] }) })) }) }));
134
+ const handleButtonLayout = useCallback((event) => {
135
+ if (loading || fullWidth)
136
+ return;
137
+ const nextWidth = event.nativeEvent.layout.width;
138
+ if (nextWidth <= 0)
139
+ return;
140
+ setRestingWidth((currentWidth) => {
141
+ if (currentWidth !== undefined && Math.abs(currentWidth - nextWidth) < 0.5) {
142
+ return currentWidth;
143
+ }
144
+ return nextWidth;
145
+ });
146
+ }, [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: [
148
+ styles.button,
149
+ preset === "default" && styles.buttonDefault,
150
+ preset === "outline" && styles.buttonOutline,
151
+ preset === "ghost" && styles.buttonGhost,
152
+ preset === "link" && styles.buttonLink,
153
+ preset === "destructive" && styles.buttonDestructive,
154
+ preset === "secondary" && styles.buttonSecondary,
155
+ fullWidth && styles.fullWidth,
156
+ withShadow && !isDisabled && shadowStyle,
157
+ state.pressed && preset === "outline" && styles.pressedMuted,
158
+ state.pressed && preset === "ghost" && styles.pressedMuted,
159
+ state.pressed && styles.pressed,
160
+ state.pressed && pressedStyleOverride,
161
+ isDisabled && styles.disabled,
162
+ isDisabled && disabledStyleOverride,
163
+ focused && !isDisabled && focusRingStyle,
164
+ loading && restingWidth !== undefined && !fullWidth && { width: restingWidth },
165
+ // Spread array styles from Slot to prevent nested arrays on web
166
+ ...(Array.isArray(styleOverride) ? styleOverride : [styleOverride]),
167
+ ], onLayout: handleButtonLayout, children: [loading && (_jsx(View, { style: styles.loaderOverlay, pointerEvents: "none", children: _jsx(ActivityIndicator, { size: "small", color: textColor }) })), _jsxs(View, { style: [styles.content, loading && styles.loadingContent], pointerEvents: loading ? "none" : "auto", children: [!!LeftAccessory && (_jsx(LeftAccessory, { style: styles.leftAccessory, pressableState: state, disabled: isDisabled })), (tx || text) ? (_jsx(StyledText, { tx: tx, text: text, txOptions: txOptions, style: [
168
+ styles.text,
169
+ state.pressed && styles.pressedText,
170
+ state.pressed && pressedTextStyleOverride,
171
+ isDisabled && disabledTextStyleOverride,
172
+ 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 }))] })] }) })) }) }) }) }));
165
182
  }
166
183
  const createStyles = (theme, size) => {
167
184
  const sizeConfig = SIZE_CONFIGS[size];
168
185
  return StyleSheet.create({
169
186
  button: {
187
+ position: "relative",
170
188
  flexDirection: "row",
171
189
  alignItems: "center",
172
190
  justifyContent: "center",
@@ -177,6 +195,15 @@ const createStyles = (theme, size) => {
177
195
  flexShrink: 0,
178
196
  ...(Platform.OS === "web" && { cursor: "pointer" }),
179
197
  },
198
+ content: {
199
+ flexDirection: "row",
200
+ alignItems: "center",
201
+ justifyContent: "center",
202
+ flexShrink: 0,
203
+ },
204
+ loadingContent: {
205
+ opacity: 0,
206
+ },
180
207
  buttonDefault: {
181
208
  backgroundColor: theme.colors.primary,
182
209
  },
@@ -189,7 +216,7 @@ const createStyles = (theme, size) => {
189
216
  buttonOutline: {
190
217
  backgroundColor: "transparent",
191
218
  borderWidth: 1,
192
- borderColor: theme.colors.border,
219
+ borderColor: theme.colors.input,
193
220
  },
194
221
  buttonGhost: {
195
222
  backgroundColor: "transparent",
@@ -209,10 +236,14 @@ const createStyles = (theme, size) => {
209
236
  textAlign: "center",
210
237
  lineHeight: sizeConfig.fontSize * 1.4,
211
238
  flexShrink: 0,
239
+ userSelect: "none",
212
240
  },
213
241
  pressed: {
214
242
  opacity: 0.9,
215
243
  },
244
+ pressedMuted: {
245
+ backgroundColor: theme.colors.muted,
246
+ },
216
247
  pressedText: {
217
248
  opacity: 0.9,
218
249
  },
@@ -225,8 +256,10 @@ const createStyles = (theme, size) => {
225
256
  rightAccessory: {
226
257
  marginLeft: spacing.sm,
227
258
  },
228
- loader: {
229
- marginRight: spacing.sm,
259
+ loaderOverlay: {
260
+ ...StyleSheet.absoluteFillObject,
261
+ alignItems: "center",
262
+ justifyContent: "center",
230
263
  },
231
264
  });
232
265
  };
@@ -39,8 +39,9 @@ function useCardContext() {
39
39
  return ctx;
40
40
  }
41
41
  function Card({ children, style: styleOverride, variant = "default", onPress, disabled }) {
42
- const { theme } = useTheme();
42
+ const { theme, getShadowStyle } = useTheme();
43
43
  const styles = createCardStyles(theme);
44
+ const shadowStyle = getShadowStyle("subtle");
44
45
  const ctx = { theme, styles };
45
46
  const { animatedStyle: scaleStyle, pressHandlers } = useScalePress({
46
47
  disabled: !onPress || !!disabled,
@@ -50,6 +51,7 @@ function Card({ children, style: styleOverride, variant = "default", onPress, di
50
51
  const cardContent = (_jsx(View, { style: [
51
52
  styles.card,
52
53
  variant === "default" && styles.cardDefault,
54
+ variant === "default" && shadowStyle,
53
55
  variant === "outline" && styles.cardOutline,
54
56
  variant === "ghost" && styles.cardGhost,
55
57
  styleOverride,
@@ -82,7 +84,6 @@ function CardDescription({ children, style: styleOverride, ...props }) {
82
84
  const createCardStyles = (theme) => StyleSheet.create({
83
85
  card: {
84
86
  borderRadius: spacing.radiusLg,
85
- overflow: "hidden",
86
87
  },
87
88
  cardDefault: {
88
89
  backgroundColor: theme.colors.card,
@@ -116,7 +117,7 @@ const createCardStyles = (theme) => StyleSheet.create({
116
117
  title: {
117
118
  fontSize: 18,
118
119
  lineHeight: 24,
119
- letterSpacing: -0.3,
120
+ letterSpacing: 0,
120
121
  },
121
122
  description: {
122
123
  fontSize: 14,
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { View, StyleSheet, Pressable } from "react-native";
2
+ import { View, StyleSheet, Pressable, Platform } from "react-native";
3
3
  import Animated, { useSharedValue, useAnimatedStyle, withTiming, useReducedMotion, } from "react-native-reanimated";
4
4
  import { Icon } from "./Icon.js";
5
5
  import { StyledText } from "./StyledText.js";
@@ -43,7 +43,7 @@ function Checkbox({ size = "md", label, indeterminate = false, error = false, st
43
43
  const flattenedStyle = styleOverride ? StyleSheet.flatten(styleOverride) : undefined;
44
44
  const checkboxElement = (_jsx(CheckboxPrimitive.Root, { ...props, checked: checked, onCheckedChange: wrappedOnCheckedChange, disabled: disabled, style: {
45
45
  borderColor,
46
- backgroundColor: checked || indeterminate ? theme.colors.primary : "transparent",
46
+ backgroundColor: checked || indeterminate ? theme.colors.primary : theme.colors.background,
47
47
  borderRadius: spacing.radiusSm,
48
48
  borderWidth: 1,
49
49
  width: sizeConfig.size,
@@ -51,6 +51,7 @@ function Checkbox({ size = "md", label, indeterminate = false, error = false, st
51
51
  justifyContent: "center",
52
52
  alignItems: "center",
53
53
  opacity: disabled ? 0.5 : 1,
54
+ ...(Platform.OS === "web" && { cursor: disabled ? "not-allowed" : "pointer" }),
54
55
  ...(flattenedStyle || {}),
55
56
  }, hitSlop: DEFAULT_HIT_SLOP, accessibilityRole: "checkbox", accessibilityState: {
56
57
  checked: indeterminate ? "mixed" : checked,
@@ -67,18 +68,19 @@ function Checkbox({ size = "md", label, indeterminate = false, error = false, st
67
68
  return (_jsxs(Pressable, { onPress: () => !disabled && wrappedOnCheckedChange(!checked), style: [styles.container, labelStyle], disabled: disabled, accessibilityRole: "checkbox", accessibilityState: {
68
69
  checked: indeterminate ? "mixed" : checked,
69
70
  disabled: !!disabled,
70
- }, accessibilityLabel: label, children: [checkboxElement, _jsx(View, { style: styles.labelContainer, children: _jsxs(StyledText, { style: [
71
+ }, accessibilityLabel: label, children: [checkboxElement, _jsx(View, { style: styles.labelContainer, children: _jsxs(StyledText, { selectable: false, style: [
71
72
  styles.label,
72
73
  { color: theme.colors.text },
73
74
  disabled && styles.disabledLabel,
74
75
  error && { color: theme.colors.destructive },
75
- ], children: [label, required && (_jsx(StyledText, { style: [styles.required, { color: theme.colors.destructive }], children: " *" }))] }) })] }));
76
+ ], children: [label, required && (_jsx(StyledText, { selectable: false, style: [styles.required, { color: theme.colors.destructive }], children: " *" }))] }) })] }));
76
77
  }
77
78
  const styles = StyleSheet.create({
78
79
  container: {
79
80
  flexDirection: "row",
80
81
  alignItems: "center",
81
82
  gap: spacing.sm,
83
+ minHeight: spacing.touchTarget,
82
84
  },
83
85
  labelContainer: {
84
86
  flex: 1,
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import * as React from "react";
3
3
  import { Animated, Platform, StyleSheet, View } from "react-native";
4
- import { TextClassContext } from "./StyledText.js";
4
+ import { TextClassContext, TextSelectabilityContext } from "./StyledText.js";
5
5
  import { spacing } from "../constants/spacing.js";
6
6
  import { useTheme } from "../hooks/useTheme.js";
7
7
  import * as CollapsiblePrimitive from "@rn-primitives/collapsible";
@@ -10,19 +10,20 @@ function Collapsible({ children, ...props }) {
10
10
  }
11
11
  function CollapsibleTrigger({ style: styleOverride, ...props }) {
12
12
  const { theme } = useTheme();
13
- return (_jsx(TextClassContext.Provider, { value: "", children: _jsx(CollapsiblePrimitive.Trigger, { ...props, style: {
14
- flexDirection: "row",
15
- alignItems: "center",
16
- justifyContent: "space-between",
17
- paddingVertical: spacing.sm,
18
- ...(Platform.OS === "web" && {
19
- cursor: "pointer",
20
- outlineStyle: "none",
21
- }),
22
- ...(styleOverride && typeof styleOverride !== "function"
23
- ? StyleSheet.flatten(styleOverride)
24
- : {}),
25
- } }) }));
13
+ return (_jsx(TextClassContext.Provider, { value: "", children: _jsx(TextSelectabilityContext.Provider, { value: false, children: _jsx(CollapsiblePrimitive.Trigger, { ...props, style: {
14
+ flexDirection: "row",
15
+ alignItems: "center",
16
+ justifyContent: "space-between",
17
+ paddingVertical: spacing.sm,
18
+ ...(Platform.OS === "web" && {
19
+ cursor: "pointer",
20
+ outlineStyle: "none",
21
+ userSelect: "none",
22
+ }),
23
+ ...(styleOverride && typeof styleOverride !== "function"
24
+ ? StyleSheet.flatten(styleOverride)
25
+ : {}),
26
+ } }) }) }));
26
27
  }
27
28
  function CollapsibleContent({ forceMount, style: styleOverride, children, ...props }) {
28
29
  const { theme } = useTheme();