@mrmeg/expo-ui 0.13.0-rsd.0 → 0.13.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 (81) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/LLM_USAGE.md +15 -0
  3. package/README.md +15 -1
  4. package/dist/components/Accordion.d.ts +18 -38
  5. package/dist/components/Accordion.js +121 -137
  6. package/dist/components/AnimatedView.d.ts +24 -13
  7. package/dist/components/AnimatedView.js +29 -70
  8. package/dist/components/Badge.d.ts +2 -2
  9. package/dist/components/Badge.js +47 -45
  10. package/dist/components/BottomSheet.d.ts +73 -30
  11. package/dist/components/BottomSheet.js +225 -191
  12. package/dist/components/Button.d.ts +56 -43
  13. package/dist/components/Button.js +203 -189
  14. package/dist/components/Card.d.ts +10 -7
  15. package/dist/components/Card.js +105 -90
  16. package/dist/components/Checkbox.d.ts +8 -20
  17. package/dist/components/Checkbox.js +80 -77
  18. package/dist/components/Collapsible.d.ts +47 -47
  19. package/dist/components/Collapsible.js +29 -108
  20. package/dist/components/Dialog.d.ts +121 -80
  21. package/dist/components/Dialog.js +132 -225
  22. package/dist/components/DismissKeyboard.js +2 -33
  23. package/dist/components/Drawer.d.ts +83 -37
  24. package/dist/components/Drawer.js +512 -252
  25. package/dist/components/DropdownMenu.d.ts +113 -106
  26. package/dist/components/DropdownMenu.js +204 -350
  27. package/dist/components/EmptyState.d.ts +2 -2
  28. package/dist/components/EmptyState.js +34 -41
  29. package/dist/components/InputOTP.d.ts +53 -19
  30. package/dist/components/InputOTP.js +102 -81
  31. package/dist/components/KeyboardAvoidingView.d.ts +3 -6
  32. package/dist/components/KeyboardAvoidingView.js +5 -18
  33. package/dist/components/Label.d.ts +17 -12
  34. package/dist/components/Label.js +51 -38
  35. package/dist/components/MaxWidthContainer.d.ts +16 -5
  36. package/dist/components/MaxWidthContainer.js +29 -28
  37. package/dist/components/Notification.d.ts +9 -5
  38. package/dist/components/Notification.js +187 -190
  39. package/dist/components/Popover.d.ts +66 -38
  40. package/dist/components/Popover.js +69 -158
  41. package/dist/components/Progress.d.ts +4 -5
  42. package/dist/components/Progress.js +77 -65
  43. package/dist/components/RadioGroup.d.ts +24 -30
  44. package/dist/components/RadioGroup.js +94 -90
  45. package/dist/components/Select.d.ts +74 -62
  46. package/dist/components/Select.js +154 -241
  47. package/dist/components/Separator.d.ts +23 -13
  48. package/dist/components/Separator.js +36 -29
  49. package/dist/components/Skeleton.d.ts +7 -8
  50. package/dist/components/Skeleton.js +61 -74
  51. package/dist/components/StyledText.context.d.ts +2 -6
  52. package/dist/components/StyledText.context.js +0 -3
  53. package/dist/components/StyledText.d.ts +7 -29
  54. package/dist/components/StyledText.js +29 -92
  55. package/dist/components/Switch.d.ts +6 -18
  56. package/dist/components/Switch.js +106 -112
  57. package/dist/components/Tabs.d.ts +16 -26
  58. package/dist/components/Tabs.js +91 -189
  59. package/dist/components/TextInput.d.ts +19 -6
  60. package/dist/components/TextInput.js +198 -226
  61. package/dist/components/Toggle.d.ts +41 -23
  62. package/dist/components/Toggle.js +98 -84
  63. package/dist/components/ToggleGroup.d.ts +29 -37
  64. package/dist/components/ToggleGroup.js +108 -113
  65. package/dist/components/Tooltip.d.ts +111 -41
  66. package/dist/components/Tooltip.js +118 -156
  67. package/dist/components/UIProvider.d.ts +1 -1
  68. package/dist/components/UIProvider.js +1 -1
  69. package/dist/components/keyboardFocusRegistry.d.ts +15 -1
  70. package/dist/components/keyboardFocusRegistry.js +39 -2
  71. package/dist/hooks/useTheme.d.ts +10 -34
  72. package/dist/hooks/useTheme.js +8 -20
  73. package/dist/lib/index.d.ts +0 -2
  74. package/dist/lib/index.js +0 -2
  75. package/package.json +29 -11
  76. package/dist/lib/portal.d.ts +0 -16
  77. package/dist/lib/portal.js +0 -84
  78. package/dist/lib/styles.d.ts +0 -32
  79. package/dist/lib/styles.js +0 -91
  80. package/dist/lib/useAnchoredPosition.d.ts +0 -57
  81. package/dist/lib/useAnchoredPosition.js +0 -120
@@ -1,14 +1,7 @@
1
1
  import React, { ComponentType } from "react";
2
+ import { PressableProps, PressableStateCallbackType, DimensionValue, StyleProp, TextStyle, ViewStyle } from "react-native";
2
3
  import { TextProps } from "./StyledText";
3
4
  import { type IconProps } from "./Icon";
4
- import { type TextStyleValue } from "./StyledText.context";
5
- import { type LooseStyle } from "../lib/styles";
6
- /**
7
- * Press/pointer/focus handlers accept an optional event arg. Kept loose
8
- * (`any`) so consumers can pass react-native GestureResponderEvent handlers and
9
- * expo-router's <Link asChild> injected onPress without a type mismatch.
10
- */
11
- type PointerHandler = (event?: any) => void;
12
5
  /**
13
6
  * Button variants
14
7
  */
@@ -17,14 +10,23 @@ type Presets = "default" | "outline" | "ghost" | "link" | "destructive" | "secon
17
10
  * Button size variants
18
11
  */
19
12
  export type ButtonSize = "sm" | "md" | "lg";
20
- export type ButtonAccessoryStyle = TextStyleValue;
13
+ export type ButtonAccessoryStyle = {
14
+ margin?: DimensionValue;
15
+ marginHorizontal?: DimensionValue;
16
+ marginVertical?: DimensionValue;
17
+ marginTop?: DimensionValue;
18
+ marginRight?: DimensionValue;
19
+ marginBottom?: DimensionValue;
20
+ marginLeft?: DimensionValue;
21
+ marginStart?: DimensionValue;
22
+ marginEnd?: DimensionValue;
23
+ };
21
24
  export interface ButtonAccessoryProps {
22
25
  style: ButtonAccessoryStyle;
23
- /** True while the button is pressed (pointer/touch down). */
24
- pressed: boolean;
26
+ pressableState: PressableStateCallbackType;
25
27
  disabled?: boolean;
26
28
  }
27
- export interface ButtonProps {
29
+ export interface ButtonProps extends PressableProps {
28
30
  /**
29
31
  * Text which is looked up via i18n.
30
32
  */
@@ -38,38 +40,25 @@ export interface ButtonProps {
38
40
  */
39
41
  txOptions?: TextProps["txOptions"];
40
42
  /**
41
- * Press handler. Mapped to the underlying element's onClick.
43
+ * An optional style override useful for padding & margin.
42
44
  */
43
- onPress?: PointerHandler;
44
- /** Pointer-down handler (maps to onPointerDown). */
45
- onPressIn?: PointerHandler;
46
- /** Pointer-up handler (maps to onPointerUp). */
47
- onPressOut?: PointerHandler;
48
- /** Focus handler. */
49
- onFocus?: PointerHandler;
50
- /** Blur handler. */
51
- onBlur?: PointerHandler;
52
- /**
53
- * An optional style override useful for padding & margin. Accepts
54
- * react-strict-dom styles or plain CSS-shaped objects during the migration.
55
- */
56
- style?: LooseStyle;
45
+ style?: StyleProp<ViewStyle>;
57
46
  /**
58
47
  * An optional style override for the "pressed" state.
59
48
  */
60
- pressedStyle?: LooseStyle;
49
+ pressedStyle?: StyleProp<ViewStyle>;
61
50
  /**
62
51
  * An optional style override for the button text.
63
52
  */
64
- textStyle?: TextStyleValue;
53
+ textStyle?: StyleProp<TextStyle>;
65
54
  /**
66
55
  * An optional style override for the button text when in the "pressed" state.
67
56
  */
68
- pressedTextStyle?: TextStyleValue;
57
+ pressedTextStyle?: StyleProp<TextStyle>;
69
58
  /**
70
59
  * An optional style override for the button text when in the "disabled" state.
71
60
  */
72
- disabledTextStyle?: TextStyleValue;
61
+ disabledTextStyle?: StyleProp<TextStyle>;
73
62
  /**
74
63
  * One of the different types of button presets.
75
64
  * @default "default"
@@ -99,7 +88,7 @@ export interface ButtonProps {
99
88
  /**
100
89
  * An optional style override for the disabled state
101
90
  */
102
- disabledStyle?: LooseStyle;
91
+ disabledStyle?: StyleProp<ViewStyle>;
103
92
  /**
104
93
  * Whether to show shadow
105
94
  */
@@ -112,17 +101,9 @@ export interface ButtonProps {
112
101
  * Whether button should take full width of container
113
102
  */
114
103
  fullWidth?: boolean;
115
- /** Forwarded accessibility label. */
116
- "aria-label"?: string;
117
- /** Forwarded test id (also accepts `data-testid`). */
118
- testID?: string;
119
- /** Forwarded test id (DOM-style; alias of testID). */
120
- "data-testid"?: string;
121
- /** Accessibility role hint (kept for RN-API compatibility; defaults button). */
122
- accessibilityLabel?: string;
123
104
  }
124
105
  /**
125
- * Enhanced Button Component (react-strict-dom)
106
+ * Enhanced Button Component
126
107
  *
127
108
  * Features:
128
109
  * - 6 variants (default, outline, ghost, link, destructive, secondary)
@@ -135,9 +116,25 @@ export interface ButtonProps {
135
116
  *
136
117
  * Usage:
137
118
  * ```tsx
119
+ * // Simplest path — plain label via the `text` prop
138
120
  * <Button onPress={handler} text="Click Me" />
121
+ *
122
+ * // Different variants
139
123
  * <Button preset="outline" onPress={handler} text="Outline" />
124
+ * <Button preset="ghost" onPress={handler} text="Ghost" />
125
+ * <Button preset="destructive" onPress={handler} text="Delete" />
126
+ *
127
+ * // Different sizes
128
+ * <Button size="sm" onPress={handler} text="Small" />
129
+ * <Button size="lg" onPress={handler} text="Large" />
130
+ *
131
+ * // Loading state
140
132
  * <Button loading onPress={handler} text="Processing..." />
133
+ *
134
+ * // Full width
135
+ * <Button fullWidth onPress={handler} text="Submit" />
136
+ *
137
+ * // Composed content — icon + label via subcomponents
141
138
  * <Button onPress={handler}>
142
139
  * <Button.Icon name="heart" />
143
140
  * <Button.Text>Like</Button.Text>
@@ -148,19 +145,35 @@ declare function ButtonRoot(props: ButtonProps): React.JSX.Element;
148
145
  /**
149
146
  * Button.Text
150
147
  * Text content for a Button. Inherits the button's control typography, color,
151
- * and non-selectable behavior from context.
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>
155
+ * ```
152
156
  */
153
157
  declare function ButtonText(props: TextProps): React.JSX.Element;
154
158
  /**
155
159
  * Button.Icon
156
160
  * Icon content for a Button. Defaults its color to the button's text color
157
- * (from context) and is decorative by default.
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
+ * ```
158
169
  */
159
170
  declare function ButtonIcon(props: IconProps): React.JSX.Element;
160
171
  /**
161
172
  * Button with explicit subcomponents.
162
173
  * - `Button.Text` for label text (inherits control typography)
163
174
  * - `Button.Icon` for icons (inherits the button's text color)
175
+ *
176
+ * The `tx`/`text` props remain the simplest path for plain labels.
164
177
  */
165
178
  declare const Button: typeof ButtonRoot & {
166
179
  Text: typeof ButtonText;
@@ -1,14 +1,14 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { use, useCallback, useMemo, useState, } from "react";
3
- import { css, html } from "react-strict-dom";
2
+ import { use, useCallback, useMemo, useState } from "react";
3
+ import { Pressable, StyleSheet, View, Platform, ActivityIndicator, Animated, } from "react-native";
4
4
  import { spacing } from "../constants/spacing.js";
5
5
  import { StyledText } from "./StyledText.js";
6
6
  import { Icon } from "./Icon.js";
7
- import { TextColorContext, TextSelectabilityContext, TextStyleContext, } from "./StyledText.context";
7
+ import { TextColorContext, TextSelectabilityContext, TextStyleContext } from "./StyledText.context";
8
8
  import { fontFamilies } from "../constants/fonts.js";
9
9
  import { palette } from "../constants/colors.js";
10
10
  import { useTheme } from "../hooks/useTheme.js";
11
- import { flattenStyle } from "../lib/styles.js";
11
+ import { useScalePress } from "../hooks/useScalePress.js";
12
12
  const SIZE_CONFIGS = {
13
13
  sm: {
14
14
  paddingVertical: spacing.xxs,
@@ -29,120 +29,9 @@ const SIZE_CONFIGS = {
29
29
  height: 40,
30
30
  },
31
31
  };
32
- // css.keyframes() MUST be called inline and unconditionally — StyleX's babel
33
- // plugin (web) can only fold a keyframe name defined in the same file, and it
34
- // cannot fold a runtime ternary condition. On native css.keyframes is a no-op
35
- // that just returns its arg (a __DEV__ console log), and the animationName has
36
- // no effect — the spinner shows a static ring (native animation fidelity gap).
37
- const spin = css.keyframes({
38
- from: { transform: "rotate(0deg)" },
39
- to: { transform: "rotate(360deg)" },
40
- });
41
- // Static + function-rule styles. Theme colors and per-size numbers are runtime
42
- // values carried via function rules; layout/structure is static where possible.
43
- const styles = css.create({
44
- pressable: (alignSelf) => ({
45
- alignSelf,
46
- appearance: "none",
47
- backgroundColor: "transparent",
48
- borderWidth: 0,
49
- padding: 0,
50
- margin: 0,
51
- cursor: "pointer",
52
- }),
53
- // StyleX requires values referenced inside css.create() to be statically
54
- // resolvable, AND css.defineConsts is unusable here (it returns undefined on
55
- // the react-strict-dom native runtime). So spacing tokens are threaded in as
56
- // function-rule arguments. borderRadius literal (6) = spacing.radiusMd.
57
- surface: (paddingVertical, paddingHorizontal, minHeight, borderRadius) => ({
58
- position: "relative",
59
- display: "flex",
60
- flexDirection: "row",
61
- alignItems: "center",
62
- justifyContent: "center",
63
- borderRadius,
64
- paddingTop: paddingVertical,
65
- paddingBottom: paddingVertical,
66
- paddingLeft: paddingHorizontal,
67
- paddingRight: paddingHorizontal,
68
- minHeight,
69
- // NOTE: no flexShrink here. This is the button's OUTERMOST element, whose
70
- // parent is consumer-controlled and often display:block on web — react-strict-dom
71
- // warns "display:flex is required on the parent for flexShrink to have an effect."
72
- // Shrink behaviour in a flex row is the parent's concern, not this leaf's.
73
- transitionProperty: "transform, opacity",
74
- transitionDuration: "0.1s",
75
- }),
76
- bg: (backgroundColor) => ({ backgroundColor }),
77
- outline: (borderColor) => ({
78
- backgroundColor: "transparent",
79
- borderWidth: 1,
80
- borderStyle: "solid",
81
- borderColor,
82
- }),
83
- transparentBg: { backgroundColor: "transparent" },
84
- link: {
85
- backgroundColor: "transparent",
86
- paddingTop: 0,
87
- paddingBottom: 0,
88
- paddingLeft: 0,
89
- paddingRight: 0,
90
- },
91
- fullWidth: { width: "100%" },
92
- scalePressed: { transform: "scale(0.97)" },
93
- pressed: { opacity: 0.9 },
94
- pressedMuted: (backgroundColor) => ({ backgroundColor }),
95
- disabled: { opacity: 0.6, cursor: "default" },
96
- focusRing: (boxShadow) => ({ boxShadow }),
97
- shadow: (boxShadow) => ({ boxShadow }),
98
- content: {
99
- display: "flex",
100
- flexDirection: "row",
101
- alignItems: "center",
102
- justifyContent: "center",
103
- flexShrink: 0,
104
- },
105
- loadingContent: { opacity: 0 },
106
- loaderOverlay: {
107
- position: "absolute",
108
- top: 0,
109
- left: 0,
110
- right: 0,
111
- bottom: 0,
112
- display: "flex",
113
- alignItems: "center",
114
- justifyContent: "center",
115
- pointerEvents: "none",
116
- },
117
- spinner: (color) => ({
118
- width: 16,
119
- height: 16,
120
- borderRadius: 9999,
121
- borderWidth: 2,
122
- borderStyle: "solid",
123
- borderColor: color,
124
- borderTopColor: "transparent",
125
- animationName: spin,
126
- animationDuration: "0.7s",
127
- animationIterationCount: "infinite",
128
- animationTimingFunction: "linear",
129
- }),
130
- });
131
- // Text style passed to the label via context. Plain object (function of size).
132
- function buildTextStyle(size) {
133
- const sizeConfig = SIZE_CONFIGS[size];
134
- return {
135
- fontFamily: fontFamilies.sansSerif.regular,
136
- fontSize: sizeConfig.fontSize,
137
- fontWeight: "500",
138
- textAlign: "center",
139
- lineHeight: sizeConfig.fontSize * 1.4,
140
- flexShrink: 0,
141
- userSelect: "none",
142
- };
143
- }
32
+ const getNativeHitSlop = (sizeConfig) => Math.ceil(Math.max(0, spacing.touchTarget - sizeConfig.height) / 2);
144
33
  /**
145
- * Enhanced Button Component (react-strict-dom)
34
+ * Enhanced Button Component
146
35
  *
147
36
  * Features:
148
37
  * - 6 variants (default, outline, ghost, link, destructive, secondary)
@@ -155,9 +44,25 @@ function buildTextStyle(size) {
155
44
  *
156
45
  * Usage:
157
46
  * ```tsx
47
+ * // Simplest path — plain label via the `text` prop
158
48
  * <Button onPress={handler} text="Click Me" />
49
+ *
50
+ * // Different variants
159
51
  * <Button preset="outline" onPress={handler} text="Outline" />
52
+ * <Button preset="ghost" onPress={handler} text="Ghost" />
53
+ * <Button preset="destructive" onPress={handler} text="Delete" />
54
+ *
55
+ * // Different sizes
56
+ * <Button size="sm" onPress={handler} text="Small" />
57
+ * <Button size="lg" onPress={handler} text="Large" />
58
+ *
59
+ * // Loading state
160
60
  * <Button loading onPress={handler} text="Processing..." />
61
+ *
62
+ * // Full width
63
+ * <Button fullWidth onPress={handler} text="Submit" />
64
+ *
65
+ * // Composed content — icon + label via subcomponents
161
66
  * <Button onPress={handler}>
162
67
  * <Button.Icon name="heart" />
163
68
  * <Button.Text>Like</Button.Text>
@@ -165,17 +70,20 @@ function buildTextStyle(size) {
165
70
  * ```
166
71
  */
167
72
  function ButtonRoot(props) {
168
- 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, onPress, onFocus, onBlur, onPressIn, onPressOut, "aria-label": ariaLabel, accessibilityLabel, testID, "data-testid": dataTestId, } = props;
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;
169
74
  const { theme, getContrastingColor, getFocusRingStyle, getShadowStyle } = useTheme();
170
- const sizeConfig = SIZE_CONFIGS[size];
75
+ const styles = useMemo(() => createStyles(theme, size), [theme, size]);
171
76
  const shadowStyle = getShadowStyle("base");
77
+ const sizeConfig = SIZE_CONFIGS[size];
172
78
  const focusRingStyle = getFocusRingStyle();
173
- const textStyleValue = useMemo(() => buildTextStyle(size), [size]);
174
- // Background color per preset (override style may set its own).
175
- const overrideBgColor = flattenStyle(styleOverride)?.backgroundColor;
79
+ // Pre-compute background color for contrast calculation
80
+ // Always flatten to handle both array styles (from Slot) and RegisteredStyle
81
+ const flattenedStyle = styleOverride ? StyleSheet.flatten(styleOverride) : undefined;
82
+ const customBgColor = flattenedStyle?.backgroundColor;
83
+ // Calculate background color for each preset
176
84
  let backgroundColor;
177
- if (typeof overrideBgColor === "string") {
178
- backgroundColor = overrideBgColor;
85
+ if (customBgColor && typeof customBgColor === "string") {
86
+ backgroundColor = customBgColor;
179
87
  }
180
88
  else if (preset === "default") {
181
89
  backgroundColor = theme.colors.primary;
@@ -192,7 +100,7 @@ function ButtonRoot(props) {
192
100
  else {
193
101
  backgroundColor = theme.colors.primary;
194
102
  }
195
- // Text color per preset for readable contrast.
103
+ // Determine text color per preset for readable contrast
196
104
  const textColor = preset === "outline"
197
105
  ? theme.colors.foreground
198
106
  : preset === "ghost"
@@ -205,87 +113,101 @@ function ButtonRoot(props) {
205
113
  ? theme.colors.secondaryForeground
206
114
  : getContrastingColor(backgroundColor, palette.white, palette.black);
207
115
  const [focused, setFocused] = useState(false);
208
- const [pressed, setPressed] = useState(false);
116
+ const [restingWidth, setRestingWidth] = useState();
209
117
  const isDisabled = disabled || loading;
210
- const handleClick = useCallback(() => {
211
- if (isDisabled)
212
- return;
213
- onPress?.();
214
- }, [isDisabled, onPress]);
215
- const handleFocus = (event) => {
216
- // Keyboard focus shows the ring; pointer focus does not (mirrors
217
- // :focus-visible). On web the event target exposes matches(); fall back to
218
- // showing the ring when it can't be queried.
118
+ const { animatedStyle: scaleStyle, pressHandlers } = useScalePress({
119
+ disabled: !!isDisabled,
120
+ haptic: false,
121
+ scaleTo: preset === "link" ? 1 : 0.97,
122
+ });
123
+ const showFocusRing = (event) => {
124
+ // On web, pointer taps focus the element too, which left the ring visible
125
+ // after every click. :focus-visible is only true for keyboard-driven
126
+ // focus, so gate the ring on it; fall back to showing the ring when the
127
+ // target can't be queried (non-DOM targets, older engines).
219
128
  let ringVisible = true;
220
- const target = event?.target;
221
- if (target && typeof target.matches === "function") {
222
- try {
223
- ringVisible = target.matches(":focus-visible");
224
- }
225
- catch {
226
- ringVisible = true;
129
+ if (Platform.OS === "web") {
130
+ const target = event?.nativeEvent?.target;
131
+ if (target && typeof target.matches === "function") {
132
+ try {
133
+ ringVisible = target.matches(":focus-visible");
134
+ }
135
+ catch {
136
+ ringVisible = true;
137
+ }
227
138
  }
228
139
  }
229
140
  setFocused(ringVisible);
230
141
  onFocus?.(event);
231
142
  };
232
- const handleBlur = (event) => {
143
+ const hideFocusRing = (event) => {
233
144
  setFocused(false);
234
145
  onBlur?.(event);
235
146
  };
236
- const handlePointerDown = (event) => {
237
- if (isDisabled)
238
- return;
239
- setPressed(true);
147
+ const handlePressIn = (event) => {
148
+ pressHandlers.onPressIn();
240
149
  onPressIn?.(event);
241
150
  };
242
- const handlePointerUp = (event) => {
243
- if (isDisabled)
244
- return;
245
- setPressed(false);
151
+ const handlePressOut = (event) => {
152
+ pressHandlers.onPressOut();
246
153
  onPressOut?.(event);
247
154
  };
248
- const handlePointerLeave = () => {
249
- if (pressed)
250
- setPressed(false);
251
- };
252
- const surfaceStyles = [
253
- styles.surface(sizeConfig.paddingVertical, sizeConfig.paddingHorizontal, sizeConfig.height, spacing.radiusMd),
254
- preset === "default" || preset === "secondary" || preset === "destructive"
255
- ? styles.bg(backgroundColor)
256
- : null,
257
- preset === "outline" ? styles.outline(theme.colors.input) : null,
258
- preset === "ghost" ? styles.transparentBg : null,
259
- preset === "link" ? styles.link : null,
260
- fullWidth ? styles.fullWidth : null,
261
- withShadow && !isDisabled ? styles.shadow(shadowStyle.boxShadow ?? "none") : null,
262
- pressed && (preset === "outline" || preset === "ghost")
263
- ? styles.pressedMuted(theme.colors.muted)
264
- : null,
265
- pressed ? styles.pressed : null,
266
- pressed && !isDisabled ? styles.scalePressed : null,
267
- pressed ? pressedStyleOverride : null,
268
- isDisabled ? styles.disabled : null,
269
- isDisabled ? disabledStyleOverride : null,
270
- focused && !isDisabled ? styles.focusRing(focusRingStyle.boxShadow ?? "none") : null,
271
- styleOverride,
272
- ];
273
- const labelStyle = [
274
- textStyleValue,
275
- pressed ? { opacity: 0.9 } : null,
276
- pressed ? pressedTextStyleOverride : null,
277
- isDisabled ? disabledTextStyleOverride : null,
278
- textStyleOverride,
279
- ];
280
- return (_jsx(TextColorContext.Provider, { value: textColor, children: _jsx(TextSelectabilityContext.Provider, { value: false, children: _jsx(TextStyleContext.Provider, { value: textStyleValue, children: _jsxs(html.button, { type: "button", "aria-label": ariaLabel ?? accessibilityLabel, "aria-busy": loading, "aria-disabled": isDisabled, "data-testid": dataTestId ?? testID, disabled: isDisabled, onClick: handleClick, onPointerDown: handlePointerDown, onPointerUp: handlePointerUp, onPointerLeave: handlePointerLeave, onFocus: handleFocus, onBlur: handleBlur, style: [
281
- styles.pressable(fullWidth ? "stretch" : "flex-start"),
282
- ...surfaceStyles,
283
- ], children: [loading && (_jsx(html.div, { style: styles.loaderOverlay, children: _jsx(html.div, { style: styles.spinner(textColor) }) })), _jsxs(html.div, { style: [styles.content, loading ? styles.loadingContent : null], children: [!!LeftAccessory && (_jsx(LeftAccessory, { style: { marginRight: spacing.sm }, pressed: pressed, disabled: isDisabled })), (tx || text) ? (_jsx(StyledText, { tx: tx, text: text, txOptions: txOptions, style: labelStyle })) : (children ?? null), !!RightAccessory && (_jsx(RightAccessory, { style: { marginLeft: spacing.sm }, pressed: pressed, disabled: isDisabled }))] })] }) }) }) }));
155
+ const handleButtonLayout = useCallback((event) => {
156
+ if (loading || fullWidth)
157
+ return;
158
+ const nextWidth = event.nativeEvent.layout.width;
159
+ if (nextWidth <= 0)
160
+ return;
161
+ setRestingWidth((currentWidth) => {
162
+ if (currentWidth !== undefined && Math.abs(currentWidth - nextWidth) < 0.5) {
163
+ return currentWidth;
164
+ }
165
+ return nextWidth;
166
+ });
167
+ }, [fullWidth, loading]);
168
+ 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: [
169
+ styles.button,
170
+ preset === "default" && styles.buttonDefault,
171
+ preset === "outline" && styles.buttonOutline,
172
+ preset === "ghost" && styles.buttonGhost,
173
+ preset === "link" && styles.buttonLink,
174
+ preset === "destructive" && styles.buttonDestructive,
175
+ preset === "secondary" && styles.buttonSecondary,
176
+ fullWidth && styles.fullWidth,
177
+ withShadow && !isDisabled && shadowStyle,
178
+ state.pressed && preset === "outline" && styles.pressedMuted,
179
+ state.pressed && preset === "ghost" && styles.pressedMuted,
180
+ state.pressed && styles.pressed,
181
+ state.pressed && pressedStyleOverride,
182
+ isDisabled && styles.disabled,
183
+ isDisabled && disabledStyleOverride,
184
+ focused && !isDisabled && focusRingStyle,
185
+ loading && restingWidth !== undefined && !fullWidth && { width: restingWidth },
186
+ // Spread array styles from Slot to prevent nested arrays on web
187
+ ...(Array.isArray(styleOverride) ? styleOverride : [styleOverride]),
188
+ ], 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: [
189
+ styles.text,
190
+ state.pressed && styles.pressedText,
191
+ state.pressed && pressedTextStyleOverride,
192
+ isDisabled && disabledTextStyleOverride,
193
+ textStyleOverride,
194
+ ] })) : (
195
+ // Children render as-is. For text content, use the explicit
196
+ // <Button.Text> subcomponent so it inherits control typography
197
+ // via context instead of relying on a runtime `typeof` check.
198
+ children ?? null), !!RightAccessory && (_jsx(RightAccessory, { style: styles.rightAccessory, pressableState: state, disabled: isDisabled }))] })] }) })) }) }) }) }));
284
199
  }
285
200
  /**
286
201
  * Button.Text
287
202
  * Text content for a Button. Inherits the button's control typography, color,
288
- * and non-selectable behavior from context.
203
+ * and non-selectable behavior from context, so callers state their intent
204
+ * explicitly instead of relying on the component to inspect `typeof children`.
205
+ *
206
+ * ```tsx
207
+ * <Button onPress={save}>
208
+ * <Button.Text>Save</Button.Text>
209
+ * </Button>
210
+ * ```
289
211
  */
290
212
  function ButtonText(props) {
291
213
  return _jsx(StyledText, { ...props });
@@ -293,16 +215,108 @@ function ButtonText(props) {
293
215
  /**
294
216
  * Button.Icon
295
217
  * Icon content for a Button. Defaults its color to the button's text color
296
- * (from context) and is decorative by default.
218
+ * (from context) and is decorative by default, since the label conveys meaning.
219
+ *
220
+ * ```tsx
221
+ * <Button onPress={like}>
222
+ * <Button.Icon name="heart" />
223
+ * <Button.Text>Like</Button.Text>
224
+ * </Button>
225
+ * ```
297
226
  */
298
227
  function ButtonIcon(props) {
299
228
  const contextColor = use(TextColorContext);
300
229
  return _jsx(Icon, { decorative: true, color: contextColor, ...props });
301
230
  }
231
+ const createStyles = (theme, size) => {
232
+ const sizeConfig = SIZE_CONFIGS[size];
233
+ return StyleSheet.create({
234
+ button: {
235
+ position: "relative",
236
+ flexDirection: "row",
237
+ alignItems: "center",
238
+ justifyContent: "center",
239
+ borderRadius: spacing.radiusMd,
240
+ paddingVertical: sizeConfig.paddingVertical,
241
+ paddingHorizontal: sizeConfig.paddingHorizontal,
242
+ minHeight: sizeConfig.height,
243
+ flexShrink: 0,
244
+ ...(Platform.OS === "web" && { cursor: "pointer" }),
245
+ },
246
+ content: {
247
+ flexDirection: "row",
248
+ alignItems: "center",
249
+ justifyContent: "center",
250
+ flexShrink: 0,
251
+ },
252
+ loadingContent: {
253
+ opacity: 0,
254
+ },
255
+ buttonDefault: {
256
+ backgroundColor: theme.colors.primary,
257
+ },
258
+ buttonSecondary: {
259
+ backgroundColor: theme.colors.secondary,
260
+ },
261
+ buttonDestructive: {
262
+ backgroundColor: theme.colors.destructive,
263
+ },
264
+ buttonOutline: {
265
+ backgroundColor: "transparent",
266
+ borderWidth: 1,
267
+ borderColor: theme.colors.input,
268
+ },
269
+ buttonGhost: {
270
+ backgroundColor: "transparent",
271
+ },
272
+ buttonLink: {
273
+ backgroundColor: "transparent",
274
+ paddingVertical: 0,
275
+ paddingHorizontal: 0,
276
+ },
277
+ fullWidth: {
278
+ width: "100%",
279
+ },
280
+ text: {
281
+ fontFamily: fontFamilies.sansSerif.regular,
282
+ fontSize: sizeConfig.fontSize,
283
+ fontWeight: "500",
284
+ textAlign: "center",
285
+ lineHeight: sizeConfig.fontSize * 1.4,
286
+ flexShrink: 0,
287
+ userSelect: "none",
288
+ },
289
+ pressed: {
290
+ opacity: 0.9,
291
+ },
292
+ pressedMuted: {
293
+ backgroundColor: theme.colors.muted,
294
+ },
295
+ pressedText: {
296
+ opacity: 0.9,
297
+ },
298
+ disabled: {
299
+ opacity: 0.6,
300
+ },
301
+ leftAccessory: {
302
+ marginRight: spacing.sm,
303
+ },
304
+ rightAccessory: {
305
+ marginLeft: spacing.sm,
306
+ },
307
+ loaderOverlay: {
308
+ ...StyleSheet.absoluteFill,
309
+ alignItems: "center",
310
+ justifyContent: "center",
311
+ },
312
+ });
313
+ };
302
314
  /**
303
315
  * Button with explicit subcomponents.
304
316
  * - `Button.Text` for label text (inherits control typography)
305
317
  * - `Button.Icon` for icons (inherits the button's text color)
318
+ *
319
+ * The `tx`/`text` props remain the simplest path for plain labels.
306
320
  */
307
321
  const Button = Object.assign(ButtonRoot, {
308
322
  Text: ButtonText,