@mrmeg/expo-ui 0.27.0 → 0.28.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 (145) hide show
  1. package/CHANGELOG.md +712 -0
  2. package/LLM_USAGE.md +260 -32
  3. package/README.md +558 -101
  4. package/dist/components/Accordion.js +12 -6
  5. package/dist/components/AnimatedView.d.ts +1 -1
  6. package/dist/components/AnimatedView.js +5 -2
  7. package/dist/components/Avatar.d.ts +1 -1
  8. package/dist/components/Avatar.js +2 -2
  9. package/dist/components/Badge.js +5 -1
  10. package/dist/components/BottomSheet.d.ts +66 -9
  11. package/dist/components/BottomSheet.js +143 -39
  12. package/dist/components/Button.d.ts +20 -4
  13. package/dist/components/Button.js +64 -43
  14. package/dist/components/Card.d.ts +1 -1
  15. package/dist/components/Card.js +24 -4
  16. package/dist/components/Carousel.js +2 -2
  17. package/dist/components/Checkbox.js +51 -44
  18. package/dist/components/Collapsible.js +7 -3
  19. package/dist/components/Dialog.js +178 -29
  20. package/dist/components/Drawer.d.ts +1 -1
  21. package/dist/components/Drawer.js +65 -55
  22. package/dist/components/DropdownMenu.js +11 -7
  23. package/dist/components/EmptyState.d.ts +2 -2
  24. package/dist/components/EmptyState.js +5 -2
  25. package/dist/components/Hydrated.d.ts +31 -0
  26. package/dist/components/Hydrated.js +30 -0
  27. package/dist/components/Icon.d.ts +17 -5
  28. package/dist/components/Icon.js +31 -10
  29. package/dist/components/InputOTP.js +7 -3
  30. package/dist/components/Item.d.ts +103 -4
  31. package/dist/components/Item.js +168 -15
  32. package/dist/components/KeyboardAvoidingView.d.ts +1 -1
  33. package/dist/components/KeyboardAvoidingView.js +4 -1
  34. package/dist/components/Label.js +3 -2
  35. package/dist/components/MaxWidthContainer.js +1 -1
  36. package/dist/components/Notification.js +45 -27
  37. package/dist/components/Popover.d.ts +18 -5
  38. package/dist/components/Popover.js +72 -8
  39. package/dist/components/Progress.js +8 -6
  40. package/dist/components/RadioGroup.js +52 -49
  41. package/dist/components/Screen.d.ts +61 -0
  42. package/dist/components/Screen.js +62 -0
  43. package/dist/components/SectionHeader.d.ts +1 -1
  44. package/dist/components/SegmentedControl.d.ts +12 -4
  45. package/dist/components/SegmentedControl.js +94 -4
  46. package/dist/components/Select.js +21 -26
  47. package/dist/components/Skeleton.js +24 -10
  48. package/dist/components/Slider.d.ts +11 -3
  49. package/dist/components/Slider.js +74 -6
  50. package/dist/components/StatCard.d.ts +4 -2
  51. package/dist/components/StatCard.js +3 -1
  52. package/dist/components/StyledText.d.ts +7 -1
  53. package/dist/components/StyledText.js +5 -3
  54. package/dist/components/Switch.d.ts +1 -1
  55. package/dist/components/Switch.js +23 -32
  56. package/dist/components/Tabs.d.ts +2 -2
  57. package/dist/components/Tabs.js +13 -24
  58. package/dist/components/TextInput.d.ts +1 -1
  59. package/dist/components/TextInput.js +73 -27
  60. package/dist/components/Toggle.d.ts +2 -42
  61. package/dist/components/Toggle.js +38 -27
  62. package/dist/components/ToggleGroup.d.ts +1 -1
  63. package/dist/components/ToggleGroup.js +37 -32
  64. package/dist/components/Tooltip.d.ts +1 -1
  65. package/dist/components/Tooltip.js +9 -3
  66. package/dist/components/UIProvider.d.ts +22 -2
  67. package/dist/components/UIProvider.js +15 -3
  68. package/dist/components/bottomSheetDismiss.d.ts +19 -0
  69. package/dist/components/bottomSheetDismiss.js +44 -0
  70. package/dist/components/bottomSheetHostSupport.d.ts +46 -0
  71. package/dist/components/bottomSheetHostSupport.js +67 -0
  72. package/dist/components/index.d.ts +46 -44
  73. package/dist/components/index.js +2 -0
  74. package/dist/components/keyboardDismiss.d.ts +25 -0
  75. package/dist/components/keyboardDismiss.js +60 -3
  76. package/dist/components/nativeTextField.android.js +13 -9
  77. package/dist/constants/colors.d.ts +40 -5
  78. package/dist/constants/colors.js +26 -8
  79. package/dist/constants/fonts.d.ts +41 -2
  80. package/dist/constants/fonts.js +46 -7
  81. package/dist/constants/index.d.ts +5 -4
  82. package/dist/constants/index.js +1 -0
  83. package/dist/constants/interaction.d.ts +18 -0
  84. package/dist/constants/interaction.js +18 -0
  85. package/dist/constants/spacing.d.ts +2 -1
  86. package/dist/constants/spacing.js +2 -1
  87. package/dist/design-system.json +154 -2
  88. package/dist/hooks/index.d.ts +12 -7
  89. package/dist/hooks/index.js +5 -0
  90. package/dist/hooks/useDimensions.d.ts +13 -8
  91. package/dist/hooks/useDimensions.js +123 -48
  92. package/dist/hooks/useFocusVisible.d.ts +30 -0
  93. package/dist/hooks/useFocusVisible.js +45 -0
  94. package/dist/hooks/useFontStyle.d.ts +2 -2
  95. package/dist/hooks/useFontStyle.js +3 -2
  96. package/dist/hooks/useHydrated.d.ts +13 -0
  97. package/dist/hooks/useHydrated.js +19 -0
  98. package/dist/hooks/useNotificationOffset.d.ts +20 -0
  99. package/dist/hooks/useNotificationOffset.js +34 -0
  100. package/dist/hooks/useResources.d.ts +21 -20
  101. package/dist/hooks/useResources.js +72 -68
  102. package/dist/hooks/useScalePress.d.ts +8 -5
  103. package/dist/hooks/useScalePress.js +16 -9
  104. package/dist/hooks/useShape.d.ts +24 -0
  105. package/dist/hooks/useShape.js +24 -0
  106. package/dist/hooks/useStaggeredEntrance.js +9 -7
  107. package/dist/hooks/useTheme.d.ts +3 -3
  108. package/dist/hooks/useTheme.js +177 -66
  109. package/dist/hooks/useWindowInsets.d.ts +13 -0
  110. package/dist/hooks/useWindowInsets.js +23 -0
  111. package/dist/index.d.ts +5 -5
  112. package/dist/lib/composeRefs.d.ts +8 -0
  113. package/dist/lib/composeRefs.js +20 -0
  114. package/dist/lib/haptics.d.ts +12 -0
  115. package/dist/lib/haptics.js +21 -0
  116. package/dist/lib/index.d.ts +6 -4
  117. package/dist/lib/index.js +2 -0
  118. package/dist/lib/interFonts.d.ts +15 -0
  119. package/dist/lib/interFonts.js +1 -0
  120. package/dist/lib/interFonts.native.d.ts +3 -0
  121. package/dist/lib/interFonts.native.js +20 -0
  122. package/dist/lib/popoverPlacement.d.ts +36 -0
  123. package/dist/lib/popoverPlacement.js +29 -0
  124. package/dist/lib/stateSurface.d.ts +28 -0
  125. package/dist/lib/stateSurface.js +30 -0
  126. package/dist/lib/themedStyles.d.ts +1 -1
  127. package/dist/lib/useAnimatedValue.d.ts +15 -0
  128. package/dist/lib/useAnimatedValue.js +19 -0
  129. package/dist/lib/webHitTarget.d.ts +40 -0
  130. package/dist/lib/webHitTarget.js +37 -0
  131. package/dist/state/feedbackStore.d.ts +24 -0
  132. package/dist/state/feedbackStore.js +16 -0
  133. package/dist/state/globalUIStore.d.ts +27 -0
  134. package/dist/state/globalUIStore.js +23 -0
  135. package/dist/state/index.d.ts +7 -5
  136. package/dist/state/index.js +2 -0
  137. package/dist/state/initialScheme.d.ts +27 -0
  138. package/dist/state/initialScheme.js +29 -0
  139. package/dist/state/notify.d.ts +1 -1
  140. package/dist/state/themeColorScope.d.ts +1 -1
  141. package/dist/state/themeStore.d.ts +69 -16
  142. package/dist/state/themeStore.js +65 -38
  143. package/llms-full.md +56 -8
  144. package/llms.txt +10 -1
  145. package/package.json +69 -2
@@ -2,60 +2,51 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { palette } from "../constants/colors.js";
3
3
  import { spacing } from "../constants/spacing.js";
4
4
  import { useTheme } from "../hooks/useTheme.js";
5
- import { hapticLight } from "../lib/haptics.js";
5
+ import { useFocusVisible } from "../hooks/useFocusVisible.js";
6
+ import { hapticSelection } from "../lib/haptics.js";
7
+ import { interaction } from "../constants/interaction.js";
8
+ import { stateSurfaceProps } from "../lib/stateSurface.js";
9
+ import { useAnimatedValue } from "../lib/useAnimatedValue.js";
6
10
  import * as SwitchPrimitives from "@rn-primitives/switch";
7
- import { useCallback, useEffect, useRef, useState } from "react";
11
+ import { useCallback, useEffect, useRef } from "react";
8
12
  import { ActivityIndicator, Platform, StyleSheet, View, Animated } from "react-native";
9
13
  import { StyledText } from "./StyledText.js";
10
14
  import { useReducedMotion } from "../hooks/useReduceMotion.js";
11
15
  import { useScalePress } from "../hooks/useScalePress.js";
16
+ import { shouldUseNativeDriver } from "../lib/animations.js";
17
+ import { useItemControlLabel } from "./Item.js";
12
18
  const DEFAULT_HIT_SLOP = 8;
13
- function Switch({ variant = "default", labelOn, labelOff, size = { width: 44, height: 24 }, thumbSize = 20, loading = false, style: styleOverride, ...props }) {
19
+ function Switch({ variant = "default", labelOn, labelOff, size = { width: 44, height: 24 }, thumbSize = 20, loading = false, style: styleOverride, onCheckedChange, ...props }) {
14
20
  const { theme, getContrastingColor, getShadowStyle, getFocusRingStyle, withAlpha } = useTheme();
15
21
  const reduceMotion = useReducedMotion();
16
22
  const hasMounted = useRef(false);
17
- const [focused, setFocused] = useState(false);
18
23
  const focusRingStyle = getFocusRingStyle();
19
24
  const { animatedStyle: scaleStyle, pressHandlers } = useScalePress({
20
25
  disabled: !!props.disabled,
21
- scaleTo: 0.92,
26
+ scaleTo: interaction.controlPressedScale,
22
27
  haptic: false,
23
28
  });
24
- const showFocusRing = (event) => {
25
- let ringVisible = true;
26
- if (Platform.OS === "web") {
27
- const target = event?.nativeEvent?.target;
28
- if (target && typeof target.matches === "function") {
29
- try {
30
- ringVisible = target.matches(":focus-visible");
31
- }
32
- catch {
33
- ringVisible = true;
34
- }
35
- }
36
- }
37
- setFocused(ringVisible);
38
- };
39
- const hideFocusRing = () => {
40
- setFocused(false);
41
- };
29
+ const { focused, onFocus: showFocusRing, onBlur: hideFocusRing } = useFocusVisible();
30
+ // Inside an Item row with no label of its own, the switch is named by the
31
+ // row title (aria-labelledby on web, accessibilityLabel on native).
32
+ const rowLabel = useItemControlLabel(props);
42
33
  // Fire haptic on user-initiated toggles (skip initial mount)
43
34
  const wrappedOnCheckedChange = useCallback((checked) => {
44
35
  if (hasMounted.current)
45
- hapticLight();
46
- props.onCheckedChange?.(checked);
47
- }, [props.onCheckedChange]);
36
+ hapticSelection();
37
+ onCheckedChange?.(checked);
38
+ }, [onCheckedChange]);
48
39
  useEffect(() => {
49
40
  hasMounted.current = true;
50
41
  }, []);
51
42
  // Single animated value drives everything: 0 = off, 1 = on
52
- const progress = useRef(new Animated.Value(props.checked ? 1 : 0)).current;
43
+ const progress = useAnimatedValue(props.checked ? 1 : 0);
53
44
  useEffect(() => {
54
45
  const target = props.checked ? 1 : 0;
55
46
  Animated.timing(progress, {
56
47
  toValue: target,
57
48
  duration: reduceMotion ? 0 : 120,
58
- useNativeDriver: true,
49
+ useNativeDriver: shouldUseNativeDriver,
59
50
  }).start();
60
51
  }, [props.checked, reduceMotion, progress]);
61
52
  // Thumb slides from left to right with equal inset on every side.
@@ -92,7 +83,7 @@ function Switch({ variant = "default", labelOn, labelOff, size = { width: 44, he
92
83
  const labelFontSize = size.height / 3;
93
84
  // Flatten style override for web compatibility
94
85
  const flattenedStyle = styleOverride ? StyleSheet.flatten(styleOverride) : undefined;
95
- return (_jsx(Animated.View, { style: scaleStyle, children: _jsxs(SwitchPrimitives.Root, { ...props, onCheckedChange: wrappedOnCheckedChange, onPressIn: pressHandlers.onPressIn, onPressOut: pressHandlers.onPressOut, onFocus: showFocusRing, onBlur: hideFocusRing, style: {
86
+ return (_jsx(Animated.View, { style: scaleStyle, children: _jsxs(SwitchPrimitives.Root, { ...rowLabel, ...props, onCheckedChange: wrappedOnCheckedChange, onPressIn: pressHandlers.onPressIn, onPressOut: pressHandlers.onPressOut, onFocus: showFocusRing, onBlur: hideFocusRing, style: {
96
87
  position: "relative",
97
88
  width: size.width,
98
89
  height: size.height,
@@ -113,7 +104,7 @@ function Switch({ variant = "default", labelOn, labelOff, size = { width: 44, he
113
104
  borderWidth: 1,
114
105
  borderColor: trackBorderColor,
115
106
  pointerEvents: "none",
116
- } }), labelOn && !isIOS && (_jsx(View, { style: {
107
+ } }), labelOn && !isIOS && (_jsx(View, { ...stateSurfaceProps(), style: {
117
108
  ...styles.label,
118
109
  left: labelHorizontalInset,
119
110
  right: thumbInset + thumbSize + labelGap,
@@ -136,7 +127,7 @@ function Switch({ variant = "default", labelOn, labelOff, size = { width: 44, he
136
127
  ...getShadowStyle("subtle"),
137
128
  },
138
129
  thumbAnimatedStyle,
139
- ], children: loading && (_jsx(ActivityIndicator, { size: "small", color: thumbIndicatorColor })) }) }), labelOff && !isIOS && (_jsx(View, { style: {
130
+ ], children: loading && (_jsx(ActivityIndicator, { size: "small", color: thumbIndicatorColor })) }) }), labelOff && !isIOS && (_jsx(View, { ...stateSurfaceProps(), style: {
140
131
  ...styles.label,
141
132
  left: thumbInset + thumbSize + labelGap,
142
133
  right: labelHorizontalInset,
@@ -147,7 +138,7 @@ function Switch({ variant = "default", labelOn, labelOff, size = { width: 44, he
147
138
  userSelect: "none",
148
139
  }, children: labelOff }) }))] }) }));
149
140
  }
150
- const styles = StyleSheet.create({
141
+ const styles = /*#__PURE__*/ StyleSheet.create({
151
142
  label: {
152
143
  position: "absolute",
153
144
  top: 0,
@@ -1,8 +1,8 @@
1
1
  import * as React from "react";
2
2
  import { type StyleProp, type ViewStyle } from "react-native";
3
3
  import * as TabsPrimitive from "@rn-primitives/tabs";
4
- import { StyledText } from "./StyledText";
5
- import { type IconName } from "./Icon";
4
+ import { StyledText } from "./StyledText.js";
5
+ import { type IconName } from "./Icon.js";
6
6
  type TabsVariant = "underline" | "pill";
7
7
  type TabsSize = "sm" | "md";
8
8
  export interface TabsProps extends TabsPrimitive.RootProps {
@@ -3,12 +3,15 @@ import * as React from "react";
3
3
  import { Animated, Platform, StyleSheet, View } from "react-native";
4
4
  import * as TabsPrimitive from "@rn-primitives/tabs";
5
5
  import { StyledText } from "./StyledText.js";
6
- import { TextClassContext, TextColorContext } from "./StyledText.context";
6
+ import { TextClassContext, TextColorContext } from "./StyledText.context.js";
7
7
  import { Icon } from "./Icon.js";
8
8
  import { useTheme } from "../hooks/useTheme.js";
9
+ import { useFocusVisible } from "../hooks/useFocusVisible.js";
9
10
  import { useReducedMotion } from "../hooks/useReduceMotion.js";
10
11
  import { useScalePress } from "../hooks/useScalePress.js";
12
+ import { useAnimatedValue } from "../lib/useAnimatedValue.js";
11
13
  import { spacing } from "../constants/spacing.js";
14
+ import { shouldUseNativeDriver } from "../lib/animations.js";
12
15
  // ============================================================================
13
16
  // Size configs
14
17
  // ============================================================================
@@ -65,12 +68,12 @@ function TabsTriggerInner({ icon, style, children, value, ...props }) {
65
68
  // Determine selected state by comparing trigger value with root value
66
69
  const rootContext = TabsPrimitive.useRootContext();
67
70
  const isSelected = rootContext.value === value;
68
- const activeOpacity = React.useRef(new Animated.Value(isSelected ? 1 : 0)).current;
71
+ const activeOpacity = useAnimatedValue(isSelected ? 1 : 0);
69
72
  React.useEffect(() => {
70
73
  Animated.timing(activeOpacity, {
71
74
  toValue: isSelected ? 1 : 0,
72
75
  duration: reduceMotion ? 0 : 200,
73
- useNativeDriver: true,
76
+ useNativeDriver: shouldUseNativeDriver,
74
77
  }).start();
75
78
  }, [isSelected, reduceMotion, activeOpacity]);
76
79
  const textColor = isDisabled
@@ -79,32 +82,18 @@ function TabsTriggerInner({ icon, style, children, value, ...props }) {
79
82
  ? theme.colors.foreground
80
83
  : theme.colors.mutedForeground;
81
84
  const focusRingStyle = getFocusRingStyle();
82
- const [focused, setFocused] = React.useState(false);
83
85
  const { animatedStyle: scaleStyle, pressHandlers } = useScalePress({
84
86
  disabled: isDisabled,
85
87
  scaleTo: 0.97,
86
88
  haptic: false,
87
89
  });
88
- const showFocusRing = (event) => {
89
- let ringVisible = true;
90
- if (Platform.OS === "web") {
91
- const target = event?.nativeEvent?.target;
92
- if (target && typeof target.matches === "function") {
93
- try {
94
- ringVisible = target.matches(":focus-visible");
95
- }
96
- catch {
97
- ringVisible = true;
98
- }
99
- }
100
- }
101
- setFocused(ringVisible);
102
- };
103
- const hideFocusRing = () => {
104
- setFocused(false);
105
- };
90
+ const { focused, onFocus: showFocusRing, onBlur: hideFocusRing } = useFocusVisible();
106
91
  const triggerBaseStyle = {
107
- flex: 1,
92
+ // The wrapper below shares the list's width (`flex: 1` on the row axis);
93
+ // the trigger only fills it. `flex: 1` here would act on the wrapper's
94
+ // column axis, where a 0% basis in an indefinite-height parent collapses
95
+ // the trigger to its text (16px tall on web, under the 36px it declares).
96
+ alignSelf: "stretch",
108
97
  // Allow the trigger to shrink below its content's intrinsic width so long
109
98
  // labels are constrained to the trigger's flex share (and can ellipsize)
110
99
  // instead of overflowing and clipping at the screen edge.
@@ -165,7 +154,7 @@ function TabsContent({ style, children, ...props }) {
165
154
  // ============================================================================
166
155
  // Styles
167
156
  // ============================================================================
168
- const triggerContentStyles = StyleSheet.create({
157
+ const triggerContentStyles = /*#__PURE__*/ StyleSheet.create({
169
158
  container: {
170
159
  flexDirection: "row",
171
160
  alignItems: "center",
@@ -120,7 +120,7 @@ interface TextInputCustomProps extends TextInputProps {
120
120
  * // With custom elements
121
121
  * <TextInput
122
122
  * label="Search"
123
- * leftElement={<Icon as={Search} size={20} />}
123
+ * leftElement={<Icon name="search" size={20} />}
124
124
  * />
125
125
  * ```
126
126
  */
@@ -1,19 +1,37 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useEffect, useCallback, useImperativeHandle, useMemo, useRef, useState, } from "react";
2
+ import { useEffect, useCallback, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, } from "react";
3
3
  import { StyleSheet, TextInput as RNTextInput, Platform, View, Pressable, } from "react-native";
4
4
  import { Host, useNativeState, } from "@expo/ui";
5
5
  import { NativeTextField } from "./nativeTextField";
6
6
  import { useTheme } from "../hooks/useTheme.js";
7
+ import { shapeRadius, useShape } from "../hooks/useShape.js";
7
8
  import { spacing } from "../constants/spacing.js";
9
+ import { interaction } from "../constants/interaction.js";
8
10
  import { useFontStyle } from "../hooks/useFontStyle.js";
9
11
  import { StyledText } from "./StyledText.js";
10
12
  import { Icon } from "./Icon.js";
11
13
  import { hapticLight } from "../lib/haptics.js";
12
14
  import { createThemedStyles } from "../lib/themedStyles.js";
15
+ import { stateSurfaceProps } from "../lib/stateSurface.js";
13
16
  import { palette } from "../constants/colors.js";
14
17
  import { clearKeyboardFocusedInput, setKeyboardFocusedInput, } from "./keyboardFocusRegistry.js";
15
18
  import { useTextInputSurfaceResponder } from "./keyboardDismiss.js";
16
19
  const NUMERIC_REGEX = /^[0-9]*$/;
20
+ /**
21
+ * Write the `@expo/ui` observable text buffer. `set()` is the React
22
+ * Compiler-friendly writer (`@expo/ui` >= 56.0.17); earlier releases in the
23
+ * peer range only have the `value` setter, which calls the same native
24
+ * `setValue`. Kept outside the component so the compiler doesn't see a hook
25
+ * result being mutated, which made it skip `NativeTextInput` entirely.
26
+ */
27
+ function writeNativeText(state, text) {
28
+ if (typeof state.set === "function") {
29
+ state.set(text);
30
+ }
31
+ else {
32
+ state.value = text;
33
+ }
34
+ }
17
35
  /** Platform touch-target guideline (pt); the compact fields sit below it. */
18
36
  const MIN_TOUCH_TARGET = 44;
19
37
  const SIZE_CONFIGS = {
@@ -76,7 +94,7 @@ const SIZE_CONFIGS = {
76
94
  * // With custom elements
77
95
  * <TextInput
78
96
  * label="Search"
79
- * leftElement={<Icon as={Search} size={20} />}
97
+ * leftElement={<Icon name="search" size={20} />}
80
98
  * />
81
99
  * ```
82
100
  */
@@ -96,6 +114,8 @@ export function TextInput(props) {
96
114
  */
97
115
  function WebTextInput({ variant = "outline", size = "md", label, helperText, errorText, error, required, rows, showSecureEntryToggle, leftElement, rightElement, clearable = false, wrapperStyle, focusedStyle, forceLight, secureTextEntry, inputMode, style, onChangeText, onFocus, onBlur, value, multiline, editable = true, ref, ...rest }) {
98
116
  const { theme, getContrastingColor, getFocusRingStyle } = useTheme();
117
+ // Host-app `setShape({ input })`; the underlined variant stays square.
118
+ const inputRadius = variant === "underlined" ? undefined : shapeRadius(useShape("input"));
99
119
  const styles = themedStyles(theme)[variant][size];
100
120
  const inputFont = useFontStyle("regular");
101
121
  const [focused, setFocused] = useState(false);
@@ -152,8 +172,9 @@ function WebTextInput({ variant = "outline", size = "md", label, helperText, err
152
172
  const togglePasswordVisible = () => {
153
173
  setPasswordVisible(v => !v);
154
174
  };
155
- return (_jsxs(View, { style: wrapperStyle, children: [!!label && (_jsx(View, { style: styles.labelContainer, children: _jsxs(StyledText, { selectable: false, size: "base", fontWeight: "medium", style: styles.label, children: [label, required && _jsx(StyledText, { selectable: false, fontWeight: "bold", style: styles.required, children: " *" })] }) })), _jsxs(View, { style: [styles.wrapper, focused && getFocusRingStyle()], children: [leftElement && _jsx(View, { style: styles.leftElement, children: leftElement }), _jsx(RNTextInput, { ref: ref, ...rest, editable: editable, inputMode: inputMode || "text", multiline: multiline, numberOfLines: rows, secureTextEntry: secureTextEntry && !passwordVisible, onChangeText: inputMode === "numeric" ? handleNumericChange : handleTextChange, onFocus: showInputFocusRing, onBlur: hideInputFocusRing, onContentSizeChange: (e) => setContentHeight(e.nativeEvent.contentSize.height), scrollEnabled: shouldScroll, placeholderTextColor: theme.colors.textDim, style: [
175
+ return (_jsxs(View, { style: wrapperStyle, children: [!!label && (_jsx(View, { style: styles.labelContainer, children: _jsxs(StyledText, { selectable: false, size: "base", fontWeight: "medium", style: styles.label, children: [label, required && _jsx(StyledText, { selectable: false, fontWeight: "bold", style: styles.required, children: " *" })] }) })), _jsxs(View, { style: [styles.wrapper, inputRadius, focused && getFocusRingStyle()], children: [leftElement && _jsx(View, { style: styles.leftElement, children: leftElement }), _jsx(RNTextInput, { ref: ref, ...rest, editable: editable, inputMode: inputMode || "text", multiline: multiline, numberOfLines: rows, secureTextEntry: secureTextEntry && !passwordVisible, onChangeText: inputMode === "numeric" ? handleNumericChange : handleTextChange, onFocus: showInputFocusRing, onBlur: hideInputFocusRing, onContentSizeChange: (e) => setContentHeight(e.nativeEvent.contentSize.height), scrollEnabled: shouldScroll, placeholderTextColor: theme.colors.textDim, style: [
156
176
  styles.input,
177
+ inputRadius,
157
178
  // Resolved through the theme store so `setFonts` overrides apply;
158
179
  // identical to the old hardcoded regular family by default.
159
180
  inputFont,
@@ -180,7 +201,7 @@ function WebTextInput({ variant = "outline", size = "md", label, helperText, err
180
201
  }, accessibilityLabel: "Clear input", accessibilityRole: "button", children: _jsx(Icon, { name: "x", size: spacing.iconSm, color: "textDim", decorative: true }) })), showClearButton && rightElement && !hasSecureToggle && (_jsxs(View, { style: styles.rightElements, children: [_jsx(Pressable, { onPress: () => {
181
202
  hapticLight();
182
203
  onChangeText?.("");
183
- }, accessibilityLabel: "Clear input", accessibilityRole: "button", children: _jsx(Icon, { name: "x", size: spacing.iconSm, color: "textDim", decorative: true }) }), rightElement] })), !showClearButton && rightElement && !hasSecureToggle && (_jsx(View, { style: styles.rightElement, children: rightElement })), secureTextEntry && showSecureEntryToggle && (_jsx(Pressable, { style: styles.passwordToggle, onPress: togglePasswordVisible, accessibilityLabel: passwordVisible ? "Hide password" : "Show password", accessibilityRole: "button", children: _jsx(Icon, { name: passwordVisible ? "eye-off" : "eye", size: spacing.iconSm + 4, color: "textDim" }) })), showErrorIcon && (_jsx(View, { style: [styles.errorIcon, { pointerEvents: "none" }], accessibilityLabel: "Error", children: _jsx(Icon, { name: "circle-alert", size: spacing.iconSm, color: "destructive", decorative: true }) }))] }), !!(helperText || errorText) && (_jsx(StyledText, { selectable: false, size: "sm", style: [
204
+ }, accessibilityLabel: "Clear input", accessibilityRole: "button", children: _jsx(Icon, { name: "x", size: spacing.iconSm, color: "textDim", decorative: true }) }), rightElement] })), !showClearButton && rightElement && !hasSecureToggle && (_jsx(View, { style: styles.rightElement, children: rightElement })), secureTextEntry && showSecureEntryToggle && (_jsx(Pressable, { style: styles.passwordToggle, onPress: togglePasswordVisible, accessibilityLabel: passwordVisible ? "Hide password" : "Show password", accessibilityRole: "button", children: _jsx(Icon, { name: passwordVisible ? "eye-off" : "eye", size: spacing.iconSm + 4, color: "textDim", decorative: true }) })), showErrorIcon && (_jsx(View, { style: [styles.errorIcon, { pointerEvents: "none" }], accessibilityLabel: "Error", children: _jsx(Icon, { name: "circle-alert", size: spacing.iconSm, color: "destructive", decorative: true }) }))] }), !!(helperText || errorText) && (_jsx(StyledText, { selectable: false, size: "sm", style: [
184
205
  styles.helperText,
185
206
  hasError && styles.errorText,
186
207
  ], children: errorText || helperText }))] }));
@@ -220,6 +241,7 @@ function NativeTextInput({ variant = "outline", size = "md", label, helperText,
220
241
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
221
242
  leftElement, rightElement, clearable, focusedStyle, style, ...rest }) {
222
243
  const { theme, getContrastingColor } = useTheme();
244
+ const inputShape = useShape("input");
223
245
  const styles = themedStyles(theme)[variant][size];
224
246
  const inputFont = useFontStyle("regular");
225
247
  const [passwordVisible, setPasswordVisible] = useState(false);
@@ -240,8 +262,13 @@ leftElement, rightElement, clearable, focusedStyle, style, ...rest }) {
240
262
  const [handoff, setHandoff] = useState(null);
241
263
  const handoffRef = useRef(null);
242
264
  const isFocusedRef = useRef(false);
265
+ // Latest flavour for the focus/blur/toggle handlers and the handoff timer,
266
+ // synced after each commit (before any native event can be dispatched)
267
+ // instead of written during render.
243
268
  const activeSecureRef = useRef(effectiveSecureTextEntry);
244
- activeSecureRef.current = effectiveSecureTextEntry;
269
+ useLayoutEffect(() => {
270
+ activeSecureRef.current = effectiveSecureTextEntry;
271
+ });
245
272
  // Taps that land while a handoff is in flight are counted and applied (by
246
273
  // parity) once it settles.
247
274
  const queuedTogglesRef = useRef(0);
@@ -249,7 +276,9 @@ leftElement, rightElement, clearable, focusedStyle, style, ...rest }) {
249
276
  // iOS-only per-flavour mount generation. Bumped whenever a flavour becomes
250
277
  // the incoming view so React mounts a FRESH native view (whose `autoFocus`
251
278
  // will run) instead of reusing one still unmounting from the previous handoff.
252
- const generationRef = useRef({ secure: 0, plain: 0 });
279
+ // State, not a ref: it keys the rendered Host, and the bump lands in the same
280
+ // batch as the `passwordVisible` flip that makes that flavour incoming.
281
+ const [generation, setGeneration] = useState({ secure: 0, plain: 0 });
253
282
  // Last text the JS side knows about; see handleChangeText.
254
283
  const lastTextRef = useRef(value ?? defaultValue ?? "");
255
284
  // Armed when the hide toggle hands focus to the SecureField (iOS); see
@@ -262,7 +291,7 @@ leftElement, rightElement, clearable, focusedStyle, style, ...rest }) {
262
291
  // programmatic sets); typing already updated `state` natively.
263
292
  useEffect(() => {
264
293
  if (value !== undefined && value !== state.value) {
265
- state.value = value;
294
+ writeNativeText(state, value);
266
295
  }
267
296
  if (value !== undefined)
268
297
  lastTextRef.current = value;
@@ -347,10 +376,9 @@ leftElement, rightElement, clearable, focusedStyle, style, ...rest }) {
347
376
  restoreAfterSecureHandoffRef.current = false;
348
377
  const outgoingSecure = activeSecureRef.current;
349
378
  if (Platform.OS === "ios") {
350
- if (outgoingSecure)
351
- generationRef.current.plain += 1;
352
- else
353
- generationRef.current.secure += 1;
379
+ setGeneration((current) => outgoingSecure
380
+ ? { ...current, plain: current.plain + 1 }
381
+ : { ...current, secure: current.secure + 1 });
354
382
  }
355
383
  if (Platform.OS === "ios" && isFocusedRef.current) {
356
384
  const timer = setTimeout(() => {
@@ -376,7 +404,11 @@ leftElement, rightElement, clearable, focusedStyle, style, ...rest }) {
376
404
  }
377
405
  setPasswordVisible((v) => !v);
378
406
  }, [activeInput, finishHandoff, focusRegistryToken, parentOnBlur]);
379
- toggleRef.current = togglePasswordVisible;
407
+ // `finishHandoff` replays queued taps through this ref (it is declared
408
+ // before the toggle it calls). Synced after commit, like `activeSecureRef`.
409
+ useLayoutEffect(() => {
410
+ toggleRef.current = togglePasswordVisible;
411
+ }, [togglePasswordVisible]);
380
412
  // iOS clears a secure field's existing text on the first keystroke after it
381
413
  // becomes first responder (text it did not see typed). Right after the hide
382
414
  // toggle hands focus to the SecureField that reads as "type one character,
@@ -395,7 +427,7 @@ leftElement, rightElement, clearable, focusedStyle, style, ...rest }) {
395
427
  const next = wipedByKey ? previous + text : wipedByBackspace ? previous.slice(0, -1) : text;
396
428
  lastTextRef.current = next;
397
429
  if (wiped)
398
- state.value = next;
430
+ writeNativeText(state, next);
399
431
  onChangeText?.(next);
400
432
  }, [onChangeText, state]);
401
433
  useEffect(() => {
@@ -418,13 +450,13 @@ leftElement, rightElement, clearable, focusedStyle, style, ...rest }) {
418
450
  focus: () => activeInput()?.focus(),
419
451
  blur: () => blurAll(),
420
452
  clear: () => {
421
- state.value = "";
453
+ writeNativeText(state, "");
422
454
  lastTextRef.current = "";
423
455
  },
424
456
  isFocused: () => activeInput()?.isFocused() ?? false,
425
457
  setNativeProps: (props) => {
426
458
  if (typeof props?.text === "string") {
427
- state.value = props.text;
459
+ writeNativeText(state, props.text);
428
460
  lastTextRef.current = props.text;
429
461
  }
430
462
  },
@@ -451,9 +483,9 @@ leftElement, rightElement, clearable, focusedStyle, style, ...rest }) {
451
483
  const surfaceStyle = {
452
484
  backgroundColor,
453
485
  borderColor,
454
- borderRadius: variant === "underlined" ? 0 : spacing.radiusMd,
486
+ borderRadius: variant === "underlined" ? 0 : inputShape?.borderRadius ?? spacing.radiusMd,
455
487
  borderWidth: variant === "outline" ? 1 : 0,
456
- opacity: editable === false ? 0.6 : 1,
488
+ opacity: editable === false ? interaction.disabledOpacity : 1,
457
489
  overflow: "hidden",
458
490
  };
459
491
  // Native field: transparent, padding only. The visible surface is drawn by
@@ -487,7 +519,7 @@ leftElement, rightElement, clearable, focusedStyle, style, ...rest }) {
487
519
  fontSize: sizeConfig.fontSize,
488
520
  ...(nativeFontFamily ? { fontFamily: nativeFontFamily } : null),
489
521
  };
490
- return (_jsxs(View, { style: wrapperStyle, children: [!!label && (_jsx(View, { style: styles.labelContainer, children: _jsxs(StyledText, { selectable: false, size: "base", fontWeight: "medium", style: styles.label, children: [label, required && _jsx(StyledText, { selectable: false, fontWeight: "bold", style: styles.required, children: " *" })] }) })), _jsxs(View, { style: [surfaceStyle, hasSecureToggle && styles.nativeRow], hitSlop: { top: surfaceHitSlop, bottom: surfaceHitSlop }, ...surfaceResponderProps, children: [(Platform.OS === "android" ? [effectiveSecureTextEntry] : [true, false]).map((secure) => {
522
+ return (_jsxs(View, { style: wrapperStyle, children: [!!label && (_jsx(View, { style: styles.labelContainer, children: _jsxs(StyledText, { selectable: false, size: "base", fontWeight: "medium", style: styles.label, children: [label, required && _jsx(StyledText, { selectable: false, fontWeight: "bold", style: styles.required, children: " *" })] }) })), _jsxs(View, { ...stateSurfaceProps(), style: [surfaceStyle, hasSecureToggle && styles.nativeRow], hitSlop: { top: surfaceHitSlop, bottom: surfaceHitSlop }, ...surfaceResponderProps, children: [(Platform.OS === "android" ? [effectiveSecureTextEntry] : [true, false]).map((secure) => {
491
523
  const active = secure === effectiveSecureTextEntry;
492
524
  const outgoing = handoff != null && secure === handoff.outgoingSecure;
493
525
  if (!active && !outgoing)
@@ -502,9 +534,9 @@ leftElement, rightElement, clearable, focusedStyle, style, ...rest }) {
502
534
  : styles.nativeHostHandoff, pointerEvents: inLayout ? "auto" : "none", accessibilityElementsHidden: !inLayout, importantForAccessibility: inLayout ? "auto" : "no-hide-descendants", children: _jsx(NativeTextField, { ...rest, autoFocus: rest.autoFocus || (handoff != null && active), ref: Platform.OS === "android" ? androidRef : secure ? secureRef : plainRef, value: state, defaultValue: defaultValue, onChangeText: handleChangeText, onFocus: () => handleFocus(secure), onBlur: () => handleBlur(secure), editable: editable, multiline: multiline, rows: rows, inputMode: inputMode, secureTextEntry: secure, placeholderTextColor: theme.colors.textDim, style: boxStyle, textStyle: textStyle }) }, Platform.OS === "android"
503
535
  ? "android"
504
536
  : secure
505
- ? `secure-${generationRef.current.secure}`
506
- : `plain-${generationRef.current.plain}`));
507
- }), hasSecureToggle && (_jsx(Pressable, { style: styles.nativePasswordToggle, onPress: togglePasswordVisible, accessibilityLabel: passwordVisible ? "Hide password" : "Show password", accessibilityRole: "button", children: _jsx(Icon, { name: passwordVisible ? "eye-off" : "eye", size: spacing.iconSm + 4, color: "textDim" }) }))] }), !!(helperText || errorText) && (_jsx(StyledText, { selectable: false, size: "sm", style: [styles.helperText, hasError && styles.errorText], children: errorText || helperText }))] }));
537
+ ? `secure-${generation.secure}`
538
+ : `plain-${generation.plain}`));
539
+ }), hasSecureToggle && (_jsx(Pressable, { style: styles.nativePasswordToggle, onPress: togglePasswordVisible, accessibilityLabel: passwordVisible ? "Hide password" : "Show password", accessibilityRole: "button", children: _jsx(Icon, { name: passwordVisible ? "eye-off" : "eye", size: spacing.iconSm + 4, color: "textDim", decorative: true }) }))] }), !!(helperText || errorText) && (_jsx(StyledText, { selectable: false, size: "sm", style: [styles.helperText, hasError && styles.errorText], children: errorText || helperText }))] }));
508
540
  }
509
541
  const createStyles = (theme, variant, size) => StyleSheet.create({
510
542
  nativeHost: {
@@ -529,6 +561,10 @@ const createStyles = (theme, variant, size) => StyleSheet.create({
529
561
  },
530
562
  nativePasswordToggle: {
531
563
  paddingHorizontal: spacing.xs,
564
+ minWidth: spacing.minTarget,
565
+ minHeight: spacing.minTarget,
566
+ alignItems: "center",
567
+ justifyContent: "center",
532
568
  },
533
569
  wrapper: {
534
570
  width: "100%",
@@ -554,7 +590,7 @@ const createStyles = (theme, variant, size) => StyleSheet.create({
554
590
  borderBottomWidth: 2,
555
591
  },
556
592
  disabled: {
557
- opacity: 0.6,
593
+ opacity: interaction.disabledOpacity,
558
594
  ...(Platform.OS === "web" && { cursor: "not-allowed" }),
559
595
  },
560
596
  error: {
@@ -596,11 +632,17 @@ const createStyles = (theme, variant, size) => StyleSheet.create({
596
632
  transform: [{ translateY: -10 }],
597
633
  zIndex: 1,
598
634
  },
635
+ // The eye/clear buttons draw a 16-20px glyph; the pressable itself is at
636
+ // least `spacing.minTarget` square (WCAG 2.5.8 — `hitSlop` is inert on web).
599
637
  passwordToggle: {
600
638
  position: "absolute",
601
- right: spacing.sm,
639
+ right: spacing.sm - (spacing.minTarget - 20) / 2,
602
640
  top: "50%",
603
- transform: [{ translateY: Platform.OS === "web" ? -10 : -12 }],
641
+ minWidth: spacing.minTarget,
642
+ minHeight: spacing.minTarget,
643
+ alignItems: "center",
644
+ justifyContent: "center",
645
+ transform: [{ translateY: -spacing.minTarget / 2 }],
604
646
  zIndex: 1,
605
647
  ...(Platform.OS === "web" && { cursor: "pointer" }),
606
648
  },
@@ -613,9 +655,13 @@ const createStyles = (theme, variant, size) => StyleSheet.create({
613
655
  },
614
656
  clearButton: {
615
657
  position: "absolute",
616
- right: spacing.sm,
658
+ right: spacing.sm - (spacing.minTarget - 20) / 2,
617
659
  top: "50%",
618
- transform: [{ translateY: Platform.OS === "web" ? -10 : -12 }],
660
+ minWidth: spacing.minTarget,
661
+ minHeight: spacing.minTarget,
662
+ alignItems: "center",
663
+ justifyContent: "center",
664
+ transform: [{ translateY: -spacing.minTarget / 2 }],
619
665
  zIndex: 1,
620
666
  ...(Platform.OS === "web" && { cursor: "pointer" }),
621
667
  },
@@ -632,7 +678,7 @@ const createStyles = (theme, variant, size) => StyleSheet.create({
632
678
  });
633
679
  const VARIANT_KEYS = ["outline", "filled", "underlined"];
634
680
  const SIZE_KEYS = ["sm", "md", "lg"];
635
- const themedStyles = createThemedStyles((theme) => Object.fromEntries(VARIANT_KEYS.map((variant) => [
681
+ const themedStyles = /*#__PURE__*/ createThemedStyles((theme) => Object.fromEntries(VARIANT_KEYS.map((variant) => [
636
682
  variant,
637
683
  Object.fromEntries(SIZE_KEYS.map((size) => [size, createStyles(theme, variant, size)])),
638
684
  ])));
@@ -1,7 +1,7 @@
1
1
  import React from "react";
2
2
  import * as TogglePrimitive from "@rn-primitives/toggle";
3
3
  import { ViewStyle, StyleProp } from "react-native";
4
- import type { IconName } from "./Icon";
4
+ import type { IconName } from "./Icon.js";
5
5
  export type ToggleVariant = "default" | "outline";
6
6
  export type ToggleSize = "sm" | "default" | "lg";
7
7
  export type ToggleShape = "default" | "square" | "circle";
@@ -44,47 +44,7 @@ interface ToggleProps extends Omit<TogglePrimitive.RootProps, "style"> {
44
44
  */
45
45
  style?: StyleProp<ViewStyle>;
46
46
  }
47
- /**
48
- * Enhanced Toggle Component
49
- *
50
- * Features:
51
- * - 2 variants (default, outline)
52
- * - 3 sizes (sm, default, lg)
53
- * - 3 shapes (default, square, circle)
54
- * - Loading state with spinner
55
- * - Icon-only optimization
56
- * - Style prop support
57
- * - Full accessibility
58
- *
59
- * Usage:
60
- * ```tsx
61
- * // Basic
62
- * const [pressed, setPressed] = useState(false);
63
- * <Toggle pressed={pressed} onPressedChange={setPressed}>
64
- * <SansSerifText>Toggle Me</SansSerifText>
65
- * </Toggle>
66
- *
67
- * // With icon
68
- * <Toggle pressed={pressed} onPressedChange={setPressed} iconOnly>
69
- * <ToggleIcon name="bold" />
70
- * </Toggle>
71
- *
72
- * // Different shapes
73
- * <Toggle shape="square" pressed={pressed} onPressedChange={setPressed}>
74
- * <SansSerifText>Square</SansSerifText>
75
- * </Toggle>
76
- *
77
- * <Toggle shape="circle" iconOnly pressed={pressed} onPressedChange={setPressed}>
78
- * <ToggleIcon name="bold" />
79
- * </Toggle>
80
- *
81
- * // Loading state
82
- * <Toggle loading pressed={pressed} onPressedChange={setPressed}>
83
- * <SansSerifText>Loading...</SansSerifText>
84
- * </Toggle>
85
- * ```
86
- */
87
- declare function Toggle({ variant, size, shape, loading, iconOnly, style: styleOverride, ...props }: ToggleProps): React.JSX.Element;
47
+ declare function Toggle({ variant, size, shape, loading, iconOnly, style: styleOverride, onPressedChange, ...props }: ToggleProps): React.JSX.Element;
88
48
  /**
89
49
  * ToggleIcon Component
90
50
  * Icon wrapper for use inside Toggle buttons