@mrmeg/expo-ui 0.13.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.
Files changed (55) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/LLM_USAGE.md +4 -0
  3. package/README.md +13 -9
  4. package/dist/components/Accordion.d.ts +1 -1
  5. package/dist/components/Accordion.js +25 -19
  6. package/dist/components/Badge.js +6 -5
  7. package/dist/components/BottomSheet.js +21 -19
  8. package/dist/components/Button.d.ts +2 -1
  9. package/dist/components/Button.js +14 -4
  10. package/dist/components/Checkbox.js +49 -19
  11. package/dist/components/Collapsible.d.ts +1 -1
  12. package/dist/components/Collapsible.js +22 -16
  13. package/dist/components/Drawer.js +8 -7
  14. package/dist/components/DropdownMenu.d.ts +1 -1
  15. package/dist/components/DropdownMenu.js +80 -59
  16. package/dist/components/EmptyState.d.ts +7 -1
  17. package/dist/components/EmptyState.js +23 -7
  18. package/dist/components/InputOTP.js +6 -2
  19. package/dist/components/Item.d.ts +93 -0
  20. package/dist/components/Item.js +125 -0
  21. package/dist/components/Label.js +7 -2
  22. package/dist/components/Notification.js +10 -22
  23. package/dist/components/Popover.d.ts +3 -18
  24. package/dist/components/Popover.js +10 -6
  25. package/dist/components/RadioGroup.js +49 -22
  26. package/dist/components/SectionHeader.d.ts +42 -0
  27. package/dist/components/SectionHeader.js +41 -0
  28. package/dist/components/Select.js +73 -41
  29. package/dist/components/Skeleton.js +2 -2
  30. package/dist/components/StatCard.d.ts +45 -0
  31. package/dist/components/StatCard.js +41 -0
  32. package/dist/components/StyledText.d.ts +20 -10
  33. package/dist/components/StyledText.js +64 -25
  34. package/dist/components/Switch.js +84 -57
  35. package/dist/components/Tabs.js +43 -12
  36. package/dist/components/TextInput.js +8 -9
  37. package/dist/components/Toggle.js +69 -42
  38. package/dist/components/ToggleGroup.js +82 -55
  39. package/dist/components/index.d.ts +3 -0
  40. package/dist/components/index.js +3 -0
  41. package/dist/constants/fonts.d.ts +3 -0
  42. package/dist/constants/fonts.js +35 -12
  43. package/dist/constants/index.d.ts +1 -0
  44. package/dist/constants/index.js +1 -0
  45. package/dist/constants/motion.d.ts +24 -0
  46. package/dist/constants/motion.js +24 -0
  47. package/dist/constants/spacing.d.ts +7 -7
  48. package/dist/constants/spacing.js +7 -7
  49. package/dist/hooks/useResources.d.ts +4 -2
  50. package/dist/hooks/useResources.js +30 -8
  51. package/dist/hooks/useStaggeredEntrance.d.ts +1 -1
  52. package/dist/hooks/useStaggeredEntrance.js +2 -1
  53. package/dist/hooks/useTheme.d.ts +1 -1
  54. package/dist/hooks/useTheme.js +53 -16
  55. package/package.json +24 -24
@@ -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
- sm: 0,
30
- xxl: 0,
31
- display: 0,
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
- // Map font weights to actual font family weight keys
42
- // DM Sans has: light, regular, medium, semibold
43
- // DM Serif Display has: regular only
44
- const getFontFamilyWeight = (weight) => {
45
- if (!weight || weight === "regular")
46
- return "regular";
47
- if (weight === "light")
48
- return "light";
49
- if (weight === "medium")
50
- return "medium";
51
- // semibold and bold both map to semibold (DM Sans's heaviest weight)
52
- return "semibold";
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
- // DM Serif Display only has regular — use it regardless of requested weight
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 (DM Serif Display)
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 (DM Sans)
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 — DM Serif Display only has regular weight
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 with semibold weight (DM Sans 600)
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 DM Serif Display at display size — for hero text and splash screens
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
- position: "relative",
72
- width: size.width,
73
- height: size.height,
74
- borderRadius: size.height / 2,
75
- justifyContent: "center",
76
- opacity: props.disabled ? 0.5 : 1,
77
- ...(Platform.OS === "web" && { cursor: props.disabled ? "not-allowed" : "pointer" }),
78
- ...(flattenedStyle || {}),
79
- }, hitSlop: DEFAULT_HIT_SLOP, accessibilityRole: "switch", accessibilityState: {
80
- checked: props.checked,
81
- disabled: !!props.disabled,
82
- busy: loading,
83
- }, children: [_jsx(View, { style: {
84
- ...StyleSheet.absoluteFill,
85
- borderRadius: size.height / 2,
86
- backgroundColor: trackBg,
87
- borderWidth: 1,
88
- borderColor: trackBorderColor,
89
- pointerEvents: "none",
90
- } }), labelOn && !isIOS && (_jsx(View, { style: {
91
- ...styles.label,
92
- left: labelHorizontalInset,
93
- right: thumbInset + thumbSize + labelGap,
94
- opacity: props.checked ? 1 : 0,
95
- }, children: _jsx(StyledText, { selectable: false, style: {
96
- fontFamily: fontFamilies.sansSerif.bold,
97
- fontSize: labelFontSize,
98
- color: labelOnColor,
99
- userSelect: "none",
100
- }, children: labelOn }) })), _jsx(SwitchPrimitives.Thumb, { children: _jsx(Animated.View, { style: [
101
- {
102
- width: thumbSize,
103
- height: thumbSize,
104
- marginLeft: thumbInset,
105
- borderRadius: thumbSize / 2,
106
- backgroundColor: thumbBackgroundColor,
107
- borderWidth: 1,
108
- borderColor: thumbBorderColor,
109
- justifyContent: "center",
110
- alignItems: "center",
111
- ...getShadowStyle("subtle"),
112
- },
113
- thumbAnimatedStyle,
114
- ], children: loading && (_jsx(ActivityIndicator, { size: "small", color: thumbIndicatorColor })) }) }), labelOff && !isIOS && (_jsx(View, { style: {
115
- ...styles.label,
116
- left: thumbInset + thumbSize + labelGap,
117
- right: labelHorizontalInset,
118
- opacity: props.checked ? 0 : 1,
119
- }, children: _jsx(StyledText, { selectable: false, style: {
120
- fontFamily: fontFamilies.sansSerif.bold,
121
- fontSize: labelFontSize,
122
- color: theme.colors.text,
123
- userSelect: "none",
124
- }, children: labelOff }) }))] }));
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: {
@@ -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: _jsxs(TabsPrimitive.Trigger, { value: value, style: StyleSheet.flatten([triggerBaseStyle, pillActiveStyle, style]), ...props, 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: [
101
- {
102
- position: "absolute",
103
- bottom: 0,
104
- left: 0,
105
- right: 0,
106
- height: 2,
107
- backgroundColor: theme.colors.foreground,
108
- },
109
- { opacity: activeOpacity },
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: fontFamilies.sansSerif.regular,
382
- fontWeight: "500",
383
- fontSize: 14,
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: fontFamilies.sansSerif.regular,
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
- return (_jsx(TextColorContext.Provider, { value: textColor, children: _jsx(TextClassContext.Provider, { value: "", children: _jsx(TogglePrimitive.Root, { ...props, disabled: isDisabled, style: {
105
- ...styles.root,
106
- height: sizeConfig.height,
107
- minWidth: sizeConfig.minWidth,
108
- paddingHorizontal: iconOnly ? sizeConfig.height / 2 - sizeConfig.iconSize / 2 : sizeConfig.paddingHorizontal,
109
- borderRadius: getBorderRadius(),
110
- // Base variant styles
111
- ...(variant === "default" && !props.pressed && {
112
- backgroundColor: "transparent",
113
- borderColor: theme.colors.border,
114
- }),
115
- ...(variant === "default" && props.pressed && {
116
- backgroundColor: withAlpha(theme.colors.primary, 0.1),
117
- borderColor: theme.colors.primary,
118
- }),
119
- // Outline variant styles
120
- ...(variant === "outline" && !props.pressed && {
121
- backgroundColor: "transparent",
122
- borderColor: theme.colors.primary,
123
- }),
124
- ...(variant === "outline" && props.pressed && {
125
- backgroundColor: theme.colors.primary,
126
- borderColor: theme.colors.primary,
127
- }),
128
- // Disabled state
129
- opacity: isDisabled ? 0.5 : 1,
130
- // Web-specific styles
131
- ...(Platform.OS === "web" && {
132
- cursor: isDisabled ? "not-allowed" : "pointer",
133
- transition: "all 150ms",
134
- userSelect: "none",
135
- }),
136
- // Apply custom style override
137
- ...(flattenedStyle || {}),
138
- }, hitSlop: DEFAULT_HIT_SLOP, accessibilityRole: "button", accessibilityState: {
139
- selected: props.pressed,
140
- disabled: !!isDisabled,
141
- busy: loading,
142
- }, 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 })) }) }) }));
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);