@umituz/web-design-system 1.5.1 → 1.7.2
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/package.json +14 -1
- package/src/presentation/atoms/Label.tsx +25 -0
- package/src/presentation/atoms/index.ts +2 -0
- package/src/presentation/molecules/ListItem.tsx +128 -0
- package/src/presentation/molecules/ScrollArea.tsx +50 -0
- package/src/presentation/molecules/index.ts +5 -0
- package/src/presentation/organisms/Alert.tsx +10 -19
- package/src/presentation/organisms/Collapsible.tsx +14 -0
- package/src/presentation/organisms/ConfirmDialog.tsx +97 -0
- package/src/presentation/organisms/DataTable.tsx +233 -0
- package/src/presentation/organisms/Dialog.tsx +124 -0
- package/src/presentation/organisms/EmptyState.tsx +103 -0
- package/src/presentation/organisms/FormModal.tsx +126 -0
- package/src/presentation/organisms/HoverCard.tsx +31 -0
- package/src/presentation/organisms/LoadingState.tsx +87 -0
- package/src/presentation/organisms/MetricCard.tsx +126 -0
- package/src/presentation/organisms/Popover.tsx +33 -0
- package/src/presentation/organisms/QuickActionCard.tsx +110 -0
- package/src/presentation/organisms/Sheet.tsx +135 -0
- package/src/presentation/organisms/StatCard.tsx +161 -0
- package/src/presentation/organisms/index.ts +54 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@umituz/web-design-system",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.2",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Web Design System - Atomic Design components (Atoms, Molecules, Organisms, Templates) for React applications",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -53,14 +53,27 @@
|
|
|
53
53
|
},
|
|
54
54
|
"peerDependencies": {
|
|
55
55
|
"@radix-ui/react-accordion": ">=1.0.0",
|
|
56
|
+
"@radix-ui/react-collapsible": ">=1.0.0",
|
|
57
|
+
"@radix-ui/react-dialog": ">=1.0.0",
|
|
58
|
+
"@radix-ui/react-hover-card": ">=1.0.0",
|
|
59
|
+
"@radix-ui/react-label": ">=2.0.0",
|
|
60
|
+
"@radix-ui/react-popover": ">=1.0.0",
|
|
61
|
+
"@radix-ui/react-scroll-area": ">=1.0.0",
|
|
56
62
|
"@radix-ui/react-select": ">=2.0.0",
|
|
57
63
|
"clsx": ">=2.0.0",
|
|
64
|
+
"lucide-react": ">=0.400.0",
|
|
58
65
|
"react": ">=18.0.0",
|
|
59
66
|
"react-dom": ">=18.0.0",
|
|
60
67
|
"tailwind-merge": ">=2.0.0"
|
|
61
68
|
},
|
|
62
69
|
"devDependencies": {
|
|
63
70
|
"@radix-ui/react-accordion": "^1.2.12",
|
|
71
|
+
"@radix-ui/react-collapsible": "^1.1.12",
|
|
72
|
+
"@radix-ui/react-dialog": "^1.1.15",
|
|
73
|
+
"@radix-ui/react-hover-card": "^1.1.8",
|
|
74
|
+
"@radix-ui/react-label": "^2.1.8",
|
|
75
|
+
"@radix-ui/react-popover": "^1.1.15",
|
|
76
|
+
"@radix-ui/react-scroll-area": "^1.2.10",
|
|
64
77
|
"@radix-ui/react-select": "^2.2.6",
|
|
65
78
|
"@types/react": "^18.0.0",
|
|
66
79
|
"@types/react-dom": "^18.0.0",
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Label Component (Atom)
|
|
3
|
+
* @description Form label (Shadcn/ui compatible)
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import * as React from 'react';
|
|
7
|
+
import * as LabelPrimitive from '@radix-ui/react-label';
|
|
8
|
+
import { cn } from '../../infrastructure/utils';
|
|
9
|
+
|
|
10
|
+
const Label = React.forwardRef<
|
|
11
|
+
React.ElementRef<typeof LabelPrimitive.Root>,
|
|
12
|
+
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
|
13
|
+
>(({ className, ...props }, ref) => (
|
|
14
|
+
<LabelPrimitive.Root
|
|
15
|
+
ref={ref}
|
|
16
|
+
className={cn(
|
|
17
|
+
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
|
18
|
+
className
|
|
19
|
+
)}
|
|
20
|
+
{...props}
|
|
21
|
+
/>
|
|
22
|
+
));
|
|
23
|
+
Label.displayName = LabelPrimitive.Root.displayName;
|
|
24
|
+
|
|
25
|
+
export { Label };
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ListItem Component (Molecule)
|
|
3
|
+
* @description Reusable list item with icon, content, and actions
|
|
4
|
+
* Reduces boilerplate in list components throughout the app
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { forwardRef, type ReactNode, type MouseEvent } from 'react';
|
|
8
|
+
import { cn } from '../../infrastructure/utils';
|
|
9
|
+
import { Button } from '../atoms';
|
|
10
|
+
import type { BaseProps } from '../../domain/types';
|
|
11
|
+
|
|
12
|
+
export interface ListItemProps extends BaseProps {
|
|
13
|
+
title: string;
|
|
14
|
+
description?: string;
|
|
15
|
+
icon?: ReactNode;
|
|
16
|
+
actions?: ReactNode;
|
|
17
|
+
leftContent?: ReactNode;
|
|
18
|
+
rightContent?: ReactNode;
|
|
19
|
+
onClick?: () => void;
|
|
20
|
+
href?: string;
|
|
21
|
+
disabled?: boolean;
|
|
22
|
+
selected?: boolean;
|
|
23
|
+
size?: 'sm' | 'md' | 'lg';
|
|
24
|
+
variant?: 'default' | 'bordered' | 'ghost';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const sizeStyles = {
|
|
28
|
+
sm: 'p-3 gap-3',
|
|
29
|
+
md: 'p-4 gap-4',
|
|
30
|
+
lg: 'p-5 gap-5',
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const titleSizeStyles = {
|
|
34
|
+
sm: 'text-sm',
|
|
35
|
+
md: 'text-base',
|
|
36
|
+
lg: 'text-lg',
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const descriptionSizeStyles = {
|
|
40
|
+
sm: 'text-xs',
|
|
41
|
+
md: 'text-sm',
|
|
42
|
+
lg: 'text-base',
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const iconSizeStyles = {
|
|
46
|
+
sm: 'h-4 w-4',
|
|
47
|
+
md: 'h-5 w-5',
|
|
48
|
+
lg: 'h-6 w-6',
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export const ListItem = forwardRef<HTMLDivElement, ListItemProps>(
|
|
52
|
+
(
|
|
53
|
+
{
|
|
54
|
+
className,
|
|
55
|
+
title,
|
|
56
|
+
description,
|
|
57
|
+
icon,
|
|
58
|
+
actions,
|
|
59
|
+
leftContent,
|
|
60
|
+
rightContent,
|
|
61
|
+
onClick,
|
|
62
|
+
href,
|
|
63
|
+
disabled = false,
|
|
64
|
+
selected = false,
|
|
65
|
+
size = 'md',
|
|
66
|
+
variant = 'default',
|
|
67
|
+
...props
|
|
68
|
+
},
|
|
69
|
+
ref
|
|
70
|
+
) => {
|
|
71
|
+
const baseClasses = cn(
|
|
72
|
+
'flex items-center justify-between w-full transition-all duration-200',
|
|
73
|
+
sizeStyles[size],
|
|
74
|
+
!disabled && onClick && 'cursor-pointer hover:bg-muted/50 active:scale-[0.98]',
|
|
75
|
+
selected && 'bg-muted/50 border-primary',
|
|
76
|
+
variant === 'bordered' && 'border border-border rounded-lg',
|
|
77
|
+
variant === 'ghost' && 'hover:bg-muted/30',
|
|
78
|
+
disabled && 'opacity-50 cursor-not-allowed',
|
|
79
|
+
className
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
const content = (
|
|
83
|
+
<>
|
|
84
|
+
{leftContent || icon ? (
|
|
85
|
+
<div className="flex items-center gap-3 flex-shrink-0">
|
|
86
|
+
{icon && <div className={cn(iconSizeStyles[size], 'text-muted-foreground')}>{icon}</div>}
|
|
87
|
+
{!icon && leftContent}
|
|
88
|
+
</div>
|
|
89
|
+
) : null}
|
|
90
|
+
|
|
91
|
+
<div className="flex-1 min-w-0">
|
|
92
|
+
<p className={cn('font-medium text-foreground truncate', titleSizeStyles[size])}>{title}</p>
|
|
93
|
+
{description && (
|
|
94
|
+
<p className={cn('text-muted-foreground truncate', descriptionSizeStyles[size])}>{description}</p>
|
|
95
|
+
)}
|
|
96
|
+
</div>
|
|
97
|
+
|
|
98
|
+
{rightContent || actions ? (
|
|
99
|
+
<div className="flex items-center gap-2 flex-shrink-0">
|
|
100
|
+
{rightContent}
|
|
101
|
+
{actions}
|
|
102
|
+
</div>
|
|
103
|
+
) : null}
|
|
104
|
+
</>
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
if (href && !disabled) {
|
|
108
|
+
return (
|
|
109
|
+
<a href={href} ref={ref as any} className={baseClasses} {...(props as any)}>
|
|
110
|
+
{content}
|
|
111
|
+
</a>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<div
|
|
117
|
+
ref={ref}
|
|
118
|
+
className={baseClasses}
|
|
119
|
+
onClick={disabled ? undefined : onClick}
|
|
120
|
+
{...props}
|
|
121
|
+
>
|
|
122
|
+
{content}
|
|
123
|
+
</div>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
ListItem.displayName = 'ListItem';
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ScrollArea Component (Molecule)
|
|
3
|
+
* @description Custom scrollable area (Shadcn/ui compatible)
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import * as React from 'react';
|
|
7
|
+
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
|
|
8
|
+
import { cn } from '../../infrastructure/utils';
|
|
9
|
+
|
|
10
|
+
const ScrollArea = React.forwardRef<
|
|
11
|
+
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
|
12
|
+
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
|
13
|
+
>(({ className, children, ...props }, ref) => (
|
|
14
|
+
<ScrollAreaPrimitive.Root
|
|
15
|
+
ref={ref}
|
|
16
|
+
className={cn('relative overflow-hidden', className)}
|
|
17
|
+
{...props}
|
|
18
|
+
>
|
|
19
|
+
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
|
20
|
+
{children}
|
|
21
|
+
</ScrollAreaPrimitive.Viewport>
|
|
22
|
+
<ScrollBar />
|
|
23
|
+
<ScrollAreaPrimitive.Corner />
|
|
24
|
+
</ScrollAreaPrimitive.Root>
|
|
25
|
+
));
|
|
26
|
+
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
|
|
27
|
+
|
|
28
|
+
const ScrollBar = React.forwardRef<
|
|
29
|
+
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
|
30
|
+
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
|
31
|
+
>(({ className, orientation = 'vertical', ...props }, ref) => (
|
|
32
|
+
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
|
33
|
+
ref={ref}
|
|
34
|
+
orientation={orientation}
|
|
35
|
+
className={cn(
|
|
36
|
+
'flex touch-none select-none transition-colors',
|
|
37
|
+
orientation === 'vertical' &&
|
|
38
|
+
'h-full w-2.5 border-l border-l-transparent p-[1px]',
|
|
39
|
+
orientation === 'horizontal' &&
|
|
40
|
+
'h-2.5 flex-col border-t border-t-transparent p-[1px]',
|
|
41
|
+
className
|
|
42
|
+
)}
|
|
43
|
+
{...props}
|
|
44
|
+
>
|
|
45
|
+
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
|
46
|
+
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
|
47
|
+
));
|
|
48
|
+
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
|
|
49
|
+
|
|
50
|
+
export { ScrollArea, ScrollBar };
|
|
@@ -33,3 +33,8 @@ export type { CheckboxGroupProps, CheckboxOption } from './CheckboxGroup';
|
|
|
33
33
|
|
|
34
34
|
export { InputGroup, GroupedInput } from './InputGroup';
|
|
35
35
|
export type { InputGroupProps } from './InputGroup';
|
|
36
|
+
|
|
37
|
+
export { ScrollArea, ScrollBar } from './ScrollArea';
|
|
38
|
+
|
|
39
|
+
export { ListItem } from './ListItem';
|
|
40
|
+
export type { ListItemProps } from './ListItem';
|
|
@@ -4,32 +4,23 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import * as React from 'react';
|
|
7
|
-
import { cva, type VariantProps } from 'class-variance-authority';
|
|
8
7
|
import { cn } from '../../infrastructure/utils';
|
|
9
8
|
|
|
10
|
-
|
|
11
|
-
'
|
|
12
|
-
|
|
13
|
-
variants: {
|
|
14
|
-
variant: {
|
|
15
|
-
default: 'bg-background text-foreground',
|
|
16
|
-
destructive: 'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive',
|
|
17
|
-
},
|
|
18
|
-
},
|
|
19
|
-
defaultVariants: {
|
|
20
|
-
variant: 'default',
|
|
21
|
-
},
|
|
22
|
-
}
|
|
23
|
-
);
|
|
24
|
-
|
|
25
|
-
export interface AlertProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof alertVariants> {}
|
|
9
|
+
export interface AlertProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
10
|
+
variant?: 'default' | 'destructive';
|
|
11
|
+
}
|
|
26
12
|
|
|
27
13
|
const Alert = React.forwardRef<HTMLDivElement, AlertProps>(
|
|
28
|
-
({ className, variant, ...props }, ref) => (
|
|
14
|
+
({ className, variant = 'default', ...props }, ref) => (
|
|
29
15
|
<div
|
|
30
16
|
ref={ref}
|
|
31
17
|
role="alert"
|
|
32
|
-
className={cn(
|
|
18
|
+
className={cn(
|
|
19
|
+
'relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground',
|
|
20
|
+
variant === 'default' && 'bg-background text-foreground',
|
|
21
|
+
variant === 'destructive' && 'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive',
|
|
22
|
+
className
|
|
23
|
+
)}
|
|
33
24
|
{...props}
|
|
34
25
|
/>
|
|
35
26
|
)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Collapsible Component (Organism)
|
|
3
|
+
* @description Collapsible content (Shadcn/ui compatible)
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible';
|
|
7
|
+
|
|
8
|
+
const Collapsible = CollapsiblePrimitive.Root;
|
|
9
|
+
|
|
10
|
+
const CollapsibleTrigger = CollapsiblePrimitive.Trigger;
|
|
11
|
+
|
|
12
|
+
const CollapsibleContent = CollapsiblePrimitive.Content;
|
|
13
|
+
|
|
14
|
+
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ConfirmDialog Component (Organism)
|
|
3
|
+
* @description Confirmation dialog for destructive actions
|
|
4
|
+
* Reduces boilerplate in delete/remove confirmation modals
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { forwardRef, type ReactNode } from 'react';
|
|
8
|
+
import { cn } from '../../infrastructure/utils';
|
|
9
|
+
import {
|
|
10
|
+
Dialog,
|
|
11
|
+
DialogContent,
|
|
12
|
+
DialogDescription,
|
|
13
|
+
DialogFooter,
|
|
14
|
+
DialogHeader,
|
|
15
|
+
DialogTitle,
|
|
16
|
+
} from './Dialog';
|
|
17
|
+
import { Button } from '../atoms';
|
|
18
|
+
import { AlertTriangle } from 'lucide-react';
|
|
19
|
+
import type { BaseProps } from '../../domain/types';
|
|
20
|
+
|
|
21
|
+
export interface ConfirmDialogProps extends BaseProps {
|
|
22
|
+
open: boolean;
|
|
23
|
+
onOpenChange: (open: boolean) => void;
|
|
24
|
+
title: string;
|
|
25
|
+
message?: string;
|
|
26
|
+
description?: string;
|
|
27
|
+
onConfirm: () => void | Promise<void>;
|
|
28
|
+
loading?: boolean;
|
|
29
|
+
confirmText?: string;
|
|
30
|
+
cancelText?: string;
|
|
31
|
+
variant?: 'default' | 'destructive';
|
|
32
|
+
icon?: ReactNode;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const ConfirmDialog = forwardRef<HTMLDivElement, ConfirmDialogProps>(
|
|
36
|
+
(
|
|
37
|
+
{
|
|
38
|
+
className,
|
|
39
|
+
open,
|
|
40
|
+
onOpenChange,
|
|
41
|
+
title,
|
|
42
|
+
message,
|
|
43
|
+
description,
|
|
44
|
+
onConfirm,
|
|
45
|
+
loading = false,
|
|
46
|
+
confirmText = 'Confirm',
|
|
47
|
+
cancelText = 'Cancel',
|
|
48
|
+
variant = 'destructive',
|
|
49
|
+
icon,
|
|
50
|
+
...props
|
|
51
|
+
},
|
|
52
|
+
ref
|
|
53
|
+
) => {
|
|
54
|
+
const handleConfirm = async () => {
|
|
55
|
+
if (!loading) {
|
|
56
|
+
await onConfirm();
|
|
57
|
+
onOpenChange(false);
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
63
|
+
<DialogContent className={cn('sm:max-w-[425px]', className)} ref={ref} {...props}>
|
|
64
|
+
<DialogHeader>
|
|
65
|
+
<div className="flex items-center gap-3">
|
|
66
|
+
{icon || (
|
|
67
|
+
variant === 'destructive' && (
|
|
68
|
+
<div className="w-10 h-10 rounded-full bg-destructive/10 flex items-center justify-center">
|
|
69
|
+
<AlertTriangle className="h-5 w-5 text-destructive" />
|
|
70
|
+
</div>
|
|
71
|
+
)
|
|
72
|
+
)}
|
|
73
|
+
<DialogTitle>{title}</DialogTitle>
|
|
74
|
+
</div>
|
|
75
|
+
{description && <DialogDescription>{description}</DialogDescription>}
|
|
76
|
+
</DialogHeader>
|
|
77
|
+
{message && (
|
|
78
|
+
<div className="py-4">
|
|
79
|
+
<p className="text-sm text-muted-foreground">{message}</p>
|
|
80
|
+
</div>
|
|
81
|
+
)}
|
|
82
|
+
<DialogFooter>
|
|
83
|
+
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={loading}>
|
|
84
|
+
{cancelText}
|
|
85
|
+
</Button>
|
|
86
|
+
<Button variant={variant} onClick={handleConfirm} disabled={loading}>
|
|
87
|
+
{loading && <span className="mr-2 h-4 w-4 animate-spin">⟳</span>}
|
|
88
|
+
{confirmText}
|
|
89
|
+
</Button>
|
|
90
|
+
</DialogFooter>
|
|
91
|
+
</DialogContent>
|
|
92
|
+
</Dialog>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
ConfirmDialog.displayName = 'ConfirmDialog';
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DataTable Component (Organism)
|
|
3
|
+
* @description Enhanced table component for displaying data with sorting and pagination
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { useState, useMemo } from 'react';
|
|
7
|
+
import { cn } from '../../infrastructure/utils';
|
|
8
|
+
import {
|
|
9
|
+
Table,
|
|
10
|
+
TableHeader,
|
|
11
|
+
TableBody,
|
|
12
|
+
TableFooter,
|
|
13
|
+
TableRow,
|
|
14
|
+
TableHead,
|
|
15
|
+
TableCell,
|
|
16
|
+
TableCaption,
|
|
17
|
+
} from './Table';
|
|
18
|
+
import { Button } from '../atoms/Button';
|
|
19
|
+
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react';
|
|
20
|
+
import type { BaseProps, SizeVariant, ColorVariant } from '../../domain/types';
|
|
21
|
+
|
|
22
|
+
export interface Column<T> {
|
|
23
|
+
id: string;
|
|
24
|
+
header: string;
|
|
25
|
+
accessor: keyof T | ((row: T) => React.ReactNode);
|
|
26
|
+
cell?: (row: T) => React.ReactNode;
|
|
27
|
+
sortable?: boolean;
|
|
28
|
+
className?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface DataTableProps<T> extends BaseProps {
|
|
32
|
+
data: T[];
|
|
33
|
+
columns: Column<T>[];
|
|
34
|
+
caption?: string;
|
|
35
|
+
size?: Extract<SizeVariant, 'sm' | 'md' | 'lg'>;
|
|
36
|
+
variant?: ColorVariant;
|
|
37
|
+
sortable?: boolean;
|
|
38
|
+
paginated?: boolean;
|
|
39
|
+
pageSize?: number;
|
|
40
|
+
emptyState?: {
|
|
41
|
+
title: string;
|
|
42
|
+
description?: string;
|
|
43
|
+
};
|
|
44
|
+
onRowClick?: (row: T) => void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function DataTable<T extends Record<string, unknown>>({
|
|
48
|
+
className,
|
|
49
|
+
data,
|
|
50
|
+
columns,
|
|
51
|
+
caption,
|
|
52
|
+
size = 'md',
|
|
53
|
+
variant = 'primary',
|
|
54
|
+
sortable = false,
|
|
55
|
+
paginated = false,
|
|
56
|
+
pageSize = 10,
|
|
57
|
+
emptyState,
|
|
58
|
+
onRowClick,
|
|
59
|
+
...props
|
|
60
|
+
}: DataTableProps<T>) {
|
|
61
|
+
const [sortColumn, setSortColumn] = useState<string | null>(null);
|
|
62
|
+
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
|
|
63
|
+
const [currentPage, setCurrentPage] = useState(1);
|
|
64
|
+
|
|
65
|
+
const handleSort = (columnId: string) => {
|
|
66
|
+
if (!sortable) return;
|
|
67
|
+
|
|
68
|
+
if (sortColumn === columnId) {
|
|
69
|
+
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
|
|
70
|
+
} else {
|
|
71
|
+
setSortColumn(columnId);
|
|
72
|
+
setSortDirection('asc');
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const sortedData = useMemo(() => {
|
|
77
|
+
if (!sortColumn || !sortable) return data;
|
|
78
|
+
|
|
79
|
+
return [...data].sort((a, b) => {
|
|
80
|
+
const column = columns.find((col) => col.id === sortColumn);
|
|
81
|
+
if (!column) return 0;
|
|
82
|
+
|
|
83
|
+
const aValue = typeof column.accessor === 'function' ? column.accessor(a) : a[column.accessor];
|
|
84
|
+
const bValue = typeof column.accessor === 'function' ? column.accessor(b) : b[column.accessor];
|
|
85
|
+
|
|
86
|
+
if (aValue === bValue) return 0;
|
|
87
|
+
|
|
88
|
+
const comparison = aValue < bValue ? -1 : 1;
|
|
89
|
+
return sortDirection === 'asc' ? comparison : -comparison;
|
|
90
|
+
});
|
|
91
|
+
}, [data, sortColumn, sortDirection, columns, sortable]);
|
|
92
|
+
|
|
93
|
+
const paginatedData = useMemo(() => {
|
|
94
|
+
if (!paginated) return sortedData;
|
|
95
|
+
|
|
96
|
+
const start = (currentPage - 1) * pageSize;
|
|
97
|
+
const end = start + pageSize;
|
|
98
|
+
return sortedData.slice(start, end);
|
|
99
|
+
}, [sortedData, currentPage, paginated, pageSize]);
|
|
100
|
+
|
|
101
|
+
const totalPages = Math.ceil(data.length / pageSize);
|
|
102
|
+
|
|
103
|
+
const renderCell = (row: T, column: Column<T>) => {
|
|
104
|
+
if (column.cell) {
|
|
105
|
+
return column.cell(row);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const value = typeof column.accessor === 'function' ? column.accessor(row) : row[column.accessor];
|
|
109
|
+
return value as React.ReactNode;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const sizeStyles = {
|
|
113
|
+
sm: 'text-xs',
|
|
114
|
+
md: 'text-sm',
|
|
115
|
+
lg: 'text-base',
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const paddingStyles = {
|
|
119
|
+
sm: 'px-2 py-2',
|
|
120
|
+
md: 'px-4 py-3',
|
|
121
|
+
lg: 'px-6 py-4',
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
if (data.length === 0 && emptyState) {
|
|
125
|
+
return (
|
|
126
|
+
<div className={cn('p-8 text-center', className)}>
|
|
127
|
+
<p className="font-semibold text-foreground">{emptyState.title}</p>
|
|
128
|
+
{emptyState.description && (
|
|
129
|
+
<p className="text-sm text-muted-foreground mt-1">{emptyState.description}</p>
|
|
130
|
+
)}
|
|
131
|
+
</div>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return (
|
|
136
|
+
<div className={cn('space-y-4', className)} {...props}>
|
|
137
|
+
<Table>
|
|
138
|
+
{caption && <TableCaption>{caption}</TableCaption>}
|
|
139
|
+
<TableHeader>
|
|
140
|
+
<TableRow>
|
|
141
|
+
{columns.map((column) => (
|
|
142
|
+
<TableHead
|
|
143
|
+
key={column.id}
|
|
144
|
+
className={cn(
|
|
145
|
+
sizeStyles[size],
|
|
146
|
+
paddingStyles[size],
|
|
147
|
+
column.sortable && sortable && 'cursor-pointer hover:bg-muted/50',
|
|
148
|
+
column.className
|
|
149
|
+
)}
|
|
150
|
+
onClick={() => column.sortable && handleSort(column.id)}
|
|
151
|
+
>
|
|
152
|
+
<div className="flex items-center gap-2">
|
|
153
|
+
{column.header}
|
|
154
|
+
{column.sortable && sortable && sortColumn === column.id && (
|
|
155
|
+
<span className="text-xs">{sortDirection === 'asc' ? '↑' : '↓'}</span>
|
|
156
|
+
)}
|
|
157
|
+
</div>
|
|
158
|
+
</TableHead>
|
|
159
|
+
))}
|
|
160
|
+
</TableRow>
|
|
161
|
+
</TableHeader>
|
|
162
|
+
<TableBody>
|
|
163
|
+
{paginatedData.map((row, rowIndex) => (
|
|
164
|
+
<TableRow
|
|
165
|
+
key={rowIndex}
|
|
166
|
+
className={cn(onRowClick && 'cursor-pointer hover:bg-muted/50')}
|
|
167
|
+
onClick={() => onRowClick?.(row)}
|
|
168
|
+
>
|
|
169
|
+
{columns.map((column) => (
|
|
170
|
+
<TableCell
|
|
171
|
+
key={column.id}
|
|
172
|
+
className={cn(paddingStyles[size], column.className)}
|
|
173
|
+
>
|
|
174
|
+
{renderCell(row, column)}
|
|
175
|
+
</TableCell>
|
|
176
|
+
))}
|
|
177
|
+
</TableRow>
|
|
178
|
+
))}
|
|
179
|
+
</TableBody>
|
|
180
|
+
{paginated && (
|
|
181
|
+
<TableFooter>
|
|
182
|
+
<TableRow>
|
|
183
|
+
<TableCell colSpan={columns.length}>
|
|
184
|
+
<div className="flex items-center justify-between">
|
|
185
|
+
<p className={cn('text-muted-foreground', sizeStyles[size])}>
|
|
186
|
+
Showing {((currentPage - 1) * pageSize) + 1} to {Math.min(currentPage * pageSize, data.length)} of {data.length} results
|
|
187
|
+
</p>
|
|
188
|
+
<div className="flex items-center gap-2">
|
|
189
|
+
<Button
|
|
190
|
+
size="sm"
|
|
191
|
+
variant="outline"
|
|
192
|
+
onClick={() => setCurrentPage(1)}
|
|
193
|
+
disabled={currentPage === 1}
|
|
194
|
+
>
|
|
195
|
+
<ChevronsLeft className="h-4 w-4" />
|
|
196
|
+
</Button>
|
|
197
|
+
<Button
|
|
198
|
+
size="sm"
|
|
199
|
+
variant="outline"
|
|
200
|
+
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
|
201
|
+
disabled={currentPage === 1}
|
|
202
|
+
>
|
|
203
|
+
<ChevronLeft className="h-4 w-4" />
|
|
204
|
+
</Button>
|
|
205
|
+
<span className={cn('text-sm font-medium', sizeStyles[size])}>
|
|
206
|
+
Page {currentPage} of {totalPages}
|
|
207
|
+
</span>
|
|
208
|
+
<Button
|
|
209
|
+
size="sm"
|
|
210
|
+
variant="outline"
|
|
211
|
+
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
|
212
|
+
disabled={currentPage === totalPages}
|
|
213
|
+
>
|
|
214
|
+
<ChevronRight className="h-4 w-4" />
|
|
215
|
+
</Button>
|
|
216
|
+
<Button
|
|
217
|
+
size="sm"
|
|
218
|
+
variant="outline"
|
|
219
|
+
onClick={() => setCurrentPage(totalPages)}
|
|
220
|
+
disabled={currentPage === totalPages}
|
|
221
|
+
>
|
|
222
|
+
<ChevronsRight className="h-4 w-4" />
|
|
223
|
+
</Button>
|
|
224
|
+
</div>
|
|
225
|
+
</div>
|
|
226
|
+
</TableCell>
|
|
227
|
+
</TableRow>
|
|
228
|
+
</TableFooter>
|
|
229
|
+
)}
|
|
230
|
+
</Table>
|
|
231
|
+
</div>
|
|
232
|
+
);
|
|
233
|
+
}
|