@mrmeg/expo-ui 0.7.2 → 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.
- package/dist/components/BottomSheet.d.ts +92 -16
- package/dist/components/BottomSheet.js +203 -444
- package/dist/components/Dialog.js +16 -8
- package/dist/components/SegmentedControl.d.ts +52 -0
- package/dist/components/SegmentedControl.js +25 -0
- package/dist/components/Slider.d.ts +23 -3
- package/dist/components/Slider.js +26 -147
- package/dist/components/Tabs.d.ts +1 -1
- package/dist/components/Tabs.js +10 -2
- package/dist/components/TextInput.d.ts +1 -1
- package/dist/components/TextInput.js +121 -2
- package/dist/components/index.d.ts +1 -0
- package/dist/components/index.js +1 -0
- package/package.json +2 -1
- package/dist/components/BottomSheetKeyboard.d.ts +0 -7
- package/dist/components/BottomSheetKeyboard.js +0 -39
|
@@ -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: "
|
|
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: "
|
|
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
|
|
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,
|
|
6
|
-
import {
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
const
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
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?.(
|
|
54
|
-
}, [
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
}
|
package/dist/components/Tabs.js
CHANGED
|
@@ -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(
|
|
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(
|
|
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",
|
package/dist/components/index.js
CHANGED
|
@@ -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.
|
|
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": [
|
|
@@ -108,6 +108,7 @@
|
|
|
108
108
|
"@rn-primitives/tooltip": "~1.4.0"
|
|
109
109
|
},
|
|
110
110
|
"peerDependencies": {
|
|
111
|
+
"@expo/ui": ">=56.0.0 <57.0.0",
|
|
111
112
|
"@react-native-async-storage/async-storage": ">=2.2.0 <2.3.0",
|
|
112
113
|
"expo": ">=55.0.0 <57.0.0",
|
|
113
114
|
"expo-font": ">=55.0.0 <57.0.0",
|