@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,11 +1,12 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import React, { createContext, use, useEffect, useRef } from "react";
2
+ import React, { createContext, use, useEffect, useRef, useState } from "react";
3
3
  import { View, StyleSheet, Pressable, Platform, Animated } from "react-native";
4
4
  import { StyledText } from "./StyledText.js";
5
5
  import { useTheme } from "../hooks/useTheme.js";
6
6
  import { spacing } from "../constants/spacing.js";
7
7
  import { hapticLight } from "../lib/haptics.js";
8
8
  import { useReducedMotion } from "../hooks/useReduceMotion.js";
9
+ import { useScalePress } from "../hooks/useScalePress.js";
9
10
  import * as RadioGroupPrimitive from "@rn-primitives/radio-group";
10
11
  const DEFAULT_HIT_SLOP = 8;
11
12
  const SIZE_CONFIGS = {
@@ -54,10 +55,35 @@ function RadioGroupRoot({ size = "md", error = false, value, onValueChange, styl
54
55
  * Must be rendered inside a RadioGroup.
55
56
  */
56
57
  function RadioGroupItem({ label, required = false, style: styleOverride, labelStyle, value: itemValue, disabled, ...props }) {
57
- const { theme, getContrastingColor } = useTheme();
58
+ const { theme, getContrastingColor, getFocusRingStyle } = useTheme();
58
59
  const reduceMotion = useReducedMotion();
59
60
  const { size, error, value: groupValue, onValueChange } = useRadioGroupContext();
60
61
  const sizeConfig = SIZE_CONFIGS[size];
62
+ const focusRingStyle = getFocusRingStyle();
63
+ const [focused, setFocused] = useState(false);
64
+ const { animatedStyle: scaleStyle, pressHandlers } = useScalePress({
65
+ disabled: !!disabled,
66
+ scaleTo: 0.92,
67
+ haptic: false,
68
+ });
69
+ const showFocusRing = (event) => {
70
+ let ringVisible = true;
71
+ if (Platform.OS === "web") {
72
+ const target = event?.nativeEvent?.target;
73
+ if (target && typeof target.matches === "function") {
74
+ try {
75
+ ringVisible = target.matches(":focus-visible");
76
+ }
77
+ catch {
78
+ ringVisible = true;
79
+ }
80
+ }
81
+ }
82
+ setFocused(ringVisible);
83
+ };
84
+ const hideFocusRing = () => {
85
+ setFocused(false);
86
+ };
61
87
  const isChecked = groupValue === itemValue;
62
88
  // Animated dot scale — follows Checkbox opacity pattern
63
89
  const dotScale = useRef(new Animated.Value(isChecked ? 1 : 0)).current;
@@ -75,26 +101,27 @@ function RadioGroupItem({ label, required = false, style: styleOverride, labelSt
75
101
  ? theme.colors.primary
76
102
  : getContrastingColor(theme.colors.background, theme.colors.text, theme.colors.textDim);
77
103
  const flattenedStyle = styleOverride ? StyleSheet.flatten(styleOverride) : undefined;
78
- const radioElement = (_jsx(RadioGroupPrimitive.Item, { ...props, value: itemValue, disabled: disabled, onPress: handleRadioPress, style: {
79
- ...styles.radio,
80
- borderColor,
81
- backgroundColor: theme.colors.background,
82
- borderRadius: sizeConfig.outer / 2,
83
- borderWidth: sizeConfig.borderWidth,
84
- width: sizeConfig.outer,
85
- height: sizeConfig.outer,
86
- opacity: disabled ? 0.5 : 1,
87
- ...(Platform.OS === "web" && { cursor: disabled ? "not-allowed" : "pointer" }),
88
- ...(flattenedStyle || {}),
89
- }, hitSlop: DEFAULT_HIT_SLOP, accessibilityLabel: label, children: _jsx(Animated.View, { style: [
90
- { transform: [{ scale: dotScale }] },
91
- {
92
- width: sizeConfig.inner,
93
- height: sizeConfig.inner,
94
- borderRadius: sizeConfig.inner / 2,
95
- backgroundColor: theme.colors.primary,
96
- },
97
- ] }) }));
104
+ const radioElement = (_jsx(Animated.View, { style: scaleStyle, children: _jsx(RadioGroupPrimitive.Item, { ...props, value: itemValue, disabled: disabled, onPress: handleRadioPress, onPressIn: pressHandlers.onPressIn, onPressOut: pressHandlers.onPressOut, onFocus: showFocusRing, onBlur: hideFocusRing, style: {
105
+ ...styles.radio,
106
+ borderColor,
107
+ backgroundColor: theme.colors.background,
108
+ borderRadius: sizeConfig.outer / 2,
109
+ borderWidth: sizeConfig.borderWidth,
110
+ width: sizeConfig.outer,
111
+ height: sizeConfig.outer,
112
+ opacity: disabled ? 0.5 : 1,
113
+ ...(Platform.OS === "web" && { cursor: disabled ? "not-allowed" : "pointer" }),
114
+ ...(focused && !disabled ? focusRingStyle : null),
115
+ ...(flattenedStyle || {}),
116
+ }, hitSlop: DEFAULT_HIT_SLOP, accessibilityLabel: label, children: _jsx(Animated.View, { style: [
117
+ { transform: [{ scale: dotScale }] },
118
+ {
119
+ width: sizeConfig.inner,
120
+ height: sizeConfig.inner,
121
+ borderRadius: sizeConfig.inner / 2,
122
+ backgroundColor: theme.colors.primary,
123
+ },
124
+ ] }) }) }));
98
125
  // If no label, return just the radio button
99
126
  if (!label) {
100
127
  return radioElement;
@@ -0,0 +1,42 @@
1
+ import React from "react";
2
+ import { StyleProp, ViewStyle, TextStyle } from "react-native";
3
+ import { type TextProps } from "./StyledText";
4
+ type TextPassthroughProps = Omit<TextProps, "style" | "children"> & {
5
+ style?: StyleProp<TextStyle>;
6
+ };
7
+ export interface SectionHeaderProps {
8
+ /** Small uppercase label rendered above the title. */
9
+ eyebrow?: string;
10
+ /** Section title. */
11
+ title: string;
12
+ /** Supporting copy rendered below the title. */
13
+ description?: string;
14
+ /** Text alignment for the whole block. @default "leading" */
15
+ align?: "leading" | "center";
16
+ /** Custom style override for the container. */
17
+ style?: StyleProp<ViewStyle>;
18
+ /** Passthrough props for the title text. */
19
+ titleProps?: TextPassthroughProps;
20
+ /** Passthrough props for the description text. */
21
+ descriptionProps?: TextPassthroughProps;
22
+ }
23
+ /**
24
+ * SectionHeader
25
+ *
26
+ * Composed eyebrow -> title -> description block used to introduce a page
27
+ * section. Supports a leading (default) or centered alignment; centered
28
+ * descriptions are width-constrained so long copy doesn't stretch edge to
29
+ * edge.
30
+ *
31
+ * @example
32
+ * ```tsx
33
+ * <SectionHeader
34
+ * eyebrow="Pricing"
35
+ * title="Simple, transparent plans"
36
+ * description="Pick the plan that fits your team. Upgrade or cancel anytime."
37
+ * align="center"
38
+ * />
39
+ * ```
40
+ */
41
+ export declare function SectionHeader({ eyebrow, title, description, align, style, titleProps, descriptionProps, }: SectionHeaderProps): React.JSX.Element;
42
+ export {};
@@ -0,0 +1,41 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { View } from "react-native";
3
+ import { EyebrowText, TitleText, BodyText } from "./StyledText.js";
4
+ import { useTheme } from "../hooks/useTheme.js";
5
+ import { spacing } from "../constants/spacing.js";
6
+ /**
7
+ * SectionHeader
8
+ *
9
+ * Composed eyebrow -> title -> description block used to introduce a page
10
+ * section. Supports a leading (default) or centered alignment; centered
11
+ * descriptions are width-constrained so long copy doesn't stretch edge to
12
+ * edge.
13
+ *
14
+ * @example
15
+ * ```tsx
16
+ * <SectionHeader
17
+ * eyebrow="Pricing"
18
+ * title="Simple, transparent plans"
19
+ * description="Pick the plan that fits your team. Upgrade or cancel anytime."
20
+ * align="center"
21
+ * />
22
+ * ```
23
+ */
24
+ export function SectionHeader({ eyebrow, title, description, align = "leading", style, titleProps, descriptionProps, }) {
25
+ const { theme } = useTheme();
26
+ const isCentered = align === "center";
27
+ const textAlign = isCentered ? "center" : "left";
28
+ return (_jsxs(View, { style: [{ alignItems: isCentered ? "center" : "flex-start" }, style], children: [!!eyebrow && (_jsx(EyebrowText, { style: {
29
+ color: theme.colors.accent,
30
+ marginBottom: spacing.xs,
31
+ textAlign,
32
+ }, children: eyebrow })), _jsx(TitleText, { ...titleProps, style: [
33
+ { textAlign },
34
+ !!description && { marginBottom: spacing.sm },
35
+ titleProps?.style,
36
+ ], children: title }), !!description && (_jsx(BodyText, { ...descriptionProps, style: [
37
+ { color: theme.colors.textDim, textAlign },
38
+ isCentered && { maxWidth: 560 },
39
+ descriptionProps?.style,
40
+ ], children: description }))] }));
41
+ }
@@ -1,11 +1,12 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import * as React from "react";
3
- import { Platform, StyleSheet, View } from "react-native";
3
+ import { Animated, Platform, StyleSheet, View } from "react-native";
4
4
  import { Icon } from "./Icon.js";
5
5
  import { AnimatedView } from "./AnimatedView.js";
6
6
  import { TextClassContext, TextColorContext, TextSelectabilityContext } from "./StyledText.context";
7
7
  import { useTheme } from "../hooks/useTheme.js";
8
8
  import { spacing } from "../constants/spacing.js";
9
+ import { useScalePress } from "../hooks/useScalePress.js";
9
10
  import * as SelectPrimitive from "@rn-primitives/select";
10
11
  import { FullWindowOverlay as RNFullWindowOverlay } from "react-native-screens";
11
12
  import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -34,24 +35,50 @@ const SIZE_CONFIGS = {
34
35
  },
35
36
  };
36
37
  function SelectTrigger({ size = "md", error = false, children, style: styleOverride, disabled, ...props }) {
37
- const { theme } = useTheme();
38
+ const { theme, getFocusRingStyle } = useTheme();
38
39
  const sizeConfig = SIZE_CONFIGS[size];
39
- return (_jsx(SelectPrimitive.Trigger, { disabled: disabled, ...props, style: {
40
- ...styles.trigger,
41
- height: sizeConfig.height,
42
- paddingHorizontal: sizeConfig.paddingHorizontal,
43
- borderColor: error ? theme.colors.destructive : theme.colors.border,
44
- backgroundColor: theme.colors.background,
45
- ...(Platform.OS === "web" && {
46
- cursor: disabled ? "not-allowed" : "pointer",
47
- outlineStyle: "none",
48
- userSelect: "none",
49
- }),
50
- ...(disabled && { opacity: 0.5 }),
51
- ...(styleOverride && typeof styleOverride !== "function"
52
- ? StyleSheet.flatten(styleOverride)
53
- : {}),
54
- }, children: _jsx(TextColorContext.Provider, { value: theme.colors.text, children: _jsxs(TextSelectabilityContext.Provider, { value: false, children: [typeof children === "function" ? null : children, _jsx(Icon, { name: "chevron-down", size: 16, color: theme.colors.mutedForeground })] }) }) }));
40
+ const focusRingStyle = getFocusRingStyle();
41
+ const [focused, setFocused] = React.useState(false);
42
+ const { animatedStyle: scaleStyle, pressHandlers } = useScalePress({
43
+ disabled: !!disabled,
44
+ scaleTo: 0.97,
45
+ haptic: false,
46
+ });
47
+ const showFocusRing = (event) => {
48
+ let ringVisible = true;
49
+ if (Platform.OS === "web") {
50
+ const target = event?.nativeEvent?.target;
51
+ if (target && typeof target.matches === "function") {
52
+ try {
53
+ ringVisible = target.matches(":focus-visible");
54
+ }
55
+ catch {
56
+ ringVisible = true;
57
+ }
58
+ }
59
+ }
60
+ setFocused(ringVisible);
61
+ };
62
+ const hideFocusRing = () => {
63
+ setFocused(false);
64
+ };
65
+ return (_jsx(Animated.View, { style: scaleStyle, children: _jsx(SelectPrimitive.Trigger, { disabled: disabled, ...props, onPressIn: pressHandlers.onPressIn, onPressOut: pressHandlers.onPressOut, onFocus: showFocusRing, onBlur: hideFocusRing, style: {
66
+ ...styles.trigger,
67
+ height: sizeConfig.height,
68
+ paddingHorizontal: sizeConfig.paddingHorizontal,
69
+ borderColor: error ? theme.colors.destructive : theme.colors.border,
70
+ backgroundColor: theme.colors.background,
71
+ ...(Platform.OS === "web" && {
72
+ cursor: disabled ? "not-allowed" : "pointer",
73
+ outlineStyle: "none",
74
+ userSelect: "none",
75
+ }),
76
+ ...(disabled && { opacity: 0.5 }),
77
+ ...(focused && !disabled ? focusRingStyle : null),
78
+ ...(styleOverride && typeof styleOverride !== "function"
79
+ ? StyleSheet.flatten(styleOverride)
80
+ : {}),
81
+ }, children: _jsx(TextColorContext.Provider, { value: theme.colors.text, children: _jsxs(TextSelectabilityContext.Provider, { value: false, children: [typeof children === "function" ? null : children, _jsx(Icon, { name: "chevron-down", size: 16, color: theme.colors.mutedForeground })] }) }) }) }));
55
82
  }
56
83
  function SelectValue({ size = "md", placeholder, style: styleOverride, ...props }) {
57
84
  const { theme } = useTheme();
@@ -99,29 +126,34 @@ function SelectItem({ children, style: styleOverride, ...props }) {
99
126
  // "is this a renderable node?" keeps the API explicit without switching on the
100
127
  // primitive type of children.
101
128
  const hasCustomChildren = React.isValidElement(children) || Array.isArray(children);
102
- return (_jsx(TextClassContext.Provider, { value: "", children: _jsxs(SelectPrimitive.Item, { ...props, style: {
103
- ...styles.item,
104
- ...(Platform.OS === "web" && {
105
- cursor: props.disabled ? "not-allowed" : "pointer",
106
- outlineStyle: "none",
107
- userSelect: "none",
108
- }),
109
- ...(props.disabled && { opacity: 0.5 }),
110
- ...(styleOverride && typeof styleOverride !== "function"
111
- ? StyleSheet.flatten(styleOverride)
112
- : {}),
113
- }, children: [_jsx(View, { style: {
114
- position: "absolute",
115
- left: spacing.sm,
116
- height: 14,
117
- width: 14,
118
- alignItems: "center",
119
- justifyContent: "center",
120
- }, children: _jsx(SelectPrimitive.ItemIndicator, { children: _jsx(Icon, { name: "check", size: 16, color: theme.colors.accent, ...(Platform.OS === "web" && { style: { pointerEvents: "none" } }) }) }) }), _jsx(TextSelectabilityContext.Provider, { value: false, children: hasCustomChildren ? (children) : (_jsx(SelectPrimitive.ItemText, { style: {
121
- color: theme.colors.popoverForeground,
122
- fontSize: 14,
123
- lineHeight: 20,
124
- } })) })] }) }));
129
+ const { animatedStyle: scaleStyle, pressHandlers } = useScalePress({
130
+ disabled: !!props.disabled,
131
+ scaleTo: 0.97,
132
+ haptic: false,
133
+ });
134
+ return (_jsx(TextClassContext.Provider, { value: "", children: _jsx(Animated.View, { style: scaleStyle, children: _jsxs(SelectPrimitive.Item, { ...props, onPressIn: pressHandlers.onPressIn, onPressOut: pressHandlers.onPressOut, style: {
135
+ ...styles.item,
136
+ ...(Platform.OS === "web" && {
137
+ cursor: props.disabled ? "not-allowed" : "pointer",
138
+ outlineStyle: "none",
139
+ userSelect: "none",
140
+ }),
141
+ ...(props.disabled && { opacity: 0.5 }),
142
+ ...(styleOverride && typeof styleOverride !== "function"
143
+ ? StyleSheet.flatten(styleOverride)
144
+ : {}),
145
+ }, children: [_jsx(View, { style: {
146
+ position: "absolute",
147
+ left: spacing.sm,
148
+ height: 14,
149
+ width: 14,
150
+ alignItems: "center",
151
+ justifyContent: "center",
152
+ }, children: _jsx(SelectPrimitive.ItemIndicator, { children: _jsx(Icon, { name: "check", size: 16, color: theme.colors.accent, ...(Platform.OS === "web" && { style: { pointerEvents: "none" } }) }) }) }), _jsx(TextSelectabilityContext.Provider, { value: false, children: hasCustomChildren ? (children) : (_jsx(SelectPrimitive.ItemText, { style: {
153
+ color: theme.colors.popoverForeground,
154
+ fontSize: 14,
155
+ lineHeight: 20,
156
+ } })) })] }) }) }));
125
157
  }
126
158
  function SelectGroup({ style: styleOverride, ...props }) {
127
159
  return (_jsx(SelectPrimitive.Group, { ...props, style: {
@@ -71,9 +71,9 @@ export function SkeletonAvatar({ size = 40, style }) {
71
71
  * and text lines in a Card-styled wrapper.
72
72
  */
73
73
  export function SkeletonCard({ showImage = true, imageHeight = 140, showAvatar = true, textLines = 3, style, }) {
74
- const { theme } = useTheme();
74
+ const { theme, getShadowStyle } = useTheme();
75
75
  const styles = createCardStyles(theme);
76
- return (_jsxs(View, { style: [styles.card, style], children: [showImage && (_jsx(Skeleton, { width: "100%", height: imageHeight, borderRadius: 0 })), _jsxs(View, { style: styles.body, children: [showAvatar && (_jsxs(View, { style: styles.avatarRow, children: [_jsx(SkeletonAvatar, { size: 36 }), _jsxs(View, { style: styles.avatarText, children: [_jsx(Skeleton, { width: "50%", height: 14 }), _jsx(Skeleton, { width: "30%", height: 12 })] })] })), _jsx(SkeletonText, { lines: textLines })] })] }));
76
+ return (_jsxs(View, { style: [styles.card, getShadowStyle("subtle"), style], children: [showImage && (_jsx(Skeleton, { width: "100%", height: imageHeight, borderRadius: 0 })), _jsxs(View, { style: styles.body, children: [showAvatar && (_jsxs(View, { style: styles.avatarRow, children: [_jsx(SkeletonAvatar, { size: 36 }), _jsxs(View, { style: styles.avatarText, children: [_jsx(Skeleton, { width: "50%", height: 14 }), _jsx(Skeleton, { width: "30%", height: 12 })] })] })), _jsx(SkeletonText, { lines: textLines })] })] }));
77
77
  }
78
78
  const createCardStyles = (theme) => StyleSheet.create({
79
79
  card: {
@@ -0,0 +1,45 @@
1
+ import React from "react";
2
+ import { StyleProp, ViewStyle } from "react-native";
3
+ import { type IconName } from "./Icon";
4
+ export type StatChangeDirection = "up" | "down" | "neutral";
5
+ export interface StatCardChange {
6
+ /** Signed change text, e.g. "+12.5%" or "-3 orders". */
7
+ value: string;
8
+ /** Visual direction — drives color and (for up/down) a trend icon. */
9
+ direction: StatChangeDirection;
10
+ }
11
+ export interface StatCardProps {
12
+ /** Small uppercase label describing the stat. */
13
+ label: string;
14
+ /** The headline stat value. */
15
+ value: string | number;
16
+ /** Optional unit suffix rendered smaller and muted next to the value. */
17
+ unit?: string;
18
+ /** Optional signed change line below the value. */
19
+ change?: StatCardChange;
20
+ /** Optional leading icon name (Feather), rendered beside the label. */
21
+ icon?: IconName;
22
+ /** Makes the card pressable with scale feedback (delegates to Card). */
23
+ onPress?: () => void;
24
+ /** Custom style override for the outer card. */
25
+ style?: StyleProp<ViewStyle>;
26
+ }
27
+ /**
28
+ * StatCard
29
+ *
30
+ * Dashboard stat idiom: a tiny uppercase tracked label, a large
31
+ * tabular-nums value (with an optional muted unit suffix), and an optional
32
+ * signed change line (success/destructive/muted by direction). Builds on
33
+ * `Card` for the container, default shadow, and press feedback.
34
+ *
35
+ * @example
36
+ * ```tsx
37
+ * <StatCard
38
+ * label="Revenue"
39
+ * value="48.2"
40
+ * unit="k"
41
+ * change={{ value: "+12.5%", direction: "up" }}
42
+ * />
43
+ * ```
44
+ */
45
+ export declare function StatCard({ label, value, unit, change, icon, onPress, style }: StatCardProps): React.JSX.Element;
@@ -0,0 +1,41 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { View } from "react-native";
3
+ import { Card } from "./Card.js";
4
+ import { CaptionText, StyledText } from "./StyledText.js";
5
+ import { Icon } from "./Icon.js";
6
+ import { useTheme } from "../hooks/useTheme.js";
7
+ import { spacing } from "../constants/spacing.js";
8
+ // Only "up"/"down" get a directional trend icon — "neutral" is text-only.
9
+ const DIRECTION_ICON = {
10
+ up: "trending-up",
11
+ down: "trending-down",
12
+ };
13
+ /**
14
+ * StatCard
15
+ *
16
+ * Dashboard stat idiom: a tiny uppercase tracked label, a large
17
+ * tabular-nums value (with an optional muted unit suffix), and an optional
18
+ * signed change line (success/destructive/muted by direction). Builds on
19
+ * `Card` for the container, default shadow, and press feedback.
20
+ *
21
+ * @example
22
+ * ```tsx
23
+ * <StatCard
24
+ * label="Revenue"
25
+ * value="48.2"
26
+ * unit="k"
27
+ * change={{ value: "+12.5%", direction: "up" }}
28
+ * />
29
+ * ```
30
+ */
31
+ export function StatCard({ label, value, unit, change, icon, onPress, style }) {
32
+ const { theme } = useTheme();
33
+ const changeColor = !change
34
+ ? undefined
35
+ : change.direction === "up"
36
+ ? theme.colors.success
37
+ : change.direction === "down"
38
+ ? theme.colors.destructive
39
+ : theme.colors.textDim;
40
+ return (_jsx(Card, { onPress: onPress, style: style, children: _jsxs(View, { style: { padding: spacing.lg, gap: spacing.xs }, children: [_jsxs(View, { style: { flexDirection: "row", alignItems: "center", justifyContent: "space-between" }, children: [_jsx(CaptionText, { selectable: false, style: { color: theme.colors.textDim, textTransform: "uppercase", letterSpacing: 0.96 }, children: label }), !!icon && _jsx(Icon, { name: icon, size: spacing.iconSm, color: theme.colors.mutedForeground, decorative: true })] }), _jsxs(View, { style: { flexDirection: "row", alignItems: "flex-end" }, children: [_jsx(StyledText, { selectable: false, size: "xxl", fontWeight: "bold", style: { color: theme.colors.foreground, fontVariant: ["tabular-nums"] }, children: value }), !!unit && (_jsx(StyledText, { selectable: false, size: "base", style: { color: theme.colors.textDim, marginLeft: spacing.xxs, marginBottom: 2 }, children: unit }))] }), !!change && (_jsxs(View, { style: { flexDirection: "row", alignItems: "center", gap: spacing.xxs }, children: [change.direction !== "neutral" && (_jsx(Icon, { name: DIRECTION_ICON[change.direction], size: spacing.iconXs, color: changeColor, decorative: true })), _jsx(StyledText, { selectable: false, size: "sm", style: { color: changeColor }, children: change.value })] }))] }) }));
41
+ }
@@ -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;