@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.
- package/CHANGELOG.md +81 -0
- package/README.md +50 -6
- package/dist/components/Accordion.d.ts +1 -1
- package/dist/components/Accordion.js +25 -19
- package/dist/components/Badge.js +10 -7
- package/dist/components/BottomSheet.js +21 -19
- package/dist/components/Button.d.ts +2 -1
- package/dist/components/Button.js +22 -6
- package/dist/components/Card.js +5 -3
- 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 +26 -9
- package/dist/components/InputOTP.js +10 -4
- package/dist/components/Item.d.ts +93 -0
- package/dist/components/Item.js +125 -0
- package/dist/components/Label.js +10 -4
- package/dist/components/Notification.js +14 -24
- 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 +5 -3
- 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 +85 -29
- package/dist/components/Switch.js +84 -57
- package/dist/components/Tabs.js +43 -12
- package/dist/components/TextInput.js +17 -11
- 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 +44 -5
- package/dist/constants/fonts.js +42 -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/dist/lib/index.d.ts +1 -0
- package/dist/lib/index.js +1 -0
- package/dist/lib/themedStyles.d.ts +21 -0
- package/dist/lib/themedStyles.js +33 -0
- package/dist/state/themeStore.d.ts +17 -0
- package/dist/state/themeStore.js +6 -0
- package/package.json +20 -19
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import {
|
|
2
|
+
import { useCallback, use, useEffect, useEffectEvent, useRef } from "react";
|
|
3
3
|
import { Animated, Easing, StyleSheet, View, ActivityIndicator, Pressable, Platform } from "react-native";
|
|
4
4
|
import { SafeAreaInsetsContext } from "react-native-safe-area-context";
|
|
5
|
-
import { fontFamilies } from "../constants/fonts.js";
|
|
6
5
|
import { Icon } from "./Icon.js";
|
|
7
6
|
import { useTheme } from "../hooks/useTheme.js";
|
|
8
7
|
import { useReducedMotion } from "../hooks/useReduceMotion.js";
|
|
9
8
|
import { spacing } from "../constants/spacing.js";
|
|
9
|
+
import { durations } from "../constants/motion.js";
|
|
10
10
|
import { StyledText } from "./StyledText.js";
|
|
11
11
|
import { palette } from "../constants/colors.js";
|
|
12
12
|
import { translateText } from "../lib/i18n.js";
|
|
13
|
+
import { createThemedStyles } from "../lib/themedStyles.js";
|
|
13
14
|
import { globalUIStore } from "../state/globalUIStore.js";
|
|
14
|
-
const timingIn = { duration:
|
|
15
|
-
const timingOut = { duration:
|
|
15
|
+
const timingIn = { duration: durations.fast, easing: Easing.out(Easing.quad), useNativeDriver: true };
|
|
16
|
+
const timingOut = { duration: durations.instant, easing: Easing.in(Easing.quad), useNativeDriver: true };
|
|
16
17
|
/**
|
|
17
18
|
* Notification
|
|
18
19
|
*
|
|
@@ -36,7 +37,7 @@ export const Notification = () => {
|
|
|
36
37
|
const reduceMotion = useReducedMotion();
|
|
37
38
|
const insets = use(SafeAreaInsetsContext);
|
|
38
39
|
const { alert, hide } = globalUIStore();
|
|
39
|
-
const styles =
|
|
40
|
+
const styles = themedStyles(theme);
|
|
40
41
|
const position = alert?.position ?? "top";
|
|
41
42
|
const isBottom = position === "bottom";
|
|
42
43
|
// Just opacity + translateY — no scale (scale = bouncy feel)
|
|
@@ -219,14 +220,14 @@ export const Notification = () => {
|
|
|
219
220
|
styles.alert,
|
|
220
221
|
isBottom && styles.alertBottom,
|
|
221
222
|
getShadowStyle("elevated"),
|
|
222
|
-
], children: [_jsx(View, { style: [styles.iconBadge, { backgroundColor: iconBgColor }], children: alert?.loading ? (_jsx(ActivityIndicator, { size: "small", color: iconColor })) : (_jsx(Icon, { name: icon, size: 18, color: iconColor })) }), _jsxs(View, { style: styles.alertContent, children: [!!title && (_jsx(StyledText, { selectable: false,
|
|
223
|
+
], children: [_jsx(View, { style: [styles.iconBadge, { backgroundColor: iconBgColor }], children: alert?.loading ? (_jsx(ActivityIndicator, { size: "small", color: iconColor })) : (_jsx(Icon, { name: icon, size: 18, color: iconColor })) }), _jsxs(View, { style: styles.alertContent, children: [!!title && (_jsx(StyledText, { selectable: false, size: "base", fontWeight: "semibold", style: { color: theme.colors.foreground }, numberOfLines: 1, children: title })), hasMessage && (_jsx(StyledText, { selectable: false, size: "sm", style: { color: theme.colors.mutedForeground }, numberOfLines: 2, children: message }))] }), action && (_jsx(Pressable, { style: ({ pressed }) => [
|
|
223
224
|
styles.actionButton,
|
|
224
225
|
{
|
|
225
226
|
borderColor: theme.colors.primary + "30",
|
|
226
227
|
backgroundColor: theme.colors.primary + "10",
|
|
227
228
|
},
|
|
228
229
|
pressed && styles.actionButtonPressed,
|
|
229
|
-
], hitSlop: spacing.xs, onPress: handleActionPress, accessibilityLabel: action.label, accessibilityRole: "button", children: _jsx(StyledText, { selectable: false,
|
|
230
|
+
], hitSlop: spacing.xs, onPress: handleActionPress, accessibilityLabel: action.label, accessibilityRole: "button", children: _jsx(StyledText, { selectable: false, size: "sm", fontWeight: "semibold", style: { color: theme.colors.primary }, numberOfLines: 1, children: action.label }) })), _jsx(Pressable, { style: styles.closeButton, hitSlop: spacing.sm, onPress: animateOut, accessibilityLabel: "Dismiss notification", accessibilityRole: "button", children: _jsx(Icon, { name: "x", size: 16, color: theme.colors.mutedForeground }) })] }) }));
|
|
230
231
|
};
|
|
231
232
|
const createStyles = (theme) => StyleSheet.create({
|
|
232
233
|
container: {
|
|
@@ -270,17 +271,11 @@ const createStyles = (theme) => StyleSheet.create({
|
|
|
270
271
|
justifyContent: "center",
|
|
271
272
|
gap: spacing.xxs,
|
|
272
273
|
},
|
|
273
|
-
alertTitle
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
},
|
|
279
|
-
alertDescription: {
|
|
280
|
-
fontFamily: fontFamilies.sansSerif.regular,
|
|
281
|
-
fontSize: 13,
|
|
282
|
-
lineHeight: 18,
|
|
283
|
-
},
|
|
274
|
+
// alertTitle/alertDescription/actionLabel font styling (family, size,
|
|
275
|
+
// weight, lineHeight) now comes entirely from StyledText's size/fontWeight
|
|
276
|
+
// props (see JSX above) — the nearest tokens to the previous hardcoded
|
|
277
|
+
// values (14/20/600, 13/18/regular, 13/18/600) are "base"+"semibold",
|
|
278
|
+
// "sm", and "sm"+"semibold" respectively.
|
|
284
279
|
actionButton: {
|
|
285
280
|
minHeight: 28,
|
|
286
281
|
maxWidth: 140,
|
|
@@ -295,12 +290,6 @@ const createStyles = (theme) => StyleSheet.create({
|
|
|
295
290
|
actionButtonPressed: {
|
|
296
291
|
opacity: 0.75,
|
|
297
292
|
},
|
|
298
|
-
actionLabel: {
|
|
299
|
-
fontFamily: fontFamilies.sansSerif.regular,
|
|
300
|
-
fontWeight: "600",
|
|
301
|
-
fontSize: 13,
|
|
302
|
-
lineHeight: 18,
|
|
303
|
-
},
|
|
304
293
|
closeButton: {
|
|
305
294
|
position: "absolute",
|
|
306
295
|
top: spacing.sm,
|
|
@@ -313,3 +302,4 @@ const createStyles = (theme) => StyleSheet.create({
|
|
|
313
302
|
...(Platform.OS === "web" && { cursor: "pointer" }),
|
|
314
303
|
},
|
|
315
304
|
});
|
|
305
|
+
const themedStyles = createThemedStyles(createStyles);
|
|
@@ -5,15 +5,8 @@ import { View, ViewProps } from "react-native";
|
|
|
5
5
|
* Popover Trigger Component
|
|
6
6
|
* The element that triggers the popover to open/close
|
|
7
7
|
*/
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
asChild?: boolean;
|
|
11
|
-
} & {
|
|
12
|
-
onKeyDown?: (ev: React.KeyboardEvent) => void;
|
|
13
|
-
onKeyUp?: (ev: React.KeyboardEvent) => void;
|
|
14
|
-
} & React.RefAttributes<import("@rn-primitives/popover").TriggerRef>): React.JSX.Element;
|
|
15
|
-
displayName: string;
|
|
16
|
-
};
|
|
8
|
+
type PopoverTriggerProps = PopoverPrimitive.TriggerProps;
|
|
9
|
+
declare function PopoverTrigger({ disabled, ...props }: PopoverTriggerProps): React.JSX.Element;
|
|
17
10
|
interface PopoverContentProps extends PopoverPrimitive.ContentProps {
|
|
18
11
|
/**
|
|
19
12
|
* Optional portal host name for custom portal mounting
|
|
@@ -62,15 +55,7 @@ declare const Popover: {
|
|
|
62
55
|
} & React.RefAttributes<View>): React.JSX.Element;
|
|
63
56
|
displayName: string;
|
|
64
57
|
} & {
|
|
65
|
-
Trigger:
|
|
66
|
-
({ asChild, onPress: onPressProp, disabled, ref, ...props }: Omit<import("react-native").PressableProps & React.RefAttributes<View>, "ref"> & {
|
|
67
|
-
asChild?: boolean;
|
|
68
|
-
} & {
|
|
69
|
-
onKeyDown?: (ev: React.KeyboardEvent) => void;
|
|
70
|
-
onKeyUp?: (ev: React.KeyboardEvent) => void;
|
|
71
|
-
} & React.RefAttributes<import("@rn-primitives/popover").TriggerRef>): React.JSX.Element;
|
|
72
|
-
displayName: string;
|
|
73
|
-
};
|
|
58
|
+
Trigger: typeof PopoverTrigger;
|
|
74
59
|
Content: typeof PopoverContent;
|
|
75
60
|
Header: typeof PopoverHeader;
|
|
76
61
|
Body: typeof PopoverBody;
|
|
@@ -3,16 +3,20 @@ import { AnimatedView } from "./AnimatedView.js";
|
|
|
3
3
|
import { TextClassContext, TextColorContext } from "./StyledText.context";
|
|
4
4
|
import { useTheme } from "../hooks/useTheme.js";
|
|
5
5
|
import { spacing } from "../constants/spacing.js";
|
|
6
|
+
import { useScalePress } from "../hooks/useScalePress.js";
|
|
6
7
|
import * as PopoverPrimitive from "@rn-primitives/popover";
|
|
7
8
|
import * as React from "react";
|
|
8
|
-
import { Platform, StyleSheet, View } from "react-native";
|
|
9
|
+
import { Animated, Platform, StyleSheet, View } from "react-native";
|
|
9
10
|
import { FullWindowOverlay as RNFullWindowOverlay } from "react-native-screens";
|
|
10
11
|
import { palette } from "../constants/colors.js";
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
12
|
+
function PopoverTrigger({ disabled, ...props }) {
|
|
13
|
+
const { animatedStyle: scaleStyle, pressHandlers } = useScalePress({
|
|
14
|
+
disabled: !!disabled,
|
|
15
|
+
scaleTo: 0.97,
|
|
16
|
+
haptic: false,
|
|
17
|
+
});
|
|
18
|
+
return (_jsx(Animated.View, { style: scaleStyle, children: _jsx(PopoverPrimitive.Trigger, { disabled: disabled, ...props, onPressIn: pressHandlers.onPressIn, onPressOut: pressHandlers.onPressOut }) }));
|
|
19
|
+
}
|
|
16
20
|
/**
|
|
17
21
|
* FullWindowOverlay wrapper - uses native overlay on iOS for proper z-index handling
|
|
18
22
|
* On Android/Web, uses Fragment to avoid unnecessary nesting
|
|
@@ -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: {
|
|
@@ -4,6 +4,7 @@ import { View, StyleSheet, Animated } from "react-native";
|
|
|
4
4
|
import { useTheme } from "../hooks/useTheme.js";
|
|
5
5
|
import { useReducedMotion } from "../hooks/useReduceMotion.js";
|
|
6
6
|
import { spacing } from "../constants/spacing.js";
|
|
7
|
+
import { createThemedStyles } from "../lib/themedStyles.js";
|
|
7
8
|
/**
|
|
8
9
|
* Skeleton Component
|
|
9
10
|
*
|
|
@@ -71,9 +72,9 @@ export function SkeletonAvatar({ size = 40, style }) {
|
|
|
71
72
|
* and text lines in a Card-styled wrapper.
|
|
72
73
|
*/
|
|
73
74
|
export function SkeletonCard({ showImage = true, imageHeight = 140, showAvatar = true, textLines = 3, style, }) {
|
|
74
|
-
const { theme } = useTheme();
|
|
75
|
-
const styles =
|
|
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 })] })] }));
|
|
75
|
+
const { theme, getShadowStyle } = useTheme();
|
|
76
|
+
const styles = themedStyles(theme);
|
|
77
|
+
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
78
|
}
|
|
78
79
|
const createCardStyles = (theme) => StyleSheet.create({
|
|
79
80
|
card: {
|
|
@@ -97,3 +98,4 @@ const createCardStyles = (theme) => StyleSheet.create({
|
|
|
97
98
|
gap: spacing.xs,
|
|
98
99
|
},
|
|
99
100
|
});
|
|
101
|
+
const themedStyles = createThemedStyles(createCardStyles);
|
|
@@ -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
|
+
}
|