@mrmeg/expo-ui 0.14.0 → 0.15.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.
- package/CHANGELOG.md +23 -0
- package/README.md +6 -6
- package/dist/components/Accordion.d.ts +1 -1
- package/dist/components/Accordion.js +25 -19
- package/dist/components/Badge.js +6 -5
- package/dist/components/BottomSheet.js +21 -19
- package/dist/components/Button.d.ts +2 -1
- package/dist/components/Button.js +14 -4
- package/dist/components/Checkbox.js +49 -19
- package/dist/components/Collapsible.d.ts +1 -1
- package/dist/components/Collapsible.js +22 -16
- package/dist/components/Drawer.js +8 -7
- package/dist/components/DropdownMenu.d.ts +1 -1
- package/dist/components/DropdownMenu.js +80 -59
- package/dist/components/EmptyState.d.ts +7 -1
- package/dist/components/EmptyState.js +23 -7
- package/dist/components/InputOTP.js +6 -2
- package/dist/components/Item.d.ts +93 -0
- package/dist/components/Item.js +125 -0
- package/dist/components/Label.js +7 -2
- package/dist/components/Notification.js +10 -22
- package/dist/components/Popover.d.ts +3 -18
- package/dist/components/Popover.js +10 -6
- package/dist/components/RadioGroup.js +49 -22
- package/dist/components/SectionHeader.d.ts +42 -0
- package/dist/components/SectionHeader.js +41 -0
- package/dist/components/Select.js +73 -41
- package/dist/components/Skeleton.js +2 -2
- package/dist/components/StatCard.d.ts +45 -0
- package/dist/components/StatCard.js +41 -0
- package/dist/components/StyledText.d.ts +20 -10
- package/dist/components/StyledText.js +64 -25
- package/dist/components/Switch.js +84 -57
- package/dist/components/Tabs.js +43 -12
- package/dist/components/TextInput.js +8 -9
- package/dist/components/Toggle.js +69 -42
- package/dist/components/ToggleGroup.js +82 -55
- package/dist/components/index.d.ts +3 -0
- package/dist/components/index.js +3 -0
- package/dist/constants/fonts.d.ts +3 -0
- package/dist/constants/fonts.js +35 -12
- package/dist/constants/index.d.ts +1 -0
- package/dist/constants/index.js +1 -0
- package/dist/constants/motion.d.ts +24 -0
- package/dist/constants/motion.js +24 -0
- package/dist/constants/spacing.d.ts +7 -7
- package/dist/constants/spacing.js +7 -7
- package/dist/hooks/useResources.d.ts +4 -2
- package/dist/hooks/useResources.js +30 -8
- package/dist/hooks/useStaggeredEntrance.d.ts +1 -1
- package/dist/hooks/useStaggeredEntrance.js +2 -1
- package/dist/hooks/useTheme.d.ts +1 -1
- package/dist/hooks/useTheme.js +53 -16
- package/package.json +20 -19
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { use } from "react";
|
|
3
|
-
import { Text as RNText, StyleSheet } from "react-native";
|
|
3
|
+
import { Platform, Text as RNText, StyleSheet } from "react-native";
|
|
4
4
|
import { useTheme } from "../hooks/useTheme.js";
|
|
5
5
|
import { fontFamilies } from "../constants/fonts.js";
|
|
6
6
|
import { translateText } from "../lib/i18n.js";
|
|
@@ -25,11 +25,24 @@ const LINE_HEIGHTS = {
|
|
|
25
25
|
xxl: 33.6,
|
|
26
26
|
display: 40.8,
|
|
27
27
|
};
|
|
28
|
+
// Per-size tracking (px), derived from em targets (em × fontSize, rounded to
|
|
29
|
+
// 2 decimals): tight tracking on large headings, neutral through body text,
|
|
30
|
+
// positive tracking on small/micro labels so they don't read cramped at size.
|
|
31
|
+
// RN's `letterSpacing` is px, not em, hence the pre-multiplied values here.
|
|
28
32
|
const LETTER_SPACING = {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
33
|
+
xs: 0.11, // +0.01em × 11
|
|
34
|
+
sm: 0.06, // +0.005em × 12
|
|
35
|
+
base: 0,
|
|
36
|
+
body: 0,
|
|
37
|
+
lg: -0.09, // -0.005em × 18
|
|
38
|
+
xl: -0.22, // -0.01em × 22
|
|
39
|
+
xxl: -0.42, // -0.015em × 28
|
|
40
|
+
display: -0.68, // -0.02em × 34
|
|
32
41
|
};
|
|
42
|
+
// Eyebrow/overline tracking is deliberately wider than the "sm" default
|
|
43
|
+
// above — it's a distinct uppercase-label treatment, not the same tracking
|
|
44
|
+
// used for ordinary small text at that size. +0.08em × 12px (sm fontSize).
|
|
45
|
+
const EYEBROW_LETTER_SPACING = 0.96;
|
|
33
46
|
const SEMANTIC_CONFIGS = {
|
|
34
47
|
title: { size: "xxl", weight: "semibold" },
|
|
35
48
|
heading: { size: "xl", weight: "semibold" },
|
|
@@ -37,19 +50,25 @@ const SEMANTIC_CONFIGS = {
|
|
|
37
50
|
body: { size: "body", weight: "regular" },
|
|
38
51
|
caption: { size: "sm", weight: "regular" },
|
|
39
52
|
label: { size: "base", weight: "medium" },
|
|
53
|
+
// Section-label idiom (uppercase, widely tracked) — see EYEBROW_LETTER_SPACING
|
|
54
|
+
// and the textTransform applied in StyledText below.
|
|
55
|
+
eyebrow: { size: "sm", weight: "semibold" },
|
|
40
56
|
};
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
57
|
+
// Font family weight keys now map 1:1 onto FontWeight — every weight has a
|
|
58
|
+
// real family entry (native: a distinct static Inter file; web: shared
|
|
59
|
+
// "Inter" family + numeric fontWeight, see WEB_FONT_WEIGHTS). Defaults to
|
|
60
|
+
// "regular" when no weight is requested.
|
|
61
|
+
const getFontFamilyWeight = (weight) => weight ?? "regular";
|
|
62
|
+
// Web-only: the numeric fontWeight that picks the right @font-face variant
|
|
63
|
+
// out of the single shared "Inter" CSS family. "light" has no loaded 300
|
|
64
|
+
// weight (mirrors the native light->regular mapping in constants/fonts.ts),
|
|
65
|
+
// so it renders at 400 like "regular".
|
|
66
|
+
const WEB_FONT_WEIGHTS = {
|
|
67
|
+
light: "400",
|
|
68
|
+
regular: "400",
|
|
69
|
+
medium: "500",
|
|
70
|
+
semibold: "600",
|
|
71
|
+
bold: "700",
|
|
53
72
|
};
|
|
54
73
|
/**
|
|
55
74
|
* Base Text component with theme and variant support
|
|
@@ -57,9 +76,12 @@ const getFontFamilyWeight = (weight) => {
|
|
|
57
76
|
*
|
|
58
77
|
* Features:
|
|
59
78
|
* - Size variants (xs, sm, base, body, lg, xl, xxl, display)
|
|
60
|
-
* - Semantic variants (title, heading, subheading, body, caption, label)
|
|
79
|
+
* - Semantic variants (title, heading, subheading, body, caption, label, eyebrow)
|
|
61
80
|
* - Text alignment prop
|
|
62
|
-
* - Font weight options (light, regular, medium, semibold, bold)
|
|
81
|
+
* - Font weight options (light, regular, medium, semibold, bold) — resolves to
|
|
82
|
+
* real Inter font files on native, and family + numeric fontWeight on web
|
|
83
|
+
* - Per-size letter-spacing scale (tight on large headings, positive on micro
|
|
84
|
+
* labels); explicit `style` letterSpacing always wins
|
|
63
85
|
* - Text selection enabled by default; pass `selectable={false}` for control
|
|
64
86
|
* chrome such as button labels, tabs, badges, and field labels
|
|
65
87
|
* - numberOfLines and ellipsizeMode support from RN TextProps
|
|
@@ -80,14 +102,20 @@ export function StyledText(props) {
|
|
|
80
102
|
const finalFontWeight = semanticConfig?.weight ?? fontWeight ?? "regular";
|
|
81
103
|
// Get font family based on variant and weight
|
|
82
104
|
const fontFamilyWeight = getFontFamilyWeight(finalFontWeight);
|
|
83
|
-
//
|
|
105
|
+
// Georgia (serif) only has one weight — use it regardless of requested weight
|
|
84
106
|
const fontFamily = variant === "serif"
|
|
85
107
|
? fontFamilies.serif.regular
|
|
86
108
|
: fontFamilies.sansSerif[fontFamilyWeight] ?? fontFamilies.sansSerif.regular;
|
|
109
|
+
// Web ships one Inter family for every weight, so a numeric fontWeight picks
|
|
110
|
+
// the right @font-face variant. Native ships a discrete static font file per
|
|
111
|
+
// weight (see constants/fonts.ts) — the family name alone carries the
|
|
112
|
+
// weight there, and adding a numeric fontWeight on top would faux-bold an
|
|
113
|
+
// already-bold file, so this stays web-only.
|
|
114
|
+
const resolvedFontWeight = Platform.OS === "web" ? WEB_FONT_WEIGHTS[finalFontWeight] : undefined;
|
|
87
115
|
// Get fontSize and lineHeight from size variant
|
|
88
116
|
const fontSize = FONT_SIZES[finalSize];
|
|
89
117
|
const lineHeight = LINE_HEIGHTS[finalSize];
|
|
90
|
-
const letterSpacing = LETTER_SPACING[finalSize];
|
|
118
|
+
const letterSpacing = semantic === "eyebrow" ? EYEBROW_LETTER_SPACING : LETTER_SPACING[finalSize];
|
|
91
119
|
// If style overrides fontSize without lineHeight, drop the preset lineHeight
|
|
92
120
|
// so RN uses its default (avoids clipping text with a mismatched lineHeight)
|
|
93
121
|
const flatStyle = style ? StyleSheet.flatten(style) : undefined;
|
|
@@ -107,10 +135,12 @@ export function StyledText(props) {
|
|
|
107
135
|
color,
|
|
108
136
|
fontFamily,
|
|
109
137
|
fontSize,
|
|
138
|
+
...(resolvedFontWeight !== undefined && { fontWeight: resolvedFontWeight }),
|
|
110
139
|
...(resolvedLineHeight !== undefined && { lineHeight: resolvedLineHeight }),
|
|
111
140
|
userSelect: resolvedSelectable ? "auto" : "none",
|
|
112
141
|
...(letterSpacing !== undefined && { letterSpacing }),
|
|
113
142
|
...(align && { textAlign: align }),
|
|
143
|
+
...(semantic === "eyebrow" && { textTransform: "uppercase" }),
|
|
114
144
|
},
|
|
115
145
|
resolvedContextTextStyle,
|
|
116
146
|
style,
|
|
@@ -118,35 +148,36 @@ export function StyledText(props) {
|
|
|
118
148
|
}
|
|
119
149
|
/**
|
|
120
150
|
* Serif Text Component
|
|
121
|
-
* Uses serif font family (
|
|
151
|
+
* Uses the serif font family (Georgia)
|
|
122
152
|
*/
|
|
123
153
|
export function SerifText(props) {
|
|
124
154
|
return _jsx(StyledText, { ...props, variant: "serif" });
|
|
125
155
|
}
|
|
126
156
|
/**
|
|
127
157
|
* Sans-Serif Text Component
|
|
128
|
-
* Uses sans-serif font family (
|
|
158
|
+
* Uses the sans-serif font family (Inter)
|
|
129
159
|
*/
|
|
130
160
|
export function SansSerifText(props) {
|
|
131
161
|
return _jsx(StyledText, { ...props, variant: "sansSerif" });
|
|
132
162
|
}
|
|
133
163
|
/**
|
|
134
164
|
* Serif Bold Text Component
|
|
135
|
-
* Uses serif font family —
|
|
165
|
+
* Uses the serif font family — Georgia's only shipped weight is regular
|
|
136
166
|
*/
|
|
137
167
|
export function SerifBoldText(props) {
|
|
138
168
|
return _jsx(StyledText, { ...props, variant: "serif", fontWeight: "regular" });
|
|
139
169
|
}
|
|
140
170
|
/**
|
|
141
171
|
* Sans-Serif Bold Text Component
|
|
142
|
-
* Uses sans-serif font family
|
|
172
|
+
* Uses the sans-serif font family at semibold weight (Inter 600)
|
|
143
173
|
*/
|
|
144
174
|
export function SansSerifBoldText(props) {
|
|
145
175
|
return _jsx(StyledText, { ...props, variant: "sansSerif", fontWeight: "semibold" });
|
|
146
176
|
}
|
|
147
177
|
/**
|
|
148
178
|
* Display Text Component
|
|
149
|
-
* Uses
|
|
179
|
+
* Uses the serif font family (Georgia) at display size — for hero text and
|
|
180
|
+
* splash screens
|
|
150
181
|
*/
|
|
151
182
|
export function DisplayText(props) {
|
|
152
183
|
return _jsx(StyledText, { ...props, variant: "serif", size: "display" });
|
|
@@ -188,3 +219,11 @@ export function CaptionText(props) {
|
|
|
188
219
|
export function LabelText(props) {
|
|
189
220
|
return _jsx(StyledText, { ...props, semantic: "label" });
|
|
190
221
|
}
|
|
222
|
+
/**
|
|
223
|
+
* Eyebrow Text - Small, semibold, uppercase section label with wide positive
|
|
224
|
+
* tracking (the overline idiom above a heading). Consumed by the SectionHeader
|
|
225
|
+
* primitive (separate spec).
|
|
226
|
+
*/
|
|
227
|
+
export function EyebrowText(props) {
|
|
228
|
+
return _jsx(StyledText, { ...props, semantic: "eyebrow" });
|
|
229
|
+
}
|
|
@@ -5,15 +5,41 @@ import { spacing } from "../constants/spacing.js";
|
|
|
5
5
|
import { useTheme } from "../hooks/useTheme.js";
|
|
6
6
|
import { hapticLight } from "../lib/haptics.js";
|
|
7
7
|
import * as SwitchPrimitives from "@rn-primitives/switch";
|
|
8
|
-
import { useCallback, useEffect, useRef } from "react";
|
|
8
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
9
9
|
import { ActivityIndicator, Platform, StyleSheet, View, Animated } from "react-native";
|
|
10
10
|
import { StyledText } from "./StyledText.js";
|
|
11
11
|
import { useReducedMotion } from "../hooks/useReduceMotion.js";
|
|
12
|
+
import { useScalePress } from "../hooks/useScalePress.js";
|
|
12
13
|
const DEFAULT_HIT_SLOP = 8;
|
|
13
14
|
function Switch({ variant = "default", labelOn, labelOff, size = { width: 44, height: 24 }, thumbSize = 20, loading = false, style: styleOverride, ...props }) {
|
|
14
|
-
const { theme, getContrastingColor, getShadowStyle, withAlpha } = useTheme();
|
|
15
|
+
const { theme, getContrastingColor, getShadowStyle, getFocusRingStyle, withAlpha } = useTheme();
|
|
15
16
|
const reduceMotion = useReducedMotion();
|
|
16
17
|
const hasMounted = useRef(false);
|
|
18
|
+
const [focused, setFocused] = useState(false);
|
|
19
|
+
const focusRingStyle = getFocusRingStyle();
|
|
20
|
+
const { animatedStyle: scaleStyle, pressHandlers } = useScalePress({
|
|
21
|
+
disabled: !!props.disabled,
|
|
22
|
+
scaleTo: 0.92,
|
|
23
|
+
haptic: false,
|
|
24
|
+
});
|
|
25
|
+
const showFocusRing = (event) => {
|
|
26
|
+
let ringVisible = true;
|
|
27
|
+
if (Platform.OS === "web") {
|
|
28
|
+
const target = event?.nativeEvent?.target;
|
|
29
|
+
if (target && typeof target.matches === "function") {
|
|
30
|
+
try {
|
|
31
|
+
ringVisible = target.matches(":focus-visible");
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
ringVisible = true;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
setFocused(ringVisible);
|
|
39
|
+
};
|
|
40
|
+
const hideFocusRing = () => {
|
|
41
|
+
setFocused(false);
|
|
42
|
+
};
|
|
17
43
|
// Fire haptic on user-initiated toggles (skip initial mount)
|
|
18
44
|
const wrappedOnCheckedChange = useCallback((checked) => {
|
|
19
45
|
if (hasMounted.current)
|
|
@@ -67,61 +93,62 @@ function Switch({ variant = "default", labelOn, labelOff, size = { width: 44, he
|
|
|
67
93
|
const labelFontSize = size.height / 3;
|
|
68
94
|
// Flatten style override for web compatibility
|
|
69
95
|
const flattenedStyle = styleOverride ? StyleSheet.flatten(styleOverride) : undefined;
|
|
70
|
-
return (_jsxs(SwitchPrimitives.Root, { ...props, onCheckedChange: wrappedOnCheckedChange, style: {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
{
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
96
|
+
return (_jsx(Animated.View, { style: scaleStyle, children: _jsxs(SwitchPrimitives.Root, { ...props, onCheckedChange: wrappedOnCheckedChange, onPressIn: pressHandlers.onPressIn, onPressOut: pressHandlers.onPressOut, onFocus: showFocusRing, onBlur: hideFocusRing, style: {
|
|
97
|
+
position: "relative",
|
|
98
|
+
width: size.width,
|
|
99
|
+
height: size.height,
|
|
100
|
+
borderRadius: size.height / 2,
|
|
101
|
+
justifyContent: "center",
|
|
102
|
+
opacity: props.disabled ? 0.5 : 1,
|
|
103
|
+
...(Platform.OS === "web" && { cursor: props.disabled ? "not-allowed" : "pointer" }),
|
|
104
|
+
...(focused && !props.disabled ? focusRingStyle : null),
|
|
105
|
+
...(flattenedStyle || {}),
|
|
106
|
+
}, hitSlop: DEFAULT_HIT_SLOP, accessibilityRole: "switch", accessibilityState: {
|
|
107
|
+
checked: props.checked,
|
|
108
|
+
disabled: !!props.disabled,
|
|
109
|
+
busy: loading,
|
|
110
|
+
}, children: [_jsx(View, { style: {
|
|
111
|
+
...StyleSheet.absoluteFill,
|
|
112
|
+
borderRadius: size.height / 2,
|
|
113
|
+
backgroundColor: trackBg,
|
|
114
|
+
borderWidth: 1,
|
|
115
|
+
borderColor: trackBorderColor,
|
|
116
|
+
pointerEvents: "none",
|
|
117
|
+
} }), labelOn && !isIOS && (_jsx(View, { style: {
|
|
118
|
+
...styles.label,
|
|
119
|
+
left: labelHorizontalInset,
|
|
120
|
+
right: thumbInset + thumbSize + labelGap,
|
|
121
|
+
opacity: props.checked ? 1 : 0,
|
|
122
|
+
}, children: _jsx(StyledText, { selectable: false, style: {
|
|
123
|
+
fontFamily: fontFamilies.sansSerif.bold,
|
|
124
|
+
fontSize: labelFontSize,
|
|
125
|
+
color: labelOnColor,
|
|
126
|
+
userSelect: "none",
|
|
127
|
+
}, children: labelOn }) })), _jsx(SwitchPrimitives.Thumb, { children: _jsx(Animated.View, { style: [
|
|
128
|
+
{
|
|
129
|
+
width: thumbSize,
|
|
130
|
+
height: thumbSize,
|
|
131
|
+
marginLeft: thumbInset,
|
|
132
|
+
borderRadius: thumbSize / 2,
|
|
133
|
+
backgroundColor: thumbBackgroundColor,
|
|
134
|
+
borderWidth: 1,
|
|
135
|
+
borderColor: thumbBorderColor,
|
|
136
|
+
justifyContent: "center",
|
|
137
|
+
alignItems: "center",
|
|
138
|
+
...getShadowStyle("subtle"),
|
|
139
|
+
},
|
|
140
|
+
thumbAnimatedStyle,
|
|
141
|
+
], children: loading && (_jsx(ActivityIndicator, { size: "small", color: thumbIndicatorColor })) }) }), labelOff && !isIOS && (_jsx(View, { style: {
|
|
142
|
+
...styles.label,
|
|
143
|
+
left: thumbInset + thumbSize + labelGap,
|
|
144
|
+
right: labelHorizontalInset,
|
|
145
|
+
opacity: props.checked ? 0 : 1,
|
|
146
|
+
}, children: _jsx(StyledText, { selectable: false, style: {
|
|
147
|
+
fontFamily: fontFamilies.sansSerif.bold,
|
|
148
|
+
fontSize: labelFontSize,
|
|
149
|
+
color: theme.colors.text,
|
|
150
|
+
userSelect: "none",
|
|
151
|
+
}, children: labelOff }) }))] }) }));
|
|
125
152
|
}
|
|
126
153
|
const styles = StyleSheet.create({
|
|
127
154
|
label: {
|
package/dist/components/Tabs.js
CHANGED
|
@@ -7,6 +7,7 @@ import { TextClassContext, TextColorContext } from "./StyledText.context";
|
|
|
7
7
|
import { Icon } from "./Icon.js";
|
|
8
8
|
import { useTheme } from "../hooks/useTheme.js";
|
|
9
9
|
import { useReducedMotion } from "../hooks/useReduceMotion.js";
|
|
10
|
+
import { useScalePress } from "../hooks/useScalePress.js";
|
|
10
11
|
import { spacing } from "../constants/spacing.js";
|
|
11
12
|
// ============================================================================
|
|
12
13
|
// Size configs
|
|
@@ -56,7 +57,7 @@ function TabsList({ style, children, ...props }) {
|
|
|
56
57
|
return (_jsx(TabsPrimitive.List, { style: StyleSheet.flatten([listStyle, style]), ...props, children: children }));
|
|
57
58
|
}
|
|
58
59
|
function TabsTriggerInner({ icon, style, children, value, ...props }) {
|
|
59
|
-
const { theme, getShadowStyle } = useTheme();
|
|
60
|
+
const { theme, getShadowStyle, getFocusRingStyle } = useTheme();
|
|
60
61
|
const { variant, size } = useTabsContext();
|
|
61
62
|
const sizeConfig = SIZE_CONFIGS[size];
|
|
62
63
|
const reduceMotion = useReducedMotion();
|
|
@@ -77,6 +78,31 @@ function TabsTriggerInner({ icon, style, children, value, ...props }) {
|
|
|
77
78
|
: isSelected
|
|
78
79
|
? theme.colors.foreground
|
|
79
80
|
: theme.colors.mutedForeground;
|
|
81
|
+
const focusRingStyle = getFocusRingStyle();
|
|
82
|
+
const [focused, setFocused] = React.useState(false);
|
|
83
|
+
const { animatedStyle: scaleStyle, pressHandlers } = useScalePress({
|
|
84
|
+
disabled: isDisabled,
|
|
85
|
+
scaleTo: 0.97,
|
|
86
|
+
haptic: false,
|
|
87
|
+
});
|
|
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
|
+
};
|
|
80
106
|
const triggerBaseStyle = {
|
|
81
107
|
flex: 1,
|
|
82
108
|
// Allow the trigger to shrink below its content's intrinsic width so long
|
|
@@ -97,17 +123,22 @@ function TabsTriggerInner({ icon, style, children, value, ...props }) {
|
|
|
97
123
|
borderRadius: spacing.radiusSm,
|
|
98
124
|
...getShadowStyle("subtle"),
|
|
99
125
|
} : {};
|
|
100
|
-
return (_jsx(TextColorContext.Provider, { value: textColor, children: _jsx(TextClassContext.Provider, { value: "", children:
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
126
|
+
return (_jsx(TextColorContext.Provider, { value: textColor, children: _jsx(TextClassContext.Provider, { value: "", children: _jsx(Animated.View, { style: [{ flex: 1, minWidth: 0 }, scaleStyle], children: _jsxs(TabsPrimitive.Trigger, { value: value, ...props, onPressIn: pressHandlers.onPressIn, onPressOut: pressHandlers.onPressOut, onFocus: showFocusRing, onBlur: hideFocusRing, style: StyleSheet.flatten([
|
|
127
|
+
triggerBaseStyle,
|
|
128
|
+
pillActiveStyle,
|
|
129
|
+
focused && !isDisabled && focusRingStyle,
|
|
130
|
+
style,
|
|
131
|
+
]), children: [_jsxs(View, { style: triggerContentStyles.container, children: [icon && (_jsx(Icon, { name: icon, size: sizeConfig.iconSize, color: textColor, decorative: true })), children] }), variant === "underline" && (_jsx(Animated.View, { style: [
|
|
132
|
+
{
|
|
133
|
+
position: "absolute",
|
|
134
|
+
bottom: 0,
|
|
135
|
+
left: 0,
|
|
136
|
+
right: 0,
|
|
137
|
+
height: 2,
|
|
138
|
+
backgroundColor: theme.colors.foreground,
|
|
139
|
+
},
|
|
140
|
+
{ opacity: activeOpacity },
|
|
141
|
+
] }))] }) }) }) }));
|
|
111
142
|
}
|
|
112
143
|
const TabsTrigger = TabsTriggerInner;
|
|
113
144
|
/**
|
|
@@ -146,7 +146,7 @@ function WebTextInput({ variant = "outline", size = "md", label, helperText, err
|
|
|
146
146
|
const togglePasswordVisible = () => {
|
|
147
147
|
setPasswordVisible(v => !v);
|
|
148
148
|
};
|
|
149
|
-
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: [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: [
|
|
149
|
+
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: [
|
|
150
150
|
styles.input,
|
|
151
151
|
{
|
|
152
152
|
backgroundColor,
|
|
@@ -171,7 +171,7 @@ function WebTextInput({ variant = "outline", size = "md", label, helperText, err
|
|
|
171
171
|
}, 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: () => {
|
|
172
172
|
hapticLight();
|
|
173
173
|
onChangeText?.("");
|
|
174
|
-
}, 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: "alert-circle", size: spacing.iconSm, color: "destructive", decorative: true }) }))] }), !!(helperText || errorText) && (_jsx(StyledText, { selectable: false, style: [
|
|
174
|
+
}, 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: "alert-circle", size: spacing.iconSm, color: "destructive", decorative: true }) }))] }), !!(helperText || errorText) && (_jsx(StyledText, { selectable: false, size: "sm", style: [
|
|
175
175
|
styles.helperText,
|
|
176
176
|
hasError && styles.errorText,
|
|
177
177
|
], children: errorText || helperText }))] }));
|
|
@@ -327,7 +327,7 @@ leftElement, rightElement, clearable, focusedStyle, style, ...rest }) {
|
|
|
327
327
|
fontSize: sizeConfig.fontSize,
|
|
328
328
|
...(nativeFontFamily ? { fontFamily: nativeFontFamily } : null),
|
|
329
329
|
};
|
|
330
|
-
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, { ref: surfaceRef, style: [surfaceStyle, hasSecureToggle && styles.nativeRow], onLayout: syncFocusedInputLayout, children: [_jsx(Host, { matchContents: { vertical: true }, style: hasSecureToggle ? styles.nativeHostFlex : styles.nativeHost, children: _jsx(ExpoTextInput, { ...rest, ref: innerRef, value: state, defaultValue: defaultValue, onChangeText: onChangeText, onFocus: handleFocus, onBlur: handleBlur, 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 }))] }));
|
|
330
|
+
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, { ref: surfaceRef, style: [surfaceStyle, hasSecureToggle && styles.nativeRow], onLayout: syncFocusedInputLayout, children: [_jsx(Host, { matchContents: { vertical: true }, style: hasSecureToggle ? styles.nativeHostFlex : styles.nativeHost, children: _jsx(ExpoTextInput, { ...rest, ref: innerRef, value: state, defaultValue: defaultValue, onChangeText: onChangeText, onFocus: handleFocus, onBlur: handleBlur, 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, size: "sm", style: [styles.helperText, hasError && styles.errorText], children: errorText || helperText }))] }));
|
|
331
331
|
}
|
|
332
332
|
const createStyles = (theme, variant, size) => StyleSheet.create({
|
|
333
333
|
nativeHost: {
|
|
@@ -378,18 +378,17 @@ const createStyles = (theme, variant, size) => StyleSheet.create({
|
|
|
378
378
|
marginBottom: spacing.xs,
|
|
379
379
|
},
|
|
380
380
|
label: {
|
|
381
|
-
fontFamily
|
|
382
|
-
|
|
383
|
-
|
|
381
|
+
// fontFamily/fontSize/fontWeight now come from StyledText's
|
|
382
|
+
// size="base" fontWeight="medium" props (see JSX below), which resolve
|
|
383
|
+
// the platform-correct family + weight instead of a hardcoded pair.
|
|
384
384
|
color: theme.colors.text,
|
|
385
385
|
},
|
|
386
386
|
required: {
|
|
387
|
+
// fontFamily/fontWeight now come from StyledText's fontWeight="bold" prop.
|
|
387
388
|
color: theme.colors.destructive,
|
|
388
|
-
fontFamily: fontFamilies.sansSerif.bold,
|
|
389
389
|
},
|
|
390
390
|
helperText: {
|
|
391
|
-
fontFamily
|
|
392
|
-
fontSize: 12,
|
|
391
|
+
// fontFamily/fontSize now come from StyledText's size="sm" prop (see JSX below).
|
|
393
392
|
color: theme.colors.textDim,
|
|
394
393
|
marginTop: spacing.xs,
|
|
395
394
|
},
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
import React from "react";
|
|
2
|
+
import React, { useState } from "react";
|
|
3
3
|
import { Icon } from "./Icon.js";
|
|
4
4
|
import { TextClassContext, TextColorContext, TextSelectabilityContext } from "./StyledText.context";
|
|
5
5
|
import { useTheme } from "../hooks/useTheme.js";
|
|
6
6
|
import { spacing } from "../constants/spacing.js";
|
|
7
|
+
import { useScalePress } from "../hooks/useScalePress.js";
|
|
7
8
|
import * as TogglePrimitive from "@rn-primitives/toggle";
|
|
8
|
-
import { Platform, StyleSheet, ActivityIndicator } from "react-native";
|
|
9
|
+
import { Platform, StyleSheet, ActivityIndicator, Animated } from "react-native";
|
|
9
10
|
import { palette } from "../constants/colors.js";
|
|
10
11
|
const DEFAULT_HIT_SLOP = 8;
|
|
11
12
|
// Size configurations
|
|
@@ -73,8 +74,10 @@ const TOGGLE_SIZES = {
|
|
|
73
74
|
* ```
|
|
74
75
|
*/
|
|
75
76
|
function Toggle({ variant = "default", size = "default", shape = "default", loading = false, iconOnly = false, style: styleOverride, ...props }) {
|
|
76
|
-
const { theme, getContrastingColor, withAlpha } = useTheme();
|
|
77
|
+
const { theme, getContrastingColor, getFocusRingStyle, withAlpha } = useTheme();
|
|
77
78
|
const sizeConfig = TOGGLE_SIZES[size];
|
|
79
|
+
const focusRingStyle = getFocusRingStyle();
|
|
80
|
+
const [focused, setFocused] = useState(false);
|
|
78
81
|
// Calculate text color based on state and variant
|
|
79
82
|
const getTextColor = () => {
|
|
80
83
|
if (props.pressed) {
|
|
@@ -101,45 +104,69 @@ function Toggle({ variant = "default", size = "default", shape = "default", load
|
|
|
101
104
|
const flattenedStyle = styleOverride ? StyleSheet.flatten(styleOverride) : undefined;
|
|
102
105
|
const isDisabled = props.disabled || loading;
|
|
103
106
|
const children = props.children;
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
107
|
+
const { animatedStyle: scaleStyle, pressHandlers } = useScalePress({
|
|
108
|
+
disabled: !!isDisabled,
|
|
109
|
+
scaleTo: 0.92,
|
|
110
|
+
haptic: false,
|
|
111
|
+
});
|
|
112
|
+
const showFocusRing = (event) => {
|
|
113
|
+
let ringVisible = true;
|
|
114
|
+
if (Platform.OS === "web") {
|
|
115
|
+
const target = event?.nativeEvent?.target;
|
|
116
|
+
if (target && typeof target.matches === "function") {
|
|
117
|
+
try {
|
|
118
|
+
ringVisible = target.matches(":focus-visible");
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
ringVisible = true;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
setFocused(ringVisible);
|
|
126
|
+
};
|
|
127
|
+
const hideFocusRing = () => {
|
|
128
|
+
setFocused(false);
|
|
129
|
+
};
|
|
130
|
+
return (_jsx(TextColorContext.Provider, { value: textColor, children: _jsx(TextClassContext.Provider, { value: "", children: _jsx(Animated.View, { style: scaleStyle, children: _jsx(TogglePrimitive.Root, { ...props, disabled: isDisabled, onPressIn: pressHandlers.onPressIn, onPressOut: pressHandlers.onPressOut, onFocus: showFocusRing, onBlur: hideFocusRing, style: {
|
|
131
|
+
...styles.root,
|
|
132
|
+
height: sizeConfig.height,
|
|
133
|
+
minWidth: sizeConfig.minWidth,
|
|
134
|
+
paddingHorizontal: iconOnly ? sizeConfig.height / 2 - sizeConfig.iconSize / 2 : sizeConfig.paddingHorizontal,
|
|
135
|
+
borderRadius: getBorderRadius(),
|
|
136
|
+
// Base variant styles
|
|
137
|
+
...(variant === "default" && !props.pressed && {
|
|
138
|
+
backgroundColor: "transparent",
|
|
139
|
+
borderColor: theme.colors.border,
|
|
140
|
+
}),
|
|
141
|
+
...(variant === "default" && props.pressed && {
|
|
142
|
+
backgroundColor: withAlpha(theme.colors.primary, 0.1),
|
|
143
|
+
borderColor: theme.colors.primary,
|
|
144
|
+
}),
|
|
145
|
+
// Outline variant styles
|
|
146
|
+
...(variant === "outline" && !props.pressed && {
|
|
147
|
+
backgroundColor: "transparent",
|
|
148
|
+
borderColor: theme.colors.primary,
|
|
149
|
+
}),
|
|
150
|
+
...(variant === "outline" && props.pressed && {
|
|
151
|
+
backgroundColor: theme.colors.primary,
|
|
152
|
+
borderColor: theme.colors.primary,
|
|
153
|
+
}),
|
|
154
|
+
// Disabled state
|
|
155
|
+
opacity: isDisabled ? 0.5 : 1,
|
|
156
|
+
// Web-specific styles
|
|
157
|
+
...(Platform.OS === "web" && {
|
|
158
|
+
cursor: isDisabled ? "not-allowed" : "pointer",
|
|
159
|
+
transition: "all 150ms",
|
|
160
|
+
userSelect: "none",
|
|
161
|
+
}),
|
|
162
|
+
...(focused && !isDisabled ? focusRingStyle : null),
|
|
163
|
+
// Apply custom style override
|
|
164
|
+
...(flattenedStyle || {}),
|
|
165
|
+
}, hitSlop: DEFAULT_HIT_SLOP, accessibilityRole: "button", accessibilityState: {
|
|
166
|
+
selected: props.pressed,
|
|
167
|
+
disabled: !!isDisabled,
|
|
168
|
+
busy: loading,
|
|
169
|
+
}, children: loading ? (_jsx(TextSelectabilityContext.Provider, { value: false, children: _jsx(ActivityIndicator, { size: "small", color: textColor }) })) : typeof children === "function" ? ((state) => (_jsx(TextSelectabilityContext.Provider, { value: false, children: children(state) }))) : (_jsx(TextSelectabilityContext.Provider, { value: false, children: children })) }) }) }) }));
|
|
143
170
|
}
|
|
144
171
|
function ToggleIcon({ name, size, color }) {
|
|
145
172
|
const contextColor = React.use(TextColorContext);
|