@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,18 +1,19 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useMemo, useCallback, use, useEffect, useEffectEvent, useRef } from "react";
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: 150, easing: Easing.out(Easing.quad), useNativeDriver: true };
15
- const timingOut = { duration: 100, easing: Easing.in(Easing.quad), useNativeDriver: true };
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 = useMemo(() => createStyles(theme), [theme]);
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, style: [styles.alertTitle, { color: theme.colors.foreground }], numberOfLines: 1, children: title })), hasMessage && (_jsx(StyledText, { selectable: false, style: [styles.alertDescription, { color: theme.colors.mutedForeground }], numberOfLines: 2, children: message }))] }), action && (_jsx(Pressable, { style: ({ pressed }) => [
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, style: [styles.actionLabel, { 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
+ ], 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
- fontFamily: fontFamilies.sansSerif.regular,
275
- fontWeight: "600",
276
- fontSize: 14,
277
- lineHeight: 20,
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
- declare const PopoverTrigger: {
9
- ({ asChild, onPress: onPressProp, disabled, ref, ...props }: Omit<import("react-native").PressableProps & React.RefAttributes<View>, "ref"> & {
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
- * Popover Trigger Component
13
- * The element that triggers the popover to open/close
14
- */
15
- const PopoverTrigger = PopoverPrimitive.Trigger;
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
- ...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: {
@@ -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 = 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 })] })] }));
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
+ }