@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.
- package/CHANGELOG.md +34 -0
- package/LLM_USAGE.md +4 -0
- package/README.md +13 -9
- package/dist/components/Accordion.d.ts +1 -1
- package/dist/components/Accordion.js +25 -19
- package/dist/components/Badge.js +6 -5
- package/dist/components/BottomSheet.js +21 -19
- package/dist/components/Button.d.ts +2 -1
- package/dist/components/Button.js +14 -4
- package/dist/components/Checkbox.js +49 -19
- package/dist/components/Collapsible.d.ts +1 -1
- package/dist/components/Collapsible.js +22 -16
- package/dist/components/Drawer.js +8 -7
- package/dist/components/DropdownMenu.d.ts +1 -1
- package/dist/components/DropdownMenu.js +80 -59
- package/dist/components/EmptyState.d.ts +7 -1
- package/dist/components/EmptyState.js +23 -7
- package/dist/components/InputOTP.js +6 -2
- package/dist/components/Item.d.ts +93 -0
- package/dist/components/Item.js +125 -0
- package/dist/components/Label.js +7 -2
- package/dist/components/Notification.js +10 -22
- package/dist/components/Popover.d.ts +3 -18
- package/dist/components/Popover.js +10 -6
- package/dist/components/RadioGroup.js +49 -22
- package/dist/components/SectionHeader.d.ts +42 -0
- package/dist/components/SectionHeader.js +41 -0
- package/dist/components/Select.js +73 -41
- package/dist/components/Skeleton.js +2 -2
- package/dist/components/StatCard.d.ts +45 -0
- package/dist/components/StatCard.js +41 -0
- package/dist/components/StyledText.d.ts +20 -10
- package/dist/components/StyledText.js +64 -25
- package/dist/components/Switch.js +84 -57
- package/dist/components/Tabs.js +43 -12
- package/dist/components/TextInput.js +8 -9
- package/dist/components/Toggle.js +69 -42
- package/dist/components/ToggleGroup.js +82 -55
- package/dist/components/index.d.ts +3 -0
- package/dist/components/index.js +3 -0
- package/dist/constants/fonts.d.ts +3 -0
- package/dist/constants/fonts.js +35 -12
- package/dist/constants/index.d.ts +1 -0
- package/dist/constants/index.js +1 -0
- package/dist/constants/motion.d.ts +24 -0
- package/dist/constants/motion.js +24 -0
- package/dist/constants/spacing.d.ts +7 -7
- package/dist/constants/spacing.js +7 -7
- package/dist/hooks/useResources.d.ts +4 -2
- package/dist/hooks/useResources.js +30 -8
- package/dist/hooks/useStaggeredEntrance.d.ts +1 -1
- package/dist/hooks/useStaggeredEntrance.js +2 -1
- package/dist/hooks/useTheme.d.ts +1 -1
- package/dist/hooks/useTheme.js +53 -16
- package/package.json +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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 —
|
|
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
|
|
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
|
|
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;
|