@mrmeg/expo-ui 0.7.1 → 0.7.3

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.
@@ -26,16 +26,14 @@ function DialogContent({ portalHost, style, children, ...props }) {
26
26
  StyleSheet.absoluteFill,
27
27
  { backgroundColor: theme.colors.overlay },
28
28
  Platform.OS === "web" && { zIndex: 50 },
29
- ]), children: _jsx(AnimatedView, { type: "fade", enterDuration: 200, children: _jsx(View, { style: overlayStyles.centeredContainer, children: _jsx(AnimatedView, { type: "scale", enterDuration: 250, children: _jsx(TextColorContext.Provider, { value: textColor, children: _jsx(TextClassContext.Provider, { value: "", children: _jsx(DialogPrimitive.Content, { style: StyleSheet.flatten([
29
+ ]), children: _jsx(AnimatedView, { type: "fade", enterDuration: 200, style: StyleSheet.absoluteFill, children: _jsx(View, { style: overlayStyles.centeredContainer, children: _jsx(AnimatedView, { type: "scale", enterDuration: 250, style: overlayStyles.sizer, children: _jsx(TextColorContext.Provider, { value: textColor, children: _jsx(TextClassContext.Provider, { value: "", children: _jsx(DialogPrimitive.Content, { style: StyleSheet.flatten([
30
30
  {
31
31
  backgroundColor: theme.colors.popover,
32
32
  borderColor: theme.colors.border,
33
33
  borderWidth: 1,
34
34
  borderRadius: spacing.radiusLg,
35
35
  padding: spacing.lg,
36
- width: "90%",
37
- maxWidth: 450,
38
- maxHeight: "85%",
36
+ width: "100%",
39
37
  ...getShadowStyle("soft"),
40
38
  },
41
39
  style,
@@ -103,16 +101,14 @@ function AlertDialogContent({ portalHost, style, children, ...props }) {
103
101
  StyleSheet.absoluteFill,
104
102
  { backgroundColor: theme.colors.overlay },
105
103
  Platform.OS === "web" && { zIndex: 52 },
106
- ]), children: _jsx(AnimatedView, { type: "fade", enterDuration: 200, children: _jsx(View, { style: overlayStyles.centeredContainer, children: _jsx(AnimatedView, { type: "scale", enterDuration: 250, children: _jsx(TextColorContext.Provider, { value: textColor, children: _jsx(TextClassContext.Provider, { value: "", children: _jsx(AlertDialogPrimitive.Content, { style: StyleSheet.flatten([
104
+ ]), children: _jsx(AnimatedView, { type: "fade", enterDuration: 200, style: StyleSheet.absoluteFill, children: _jsx(View, { style: overlayStyles.centeredContainer, children: _jsx(AnimatedView, { type: "scale", enterDuration: 250, style: overlayStyles.sizer, children: _jsx(TextColorContext.Provider, { value: textColor, children: _jsx(TextClassContext.Provider, { value: "", children: _jsx(AlertDialogPrimitive.Content, { style: StyleSheet.flatten([
107
105
  {
108
106
  backgroundColor: theme.colors.popover,
109
107
  borderColor: theme.colors.border,
110
108
  borderWidth: 1,
111
109
  borderRadius: spacing.radiusLg,
112
110
  padding: spacing.lg,
113
- width: "90%",
114
- maxWidth: 450,
115
- maxHeight: "85%",
111
+ width: "100%",
116
112
  ...getShadowStyle("soft"),
117
113
  },
118
114
  style,
@@ -160,6 +156,18 @@ const overlayStyles = StyleSheet.create({
160
156
  justifyContent: "center",
161
157
  alignItems: "center",
162
158
  },
159
+ // Sizes the dialog relative to the full-screen centered container. Both the
160
+ // width AND maxHeight must live on this wrapper (the direct flex child of the
161
+ // full-screen container) rather than on Content: a percentage resolves
162
+ // against the parent's resolved box, and this wrapper's box is the screen.
163
+ // Putting `maxHeight: "85%"` on Content instead resolves it against this
164
+ // (content-sized) wrapper — clamping the card to 85% of its own content
165
+ // height, so the footer spills out the bottom.
166
+ sizer: {
167
+ width: "90%",
168
+ maxWidth: 450,
169
+ maxHeight: "85%",
170
+ },
163
171
  });
164
172
  // ============================================================================
165
173
  // Exports
@@ -0,0 +1,52 @@
1
+ import { type StyleProp, type ViewStyle } from "react-native";
2
+ /**
3
+ * SegmentedControl — a horizontal single-select control backed by the
4
+ * platform's native segmented control via
5
+ * `@expo/ui/community/segmented-control`:
6
+ *
7
+ * - iOS: SwiftUI segmented `Picker` (system-styled).
8
+ * - Android: a Material segmented control, accent-tinted via `tintColor`.
9
+ * - Web: the vendored `@react-native-segmented-control/segmented-control`
10
+ * JS implementation, accent-tinted via `tintColor`.
11
+ *
12
+ * The API is value-based to match the rest of the design system (RadioGroup /
13
+ * Tabs / Select): pass the segment `values` plus a controlled `value` (or
14
+ * `defaultValue` for uncontrolled), and read selections back as the chosen
15
+ * string. A light haptic fires on each change, matching Slider / Switch.
16
+ *
17
+ * Theming: the accent color tints the selected segment on Android and web. iOS
18
+ * draws the system segmented control, which ignores a custom tint — pass
19
+ * `appearance` to force light/dark there.
20
+ *
21
+ * @example
22
+ * ```tsx
23
+ * <SegmentedControl
24
+ * values={["Day", "Week", "Month"]}
25
+ * value={range}
26
+ * onValueChange={setRange}
27
+ * />
28
+ * ```
29
+ */
30
+ export interface SegmentedControlProps {
31
+ /** Segment labels, in display order. */
32
+ values: string[];
33
+ /** Controlled selected value. Omit to use uncontrolled mode with `defaultValue`. */
34
+ value?: string;
35
+ /** Initial selected value for uncontrolled mode. Defaults to the first segment. */
36
+ defaultValue?: string;
37
+ /** Called with the selected segment's value. */
38
+ onValueChange?: (value: string) => void;
39
+ /** Disable interaction. @default false */
40
+ disabled?: boolean;
41
+ /**
42
+ * Accent color for the selected segment. Defaults to the theme accent.
43
+ * Applied on Android and web; iOS uses the system style.
44
+ */
45
+ tintColor?: string;
46
+ /** Force a color scheme irrespective of the system theme. */
47
+ appearance?: "light" | "dark";
48
+ /** Style override for the control. */
49
+ style?: StyleProp<ViewStyle>;
50
+ }
51
+ declare function SegmentedControl({ values, value: controlledValue, defaultValue, onValueChange, disabled, tintColor, appearance, style, }: SegmentedControlProps): import("react/jsx-runtime").JSX.Element;
52
+ export { SegmentedControl };
@@ -0,0 +1,25 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import React, { useCallback, useRef } from "react";
3
+ import { SegmentedControl as NativeSegmentedControl } from "@expo/ui/community/segmented-control";
4
+ import { useTheme } from "../hooks/useTheme.js";
5
+ import { hapticLight } from "../lib/haptics.js";
6
+ function SegmentedControl({ values, value: controlledValue, defaultValue, onValueChange, disabled = false, tintColor, appearance, style, }) {
7
+ const { theme } = useTheme();
8
+ const [internalValue, setInternalValue] = React.useState(defaultValue);
9
+ const isControlled = controlledValue !== undefined;
10
+ const value = isControlled ? controlledValue : internalValue;
11
+ const selectedIndex = Math.max(0, values.indexOf(value ?? values[0]));
12
+ const lastIndex = useRef(selectedIndex);
13
+ const handleValueChange = useCallback((next) => {
14
+ const nextIndex = values.indexOf(next);
15
+ if (nextIndex !== lastIndex.current) {
16
+ lastIndex.current = nextIndex;
17
+ hapticLight();
18
+ }
19
+ if (!isControlled)
20
+ setInternalValue(next);
21
+ onValueChange?.(next);
22
+ }, [isControlled, onValueChange, values]);
23
+ return (_jsx(NativeSegmentedControl, { values: values, selectedIndex: selectedIndex, enabled: !disabled, onValueChange: handleValueChange, tintColor: tintColor ?? theme.colors.accent, appearance: appearance ?? (theme.dark ? "dark" : "light"), style: style }));
24
+ }
25
+ export { SegmentedControl };
@@ -1,4 +1,24 @@
1
1
  import { StyleProp, ViewStyle } from "react-native";
2
+ /**
3
+ * Slider — a themed range input backed by the platform's native slider via
4
+ * `@expo/ui/community/slider`:
5
+ *
6
+ * - iOS: SwiftUI `Slider`
7
+ * - Android: Material 3 `Slider`
8
+ * - Web: native `<input type="range">` (themed via `accentColor`)
9
+ *
10
+ * The public `SliderProps` surface (value / onValueChange / min / max / step /
11
+ * disabled / showValue / size / style) is preserved, and the active track is
12
+ * themed with the design system's accent color on every platform. Thumb and
13
+ * inactive-track tints additionally apply on Android (iOS/web draw the system
14
+ * thumb). Haptic feedback fires on each step change, matching the prior
15
+ * hand-rolled slider.
16
+ *
17
+ * Platform-owned behaviors (props accepted for ergonomics, but the platform
18
+ * decides):
19
+ * - `size` is accepted for call-site compatibility but has no effect — the
20
+ * platform owns the track/thumb dimensions.
21
+ */
2
22
  export type SliderSize = "sm" | "md";
3
23
  export interface SliderProps {
4
24
  /** Current value */
@@ -11,14 +31,14 @@ export interface SliderProps {
11
31
  max?: number;
12
32
  /** Step increment @default 1 */
13
33
  step?: number;
14
- /** Size variant @default "md" */
34
+ /** Size variant. Accepted for compatibility; the platform owns sizing. @default "md" */
15
35
  size?: SliderSize;
16
36
  /** Disable interaction @default false */
17
37
  disabled?: boolean;
18
- /** Show value label above thumb @default false */
38
+ /** Show the current value as a label above the track @default false */
19
39
  showValue?: boolean;
20
40
  /** Style override for outer container */
21
41
  style?: StyleProp<ViewStyle>;
22
42
  }
23
- declare function Slider({ value, onValueChange, min, max, step, size, disabled, showValue, style: styleOverride, }: SliderProps): import("react/jsx-runtime").JSX.Element;
43
+ declare function Slider({ value, onValueChange, min, max, step, size: _size, disabled, showValue, style: styleOverride, }: SliderProps): import("react/jsx-runtime").JSX.Element;
24
44
  export { Slider };
@@ -2,165 +2,44 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { palette } from "../constants/colors.js";
3
3
  import { useTheme } from "../hooks/useTheme.js";
4
4
  import { hapticLight } from "../lib/haptics.js";
5
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
6
- import { Animated, PanResponder, Platform, StyleSheet, View } from "react-native";
5
+ import { useCallback, useRef } from "react";
6
+ import { View } from "react-native";
7
+ import { Slider as NativeSlider } from "@expo/ui/community/slider";
7
8
  import { StyledText } from "./StyledText.js";
8
- const SIZES = {
9
- sm: { track: 4, thumb: 16 },
10
- md: { track: 6, thumb: 20 },
11
- };
12
- function clampAndSnap(raw, min, max, step) {
13
- const clamped = Math.min(Math.max(raw, min), max);
14
- const stepped = Math.round((clamped - min) / step) * step + min;
15
- // Avoid floating-point drift
16
- return Math.round(stepped * 1e6) / 1e6;
17
- }
18
- function getValueRatio(value, min, max) {
19
- const range = max - min || 1;
20
- return Math.min(Math.max((value - min) / range, 0), 1);
21
- }
22
- function Slider({ value = 0, onValueChange, min = 0, max = 100, step = 1, size = "md", disabled = false, showValue = false, style: styleOverride, }) {
23
- const { theme, getShadowStyle, withAlpha } = useTheme();
24
- const dims = SIZES[size];
9
+ function Slider({ value = 0, onValueChange, min = 0, max = 100, step = 1,
10
+ // Accepted for call-site compatibility; the platform owns track/thumb sizing.
11
+ size: _size = "md", disabled = false, showValue = false, style: styleOverride, }) {
12
+ const { theme, withAlpha } = useTheme();
25
13
  const inactiveTrackColor = theme.dark ? withAlpha(palette.white, 0.1) : theme.colors.muted;
26
14
  const activeTrackColor = disabled
27
15
  ? theme.dark
28
16
  ? withAlpha(palette.white, 0.28)
29
17
  : theme.colors.mutedForeground
30
18
  : theme.colors.accent;
31
- const thumbBackgroundColor = theme.dark ? theme.colors.card : theme.colors.background;
32
- const thumbBorderColor = disabled
19
+ const thumbTintColor = disabled
33
20
  ? theme.dark
34
21
  ? withAlpha(palette.white, 0.32)
35
22
  : theme.colors.mutedForeground
36
23
  : theme.colors.accent;
37
- const [trackWidth, setTrackWidth] = useState(0);
38
- const trackWidthRef = useRef(0);
39
- const thumbX = useRef(new Animated.Value(0)).current;
40
- const lastSnappedValue = useRef(value);
41
- const updateFromPosition = useCallback((rawX) => {
42
- const width = trackWidthRef.current;
43
- const x = Math.min(Math.max(rawX, 0), width);
44
- thumbX.stopAnimation();
45
- thumbX.setValue(x);
46
- const ratio = width > 0 ? x / width : 0;
47
- const raw = min + ratio * (max - min);
48
- const snapped = clampAndSnap(raw, min, max, step);
49
- if (snapped !== lastSnappedValue.current) {
50
- lastSnappedValue.current = snapped;
24
+ // Fire a light haptic whenever the slider crosses a step boundary, matching
25
+ // the prior hand-rolled behavior. Native emits already-stepped values.
26
+ const lastValue = useRef(value);
27
+ const handleValueChange = useCallback((next) => {
28
+ if (next !== lastValue.current) {
29
+ lastValue.current = next;
51
30
  hapticLight();
52
31
  }
53
- onValueChange?.(snapped);
54
- }, [max, min, onValueChange, step, thumbX]);
55
- const handleGesture = useCallback((event) => {
56
- updateFromPosition(event.nativeEvent.locationX);
57
- }, [updateFromPosition]);
58
- const panResponder = useMemo(() => PanResponder.create({
59
- onStartShouldSetPanResponder: () => !disabled,
60
- onMoveShouldSetPanResponder: () => !disabled,
61
- onPanResponderGrant: handleGesture,
62
- onPanResponderMove: handleGesture,
63
- }), [disabled, handleGesture]);
64
- useEffect(() => {
65
- const ratio = getValueRatio(value, min, max);
66
- const width = trackWidthRef.current;
67
- if (width > 0) {
68
- Animated.timing(thumbX, {
69
- toValue: ratio * width,
70
- duration: 80,
71
- useNativeDriver: true,
72
- }).start();
73
- }
74
- lastSnappedValue.current = value;
75
- }, [max, min, thumbX, value]);
76
- const onTrackLayout = useCallback((e) => {
77
- const w = e.nativeEvent.layout.width;
78
- trackWidthRef.current = w;
79
- setTrackWidth(w);
80
- // Set initial thumb position without animation
81
- const ratio = getValueRatio(value, min, max);
82
- thumbX.stopAnimation();
83
- thumbX.setValue(ratio * w);
84
- }, [max, min, thumbX, value]);
85
- const safeTrackWidth = Math.max(trackWidth, 1);
86
- const fillScale = thumbX.interpolate({
87
- inputRange: [0, safeTrackWidth],
88
- outputRange: [0, 1],
89
- extrapolate: "clamp",
90
- });
91
- const thumbTranslateX = thumbX.interpolate({
92
- inputRange: [0, safeTrackWidth],
93
- outputRange: [-dims.thumb / 2, safeTrackWidth - dims.thumb / 2],
94
- extrapolate: "clamp",
95
- });
96
- const labelTranslateX = thumbX.interpolate({
97
- inputRange: [0, safeTrackWidth],
98
- outputRange: [-14, safeTrackWidth - 14],
99
- extrapolate: "clamp",
100
- });
101
- const flattenedStyle = styleOverride ? StyleSheet.flatten(styleOverride) : undefined;
102
- // Accessibility action handler
103
- const handleAccessibilityAction = useCallback((event) => {
104
- const action = event.nativeEvent.actionName;
105
- let next = value;
106
- if (action === "increment") {
107
- next = Math.min(value + step, max);
108
- }
109
- else if (action === "decrement") {
110
- next = Math.max(value - step, min);
111
- }
112
- if (next !== value) {
113
- onValueChange?.(next);
114
- }
115
- }, [value, step, min, max, onValueChange]);
116
- return (_jsxs(View, { style: [{ opacity: disabled ? 0.5 : 1 }, flattenedStyle], accessibilityRole: "adjustable", accessibilityValue: { min, max, now: value }, accessibilityActions: [
117
- { name: "increment", label: "Increment" },
118
- { name: "decrement", label: "Decrement" },
119
- ], onAccessibilityAction: handleAccessibilityAction, children: [showValue && (_jsx(Animated.View, { style: [
120
- {
121
- position: "absolute",
122
- top: -20,
123
- width: 28,
124
- alignItems: "center",
125
- },
126
- { transform: [{ translateX: labelTranslateX }] },
127
- { pointerEvents: "none" },
128
- ], children: _jsx(StyledText, { selectable: false, style: {
129
- fontSize: 12,
130
- color: theme.colors.textDim,
131
- userSelect: "none",
132
- }, children: value }) })), _jsxs(View, { style: {
133
- height: dims.thumb,
134
- justifyContent: "center",
135
- ...(Platform.OS === "web" && { cursor: disabled ? "default" : "pointer" }),
136
- }, onLayout: onTrackLayout, ...panResponder.panHandlers, children: [_jsx(View, { style: {
137
- height: dims.track,
138
- borderRadius: dims.track / 2,
139
- backgroundColor: inactiveTrackColor,
140
- overflow: "hidden",
141
- }, children: _jsx(Animated.View, { style: [
142
- {
143
- width: "100%",
144
- height: dims.track,
145
- borderRadius: dims.track / 2,
146
- backgroundColor: activeTrackColor,
147
- transformOrigin: "left",
148
- },
149
- { transform: [{ scaleX: fillScale }] },
150
- ] }) }), _jsx(Animated.View, { style: [
151
- {
152
- position: "absolute",
153
- top: 0,
154
- left: 0,
155
- width: dims.thumb,
156
- height: dims.thumb,
157
- borderRadius: dims.thumb / 2,
158
- backgroundColor: thumbBackgroundColor,
159
- borderWidth: 1,
160
- borderColor: thumbBorderColor,
161
- ...getShadowStyle("subtle"),
162
- },
163
- { transform: [{ translateX: thumbTranslateX }] },
164
- ] })] })] }));
32
+ onValueChange?.(next);
33
+ }, [onValueChange]);
34
+ return (_jsxs(View, { style: [{ opacity: disabled ? 0.5 : 1, alignSelf: "stretch" }, styleOverride], children: [showValue && (_jsx(StyledText, { selectable: false, style: {
35
+ fontSize: 12,
36
+ color: theme.colors.textDim,
37
+ marginBottom: 4,
38
+ userSelect: "none",
39
+ }, children: value })), _jsx(NativeSlider, { value: value, minimumValue: min, maximumValue: max, step: step, disabled: disabled, onValueChange: handleValueChange, style: { width: "100%" },
40
+ // Active track — honored on iOS, Android, and web (`accentColor`).
41
+ minimumTrackTintColor: activeTrackColor,
42
+ // Inactive track + thumb — honored on Android; system-drawn elsewhere.
43
+ maximumTrackTintColor: inactiveTrackColor, thumbTintColor: thumbTintColor })] }));
165
44
  }
166
45
  export { Slider };
@@ -32,7 +32,7 @@ declare function TabsTriggerInner({ icon, style, children, value, ...props }: Ta
32
32
  * </Tabs.Trigger>
33
33
  * ```
34
34
  */
35
- declare function TabsTriggerText({ style, ...props }: React.ComponentProps<typeof StyledText>): import("react/jsx-runtime").JSX.Element;
35
+ declare function TabsTriggerText({ style, numberOfLines, ...props }: React.ComponentProps<typeof StyledText>): import("react/jsx-runtime").JSX.Element;
36
36
  export interface TabsContentProps extends TabsPrimitive.ContentProps {
37
37
  style?: StyleProp<ViewStyle>;
38
38
  }
@@ -79,6 +79,10 @@ function TabsTriggerInner({ icon, style, children, value, ...props }) {
79
79
  : theme.colors.mutedForeground;
80
80
  const triggerBaseStyle = {
81
81
  flex: 1,
82
+ // Allow the trigger to shrink below its content's intrinsic width so long
83
+ // labels are constrained to the trigger's flex share (and can ellipsize)
84
+ // instead of overflowing and clipping at the screen edge.
85
+ minWidth: 0,
82
86
  height: Platform.OS === "web" ? sizeConfig.height : spacing.touchTarget,
83
87
  paddingHorizontal: sizeConfig.paddingHorizontal,
84
88
  flexDirection: "row",
@@ -119,10 +123,10 @@ const TabsTrigger = TabsTriggerInner;
119
123
  * </Tabs.Trigger>
120
124
  * ```
121
125
  */
122
- function TabsTriggerText({ style, ...props }) {
126
+ function TabsTriggerText({ style, numberOfLines = 1, ...props }) {
123
127
  const { size } = useTabsContext();
124
128
  const { fontSize } = SIZE_CONFIGS[size];
125
- return _jsx(StyledText, { selectable: false, style: [{ fontSize }, style], ...props });
129
+ return (_jsx(StyledText, { selectable: false, numberOfLines: numberOfLines, style: [{ fontSize, flexShrink: 1 }, style], ...props }));
126
130
  }
127
131
  function TabsContent({ style, children, ...props }) {
128
132
  return (_jsx(TabsPrimitive.Content, { style: StyleSheet.flatten([{ marginTop: spacing.md }, style]), ...props, children: children }));
@@ -136,6 +140,10 @@ const triggerContentStyles = StyleSheet.create({
136
140
  alignItems: "center",
137
141
  justifyContent: "center",
138
142
  gap: spacing.xs,
143
+ // Shrink with the trigger (paired with the trigger's minWidth:0) so a long
144
+ // label ellipsizes rather than forcing the trigger wider than its share.
145
+ flexShrink: 1,
146
+ minWidth: 0,
139
147
  },
140
148
  });
141
149
  // ============================================================================
@@ -120,5 +120,5 @@ interface TextInputCustomProps extends TextInputProps {
120
120
  * />
121
121
  * ```
122
122
  */
123
- export declare function TextInput({ variant, size, label, helperText, errorText, error, required, rows, showSecureEntryToggle, leftElement, rightElement, clearable, wrapperStyle, focusedStyle, forceLight, secureTextEntry, inputMode, style, onChangeText, onFocus, onBlur, value, multiline, editable, ref, ...rest }: TextInputCustomProps): import("react/jsx-runtime").JSX.Element;
123
+ export declare function TextInput(props: TextInputCustomProps): import("react/jsx-runtime").JSX.Element;
124
124
  export {};
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useMemo, useState } from "react";
2
+ import { useEffect, useImperativeHandle, useMemo, useRef, useState, } from "react";
3
3
  import { StyleSheet, TextInput as RNTextInput, Platform, View, Pressable, } from "react-native";
4
+ import { Host, TextInput as ExpoTextInput, useNativeState, } from "@expo/ui";
4
5
  import { useTheme } from "../hooks/useTheme.js";
5
6
  import { spacing } from "../constants/spacing.js";
6
7
  import { fontFamilies } from "../constants/fonts.js";
@@ -70,7 +71,21 @@ const SIZE_CONFIGS = {
70
71
  * />
71
72
  * ```
72
73
  */
73
- export function TextInput({ 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 }) {
74
+ export function TextInput(props) {
75
+ // On iOS/Android, route to the native @expo/ui field for flicker-free,
76
+ // platform-native text editing. Web keeps the full-featured RN implementation
77
+ // (no flicker problem there, and it preserves every in-field affordance).
78
+ if (Platform.OS !== "web") {
79
+ return _jsx(NativeTextInput, { ...props });
80
+ }
81
+ return _jsx(WebTextInput, { ...props });
82
+ }
83
+ /**
84
+ * Web / fallback implementation — the original React Native TextInput with the
85
+ * complete chrome (variants, sizes, overlays, password toggle, clear button,
86
+ * error icon). Unchanged from the pre-native version.
87
+ */
88
+ 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 }) {
74
89
  const { theme, getContrastingColor, getFocusRingStyle } = useTheme();
75
90
  const styles = useMemo(() => createStyles(theme, variant, size), [theme, variant, size]);
76
91
  const [focused, setFocused] = useState(false);
@@ -157,7 +172,111 @@ export function TextInput({ variant = "outline", size = "md", label, helperText,
157
172
  hasError && styles.errorText,
158
173
  ], children: errorText || helperText }))] }));
159
174
  }
175
+ /**
176
+ * Native (iOS / Android) implementation backed by `@expo/ui`'s TextInput, which
177
+ * bridges to SwiftUI's `TextField`/`SecureField` and Jetpack Compose's
178
+ * `TextField`. The text buffer lives natively (via `useNativeState`), so typing
179
+ * never round-trips through React state — eliminating the cursor flicker seen on
180
+ * controlled RN inputs.
181
+ *
182
+ * By design (reliability over feature-parity) this path renders the field plus
183
+ * sibling label / helper / error text only. The in-field overlays from the web
184
+ * implementation — password visibility toggle, clear button, left/right
185
+ * elements, and error icon — are intentionally omitted on native to avoid
186
+ * layering RN views over the native host view.
187
+ */
188
+ function NativeTextInput({ variant = "outline", size = "md", label, helperText, errorText, error, required, rows, forceLight, inputMode, onChangeText, value, defaultValue, editable = true, multiline, secureTextEntry, showSecureEntryToggle, ref, wrapperStyle,
189
+ // Web-only affordances. Destructured out of `rest` so they're NOT forwarded to
190
+ // the native field; intentionally unused on this path (see doc comment above).
191
+ // `style` (an RN TextStyle) is likewise dropped — it doesn't map to
192
+ // UniversalStyle and is replaced by the `boxStyle`/`textStyle` computed below.
193
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
194
+ leftElement, rightElement, clearable, focusedStyle, style, ...rest }) {
195
+ const { theme, getContrastingColor } = useTheme();
196
+ const styles = useMemo(() => createStyles(theme, variant, size), [theme, variant, size]);
197
+ const [passwordVisible, setPasswordVisible] = useState(false);
198
+ const hasError = error || !!errorText;
199
+ const sizeConfig = SIZE_CONFIGS[size];
200
+ // Password visibility toggle. Flipping `secureTextEntry` swaps SwiftUI's
201
+ // SecureField <-> TextField on iOS and toggles Compose's visualTransformation
202
+ // on Android; both bind the same `state` observable, so the text survives.
203
+ const hasSecureToggle = !!(secureTextEntry && showSecureEntryToggle);
204
+ const effectiveSecureTextEntry = secureTextEntry && !passwordVisible;
205
+ // Native text buffer. Seeded once; `value` changes are reconciled below.
206
+ const state = useNativeState(value ?? defaultValue ?? "");
207
+ // Reconcile controlled `value` -> native buffer WITHOUT echoing keystrokes.
208
+ // Only write when the parent's value genuinely diverges (resets, clears,
209
+ // programmatic sets); typing already updated `state` natively.
210
+ useEffect(() => {
211
+ if (value !== undefined && value !== state.value) {
212
+ state.value = value;
213
+ }
214
+ }, [value, state]);
215
+ // The inner ref is the @expo/ui handle; the outward ref is typed as
216
+ // RNTextInput because that's what the public TextInputCustomProps declares.
217
+ // We expose the subset consumers use, plus a `setNativeProps` shim so the
218
+ // uncontrolled AuthTextField can push corrected text into the native buffer.
219
+ const innerRef = useRef(null);
220
+ useImperativeHandle(ref, () => ({
221
+ focus: () => innerRef.current?.focus(),
222
+ blur: () => innerRef.current?.blur(),
223
+ clear: () => {
224
+ state.value = "";
225
+ },
226
+ isFocused: () => innerRef.current?.isFocused() ?? false,
227
+ setNativeProps: (props) => {
228
+ if (typeof props?.text === "string") {
229
+ state.value = props.text;
230
+ }
231
+ },
232
+ }), [state]);
233
+ const backgroundColor = forceLight
234
+ ? palette.white
235
+ : variant === "filled"
236
+ ? theme.colors.card
237
+ : "transparent";
238
+ const borderColor = hasError
239
+ ? theme.colors.destructive
240
+ : forceLight
241
+ ? "#d1d5db"
242
+ : theme.colors.input;
243
+ const textColor = forceLight
244
+ ? "#1f2937"
245
+ : getContrastingColor(backgroundColor === "transparent" ? theme.colors.background : backgroundColor, theme.colors.text, palette.white);
246
+ // Map variant/size to @expo/ui's UniversalStyle (translated to SwiftUI /
247
+ // Compose modifiers natively).
248
+ const boxStyle = {
249
+ backgroundColor,
250
+ borderColor,
251
+ borderRadius: variant === "underlined" ? 0 : spacing.radiusMd,
252
+ borderWidth: variant === "outline" ? 1 : 0,
253
+ paddingHorizontal: sizeConfig.paddingHorizontal,
254
+ paddingVertical: sizeConfig.paddingVertical,
255
+ opacity: editable === false ? 0.6 : 1,
256
+ ...(multiline ? null : { height: sizeConfig.height }),
257
+ };
258
+ const textStyle = {
259
+ color: textColor,
260
+ fontSize: sizeConfig.fontSize,
261
+ fontFamily: fontFamilies.sansSerif.regular,
262
+ };
263
+ return (_jsxs(View, { style: wrapperStyle, children: [!!label && (_jsx(View, { style: styles.labelContainer, children: _jsxs(StyledText, { selectable: false, style: styles.label, children: [label, required && _jsx(StyledText, { selectable: false, style: styles.required, children: " *" })] }) })), _jsxs(View, { style: hasSecureToggle ? styles.nativeRow : undefined, children: [_jsx(Host, { matchContents: { vertical: true }, style: hasSecureToggle ? styles.nativeHostFlex : styles.nativeHost, children: _jsx(ExpoTextInput, { ...rest, ref: innerRef, value: state, defaultValue: defaultValue, onChangeText: onChangeText, editable: editable, multiline: multiline, rows: rows, inputMode: inputMode, secureTextEntry: effectiveSecureTextEntry, placeholderTextColor: theme.colors.textDim, style: boxStyle, textStyle: textStyle }) }), hasSecureToggle && (_jsx(Pressable, { style: styles.nativePasswordToggle, onPress: () => setPasswordVisible((v) => !v), accessibilityLabel: passwordVisible ? "Hide password" : "Show password", accessibilityRole: "button", children: _jsx(Icon, { name: passwordVisible ? "eye-off" : "eye", size: spacing.iconSm + 4, color: "textDim" }) }))] }), !!(helperText || errorText) && (_jsx(StyledText, { selectable: false, style: [styles.helperText, hasError && styles.errorText], children: errorText || helperText }))] }));
264
+ }
160
265
  const createStyles = (theme, variant, size) => StyleSheet.create({
266
+ nativeHost: {
267
+ width: "100%",
268
+ },
269
+ nativeRow: {
270
+ flexDirection: "row",
271
+ alignItems: "center",
272
+ gap: spacing.xs,
273
+ },
274
+ nativeHostFlex: {
275
+ flex: 1,
276
+ },
277
+ nativePasswordToggle: {
278
+ paddingHorizontal: spacing.xs,
279
+ },
161
280
  wrapper: {
162
281
  width: "100%",
163
282
  position: "relative",
@@ -21,6 +21,7 @@ export * from "./Notification";
21
21
  export * from "./Popover";
22
22
  export * from "./Progress";
23
23
  export * from "./RadioGroup";
24
+ export * from "./SegmentedControl";
24
25
  export * from "./Select";
25
26
  export * from "./Separator";
26
27
  export * from "./Skeleton";
@@ -21,6 +21,7 @@ export * from "./Notification.js";
21
21
  export * from "./Popover.js";
22
22
  export * from "./Progress.js";
23
23
  export * from "./RadioGroup.js";
24
+ export * from "./SegmentedControl.js";
24
25
  export * from "./Select.js";
25
26
  export * from "./Separator.js";
26
27
  export * from "./Skeleton.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrmeg/expo-ui",
3
- "version": "0.7.1",
3
+ "version": "0.7.3",
4
4
  "private": false,
5
5
  "description": "Reusable Expo and React Native UI primitives for MrMeg projects.",
6
6
  "keywords": [
@@ -24,7 +24,8 @@
24
24
  "homepage": "https://github.com/mrmeg/expo-template/tree/main/packages/ui",
25
25
  "type": "module",
26
26
  "publishConfig": {
27
- "access": "public"
27
+ "access": "public",
28
+ "registry": "https://registry.npmjs.org/"
28
29
  },
29
30
  "main": "./dist/index.js",
30
31
  "types": "./dist/index.d.ts",
@@ -107,6 +108,7 @@
107
108
  "@rn-primitives/tooltip": "~1.4.0"
108
109
  },
109
110
  "peerDependencies": {
111
+ "@expo/ui": ">=56.0.0 <57.0.0",
110
112
  "@react-native-async-storage/async-storage": ">=2.2.0 <2.3.0",
111
113
  "expo": ">=55.0.0 <57.0.0",
112
114
  "expo-font": ">=55.0.0 <57.0.0",
@@ -1,7 +0,0 @@
1
- import { Animated } from "react-native";
2
- export declare function useBottomSheetKeyboardAnimation(): {
3
- height: Animated.Value;
4
- };
5
- export declare const BottomSheetKeyboardController: {
6
- dismiss(): void;
7
- };