@mrmeg/expo-ui 0.14.0 → 0.16.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 (61) hide show
  1. package/CHANGELOG.md +81 -0
  2. package/README.md +50 -6
  3. package/dist/components/Accordion.d.ts +1 -1
  4. package/dist/components/Accordion.js +25 -19
  5. package/dist/components/Badge.js +10 -7
  6. package/dist/components/BottomSheet.js +21 -19
  7. package/dist/components/Button.d.ts +2 -1
  8. package/dist/components/Button.js +22 -6
  9. package/dist/components/Card.js +5 -3
  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 +26 -9
  18. package/dist/components/InputOTP.js +10 -4
  19. package/dist/components/Item.d.ts +93 -0
  20. package/dist/components/Item.js +125 -0
  21. package/dist/components/Label.js +10 -4
  22. package/dist/components/Notification.js +14 -24
  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 +5 -3
  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 +85 -29
  34. package/dist/components/Switch.js +84 -57
  35. package/dist/components/Tabs.js +43 -12
  36. package/dist/components/TextInput.js +17 -11
  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 +44 -5
  42. package/dist/constants/fonts.js +42 -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/dist/lib/index.d.ts +1 -0
  56. package/dist/lib/index.js +1 -0
  57. package/dist/lib/themedStyles.d.ts +21 -0
  58. package/dist/lib/themedStyles.js +33 -0
  59. package/dist/state/themeStore.d.ts +17 -0
  60. package/dist/state/themeStore.js +6 -0
  61. package/package.json +20 -19
@@ -1,13 +1,13 @@
1
1
  import { type Ref } from "react";
2
2
  import { Text as RNText, TextProps as RNTextProps } from "react-native";
3
3
  /**
4
- * Font size variants following the DM Sans / DM Serif Display scale
4
+ * Font size variants for the type scale (Inter sans-serif / Georgia serif)
5
5
  */
6
6
  export type FontSize = "xs" | "sm" | "base" | "body" | "lg" | "xl" | "xxl" | "display";
7
7
  /**
8
8
  * Semantic text variants for different use cases
9
9
  */
10
- export type SemanticVariant = "title" | "heading" | "subheading" | "body" | "caption" | "label";
10
+ export type SemanticVariant = "title" | "heading" | "subheading" | "body" | "caption" | "label" | "eyebrow";
11
11
  /**
12
12
  * Font weight options
13
13
  */
@@ -18,7 +18,7 @@ export type FontWeight = "light" | "regular" | "medium" | "semibold" | "bold";
18
18
  export type TextAlign = "left" | "center" | "right" | "justify" | "auto";
19
19
  export type TextProps = RNTextProps & {
20
20
  /**
21
- * Font variant - serif (DM Serif Display) or sans-serif (DM Sans)
21
+ * Font variant - serif (Georgia) or sans-serif (Inter)
22
22
  */
23
23
  variant?: "serif" | "sansSerif";
24
24
  /**
@@ -62,9 +62,12 @@ export type TextProps = RNTextProps & {
62
62
  *
63
63
  * Features:
64
64
  * - Size variants (xs, sm, base, body, lg, xl, xxl, display)
65
- * - Semantic variants (title, heading, subheading, body, caption, label)
65
+ * - Semantic variants (title, heading, subheading, body, caption, label, eyebrow)
66
66
  * - Text alignment prop
67
- * - Font weight options (light, regular, medium, semibold, bold)
67
+ * - Font weight options (light, regular, medium, semibold, bold) — resolves to
68
+ * real Inter font files on native, and family + numeric fontWeight on web
69
+ * - Per-size letter-spacing scale (tight on large headings, positive on micro
70
+ * labels); explicit `style` letterSpacing always wins
68
71
  * - Text selection enabled by default; pass `selectable={false}` for control
69
72
  * chrome such as button labels, tabs, badges, and field labels
70
73
  * - numberOfLines and ellipsizeMode support from RN TextProps
@@ -72,27 +75,28 @@ export type TextProps = RNTextProps & {
72
75
  export declare function StyledText(props: TextProps): import("react").JSX.Element;
73
76
  /**
74
77
  * Serif Text Component
75
- * Uses serif font family (DM Serif Display)
78
+ * Uses the serif font family (Georgia)
76
79
  */
77
80
  export declare function SerifText(props: TextProps): import("react").JSX.Element;
78
81
  /**
79
82
  * Sans-Serif Text Component
80
- * Uses sans-serif font family (DM Sans)
83
+ * Uses the sans-serif font family (Inter)
81
84
  */
82
85
  export declare function SansSerifText(props: TextProps): import("react").JSX.Element;
83
86
  /**
84
87
  * Serif Bold Text Component
85
- * Uses serif font family — DM Serif Display only has regular weight
88
+ * Uses the serif font family — Georgia's only shipped weight is regular
86
89
  */
87
90
  export declare function SerifBoldText(props: TextProps): import("react").JSX.Element;
88
91
  /**
89
92
  * Sans-Serif Bold Text Component
90
- * Uses sans-serif font family with semibold weight (DM Sans 600)
93
+ * Uses the sans-serif font family at semibold weight (Inter 600)
91
94
  */
92
95
  export declare function SansSerifBoldText(props: TextProps): import("react").JSX.Element;
93
96
  /**
94
97
  * Display Text Component
95
- * Uses DM Serif Display at display size — for hero text and splash screens
98
+ * Uses the serif font family (Georgia) at display size — for hero text and
99
+ * splash screens
96
100
  */
97
101
  export declare function DisplayText(props: TextProps): import("react").JSX.Element;
98
102
  /**
@@ -119,3 +123,9 @@ export declare function CaptionText(props: TextProps): import("react").JSX.Eleme
119
123
  * Label Text - Medium weight text for form labels
120
124
  */
121
125
  export declare function LabelText(props: TextProps): import("react").JSX.Element;
126
+ /**
127
+ * Eyebrow Text - Small, semibold, uppercase section label with wide positive
128
+ * tracking (the overline idiom above a heading). Consumed by the SectionHeader
129
+ * primitive (separate spec).
130
+ */
131
+ export declare function EyebrowText(props: TextProps): import("react").JSX.Element;
@@ -1,8 +1,9 @@
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
- import { fontFamilies } from "../constants/fonts.js";
5
+ import { fontFamilies, defaultWebWeightStrategy } from "../constants/fonts.js";
6
+ import { useThemeStore } from "../state/themeStore.js";
6
7
  import { translateText } from "../lib/i18n.js";
7
8
  import { TextColorContext, TextSelectabilityContext, TextStyleContext } from "./StyledText.context";
8
9
  const FONT_SIZES = {
@@ -25,11 +26,24 @@ const LINE_HEIGHTS = {
25
26
  xxl: 33.6,
26
27
  display: 40.8,
27
28
  };
29
+ // Per-size tracking (px), derived from em targets (em × fontSize, rounded to
30
+ // 2 decimals): tight tracking on large headings, neutral through body text,
31
+ // positive tracking on small/micro labels so they don't read cramped at size.
32
+ // RN's `letterSpacing` is px, not em, hence the pre-multiplied values here.
28
33
  const LETTER_SPACING = {
29
- sm: 0,
30
- xxl: 0,
31
- display: 0,
34
+ xs: 0.11, // +0.01em × 11
35
+ sm: 0.06, // +0.005em × 12
36
+ base: 0,
37
+ body: 0,
38
+ lg: -0.09, // -0.005em × 18
39
+ xl: -0.22, // -0.01em × 22
40
+ xxl: -0.42, // -0.015em × 28
41
+ display: -0.68, // -0.02em × 34
32
42
  };
43
+ // Eyebrow/overline tracking is deliberately wider than the "sm" default
44
+ // above — it's a distinct uppercase-label treatment, not the same tracking
45
+ // used for ordinary small text at that size. +0.08em × 12px (sm fontSize).
46
+ const EYEBROW_LETTER_SPACING = 0.96;
33
47
  const SEMANTIC_CONFIGS = {
34
48
  title: { size: "xxl", weight: "semibold" },
35
49
  heading: { size: "xl", weight: "semibold" },
@@ -37,19 +51,25 @@ const SEMANTIC_CONFIGS = {
37
51
  body: { size: "body", weight: "regular" },
38
52
  caption: { size: "sm", weight: "regular" },
39
53
  label: { size: "base", weight: "medium" },
54
+ // Section-label idiom (uppercase, widely tracked) — see EYEBROW_LETTER_SPACING
55
+ // and the textTransform applied in StyledText below.
56
+ eyebrow: { size: "sm", weight: "semibold" },
40
57
  };
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";
58
+ // Font family weight keys now map 1:1 onto FontWeight — every weight has a
59
+ // real family entry (native: a distinct static Inter file; web: shared
60
+ // "Inter" family + numeric fontWeight, see WEB_FONT_WEIGHTS). Defaults to
61
+ // "regular" when no weight is requested.
62
+ const getFontFamilyWeight = (weight) => weight ?? "regular";
63
+ // Web-only: the numeric fontWeight that picks the right @font-face variant
64
+ // out of the single shared "Inter" CSS family. "light" has no loaded 300
65
+ // weight (mirrors the native light->regular mapping in constants/fonts.ts),
66
+ // so it renders at 400 like "regular".
67
+ const WEB_FONT_WEIGHTS = {
68
+ light: "400",
69
+ regular: "400",
70
+ medium: "500",
71
+ semibold: "600",
72
+ bold: "700",
53
73
  };
54
74
  /**
55
75
  * Base Text component with theme and variant support
@@ -57,9 +77,12 @@ const getFontFamilyWeight = (weight) => {
57
77
  *
58
78
  * Features:
59
79
  * - Size variants (xs, sm, base, body, lg, xl, xxl, display)
60
- * - Semantic variants (title, heading, subheading, body, caption, label)
80
+ * - Semantic variants (title, heading, subheading, body, caption, label, eyebrow)
61
81
  * - Text alignment prop
62
- * - Font weight options (light, regular, medium, semibold, bold)
82
+ * - Font weight options (light, regular, medium, semibold, bold) — resolves to
83
+ * real Inter font files on native, and family + numeric fontWeight on web
84
+ * - Per-size letter-spacing scale (tight on large headings, positive on micro
85
+ * labels); explicit `style` letterSpacing always wins
63
86
  * - Text selection enabled by default; pass `selectable={false}` for control
64
87
  * chrome such as button labels, tabs, badges, and field labels
65
88
  * - numberOfLines and ellipsizeMode support from RN TextProps
@@ -67,6 +90,9 @@ const getFontFamilyWeight = (weight) => {
67
90
  export function StyledText(props) {
68
91
  const { tx, text, txOptions, style, variant = "sansSerif", fontWeight, size, semantic, align, selectable, children, ref, ...otherProps } = props;
69
92
  const { theme } = useTheme();
93
+ // Subscribed, not read via getState(), so a `setFonts` call after mount
94
+ // re-renders text instead of leaving stale families on screen.
95
+ const fontOverrides = useThemeStore((s) => s.fontOverrides);
70
96
  // Check if there's a color override from parent context (e.g., Button)
71
97
  const contextColor = use(TextColorContext);
72
98
  const contextTextStyle = use(TextStyleContext);
@@ -78,16 +104,35 @@ export function StyledText(props) {
78
104
  const semanticConfig = semantic ? SEMANTIC_CONFIGS[semantic] : undefined;
79
105
  const finalSize = semanticConfig?.size ?? size ?? "body";
80
106
  const finalFontWeight = semanticConfig?.weight ?? fontWeight ?? "regular";
81
- // Get font family based on variant and weight
107
+ // Get font family based on variant and weight. Host apps can replace the
108
+ // package's bundled faces via `setFonts` (see constants/fonts.ts); merging
109
+ // per-group means an app overriding only `sansSerif` keeps the default serif.
82
110
  const fontFamilyWeight = getFontFamilyWeight(finalFontWeight);
83
- // DM Serif Display only has regular — use it regardless of requested weight
111
+ const overriddenFamilies = fontOverrides.families;
112
+ const serifFamilies = overriddenFamilies?.serif ?? fontFamilies.serif;
113
+ const sansSerifFamilies = overriddenFamilies?.sansSerif ?? fontFamilies.sansSerif;
114
+ // The serif slot has a single usable weight — use it regardless of the
115
+ // requested weight.
84
116
  const fontFamily = variant === "serif"
85
- ? fontFamilies.serif.regular
86
- : fontFamilies.sansSerif[fontFamilyWeight] ?? fontFamilies.sansSerif.regular;
117
+ ? serifFamilies.regular
118
+ : sansSerifFamilies[fontFamilyWeight] ?? sansSerifFamilies.regular;
119
+ // Weight is carried either by a numeric `fontWeight` (one multi-weight CSS
120
+ // family) or by the family name itself (discrete per-weight faces). Native
121
+ // is always the latter: adding a numeric weight on top of an already-bold
122
+ // file faux-bolds it. Web defaults to numeric for the package's own single
123
+ // "Inter" family, but an app loading per-weight faces through `expo-font`
124
+ // gets its own family per weight on web too, and must declare
125
+ // `webWeightStrategy: "family"` to avoid a second synthesised layer of bold.
126
+ const weightStrategy = Platform.OS === "web"
127
+ ? fontOverrides.webWeightStrategy ?? defaultWebWeightStrategy
128
+ : "family";
129
+ const resolvedFontWeight = weightStrategy === "numeric"
130
+ ? WEB_FONT_WEIGHTS[finalFontWeight]
131
+ : undefined;
87
132
  // Get fontSize and lineHeight from size variant
88
133
  const fontSize = FONT_SIZES[finalSize];
89
134
  const lineHeight = LINE_HEIGHTS[finalSize];
90
- const letterSpacing = LETTER_SPACING[finalSize];
135
+ const letterSpacing = semantic === "eyebrow" ? EYEBROW_LETTER_SPACING : LETTER_SPACING[finalSize];
91
136
  // If style overrides fontSize without lineHeight, drop the preset lineHeight
92
137
  // so RN uses its default (avoids clipping text with a mismatched lineHeight)
93
138
  const flatStyle = style ? StyleSheet.flatten(style) : undefined;
@@ -107,10 +152,12 @@ export function StyledText(props) {
107
152
  color,
108
153
  fontFamily,
109
154
  fontSize,
155
+ ...(resolvedFontWeight !== undefined && { fontWeight: resolvedFontWeight }),
110
156
  ...(resolvedLineHeight !== undefined && { lineHeight: resolvedLineHeight }),
111
157
  userSelect: resolvedSelectable ? "auto" : "none",
112
158
  ...(letterSpacing !== undefined && { letterSpacing }),
113
159
  ...(align && { textAlign: align }),
160
+ ...(semantic === "eyebrow" && { textTransform: "uppercase" }),
114
161
  },
115
162
  resolvedContextTextStyle,
116
163
  style,
@@ -118,35 +165,36 @@ export function StyledText(props) {
118
165
  }
119
166
  /**
120
167
  * Serif Text Component
121
- * Uses serif font family (DM Serif Display)
168
+ * Uses the serif font family (Georgia)
122
169
  */
123
170
  export function SerifText(props) {
124
171
  return _jsx(StyledText, { ...props, variant: "serif" });
125
172
  }
126
173
  /**
127
174
  * Sans-Serif Text Component
128
- * Uses sans-serif font family (DM Sans)
175
+ * Uses the sans-serif font family (Inter)
129
176
  */
130
177
  export function SansSerifText(props) {
131
178
  return _jsx(StyledText, { ...props, variant: "sansSerif" });
132
179
  }
133
180
  /**
134
181
  * Serif Bold Text Component
135
- * Uses serif font family — DM Serif Display only has regular weight
182
+ * Uses the serif font family — Georgia's only shipped weight is regular
136
183
  */
137
184
  export function SerifBoldText(props) {
138
185
  return _jsx(StyledText, { ...props, variant: "serif", fontWeight: "regular" });
139
186
  }
140
187
  /**
141
188
  * Sans-Serif Bold Text Component
142
- * Uses sans-serif font family with semibold weight (DM Sans 600)
189
+ * Uses the sans-serif font family at semibold weight (Inter 600)
143
190
  */
144
191
  export function SansSerifBoldText(props) {
145
192
  return _jsx(StyledText, { ...props, variant: "sansSerif", fontWeight: "semibold" });
146
193
  }
147
194
  /**
148
195
  * Display Text Component
149
- * Uses DM Serif Display at display size — for hero text and splash screens
196
+ * Uses the serif font family (Georgia) at display size — for hero text and
197
+ * splash screens
150
198
  */
151
199
  export function DisplayText(props) {
152
200
  return _jsx(StyledText, { ...props, variant: "serif", size: "display" });
@@ -188,3 +236,11 @@ export function CaptionText(props) {
188
236
  export function LabelText(props) {
189
237
  return _jsx(StyledText, { ...props, semantic: "label" });
190
238
  }
239
+ /**
240
+ * Eyebrow Text - Small, semibold, uppercase section label with wide positive
241
+ * tracking (the overline idiom above a heading). Consumed by the SectionHeader
242
+ * primitive (separate spec).
243
+ */
244
+ export function EyebrowText(props) {
245
+ return _jsx(StyledText, { ...props, semantic: "eyebrow" });
246
+ }
@@ -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
  /**
@@ -8,6 +8,7 @@ import { fontFamilies } from "../constants/fonts.js";
8
8
  import { StyledText } from "./StyledText.js";
9
9
  import { Icon } from "./Icon.js";
10
10
  import { hapticLight } from "../lib/haptics.js";
11
+ import { createThemedStyles } from "../lib/themedStyles.js";
11
12
  import { palette } from "../constants/colors.js";
12
13
  import { clearKeyboardFocusedInputLayout, setKeyboardFocusedInputLayout, } from "./keyboardFocusRegistry.js";
13
14
  const NUMERIC_REGEX = /^[0-9]*$/;
@@ -91,7 +92,7 @@ export function TextInput(props) {
91
92
  */
92
93
  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 }) {
93
94
  const { theme, getContrastingColor, getFocusRingStyle } = useTheme();
94
- const styles = useMemo(() => createStyles(theme, variant, size), [theme, variant, size]);
95
+ const styles = themedStyles(theme)[variant][size];
95
96
  const [focused, setFocused] = useState(false);
96
97
  const [contentHeight, setContentHeight] = useState(0);
97
98
  const [passwordVisible, setPasswordVisible] = useState(false);
@@ -146,7 +147,7 @@ function WebTextInput({ variant = "outline", size = "md", label, helperText, err
146
147
  const togglePasswordVisible = () => {
147
148
  setPasswordVisible(v => !v);
148
149
  };
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: [
150
+ 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
151
  styles.input,
151
152
  {
152
153
  backgroundColor,
@@ -171,7 +172,7 @@ function WebTextInput({ variant = "outline", size = "md", label, helperText, err
171
172
  }, 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
173
  hapticLight();
173
174
  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: [
175
+ }, 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
176
  styles.helperText,
176
177
  hasError && styles.errorText,
177
178
  ], children: errorText || helperText }))] }));
@@ -197,7 +198,7 @@ function NativeTextInput({ variant = "outline", size = "md", label, helperText,
197
198
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
198
199
  leftElement, rightElement, clearable, focusedStyle, style, ...rest }) {
199
200
  const { theme, getContrastingColor } = useTheme();
200
- const styles = useMemo(() => createStyles(theme, variant, size), [theme, variant, size]);
201
+ const styles = themedStyles(theme)[variant][size];
201
202
  const [passwordVisible, setPasswordVisible] = useState(false);
202
203
  const hasError = error || !!errorText;
203
204
  const sizeConfig = SIZE_CONFIGS[size];
@@ -327,7 +328,7 @@ leftElement, rightElement, clearable, focusedStyle, style, ...rest }) {
327
328
  fontSize: sizeConfig.fontSize,
328
329
  ...(nativeFontFamily ? { fontFamily: nativeFontFamily } : null),
329
330
  };
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 }))] }));
331
+ 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
332
  }
332
333
  const createStyles = (theme, variant, size) => StyleSheet.create({
333
334
  nativeHost: {
@@ -378,18 +379,17 @@ const createStyles = (theme, variant, size) => StyleSheet.create({
378
379
  marginBottom: spacing.xs,
379
380
  },
380
381
  label: {
381
- fontFamily: fontFamilies.sansSerif.regular,
382
- fontWeight: "500",
383
- fontSize: 14,
382
+ // fontFamily/fontSize/fontWeight now come from StyledText's
383
+ // size="base" fontWeight="medium" props (see JSX below), which resolve
384
+ // the platform-correct family + weight instead of a hardcoded pair.
384
385
  color: theme.colors.text,
385
386
  },
386
387
  required: {
388
+ // fontFamily/fontWeight now come from StyledText's fontWeight="bold" prop.
387
389
  color: theme.colors.destructive,
388
- fontFamily: fontFamilies.sansSerif.bold,
389
390
  },
390
391
  helperText: {
391
- fontFamily: fontFamilies.sansSerif.regular,
392
- fontSize: 12,
392
+ // fontFamily/fontSize now come from StyledText's size="sm" prop (see JSX below).
393
393
  color: theme.colors.textDim,
394
394
  marginTop: spacing.xs,
395
395
  },
@@ -444,3 +444,9 @@ const createStyles = (theme, variant, size) => StyleSheet.create({
444
444
  zIndex: 1,
445
445
  },
446
446
  });
447
+ const VARIANT_KEYS = ["outline", "filled", "underlined"];
448
+ const SIZE_KEYS = ["sm", "md", "lg"];
449
+ const themedStyles = createThemedStyles((theme) => Object.fromEntries(VARIANT_KEYS.map((variant) => [
450
+ variant,
451
+ Object.fromEntries(SIZE_KEYS.map((size) => [size, createStyles(theme, variant, size)])),
452
+ ])));