@umituz/web-design-system 1.3.1 → 1.7.1
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 +20 -2
- package/src/presentation/atoms/Label.tsx +25 -0
- package/src/presentation/atoms/index.ts +2 -0
- package/src/presentation/molecules/Avatar.tsx +47 -19
- package/src/presentation/molecules/ListItem.tsx +128 -0
- package/src/presentation/molecules/ScrollArea.tsx +50 -0
- package/src/presentation/molecules/Select.tsx +152 -33
- package/src/presentation/molecules/index.ts +8 -3
- package/src/presentation/organisms/Accordion.tsx +53 -111
- package/src/presentation/organisms/Alert.tsx +43 -85
- 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 +57 -4
|
@@ -1,117 +1,59 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Accordion Component (Organism)
|
|
3
|
-
* @description Collapsible content sections
|
|
3
|
+
* @description Collapsible content sections (Shadcn/ui compatible)
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import
|
|
6
|
+
import * as React from 'react';
|
|
7
|
+
import * as AccordionPrimitive from '@radix-ui/react-accordion';
|
|
8
|
+
import { ChevronDown } from 'lucide-react';
|
|
7
9
|
import { cn } from '../../infrastructure/utils';
|
|
8
|
-
import type { BaseProps } from '../../domain/types';
|
|
9
|
-
import { Icon } from '../atoms/Icon';
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
return (
|
|
61
|
-
<div
|
|
62
|
-
key={item.value}
|
|
63
|
-
className={cn(
|
|
64
|
-
'group',
|
|
65
|
-
variantStyles[variant],
|
|
66
|
-
variant === 'bordered' && isOpen && 'ring-1 ring-ring'
|
|
67
|
-
)}
|
|
68
|
-
>
|
|
69
|
-
{/* Header */}
|
|
70
|
-
<button
|
|
71
|
-
onClick={() => toggleItem(item.value)}
|
|
72
|
-
disabled={item.disabled}
|
|
73
|
-
className={cn(
|
|
74
|
-
'flex w-full items-center justify-between py-4 font-medium transition-all',
|
|
75
|
-
'hover:text-foreground',
|
|
76
|
-
item.disabled && 'opacity-50 cursor-not-allowed',
|
|
77
|
-
variant === 'bordered' && 'px-4'
|
|
78
|
-
)}
|
|
79
|
-
>
|
|
80
|
-
<span>{item.title}</span>
|
|
81
|
-
<Icon
|
|
82
|
-
className={cn(
|
|
83
|
-
'transition-transform duration-200',
|
|
84
|
-
isOpen && 'rotate-180'
|
|
85
|
-
)}
|
|
86
|
-
size="sm"
|
|
87
|
-
>
|
|
88
|
-
<path
|
|
89
|
-
strokeLinecap="round"
|
|
90
|
-
strokeLinejoin="round"
|
|
91
|
-
d="M19.5 8.25l-7.5 7.5-7.5-7.5"
|
|
92
|
-
/>
|
|
93
|
-
</Icon>
|
|
94
|
-
</button>
|
|
95
|
-
|
|
96
|
-
{/* Content */}
|
|
97
|
-
{isOpen && (
|
|
98
|
-
<div
|
|
99
|
-
className={cn(
|
|
100
|
-
'overflow-hidden',
|
|
101
|
-
'animate-accordion-down',
|
|
102
|
-
variant === 'bordered' && 'px-4 pb-4'
|
|
103
|
-
)}
|
|
104
|
-
>
|
|
105
|
-
<div className="pb-4 text-sm text-muted-foreground">
|
|
106
|
-
{item.content}
|
|
107
|
-
</div>
|
|
108
|
-
</div>
|
|
109
|
-
)}
|
|
110
|
-
</div>
|
|
111
|
-
);
|
|
112
|
-
})}
|
|
113
|
-
</div>
|
|
114
|
-
);
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
Accordion.displayName = 'Accordion';
|
|
11
|
+
const Accordion = AccordionPrimitive.Root;
|
|
12
|
+
|
|
13
|
+
const AccordionItem = React.forwardRef<
|
|
14
|
+
React.ElementRef<typeof AccordionPrimitive.Item>,
|
|
15
|
+
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
|
|
16
|
+
>(({ className, ...props }, ref) => (
|
|
17
|
+
<AccordionPrimitive.Item
|
|
18
|
+
ref={ref}
|
|
19
|
+
className={cn('border-b', className)}
|
|
20
|
+
{...props}
|
|
21
|
+
/>
|
|
22
|
+
));
|
|
23
|
+
AccordionItem.displayName = 'AccordionItem';
|
|
24
|
+
|
|
25
|
+
const AccordionTrigger = React.forwardRef<
|
|
26
|
+
React.ElementRef<typeof AccordionPrimitive.Trigger>,
|
|
27
|
+
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
|
|
28
|
+
>(({ className, children, ...props }, ref) => (
|
|
29
|
+
<AccordionPrimitive.Header className="flex">
|
|
30
|
+
<AccordionPrimitive.Trigger
|
|
31
|
+
ref={ref}
|
|
32
|
+
className={cn(
|
|
33
|
+
'flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180',
|
|
34
|
+
className
|
|
35
|
+
)}
|
|
36
|
+
{...props}
|
|
37
|
+
>
|
|
38
|
+
{children}
|
|
39
|
+
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
|
|
40
|
+
</AccordionPrimitive.Trigger>
|
|
41
|
+
</AccordionPrimitive.Header>
|
|
42
|
+
));
|
|
43
|
+
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
|
|
44
|
+
|
|
45
|
+
const AccordionContent = React.forwardRef<
|
|
46
|
+
React.ElementRef<typeof AccordionPrimitive.Content>,
|
|
47
|
+
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
|
|
48
|
+
>(({ className, children, ...props }, ref) => (
|
|
49
|
+
<AccordionPrimitive.Content
|
|
50
|
+
ref={ref}
|
|
51
|
+
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
|
|
52
|
+
{...props}
|
|
53
|
+
>
|
|
54
|
+
<div className={cn('pb-4 pt-0', className)}>{children}</div>
|
|
55
|
+
</AccordionPrimitive.Content>
|
|
56
|
+
));
|
|
57
|
+
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
|
|
58
|
+
|
|
59
|
+
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
|
|
@@ -1,96 +1,54 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Alert Component (Organism)
|
|
3
|
-
* @description Feedback message
|
|
3
|
+
* @description Feedback message (Shadcn/ui compatible)
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import
|
|
6
|
+
import * as React from 'react';
|
|
7
7
|
import { cn } from '../../infrastructure/utils';
|
|
8
|
-
import type { BaseProps, ChildrenProps, ColorVariant } from '../../domain/types';
|
|
9
|
-
import { Icon } from '../atoms/Icon';
|
|
10
8
|
|
|
11
|
-
export
|
|
12
|
-
|
|
13
|
-
export interface AlertProps extends HTMLAttributes<HTMLDivElement>, BaseProps {
|
|
14
|
-
variant?: AlertVariant;
|
|
15
|
-
showIcon?: boolean;
|
|
9
|
+
export interface AlertProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
10
|
+
variant?: 'default' | 'destructive';
|
|
16
11
|
}
|
|
17
12
|
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
},
|
|
31
|
-
info: {
|
|
32
|
-
container: 'border-primary bg-primary/10 text-primary',
|
|
33
|
-
iconColor: 'text-primary',
|
|
34
|
-
},
|
|
35
|
-
};
|
|
36
|
-
|
|
37
|
-
const icons: Record<AlertVariant, React.ReactNode> = {
|
|
38
|
-
success: (
|
|
39
|
-
<path
|
|
40
|
-
strokeLinecap="round"
|
|
41
|
-
strokeLinejoin="round"
|
|
42
|
-
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
43
|
-
/>
|
|
44
|
-
),
|
|
45
|
-
warning: (
|
|
46
|
-
<path
|
|
47
|
-
strokeLinecap="round"
|
|
48
|
-
strokeLinejoin="round"
|
|
49
|
-
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
|
|
50
|
-
/>
|
|
51
|
-
),
|
|
52
|
-
destructive: (
|
|
53
|
-
<path
|
|
54
|
-
strokeLinecap="round"
|
|
55
|
-
strokeLinejoin="round"
|
|
56
|
-
d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"
|
|
57
|
-
/>
|
|
58
|
-
),
|
|
59
|
-
info: (
|
|
60
|
-
<path
|
|
61
|
-
strokeLinecap="round"
|
|
62
|
-
strokeLinejoin="round"
|
|
63
|
-
d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z"
|
|
13
|
+
const Alert = React.forwardRef<HTMLDivElement, AlertProps>(
|
|
14
|
+
({ className, variant = 'default', ...props }, ref) => (
|
|
15
|
+
<div
|
|
16
|
+
ref={ref}
|
|
17
|
+
role="alert"
|
|
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
|
+
)}
|
|
24
|
+
{...props}
|
|
64
25
|
/>
|
|
65
|
-
)
|
|
66
|
-
};
|
|
67
|
-
|
|
68
|
-
export const Alert = forwardRef<HTMLDivElement, AlertProps>(
|
|
69
|
-
({ className, variant = 'info', showIcon = true, children, ...props }, ref) => {
|
|
70
|
-
const styles = variantStyles[variant];
|
|
71
|
-
|
|
72
|
-
return (
|
|
73
|
-
<div
|
|
74
|
-
ref={ref}
|
|
75
|
-
role="alert"
|
|
76
|
-
className={cn(
|
|
77
|
-
'relative w-full rounded-lg border p-4',
|
|
78
|
-
styles.container,
|
|
79
|
-
className
|
|
80
|
-
)}
|
|
81
|
-
{...props}
|
|
82
|
-
>
|
|
83
|
-
<div className="flex items-start gap-3">
|
|
84
|
-
{showIcon && (
|
|
85
|
-
<Icon className={cn('shrink-0 mt-0.5', styles.iconColor)} size="sm">
|
|
86
|
-
{icons[variant]}
|
|
87
|
-
</Icon>
|
|
88
|
-
)}
|
|
89
|
-
<div className="flex-1">{children}</div>
|
|
90
|
-
</div>
|
|
91
|
-
</div>
|
|
92
|
-
);
|
|
93
|
-
}
|
|
26
|
+
)
|
|
94
27
|
);
|
|
95
|
-
|
|
96
28
|
Alert.displayName = 'Alert';
|
|
29
|
+
|
|
30
|
+
const AlertTitle = React.forwardRef<
|
|
31
|
+
HTMLParagraphElement,
|
|
32
|
+
React.HTMLAttributes<HTMLHeadingElement>
|
|
33
|
+
>(({ className, ...props }, ref) => (
|
|
34
|
+
<h5
|
|
35
|
+
ref={ref}
|
|
36
|
+
className={cn('mb-1 font-medium leading-none tracking-tight', className)}
|
|
37
|
+
{...props}
|
|
38
|
+
/>
|
|
39
|
+
));
|
|
40
|
+
AlertTitle.displayName = 'AlertTitle';
|
|
41
|
+
|
|
42
|
+
const AlertDescription = React.forwardRef<
|
|
43
|
+
HTMLParagraphElement,
|
|
44
|
+
React.HTMLAttributes<HTMLParagraphElement>
|
|
45
|
+
>(({ className, ...props }, ref) => (
|
|
46
|
+
<div
|
|
47
|
+
ref={ref}
|
|
48
|
+
className={cn('text-sm [&_p]:leading-relaxed', className)}
|
|
49
|
+
{...props}
|
|
50
|
+
/>
|
|
51
|
+
));
|
|
52
|
+
AlertDescription.displayName = 'AlertDescription';
|
|
53
|
+
|
|
54
|
+
export { Alert, AlertTitle, AlertDescription };
|
|
@@ -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
|
+
}
|