@beyondcorp/beyond-ui-native 0.1.1 → 0.1.3
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/dist/{chunk-2B6HTSGM.mjs → chunk-RNHEGFYR.mjs} +16 -2
- package/dist/chunk-RNHEGFYR.mjs.map +1 -0
- package/dist/index.d.mts +2 -145
- package/dist/index.d.ts +2 -145
- package/dist/index.js +37 -8
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +23 -8
- package/dist/index.mjs.map +1 -1
- package/dist/preset-BmUHLoAa.d.mts +274 -0
- package/dist/preset-BmUHLoAa.d.ts +274 -0
- package/dist/preset.d.mts +1 -117
- package/dist/preset.d.ts +1 -117
- package/dist/preset.js +19 -3
- package/dist/preset.js.map +1 -1
- package/dist/preset.mjs +5 -3
- package/package.json +11 -5
- package/src/components/accordion/accordion.tsx +245 -0
- package/src/components/accordion/index.ts +1 -0
- package/src/components/alert/alert.tsx +118 -0
- package/src/components/alert/index.ts +1 -0
- package/src/components/avatar/avatar.tsx +125 -0
- package/src/components/avatar/index.ts +1 -0
- package/src/components/badge/badge.tsx +89 -0
- package/src/components/badge/index.ts +1 -0
- package/src/components/bottom-sheet/bottom-sheet.tsx +336 -0
- package/src/components/bottom-sheet/index.ts +1 -0
- package/src/components/button/button.tsx +172 -0
- package/src/components/button/index.ts +1 -0
- package/src/components/card/card.tsx +121 -0
- package/src/components/card/index.ts +1 -0
- package/src/components/checkbox/checkbox.tsx +100 -0
- package/src/components/checkbox/index.ts +1 -0
- package/src/components/data-table/data-table.tsx +277 -0
- package/src/components/data-table/index.ts +1 -0
- package/src/components/dialog/dialog.tsx +278 -0
- package/src/components/dialog/index.ts +1 -0
- package/src/components/input/index.ts +1 -0
- package/src/components/input/input.tsx +130 -0
- package/src/components/radio/index.ts +1 -0
- package/src/components/radio/radio.tsx +117 -0
- package/src/components/select/index.ts +1 -0
- package/src/components/select/select.tsx +375 -0
- package/src/components/separator/index.ts +1 -0
- package/src/components/separator/separator.tsx +28 -0
- package/src/components/skeleton/index.ts +1 -0
- package/src/components/skeleton/skeleton.tsx +43 -0
- package/src/components/spinner/index.ts +1 -0
- package/src/components/spinner/spinner.tsx +46 -0
- package/src/components/stats-card/index.ts +1 -0
- package/src/components/stats-card/stats-card.tsx +92 -0
- package/src/components/switch/index.ts +1 -0
- package/src/components/switch/switch.tsx +80 -0
- package/src/components/tabs/index.ts +1 -0
- package/src/components/tabs/tabs.tsx +172 -0
- package/src/components/text/heading.tsx +50 -0
- package/src/components/text/index.ts +2 -0
- package/src/components/text/text.tsx +62 -0
- package/src/components/textarea/index.ts +1 -0
- package/src/components/textarea/textarea.tsx +87 -0
- package/src/components/toast/index.ts +2 -0
- package/src/components/toast/toast.tsx +136 -0
- package/src/components/toast/types.ts +32 -0
- package/src/index.ts +42 -0
- package/src/primitives/box.tsx +25 -0
- package/src/primitives/index.ts +2 -0
- package/src/primitives/pressable.tsx +49 -0
- package/src/providers/BeyondUIProvider.tsx +100 -0
- package/src/templates/auth/ForgotPasswordTemplate.tsx +132 -0
- package/src/templates/auth/SignInTemplate.tsx +213 -0
- package/src/templates/auth/SignUpTemplate.tsx +233 -0
- package/src/templates/auth/index.ts +3 -0
- package/src/templates/dashboard/DashboardTemplate.tsx +205 -0
- package/src/templates/dashboard/index.ts +1 -0
- package/src/templates/index.ts +3 -0
- package/src/templates/settings/SettingsTemplate.tsx +226 -0
- package/src/templates/settings/index.ts +1 -0
- package/src/theme/preset.ts +45 -0
- package/src/theme/tokens.ts +144 -0
- package/src/types/nativewind-env.d.ts +26 -0
- package/src/utilities/accessibility.ts +43 -0
- package/src/utilities/cn.ts +9 -0
- package/src/utilities/index.ts +2 -0
- package/dist/chunk-2B6HTSGM.mjs.map +0 -1
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import {
|
|
3
|
+
ActivityIndicator,
|
|
4
|
+
Pressable,
|
|
5
|
+
Text,
|
|
6
|
+
View,
|
|
7
|
+
type PressableProps,
|
|
8
|
+
type TextProps,
|
|
9
|
+
} from "react-native";
|
|
10
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
11
|
+
import { cn } from "../../utilities/cn";
|
|
12
|
+
|
|
13
|
+
export const buttonVariants = cva(
|
|
14
|
+
"flex-row items-center justify-center rounded-xl transition-colors",
|
|
15
|
+
{
|
|
16
|
+
variants: {
|
|
17
|
+
variant: {
|
|
18
|
+
primary: "bg-primary-600 active:bg-primary-700 dark:bg-primary-600 dark:active:bg-primary-700",
|
|
19
|
+
secondary: "bg-secondary-600 active:bg-secondary-700 dark:bg-secondary-600 dark:active:bg-secondary-700",
|
|
20
|
+
danger: "bg-danger-600 active:bg-danger-700 dark:bg-danger-600 dark:active:bg-danger-700",
|
|
21
|
+
destructive: "bg-danger-600 active:bg-danger-700 dark:bg-danger-600 dark:active:bg-danger-700",
|
|
22
|
+
success: "bg-success-600 active:bg-success-700 dark:bg-success-600 dark:active:bg-success-700",
|
|
23
|
+
warning: "bg-warning-600 active:bg-warning-700 dark:bg-warning-600 dark:active:bg-warning-700",
|
|
24
|
+
outline: "border border-neutral-300 dark:border-neutral-700 bg-transparent active:bg-neutral-100 dark:active:bg-neutral-800",
|
|
25
|
+
ghost: "bg-transparent active:bg-neutral-100 dark:active:bg-neutral-800",
|
|
26
|
+
link: "bg-transparent underline",
|
|
27
|
+
},
|
|
28
|
+
size: {
|
|
29
|
+
sm: "min-h-8 px-3 py-1.5 rounded-lg",
|
|
30
|
+
md: "min-h-10 px-4 py-2 rounded-xl",
|
|
31
|
+
lg: "min-h-12 px-6 py-2.5 rounded-xl",
|
|
32
|
+
xl: "min-h-14 px-8 py-3 rounded-2xl",
|
|
33
|
+
icon: "min-h-10 min-w-10 p-0 rounded-xl",
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
defaultVariants: {
|
|
37
|
+
variant: "primary",
|
|
38
|
+
size: "md",
|
|
39
|
+
},
|
|
40
|
+
}
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
export const buttonTextVariants = cva("font-semibold text-center select-none", {
|
|
44
|
+
variants: {
|
|
45
|
+
variant: {
|
|
46
|
+
primary: "text-white",
|
|
47
|
+
secondary: "text-white",
|
|
48
|
+
danger: "text-white",
|
|
49
|
+
destructive: "text-white",
|
|
50
|
+
success: "text-white",
|
|
51
|
+
warning: "text-white",
|
|
52
|
+
outline: "text-neutral-800 dark:text-neutral-100",
|
|
53
|
+
ghost: "text-neutral-800 dark:text-neutral-100",
|
|
54
|
+
link: "text-primary-600 dark:text-primary-400 underline",
|
|
55
|
+
},
|
|
56
|
+
size: {
|
|
57
|
+
sm: "text-xs",
|
|
58
|
+
md: "text-sm",
|
|
59
|
+
lg: "text-base font-semibold",
|
|
60
|
+
xl: "text-lg font-bold",
|
|
61
|
+
icon: "text-sm",
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
defaultVariants: {
|
|
65
|
+
variant: "primary",
|
|
66
|
+
size: "md",
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
type ButtonVariantProps = VariantProps<typeof buttonVariants>;
|
|
71
|
+
|
|
72
|
+
export interface ButtonProps
|
|
73
|
+
extends Omit<PressableProps, "children">,
|
|
74
|
+
ButtonVariantProps {
|
|
75
|
+
children?: React.ReactNode;
|
|
76
|
+
loading?: boolean;
|
|
77
|
+
disabled?: boolean;
|
|
78
|
+
leftIcon?: React.ReactNode;
|
|
79
|
+
rightIcon?: React.ReactNode;
|
|
80
|
+
className?: string;
|
|
81
|
+
textClassName?: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
interface ButtonContextValue {
|
|
85
|
+
variant: ButtonVariantProps["variant"];
|
|
86
|
+
size: ButtonVariantProps["size"];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const ButtonContext = React.createContext<ButtonContextValue>({
|
|
90
|
+
variant: "primary",
|
|
91
|
+
size: "md",
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
export const Button = React.forwardRef<View, ButtonProps>(function Button(
|
|
95
|
+
{
|
|
96
|
+
children,
|
|
97
|
+
variant = "primary",
|
|
98
|
+
size = "md",
|
|
99
|
+
loading = false,
|
|
100
|
+
disabled = false,
|
|
101
|
+
leftIcon,
|
|
102
|
+
rightIcon,
|
|
103
|
+
className,
|
|
104
|
+
textClassName,
|
|
105
|
+
...props
|
|
106
|
+
},
|
|
107
|
+
ref
|
|
108
|
+
) {
|
|
109
|
+
const isDisabled = disabled || loading;
|
|
110
|
+
const isLightText = variant === "outline" || variant === "ghost" || variant === "link";
|
|
111
|
+
const spinnerColor = isLightText ? "#2563eb" : "#ffffff";
|
|
112
|
+
|
|
113
|
+
return (
|
|
114
|
+
<ButtonContext.Provider value={{ variant, size }}>
|
|
115
|
+
<Pressable
|
|
116
|
+
ref={ref}
|
|
117
|
+
accessibilityRole="button"
|
|
118
|
+
accessibilityState={{ disabled: !!isDisabled, busy: loading }}
|
|
119
|
+
disabled={isDisabled}
|
|
120
|
+
className={cn(
|
|
121
|
+
buttonVariants({ variant, size }),
|
|
122
|
+
isDisabled && "opacity-50",
|
|
123
|
+
className
|
|
124
|
+
)}
|
|
125
|
+
{...props}
|
|
126
|
+
>
|
|
127
|
+
{loading ? (
|
|
128
|
+
<ActivityIndicator color={spinnerColor} className="mr-2" size="small" />
|
|
129
|
+
) : leftIcon ? (
|
|
130
|
+
<View className="mr-2">{leftIcon}</View>
|
|
131
|
+
) : null}
|
|
132
|
+
|
|
133
|
+
{typeof children === "string" || typeof children === "number" ? (
|
|
134
|
+
<Text
|
|
135
|
+
className={cn(
|
|
136
|
+
buttonTextVariants({ variant, size }),
|
|
137
|
+
textClassName
|
|
138
|
+
)}
|
|
139
|
+
>
|
|
140
|
+
{children}
|
|
141
|
+
</Text>
|
|
142
|
+
) : (
|
|
143
|
+
children
|
|
144
|
+
)}
|
|
145
|
+
|
|
146
|
+
{rightIcon && !loading ? (
|
|
147
|
+
<View className="ml-2">{rightIcon}</View>
|
|
148
|
+
) : null}
|
|
149
|
+
</Pressable>
|
|
150
|
+
</ButtonContext.Provider>
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
Button.displayName = "Button";
|
|
155
|
+
|
|
156
|
+
export function ButtonText({
|
|
157
|
+
children,
|
|
158
|
+
className,
|
|
159
|
+
...props
|
|
160
|
+
}: TextProps & { className?: string }) {
|
|
161
|
+
const { variant, size } = React.useContext(ButtonContext);
|
|
162
|
+
return (
|
|
163
|
+
<Text
|
|
164
|
+
className={cn(buttonTextVariants({ variant, size }), className)}
|
|
165
|
+
{...props}
|
|
166
|
+
>
|
|
167
|
+
{children}
|
|
168
|
+
</Text>
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
ButtonText.displayName = "Button.Text";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./button";
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { View, Text, type ViewProps, type TextProps } from "react-native";
|
|
3
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
4
|
+
import { cn } from "../../utilities/cn";
|
|
5
|
+
|
|
6
|
+
export const cardVariants = cva(
|
|
7
|
+
"w-full rounded-2xl border bg-white dark:bg-neutral-900 overflow-hidden",
|
|
8
|
+
{
|
|
9
|
+
variants: {
|
|
10
|
+
variant: {
|
|
11
|
+
default: "border-neutral-200 dark:border-neutral-800 shadow-sm",
|
|
12
|
+
elevated: "border-neutral-200/80 dark:border-neutral-800 shadow-md",
|
|
13
|
+
outlined: "border-2 border-primary-200 dark:border-primary-800",
|
|
14
|
+
ghost: "border-transparent bg-transparent shadow-none",
|
|
15
|
+
},
|
|
16
|
+
padding: {
|
|
17
|
+
none: "p-0",
|
|
18
|
+
sm: "p-3",
|
|
19
|
+
md: "p-4",
|
|
20
|
+
lg: "p-6",
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
defaultVariants: {
|
|
24
|
+
variant: "default",
|
|
25
|
+
padding: "none",
|
|
26
|
+
},
|
|
27
|
+
}
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
export interface CardProps
|
|
31
|
+
extends ViewProps,
|
|
32
|
+
VariantProps<typeof cardVariants> {
|
|
33
|
+
className?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const Card = React.forwardRef<View, CardProps>(function Card(
|
|
37
|
+
{ className, variant, padding, ...props },
|
|
38
|
+
ref
|
|
39
|
+
) {
|
|
40
|
+
return (
|
|
41
|
+
<View
|
|
42
|
+
ref={ref}
|
|
43
|
+
className={cn(cardVariants({ variant, padding }), className)}
|
|
44
|
+
{...props}
|
|
45
|
+
/>
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
Card.displayName = "Card";
|
|
49
|
+
|
|
50
|
+
export const CardHeader = React.forwardRef<
|
|
51
|
+
View,
|
|
52
|
+
ViewProps & { className?: string }
|
|
53
|
+
>(function CardHeader({ className, ...props }, ref) {
|
|
54
|
+
return (
|
|
55
|
+
<View
|
|
56
|
+
ref={ref}
|
|
57
|
+
className={cn("flex-col space-y-1.5 p-4 pb-2", className)}
|
|
58
|
+
{...props}
|
|
59
|
+
/>
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
CardHeader.displayName = "Card.Header";
|
|
63
|
+
|
|
64
|
+
export const CardTitle = React.forwardRef<
|
|
65
|
+
Text,
|
|
66
|
+
TextProps & { className?: string }
|
|
67
|
+
>(function CardTitle({ className, ...props }, ref) {
|
|
68
|
+
return (
|
|
69
|
+
<Text
|
|
70
|
+
ref={ref}
|
|
71
|
+
className={cn(
|
|
72
|
+
"text-lg font-bold tracking-tight text-neutral-900 dark:text-neutral-50",
|
|
73
|
+
className
|
|
74
|
+
)}
|
|
75
|
+
{...props}
|
|
76
|
+
/>
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
CardTitle.displayName = "Card.Title";
|
|
80
|
+
|
|
81
|
+
export const CardDescription = React.forwardRef<
|
|
82
|
+
Text,
|
|
83
|
+
TextProps & { className?: string }
|
|
84
|
+
>(function CardDescription({ className, ...props }, ref) {
|
|
85
|
+
return (
|
|
86
|
+
<Text
|
|
87
|
+
ref={ref}
|
|
88
|
+
className={cn("text-sm text-neutral-500 dark:text-neutral-400", className)}
|
|
89
|
+
{...props}
|
|
90
|
+
/>
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
CardDescription.displayName = "Card.Description";
|
|
94
|
+
|
|
95
|
+
export const CardContent = React.forwardRef<
|
|
96
|
+
View,
|
|
97
|
+
ViewProps & { className?: string }
|
|
98
|
+
>(function CardContent({ className, ...props }, ref) {
|
|
99
|
+
return (
|
|
100
|
+
<View
|
|
101
|
+
ref={ref}
|
|
102
|
+
className={cn("p-4 pt-1", className)}
|
|
103
|
+
{...props}
|
|
104
|
+
/>
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
CardContent.displayName = "Card.Content";
|
|
108
|
+
|
|
109
|
+
export const CardFooter = React.forwardRef<
|
|
110
|
+
View,
|
|
111
|
+
ViewProps & { className?: string }
|
|
112
|
+
>(function CardFooter({ className, ...props }, ref) {
|
|
113
|
+
return (
|
|
114
|
+
<View
|
|
115
|
+
ref={ref}
|
|
116
|
+
className={cn("flex-row items-center p-4 pt-0", className)}
|
|
117
|
+
{...props}
|
|
118
|
+
/>
|
|
119
|
+
);
|
|
120
|
+
});
|
|
121
|
+
CardFooter.displayName = "Card.Footer";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./card";
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import {
|
|
3
|
+
View,
|
|
4
|
+
Text,
|
|
5
|
+
Pressable,
|
|
6
|
+
type PressableProps,
|
|
7
|
+
type GestureResponderEvent,
|
|
8
|
+
} from "react-native";
|
|
9
|
+
import { cn } from "../../utilities/cn";
|
|
10
|
+
|
|
11
|
+
export interface CheckboxProps extends Omit<PressableProps, "onPress"> {
|
|
12
|
+
checked?: boolean;
|
|
13
|
+
defaultChecked?: boolean;
|
|
14
|
+
onCheckedChange?: (checked: boolean) => void;
|
|
15
|
+
disabled?: boolean;
|
|
16
|
+
className?: string;
|
|
17
|
+
indicatorClassName?: string;
|
|
18
|
+
label?: string;
|
|
19
|
+
labelClassName?: string;
|
|
20
|
+
onPress?: (e: GestureResponderEvent) => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const Checkbox = React.forwardRef<
|
|
24
|
+
React.ElementRef<typeof Pressable>,
|
|
25
|
+
CheckboxProps
|
|
26
|
+
>(function Checkbox(
|
|
27
|
+
{
|
|
28
|
+
checked: controlledChecked,
|
|
29
|
+
defaultChecked = false,
|
|
30
|
+
onCheckedChange,
|
|
31
|
+
disabled = false,
|
|
32
|
+
className,
|
|
33
|
+
indicatorClassName,
|
|
34
|
+
label,
|
|
35
|
+
labelClassName,
|
|
36
|
+
onPress,
|
|
37
|
+
...props
|
|
38
|
+
},
|
|
39
|
+
ref
|
|
40
|
+
) {
|
|
41
|
+
const [uncontrolledChecked, setUncontrolledChecked] = React.useState(defaultChecked);
|
|
42
|
+
const isControlled = controlledChecked !== undefined;
|
|
43
|
+
const isChecked = isControlled ? controlledChecked : uncontrolledChecked;
|
|
44
|
+
|
|
45
|
+
const handlePress = (e: GestureResponderEvent) => {
|
|
46
|
+
if (disabled) return;
|
|
47
|
+
const nextChecked = !isChecked;
|
|
48
|
+
if (!isControlled) {
|
|
49
|
+
setUncontrolledChecked(nextChecked);
|
|
50
|
+
}
|
|
51
|
+
onCheckedChange?.(nextChecked);
|
|
52
|
+
onPress?.(e);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const content = (
|
|
56
|
+
<Pressable
|
|
57
|
+
ref={ref}
|
|
58
|
+
role="checkbox"
|
|
59
|
+
accessibilityRole="checkbox"
|
|
60
|
+
accessibilityState={{ checked: isChecked, disabled: !!disabled }}
|
|
61
|
+
disabled={disabled}
|
|
62
|
+
onPress={handlePress}
|
|
63
|
+
className={cn(
|
|
64
|
+
"h-6 w-6 shrink-0 items-center justify-center rounded-md border border-neutral-300 dark:border-neutral-700 bg-white dark:bg-neutral-900",
|
|
65
|
+
isChecked && "bg-primary-600 border-primary-600 dark:bg-primary-600 dark:border-primary-600",
|
|
66
|
+
disabled && "opacity-50",
|
|
67
|
+
className
|
|
68
|
+
)}
|
|
69
|
+
{...props}
|
|
70
|
+
>
|
|
71
|
+
{isChecked ? (
|
|
72
|
+
<View className={cn("items-center justify-center", indicatorClassName)}>
|
|
73
|
+
<Text className="text-white text-xs font-bold leading-none">✓</Text>
|
|
74
|
+
</View>
|
|
75
|
+
) : null}
|
|
76
|
+
</Pressable>
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
if (!label) {
|
|
80
|
+
return content;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<View className="flex-row items-center gap-2.5">
|
|
85
|
+
{content}
|
|
86
|
+
<Text
|
|
87
|
+
onPress={!disabled ? handlePress : undefined}
|
|
88
|
+
className={cn(
|
|
89
|
+
"text-sm font-medium text-neutral-800 dark:text-neutral-200",
|
|
90
|
+
disabled && "opacity-50",
|
|
91
|
+
labelClassName
|
|
92
|
+
)}
|
|
93
|
+
>
|
|
94
|
+
{label}
|
|
95
|
+
</Text>
|
|
96
|
+
</View>
|
|
97
|
+
);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
Checkbox.displayName = "Checkbox";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./checkbox";
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import {
|
|
3
|
+
View,
|
|
4
|
+
ScrollView,
|
|
5
|
+
Pressable,
|
|
6
|
+
type ViewProps,
|
|
7
|
+
} from "react-native";
|
|
8
|
+
import { cn } from "../../utilities/cn";
|
|
9
|
+
import { Text } from "../text/text";
|
|
10
|
+
import { Input } from "../input/input";
|
|
11
|
+
import { Button } from "../button/button";
|
|
12
|
+
import { Card, CardContent } from "../card/card";
|
|
13
|
+
import { Skeleton } from "../skeleton/skeleton";
|
|
14
|
+
|
|
15
|
+
export interface Column<T> {
|
|
16
|
+
key: keyof T | string;
|
|
17
|
+
title: string;
|
|
18
|
+
sortable?: boolean;
|
|
19
|
+
align?: "left" | "center" | "right";
|
|
20
|
+
width?: number;
|
|
21
|
+
render?: (value: any, item: T) => React.ReactNode;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface DataTableProps<T extends Record<string, any>> extends ViewProps {
|
|
25
|
+
columns: Column<T>[];
|
|
26
|
+
data: T[];
|
|
27
|
+
keyExtractor?: (item: T, index: number) => string;
|
|
28
|
+
mode?: "table" | "card";
|
|
29
|
+
searchable?: boolean;
|
|
30
|
+
searchPlaceholder?: string;
|
|
31
|
+
searchKeys?: (keyof T)[];
|
|
32
|
+
loading?: boolean;
|
|
33
|
+
pageSize?: number;
|
|
34
|
+
emptyText?: string;
|
|
35
|
+
onRowPress?: (item: T) => void;
|
|
36
|
+
className?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function DataTable<T extends Record<string, any>>({
|
|
40
|
+
columns,
|
|
41
|
+
data,
|
|
42
|
+
keyExtractor = (_, i) => String(i),
|
|
43
|
+
mode = "card",
|
|
44
|
+
searchable = true,
|
|
45
|
+
searchPlaceholder = "Search records...",
|
|
46
|
+
searchKeys,
|
|
47
|
+
loading = false,
|
|
48
|
+
pageSize = 5,
|
|
49
|
+
emptyText = "No records found",
|
|
50
|
+
onRowPress,
|
|
51
|
+
className,
|
|
52
|
+
...props
|
|
53
|
+
}: DataTableProps<T>) {
|
|
54
|
+
const [search, setSearch] = React.useState("");
|
|
55
|
+
const [sortKey, setSortKey] = React.useState<string | null>(null);
|
|
56
|
+
const [sortDirection, setSortDirection] = React.useState<"asc" | "desc">("asc");
|
|
57
|
+
const [currentPage, setCurrentPage] = React.useState(1);
|
|
58
|
+
|
|
59
|
+
// Filter
|
|
60
|
+
const filteredData = React.useMemo(() => {
|
|
61
|
+
if (!search.trim()) return data;
|
|
62
|
+
const query = search.toLowerCase();
|
|
63
|
+
const keys = searchKeys || (columns.map((c) => c.key) as (keyof T)[]);
|
|
64
|
+
|
|
65
|
+
return data.filter((item) => {
|
|
66
|
+
return keys.some((k) => {
|
|
67
|
+
const val = item[k];
|
|
68
|
+
if (val == null) return false;
|
|
69
|
+
return String(val).toLowerCase().includes(query);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
}, [data, search, searchKeys, columns]);
|
|
73
|
+
|
|
74
|
+
// Sort
|
|
75
|
+
const sortedData = React.useMemo(() => {
|
|
76
|
+
if (!sortKey) return filteredData;
|
|
77
|
+
return [...filteredData].sort((a, b) => {
|
|
78
|
+
const valA = a[sortKey];
|
|
79
|
+
const valB = b[sortKey];
|
|
80
|
+
if (valA === valB) return 0;
|
|
81
|
+
if (valA == null) return 1;
|
|
82
|
+
if (valB == null) return -1;
|
|
83
|
+
|
|
84
|
+
if (typeof valA === "number" && typeof valB === "number") {
|
|
85
|
+
return sortDirection === "asc" ? valA - valB : valB - valA;
|
|
86
|
+
}
|
|
87
|
+
const strA = String(valA).toLowerCase();
|
|
88
|
+
const strB = String(valB).toLowerCase();
|
|
89
|
+
return sortDirection === "asc"
|
|
90
|
+
? strA.localeCompare(strB)
|
|
91
|
+
: strB.localeCompare(strA);
|
|
92
|
+
});
|
|
93
|
+
}, [filteredData, sortKey, sortDirection]);
|
|
94
|
+
|
|
95
|
+
// Pagination
|
|
96
|
+
const totalPages = Math.max(1, Math.ceil(sortedData.length / pageSize));
|
|
97
|
+
const paginatedData = React.useMemo(() => {
|
|
98
|
+
const start = (currentPage - 1) * pageSize;
|
|
99
|
+
return sortedData.slice(start, start + pageSize);
|
|
100
|
+
}, [sortedData, currentPage, pageSize]);
|
|
101
|
+
|
|
102
|
+
const handleSort = (colKey: string) => {
|
|
103
|
+
if (sortKey === colKey) {
|
|
104
|
+
if (sortDirection === "asc") {
|
|
105
|
+
setSortDirection("desc");
|
|
106
|
+
} else {
|
|
107
|
+
setSortKey(null);
|
|
108
|
+
setSortDirection("asc");
|
|
109
|
+
}
|
|
110
|
+
} else {
|
|
111
|
+
setSortKey(colKey);
|
|
112
|
+
setSortDirection("asc");
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
<View className={cn("w-full gap-4", className)} {...props}>
|
|
118
|
+
{/* Search Input */}
|
|
119
|
+
{searchable && (
|
|
120
|
+
<Input
|
|
121
|
+
placeholder={searchPlaceholder}
|
|
122
|
+
value={search}
|
|
123
|
+
onChangeText={(val) => {
|
|
124
|
+
setSearch(val);
|
|
125
|
+
setCurrentPage(1);
|
|
126
|
+
}}
|
|
127
|
+
className="bg-card"
|
|
128
|
+
/>
|
|
129
|
+
)}
|
|
130
|
+
|
|
131
|
+
{/* Loading Skeleton */}
|
|
132
|
+
{loading ? (
|
|
133
|
+
<View className="gap-3">
|
|
134
|
+
{[1, 2, 3].map((idx) => (
|
|
135
|
+
<Skeleton key={idx} className="h-20 w-full rounded-2xl" />
|
|
136
|
+
))}
|
|
137
|
+
</View>
|
|
138
|
+
) : paginatedData.length === 0 ? (
|
|
139
|
+
<Card className="items-center justify-center p-8">
|
|
140
|
+
<Text className="text-muted text-base">{emptyText}</Text>
|
|
141
|
+
</Card>
|
|
142
|
+
) : mode === "card" ? (
|
|
143
|
+
/* Mobile Card Mode */
|
|
144
|
+
<View className="gap-3">
|
|
145
|
+
{paginatedData.map((item, idx) => (
|
|
146
|
+
<Pressable
|
|
147
|
+
key={keyExtractor(item, idx)}
|
|
148
|
+
disabled={!onRowPress}
|
|
149
|
+
onPress={() => onRowPress?.(item)}
|
|
150
|
+
className="active:opacity-80"
|
|
151
|
+
>
|
|
152
|
+
<Card className="p-4 gap-2">
|
|
153
|
+
{columns.map((col) => {
|
|
154
|
+
const rawVal = item[col.key as string];
|
|
155
|
+
const renderedVal = col.render ? col.render(rawVal, item) : String(rawVal ?? "—");
|
|
156
|
+
|
|
157
|
+
return (
|
|
158
|
+
<View
|
|
159
|
+
key={String(col.key)}
|
|
160
|
+
className="flex-row items-center justify-between py-1 border-b border-border/40 last:border-b-0"
|
|
161
|
+
>
|
|
162
|
+
<Text className="text-xs font-semibold text-muted uppercase tracking-wider">
|
|
163
|
+
{col.title}
|
|
164
|
+
</Text>
|
|
165
|
+
<View className="shrink">
|
|
166
|
+
{typeof renderedVal === "string" ? (
|
|
167
|
+
<Text className="text-sm font-medium text-foreground">
|
|
168
|
+
{renderedVal}
|
|
169
|
+
</Text>
|
|
170
|
+
) : (
|
|
171
|
+
renderedVal
|
|
172
|
+
)}
|
|
173
|
+
</View>
|
|
174
|
+
</View>
|
|
175
|
+
);
|
|
176
|
+
})}
|
|
177
|
+
</Card>
|
|
178
|
+
</Pressable>
|
|
179
|
+
))}
|
|
180
|
+
</View>
|
|
181
|
+
) : (
|
|
182
|
+
/* Horizontal Table Mode */
|
|
183
|
+
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
|
184
|
+
<View className="rounded-2xl border border-border bg-card overflow-hidden">
|
|
185
|
+
{/* Header */}
|
|
186
|
+
<View className="flex-row bg-muted/10 border-b border-border">
|
|
187
|
+
{columns.map((col) => (
|
|
188
|
+
<Pressable
|
|
189
|
+
key={String(col.key)}
|
|
190
|
+
disabled={!col.sortable}
|
|
191
|
+
onPress={() => handleSort(String(col.key))}
|
|
192
|
+
style={{ width: col.width || 120 }}
|
|
193
|
+
className="px-4 py-3 flex-row items-center justify-between"
|
|
194
|
+
>
|
|
195
|
+
<Text className="text-xs font-bold text-foreground uppercase tracking-wider">
|
|
196
|
+
{col.title}
|
|
197
|
+
</Text>
|
|
198
|
+
{col.sortable && (
|
|
199
|
+
<Text className="text-xs text-muted">
|
|
200
|
+
{sortKey === col.key
|
|
201
|
+
? sortDirection === "asc"
|
|
202
|
+
? "▲"
|
|
203
|
+
: "▼"
|
|
204
|
+
: "⇅"}
|
|
205
|
+
</Text>
|
|
206
|
+
)}
|
|
207
|
+
</Pressable>
|
|
208
|
+
))}
|
|
209
|
+
</View>
|
|
210
|
+
|
|
211
|
+
{/* Rows */}
|
|
212
|
+
{paginatedData.map((item, idx) => (
|
|
213
|
+
<Pressable
|
|
214
|
+
key={keyExtractor(item, idx)}
|
|
215
|
+
disabled={!onRowPress}
|
|
216
|
+
onPress={() => onRowPress?.(item)}
|
|
217
|
+
className="flex-row border-b border-border/60 active:bg-muted/10"
|
|
218
|
+
>
|
|
219
|
+
{columns.map((col) => {
|
|
220
|
+
const rawVal = item[col.key as string];
|
|
221
|
+
const renderedVal = col.render ? col.render(rawVal, item) : String(rawVal ?? "—");
|
|
222
|
+
|
|
223
|
+
return (
|
|
224
|
+
<View
|
|
225
|
+
key={String(col.key)}
|
|
226
|
+
style={{ width: col.width || 120 }}
|
|
227
|
+
className="px-4 py-3 justify-center"
|
|
228
|
+
>
|
|
229
|
+
{typeof renderedVal === "string" ? (
|
|
230
|
+
<Text className="text-sm text-foreground">{renderedVal}</Text>
|
|
231
|
+
) : (
|
|
232
|
+
renderedVal
|
|
233
|
+
)}
|
|
234
|
+
</View>
|
|
235
|
+
);
|
|
236
|
+
})}
|
|
237
|
+
</Pressable>
|
|
238
|
+
))}
|
|
239
|
+
</View>
|
|
240
|
+
</ScrollView>
|
|
241
|
+
)}
|
|
242
|
+
|
|
243
|
+
{/* Pagination Footer */}
|
|
244
|
+
{totalPages > 1 && (
|
|
245
|
+
<View className="flex-row items-center justify-between pt-2">
|
|
246
|
+
<Text className="text-xs text-muted">
|
|
247
|
+
Showing {(currentPage - 1) * pageSize + 1} -{" "}
|
|
248
|
+
{Math.min(currentPage * pageSize, sortedData.length)} of{" "}
|
|
249
|
+
{sortedData.length}
|
|
250
|
+
</Text>
|
|
251
|
+
|
|
252
|
+
<View className="flex-row items-center gap-2">
|
|
253
|
+
<Button
|
|
254
|
+
variant="outline"
|
|
255
|
+
size="sm"
|
|
256
|
+
disabled={currentPage <= 1}
|
|
257
|
+
onPress={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
|
258
|
+
>
|
|
259
|
+
Prev
|
|
260
|
+
</Button>
|
|
261
|
+
<Text className="text-xs font-medium text-foreground px-1">
|
|
262
|
+
{currentPage} / {totalPages}
|
|
263
|
+
</Text>
|
|
264
|
+
<Button
|
|
265
|
+
variant="outline"
|
|
266
|
+
size="sm"
|
|
267
|
+
disabled={currentPage >= totalPages}
|
|
268
|
+
onPress={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
|
269
|
+
>
|
|
270
|
+
Next
|
|
271
|
+
</Button>
|
|
272
|
+
</View>
|
|
273
|
+
</View>
|
|
274
|
+
)}
|
|
275
|
+
</View>
|
|
276
|
+
);
|
|
277
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./data-table";
|