@mci-ui/mci-ui 0.0.5 → 0.0.7
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/README.md +3 -71
- package/dist/index.cjs +5 -5
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +7 -7
- package/dist/index.js.map +1 -1
- package/package.json +11 -2
- package/src/App.tsx +5 -0
- package/src/index.ts +13 -0
- package/src/main.tsx +10 -0
- package/src/shared/hooks/toast.ts +32 -0
- package/src/shared/lib/utils.ts +37 -0
- package/src/shared/types/MciTableType.ts +10 -0
- package/src/shared/ui/breadcrumb/Breadcrumb.tsx +68 -0
- package/src/shared/ui/button/Button.tsx +105 -0
- package/src/shared/ui/collapse/Collapse.tsx +98 -0
- package/src/shared/ui/inputMain/InputMain.tsx +241 -0
- package/src/shared/ui/mciTable/MciTable.tsx +166 -0
- package/src/shared/ui/modal/Modal.tsx +92 -0
- package/src/shared/ui/pagination/Pagination.tsx +141 -0
- package/src/shared/ui/skeleton/Skeleton.tsx +33 -0
- package/src/shared/ui/tabs/Tabs.tsx +192 -0
- package/src/shared/ui/tag/Tag.tsx +92 -0
- package/src/shared/ui/textarea/Textarea.tsx +72 -0
- package/src/shared/ui/toast/Toast.tsx +150 -0
- package/src/shared/ui/toast/ToastContainer.tsx +19 -0
- package/src/shared/ui/tooltip/Tooltip.tsx +58 -0
- package/src/styles/index.css +328 -0
- package/dist/index-FDS4T4PN.css +0 -36
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { Loader } from 'lucide-react'
|
|
2
|
+
import React from 'react'
|
|
3
|
+
import { cn } from '../../lib/utils.ts'
|
|
4
|
+
|
|
5
|
+
type ButtonProps = {
|
|
6
|
+
text?: string
|
|
7
|
+
icon?: React.ReactNode
|
|
8
|
+
iconPosition?: 'left' | 'right'
|
|
9
|
+
size?: 'sm' | 'md' | 'lg'
|
|
10
|
+
variant?: 'primary' | 'secondary' | 'accent'
|
|
11
|
+
loading?: boolean
|
|
12
|
+
disabled?: boolean
|
|
13
|
+
onClick?: () => void
|
|
14
|
+
className?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export default function Button({
|
|
18
|
+
text,
|
|
19
|
+
icon,
|
|
20
|
+
iconPosition = 'left',
|
|
21
|
+
size = 'md',
|
|
22
|
+
variant = 'accent',
|
|
23
|
+
loading = false,
|
|
24
|
+
disabled = false,
|
|
25
|
+
onClick,
|
|
26
|
+
className,
|
|
27
|
+
}: ButtonProps) {
|
|
28
|
+
const sizes = {
|
|
29
|
+
sm: 'h-[38px] px-3 text-sm gap-2',
|
|
30
|
+
md: 'h-[48px] px-4 text-base gap-2.5',
|
|
31
|
+
lg: 'h-[56px] px-5 text-lg gap-3',
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const variants = {
|
|
35
|
+
primary: `
|
|
36
|
+
bg-secondary-500
|
|
37
|
+
hover:bg-secondary-600
|
|
38
|
+
active:bg-secondary-700
|
|
39
|
+
hover:shadow-lg
|
|
40
|
+
hover:shadow-secondary-500/30
|
|
41
|
+
active:scale-[0.98]
|
|
42
|
+
text-white
|
|
43
|
+
`,
|
|
44
|
+
secondary: `
|
|
45
|
+
bg-secondary-50
|
|
46
|
+
hover:bg-secondary-100
|
|
47
|
+
active:bg-secondary-200
|
|
48
|
+
hover:shadow-md
|
|
49
|
+
hover:shadow-secondary-500/10
|
|
50
|
+
active:scale-[0.98]
|
|
51
|
+
text-secondary-700
|
|
52
|
+
border border-secondary-200
|
|
53
|
+
hover:border-secondary-300
|
|
54
|
+
`,
|
|
55
|
+
accent: `
|
|
56
|
+
bg-accent-500
|
|
57
|
+
hover:bg-accent-600
|
|
58
|
+
active:bg-accent-700
|
|
59
|
+
hover:shadow-lg
|
|
60
|
+
hover:shadow-accent-500/30
|
|
61
|
+
active:scale-[0.98]
|
|
62
|
+
text-white
|
|
63
|
+
`,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<button
|
|
68
|
+
onClick={onClick}
|
|
69
|
+
disabled={disabled || loading}
|
|
70
|
+
className={cn(
|
|
71
|
+
'inline-flex items-center justify-center rounded-[7px] font-medium',
|
|
72
|
+
'transition-all duration-300 ease-in-out',
|
|
73
|
+
'transform hover:-translate-y-0.5',
|
|
74
|
+
'disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:translate-y-0 disabled:hover:shadow-none',
|
|
75
|
+
'focus:outline-none focus:ring-2 focus:ring-offset-2',
|
|
76
|
+
variant === 'primary' && 'focus:ring-secondary-500',
|
|
77
|
+
variant === 'secondary' && 'focus:ring-secondary-400',
|
|
78
|
+
variant === 'accent' && 'focus:ring-accent-500',
|
|
79
|
+
variants[variant],
|
|
80
|
+
sizes[size],
|
|
81
|
+
className
|
|
82
|
+
)}
|
|
83
|
+
>
|
|
84
|
+
{/* ICON LEFT */}
|
|
85
|
+
{icon && iconPosition === 'left' && (
|
|
86
|
+
<span className='flex items-center justify-center transition-transform duration-300 ease-in-out group-hover:scale-110'>
|
|
87
|
+
{loading ? <Loader className='animate-spin' size={20} /> : icon}
|
|
88
|
+
</span>
|
|
89
|
+
)}
|
|
90
|
+
|
|
91
|
+
{/* TEXT */}
|
|
92
|
+
{text && <span className='transition-all duration-300'>{text}</span>}
|
|
93
|
+
|
|
94
|
+
{/* ICON RIGHT */}
|
|
95
|
+
{icon && iconPosition === 'right' && (
|
|
96
|
+
<span className='flex items-center justify-center transition-transform duration-300 ease-in-out group-hover:scale-110'>
|
|
97
|
+
{loading ? <Loader className='animate-spin' size={20} /> : icon}
|
|
98
|
+
</span>
|
|
99
|
+
)}
|
|
100
|
+
|
|
101
|
+
{/* AGAR TEXT VA ICON YO'Q BO'LSA */}
|
|
102
|
+
{loading && <Loader className='animate-spin' size={20} />}
|
|
103
|
+
</button>
|
|
104
|
+
)
|
|
105
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { ChevronDownIcon } from 'lucide-react'
|
|
2
|
+
import React, { useEffect, useRef, useState } from 'react'
|
|
3
|
+
import { cn } from '../../lib/utils.ts'
|
|
4
|
+
|
|
5
|
+
type CollapseProps = {
|
|
6
|
+
title: string
|
|
7
|
+
children: React.ReactNode
|
|
8
|
+
defaultOpen?: boolean
|
|
9
|
+
icon?: React.ReactNode
|
|
10
|
+
variant?: 'primary' | 'secondary' | 'accent'
|
|
11
|
+
className?: string
|
|
12
|
+
titleClassName?: string
|
|
13
|
+
contentClassName?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export default function Collapse({
|
|
17
|
+
title,
|
|
18
|
+
children,
|
|
19
|
+
defaultOpen = false,
|
|
20
|
+
icon,
|
|
21
|
+
variant = 'primary',
|
|
22
|
+
className,
|
|
23
|
+
titleClassName,
|
|
24
|
+
contentClassName,
|
|
25
|
+
}: CollapseProps) {
|
|
26
|
+
const [isOpen, setIsOpen] = useState(defaultOpen)
|
|
27
|
+
const [contentHeight, setContentHeight] = useState(0)
|
|
28
|
+
const contentRef = useRef<HTMLDivElement>(null)
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
if (contentRef.current) {
|
|
32
|
+
setContentHeight(isOpen ? contentRef.current.scrollHeight : 0)
|
|
33
|
+
}
|
|
34
|
+
}, [isOpen])
|
|
35
|
+
|
|
36
|
+
const variantClasses = {
|
|
37
|
+
primary: {
|
|
38
|
+
title:
|
|
39
|
+
'bg-secondary-50 hover:bg-secondary-100 border-secondary-200 text-secondary-800',
|
|
40
|
+
content: 'bg-secondary-25 border-secondary-100',
|
|
41
|
+
},
|
|
42
|
+
secondary: {
|
|
43
|
+
title:
|
|
44
|
+
'bg-accent-50 hover:bg-accent-100 border-accent-200 text-accent-800',
|
|
45
|
+
content: 'bg-accent-25 border-accent-100',
|
|
46
|
+
},
|
|
47
|
+
accent: {
|
|
48
|
+
title: 'bg-gray-50 hover:bg-gray-100 border-gray-200 text-gray-800',
|
|
49
|
+
content: 'bg-gray-25 border-gray-100',
|
|
50
|
+
},
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const variantConfig = variantClasses[variant]
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<div className={cn('border rounded-lg overflow-hidden', className)}>
|
|
57
|
+
{/* Header */}
|
|
58
|
+
<button
|
|
59
|
+
onClick={() => setIsOpen(!isOpen)}
|
|
60
|
+
className={cn(
|
|
61
|
+
'w-full flex items-center justify-between p-4 transition-all duration-300',
|
|
62
|
+
'focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-1 focus-visible:ring-blue-500',
|
|
63
|
+
'border-b',
|
|
64
|
+
variantConfig.title
|
|
65
|
+
)}
|
|
66
|
+
>
|
|
67
|
+
<div className='flex items-center gap-3'>
|
|
68
|
+
{icon && <span className='flex-shrink-0'>{icon}</span>}
|
|
69
|
+
<span className={cn('font-medium text-left', titleClassName)}>
|
|
70
|
+
{title}
|
|
71
|
+
</span>
|
|
72
|
+
</div>
|
|
73
|
+
|
|
74
|
+
<ChevronDownIcon
|
|
75
|
+
className={cn(
|
|
76
|
+
'w-5 h-5 transition-transform duration-300 flex-shrink-0',
|
|
77
|
+
isOpen && 'rotate-180'
|
|
78
|
+
)}
|
|
79
|
+
/>
|
|
80
|
+
</button>
|
|
81
|
+
|
|
82
|
+
{/* Content */}
|
|
83
|
+
<div
|
|
84
|
+
className={cn(
|
|
85
|
+
'transition-all duration-300 ease-out overflow-hidden',
|
|
86
|
+
variantConfig.content
|
|
87
|
+
)}
|
|
88
|
+
style={{
|
|
89
|
+
height: `${contentHeight}px`,
|
|
90
|
+
}}
|
|
91
|
+
>
|
|
92
|
+
<div ref={contentRef} className={cn('p-4', contentClassName)}>
|
|
93
|
+
{children}
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
</div>
|
|
97
|
+
)
|
|
98
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { Eye, EyeOff } from 'lucide-react'
|
|
2
|
+
import React, { useMemo, useState } from 'react'
|
|
3
|
+
import { cn } from '../../lib/utils.ts'
|
|
4
|
+
|
|
5
|
+
type InputProps = {
|
|
6
|
+
label?: string
|
|
7
|
+
placeholder?: string
|
|
8
|
+
type?: 'text' | 'email' | 'password' | 'number' | 'tel'
|
|
9
|
+
icon?: React.ReactNode
|
|
10
|
+
iconPosition?: 'left' | 'right'
|
|
11
|
+
size?: 'sm' | 'md' | 'lg'
|
|
12
|
+
error?: string
|
|
13
|
+
success?: string
|
|
14
|
+
disabled?: boolean
|
|
15
|
+
required?: boolean
|
|
16
|
+
value?: string
|
|
17
|
+
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void
|
|
18
|
+
onFocus?: () => void
|
|
19
|
+
onBlur?: () => void
|
|
20
|
+
className?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export default function InputMain({
|
|
24
|
+
label,
|
|
25
|
+
placeholder,
|
|
26
|
+
type = 'text',
|
|
27
|
+
icon,
|
|
28
|
+
iconPosition = 'left',
|
|
29
|
+
size = 'md',
|
|
30
|
+
error,
|
|
31
|
+
success,
|
|
32
|
+
disabled = false,
|
|
33
|
+
required = false,
|
|
34
|
+
value,
|
|
35
|
+
onChange,
|
|
36
|
+
onFocus,
|
|
37
|
+
onBlur,
|
|
38
|
+
className,
|
|
39
|
+
}: InputProps) {
|
|
40
|
+
const [isFocused, setIsFocused] = useState(false)
|
|
41
|
+
const [internalValue, setInternalValue] = useState('')
|
|
42
|
+
const [showPassword, setShowPassword] = useState(false)
|
|
43
|
+
const [autoFilled, setAutoFilled] = useState(false)
|
|
44
|
+
|
|
45
|
+
const currentValue = value ?? internalValue
|
|
46
|
+
const hasValue = Boolean(currentValue) || autoFilled
|
|
47
|
+
const isPassword = type === 'password'
|
|
48
|
+
|
|
49
|
+
const handleFocus = () => {
|
|
50
|
+
setIsFocused(true)
|
|
51
|
+
onFocus?.()
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const handleBlur = () => {
|
|
55
|
+
setIsFocused(false)
|
|
56
|
+
onBlur?.()
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
60
|
+
if (value === undefined) setInternalValue(e.target.value)
|
|
61
|
+
onChange?.(e)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const togglePassword = () => setShowPassword(p => !p)
|
|
65
|
+
|
|
66
|
+
const sizes = useMemo(
|
|
67
|
+
() => ({
|
|
68
|
+
sm: {
|
|
69
|
+
input: 'h-[38px] text-sm',
|
|
70
|
+
padding: icon
|
|
71
|
+
? iconPosition === 'left'
|
|
72
|
+
? 'pl-10 pr-3'
|
|
73
|
+
: 'pl-3 pr-10'
|
|
74
|
+
: 'px-3',
|
|
75
|
+
icon: 'w-4 h-4',
|
|
76
|
+
iconWrapper: iconPosition === 'left' ? 'left-3' : 'right-3',
|
|
77
|
+
labelFont: 'text-xs',
|
|
78
|
+
},
|
|
79
|
+
md: {
|
|
80
|
+
input: 'h-[48px] text-base',
|
|
81
|
+
padding: icon
|
|
82
|
+
? iconPosition === 'left'
|
|
83
|
+
? 'pl-12 pr-4'
|
|
84
|
+
: 'pl-4 pr-12'
|
|
85
|
+
: 'px-4',
|
|
86
|
+
icon: 'w-5 h-5',
|
|
87
|
+
iconWrapper: iconPosition === 'left' ? 'left-3.5' : 'right-3.5',
|
|
88
|
+
labelFont: 'text-sm',
|
|
89
|
+
},
|
|
90
|
+
lg: {
|
|
91
|
+
input: 'h-[56px] text-lg',
|
|
92
|
+
padding: icon
|
|
93
|
+
? iconPosition === 'left'
|
|
94
|
+
? 'pl-14 pr-5'
|
|
95
|
+
: 'pl-5 pr-14'
|
|
96
|
+
: 'px-5',
|
|
97
|
+
icon: 'w-6 h-6',
|
|
98
|
+
iconWrapper: iconPosition === 'left' ? 'left-4' : 'right-4',
|
|
99
|
+
labelFont: 'text-base',
|
|
100
|
+
},
|
|
101
|
+
}),
|
|
102
|
+
[icon, iconPosition]
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
const stateStyles = useMemo(() => {
|
|
106
|
+
if (error)
|
|
107
|
+
return 'border-error-500 focus:border-error-600 focus:ring-error-500/20'
|
|
108
|
+
if (success)
|
|
109
|
+
return 'border-success-500 focus:border-success-600 focus:ring-success-500/20'
|
|
110
|
+
return 'border-gray-300 hover:border-gray-400 focus:border-accent-500 focus:ring-accent-500/20'
|
|
111
|
+
}, [error, success])
|
|
112
|
+
|
|
113
|
+
// Autofill holatini aniqlash
|
|
114
|
+
const handleAutoFill = (e: React.AnimationEvent<HTMLInputElement>) => {
|
|
115
|
+
if (e.animationName === 'onAutoFillStart') {
|
|
116
|
+
setAutoFilled(true)
|
|
117
|
+
} else if (e.animationName === 'onAutoFillCancel') {
|
|
118
|
+
setAutoFilled(false)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<div className={cn('w-full', className)}>
|
|
124
|
+
<div className='relative flex items-center'>
|
|
125
|
+
<input
|
|
126
|
+
type={isPassword && showPassword ? 'text' : type}
|
|
127
|
+
value={currentValue}
|
|
128
|
+
onChange={handleChange}
|
|
129
|
+
onFocus={handleFocus}
|
|
130
|
+
onBlur={handleBlur}
|
|
131
|
+
onAnimationStart={handleAutoFill}
|
|
132
|
+
disabled={disabled}
|
|
133
|
+
placeholder={label ? '' : placeholder}
|
|
134
|
+
required={required}
|
|
135
|
+
className={cn(
|
|
136
|
+
'w-full rounded-[7px] border bg-white font-medium outline-none transition-all duration-300 ease-in-out',
|
|
137
|
+
'focus:ring-4 disabled:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-60 autofill:shadow-[inset_0_0_0px_1000px_white]',
|
|
138
|
+
sizes[size].input,
|
|
139
|
+
sizes[size].padding,
|
|
140
|
+
stateStyles
|
|
141
|
+
)}
|
|
142
|
+
autoComplete='on'
|
|
143
|
+
/>
|
|
144
|
+
|
|
145
|
+
{/* FLOATING LABEL */}
|
|
146
|
+
{label && (
|
|
147
|
+
<label
|
|
148
|
+
className={cn(
|
|
149
|
+
'absolute pointer-events-none transition-all duration-300 ease-in-out font-medium',
|
|
150
|
+
icon && iconPosition === 'left' && !hasValue && !isFocused
|
|
151
|
+
? sizes[size].padding
|
|
152
|
+
: 'left-4',
|
|
153
|
+
isFocused || hasValue
|
|
154
|
+
? cn(
|
|
155
|
+
'top-0 -translate-y-1/2 bg-white px-2',
|
|
156
|
+
sizes[size].labelFont,
|
|
157
|
+
error
|
|
158
|
+
? 'text-error-600'
|
|
159
|
+
: success
|
|
160
|
+
? 'text-success-600'
|
|
161
|
+
: 'text-accent-600'
|
|
162
|
+
)
|
|
163
|
+
: cn(
|
|
164
|
+
'top-1/2 -translate-y-1/2 text-gray-500',
|
|
165
|
+
sizes[size].input.includes('38') && 'text-sm',
|
|
166
|
+
sizes[size].input.includes('48') && 'text-base',
|
|
167
|
+
sizes[size].input.includes('56') && 'text-lg'
|
|
168
|
+
)
|
|
169
|
+
)}
|
|
170
|
+
>
|
|
171
|
+
{label}
|
|
172
|
+
{required && <span className='text-error-500 ml-1'>*</span>}
|
|
173
|
+
</label>
|
|
174
|
+
)}
|
|
175
|
+
|
|
176
|
+
{/* ICON */}
|
|
177
|
+
{icon && (
|
|
178
|
+
<div
|
|
179
|
+
className={cn(
|
|
180
|
+
'absolute top-1/2 -translate-y-1/2 flex items-center transition-all duration-300',
|
|
181
|
+
sizes[size].iconWrapper,
|
|
182
|
+
error
|
|
183
|
+
? 'text-error-500'
|
|
184
|
+
: success
|
|
185
|
+
? 'text-success-500'
|
|
186
|
+
: isFocused
|
|
187
|
+
? 'text-accent-500'
|
|
188
|
+
: 'text-gray-400'
|
|
189
|
+
)}
|
|
190
|
+
>
|
|
191
|
+
<span className={cn(sizes[size].icon, 'mr-2')}>{icon}</span>
|
|
192
|
+
</div>
|
|
193
|
+
)}
|
|
194
|
+
|
|
195
|
+
{/* PASSWORD TOGGLE */}
|
|
196
|
+
{isPassword && (
|
|
197
|
+
<button
|
|
198
|
+
type='button'
|
|
199
|
+
onClick={togglePassword}
|
|
200
|
+
className={cn(
|
|
201
|
+
'absolute top-1/2 -translate-y-1/2 flex items-center justify-center text-gray-400 hover:text-gray-600 transition-all',
|
|
202
|
+
'right-3'
|
|
203
|
+
)}
|
|
204
|
+
>
|
|
205
|
+
{showPassword ? (
|
|
206
|
+
<EyeOff className={cn(sizes[size].icon)} />
|
|
207
|
+
) : (
|
|
208
|
+
<Eye className={cn(sizes[size].icon)} />
|
|
209
|
+
)}
|
|
210
|
+
</button>
|
|
211
|
+
)}
|
|
212
|
+
</div>
|
|
213
|
+
|
|
214
|
+
{/* ERROR MESSAGE */}
|
|
215
|
+
{error && (
|
|
216
|
+
<p className='mt-1.5 text-sm text-error-600 flex items-center gap-1 animate-[slideDown_0.3s_ease-out]'>
|
|
217
|
+
{error}
|
|
218
|
+
</p>
|
|
219
|
+
)}
|
|
220
|
+
|
|
221
|
+
{/* SUCCESS MESSAGE */}
|
|
222
|
+
{success && !error && (
|
|
223
|
+
<p className='mt-1.5 text-sm text-success-600 flex items-center gap-1 animate-[slideDown_0.3s_ease-out]'>
|
|
224
|
+
{success}
|
|
225
|
+
</p>
|
|
226
|
+
)}
|
|
227
|
+
|
|
228
|
+
{/* Autofill animatsiyasi */}
|
|
229
|
+
<style>{`
|
|
230
|
+
input {
|
|
231
|
+
animation-name: onAutoFillCancel;
|
|
232
|
+
}
|
|
233
|
+
input:-webkit-autofill {
|
|
234
|
+
animation-name: onAutoFillStart;
|
|
235
|
+
}
|
|
236
|
+
@keyframes onAutoFillStart {}
|
|
237
|
+
@keyframes onAutoFillCancel {}
|
|
238
|
+
`}</style>
|
|
239
|
+
</div>
|
|
240
|
+
)
|
|
241
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { ArrowUpDown, Box, ChevronDown, ChevronUp } from 'lucide-react'
|
|
2
|
+
import { useMemo, useState } from 'react'
|
|
3
|
+
import { cn } from '../../lib/utils'
|
|
4
|
+
import type { MciTableColumn } from '../../types/MciTableType'
|
|
5
|
+
import Skeleton from "../skeleton/Skeleton.tsx";
|
|
6
|
+
|
|
7
|
+
interface MciTableProps<T extends Record<string, unknown>> {
|
|
8
|
+
columns: MciTableColumn<T>[]
|
|
9
|
+
data: T[]
|
|
10
|
+
loading?: boolean
|
|
11
|
+
variant?: 'clean' | 'elevated' | 'bordered'
|
|
12
|
+
skeletonRows?: number
|
|
13
|
+
actions?: React.ReactNode
|
|
14
|
+
noDataText?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export default function MciTable<T extends Record<string, unknown>>({
|
|
18
|
+
columns,
|
|
19
|
+
data,
|
|
20
|
+
loading = false,
|
|
21
|
+
variant = 'clean',
|
|
22
|
+
skeletonRows = 5,
|
|
23
|
+
actions,
|
|
24
|
+
noDataText,
|
|
25
|
+
}: MciTableProps<T>) {
|
|
26
|
+
const [sortConfig, setSortConfig] = useState<{
|
|
27
|
+
key: keyof T
|
|
28
|
+
direction: 'asc' | 'desc' | null
|
|
29
|
+
} | null>(null)
|
|
30
|
+
|
|
31
|
+
const sortedData = useMemo(() => {
|
|
32
|
+
if (!sortConfig || !sortConfig.direction) return data
|
|
33
|
+
return [...data].sort((a, b) => {
|
|
34
|
+
const aVal = a[sortConfig.key]
|
|
35
|
+
const bVal = b[sortConfig.key]
|
|
36
|
+
if (aVal < bVal) return sortConfig.direction === 'asc' ? -1 : 1
|
|
37
|
+
if (aVal > bVal) return sortConfig.direction === 'asc' ? 1 : -1
|
|
38
|
+
return 0
|
|
39
|
+
})
|
|
40
|
+
}, [data, sortConfig])
|
|
41
|
+
|
|
42
|
+
const handleSort = (col: MciTableColumn<T>) => {
|
|
43
|
+
if (!col.sortable) return
|
|
44
|
+
setSortConfig(prev => {
|
|
45
|
+
if (!prev || prev.key !== col.key) {
|
|
46
|
+
return { key: col.key, direction: 'asc' }
|
|
47
|
+
}
|
|
48
|
+
if (prev.direction === 'asc') return { key: col.key, direction: 'desc' }
|
|
49
|
+
if (prev.direction === 'desc') return { key: col.key, direction: null }
|
|
50
|
+
return null
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const getSortIcon = (col: MciTableColumn<T>) => {
|
|
55
|
+
if (!col.sortable) return null
|
|
56
|
+
if (!sortConfig || sortConfig.key !== col.key || !sortConfig.direction) {
|
|
57
|
+
return <ArrowUpDown size={15} className='opacity-40' />
|
|
58
|
+
}
|
|
59
|
+
return sortConfig.direction === 'asc' ? (
|
|
60
|
+
<ChevronUp size={15} />
|
|
61
|
+
) : (
|
|
62
|
+
<ChevronDown size={15} />
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const variantStyle = cn(
|
|
67
|
+
'w-full border-separate border-spacing-0 transition-all duration-300',
|
|
68
|
+
variant === 'clean' && 'bg-white',
|
|
69
|
+
variant === 'elevated' && 'bg-white shadow-lg',
|
|
70
|
+
variant === 'bordered' && 'border border-secondary-200 rounded-lg'
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
const headerCell = (col: MciTableColumn<T>) =>
|
|
74
|
+
cn(
|
|
75
|
+
'bg-secondary-50 text-secondary-800 text-sm font-semibold py-3 px-4 select-none',
|
|
76
|
+
'border-b border-secondary-200 transition-colors',
|
|
77
|
+
col.align === 'center' && 'text-center',
|
|
78
|
+
col.align === 'right' && 'text-right',
|
|
79
|
+
col.sortable && 'cursor-pointer hover:bg-secondary-100/70'
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<div className='w-full space-y-3'>
|
|
84
|
+
{actions && (
|
|
85
|
+
<div className='flex justify-between items-center mb-2'>{actions}</div>
|
|
86
|
+
)}
|
|
87
|
+
|
|
88
|
+
<div className='w-full overflow-x-auto rounded-lg'>
|
|
89
|
+
<table className={variantStyle}>
|
|
90
|
+
<thead>
|
|
91
|
+
<tr>
|
|
92
|
+
{columns.map((col, idx) => (
|
|
93
|
+
<th
|
|
94
|
+
key={String(col.key)}
|
|
95
|
+
onClick={() => handleSort(col)}
|
|
96
|
+
className={cn(
|
|
97
|
+
headerCell(col),
|
|
98
|
+
idx === 0 && 'rounded-tl-lg',
|
|
99
|
+
idx === columns.length - 1 && 'rounded-tr-lg'
|
|
100
|
+
)}
|
|
101
|
+
style={{ width: col.width }}
|
|
102
|
+
>
|
|
103
|
+
<div className='flex items-center justify-between gap-2'>
|
|
104
|
+
<span className='truncate'>{col.title}</span>
|
|
105
|
+
{getSortIcon(col)}
|
|
106
|
+
</div>
|
|
107
|
+
</th>
|
|
108
|
+
))}
|
|
109
|
+
</tr>
|
|
110
|
+
</thead>
|
|
111
|
+
|
|
112
|
+
<tbody>
|
|
113
|
+
{loading
|
|
114
|
+
? Array.from({ length: skeletonRows }).map((_, idx) => (
|
|
115
|
+
<tr key={idx}>
|
|
116
|
+
{columns.map((_, i) => (
|
|
117
|
+
<td key={i} className='px-4 py-3'>
|
|
118
|
+
<Skeleton height={18} variant='rounded' />
|
|
119
|
+
</td>
|
|
120
|
+
))}
|
|
121
|
+
</tr>
|
|
122
|
+
))
|
|
123
|
+
: sortedData.map((row, idx) => (
|
|
124
|
+
<tr
|
|
125
|
+
key={idx}
|
|
126
|
+
className={cn(
|
|
127
|
+
'border-b border-secondary-100 transition-all duration-200'
|
|
128
|
+
)}
|
|
129
|
+
>
|
|
130
|
+
{columns.map((col, i) => (
|
|
131
|
+
<td
|
|
132
|
+
key={i}
|
|
133
|
+
className={cn(
|
|
134
|
+
'px-4 py-3 text-sm text-gray-800',
|
|
135
|
+
col.align === 'center' && 'text-center',
|
|
136
|
+
col.align === 'right' && 'text-right'
|
|
137
|
+
)}
|
|
138
|
+
>
|
|
139
|
+
{col.render
|
|
140
|
+
? col.render(row[col.key], row)
|
|
141
|
+
: String(row[col.key])}
|
|
142
|
+
</td>
|
|
143
|
+
))}
|
|
144
|
+
</tr>
|
|
145
|
+
))}
|
|
146
|
+
</tbody>
|
|
147
|
+
</table>
|
|
148
|
+
</div>
|
|
149
|
+
|
|
150
|
+
{!loading && sortedData.length === 0 && (
|
|
151
|
+
<div className='flex flex-col items-center justify-center py-10 text-gray-500 dark:text-gray-400 animate-[fadeIn_0.4s_ease-in-out]'>
|
|
152
|
+
<div className='relative'>
|
|
153
|
+
<Box
|
|
154
|
+
size={64}
|
|
155
|
+
className='mb-3 text-secondary-400 dark:text-secondary-500 opacity-80 animate-[float_2.5s_ease-in-out_infinite]'
|
|
156
|
+
/>
|
|
157
|
+
<div className='absolute inset-0 blur-2xl bg-secondary-200/20 dark:bg-secondary-700/20 rounded-full scale-75 animate-[pulse_3s_ease-in-out_infinite]' />
|
|
158
|
+
</div>
|
|
159
|
+
<p className='text-sm font-medium animate-[fadeUp_0.6s_ease-out]'>
|
|
160
|
+
{noDataText}
|
|
161
|
+
</p>
|
|
162
|
+
</div>
|
|
163
|
+
)}
|
|
164
|
+
</div>
|
|
165
|
+
)
|
|
166
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { X } from 'lucide-react'
|
|
2
|
+
import React, { useEffect, useRef } from 'react'
|
|
3
|
+
import { cn, useClickOutside, useEscapeKey } from '../../lib/utils'
|
|
4
|
+
|
|
5
|
+
interface ModalProps {
|
|
6
|
+
show: boolean
|
|
7
|
+
setShow: (show: boolean) => void
|
|
8
|
+
title?: string
|
|
9
|
+
Header?: React.ReactNode
|
|
10
|
+
Body?: React.ReactNode
|
|
11
|
+
footer?: React.ReactNode
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export default function Modal({
|
|
15
|
+
show,
|
|
16
|
+
setShow,
|
|
17
|
+
title,
|
|
18
|
+
Header,
|
|
19
|
+
Body,
|
|
20
|
+
footer,
|
|
21
|
+
}: ModalProps) {
|
|
22
|
+
const modalRef = useRef<HTMLDivElement>(null!)
|
|
23
|
+
|
|
24
|
+
// ESC
|
|
25
|
+
const { handleEscape } = useEscapeKey(() => setShow(false))
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
if (show) {
|
|
28
|
+
document.addEventListener('keydown', handleEscape)
|
|
29
|
+
document.body.style.overflow = 'hidden'
|
|
30
|
+
} else {
|
|
31
|
+
document.body.style.overflow = 'unset'
|
|
32
|
+
}
|
|
33
|
+
return () => document.removeEventListener('keydown', handleEscape)
|
|
34
|
+
}, [show, handleEscape])
|
|
35
|
+
|
|
36
|
+
// Click outside
|
|
37
|
+
const { handleClick } = useClickOutside(modalRef, () => setShow(false))
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
if (show) document.addEventListener('mousedown', handleClick)
|
|
40
|
+
return () => document.removeEventListener('mousedown', handleClick)
|
|
41
|
+
}, [show, handleClick])
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<div
|
|
45
|
+
className={cn(
|
|
46
|
+
'fixed inset-0 z-50 flex items-center justify-center p-4 bg-secondary-100/50 backdrop-blur-sm transition-all duration-500 ease-in-out',
|
|
47
|
+
show
|
|
48
|
+
? 'opacity-100 pointer-events-auto'
|
|
49
|
+
: 'opacity-0 pointer-events-none'
|
|
50
|
+
)}
|
|
51
|
+
>
|
|
52
|
+
<div
|
|
53
|
+
ref={modalRef}
|
|
54
|
+
className={cn(
|
|
55
|
+
'relative w-full max-w-lg rounded-[7px] shadow-xl bg-secondary-50 text-gray-800',
|
|
56
|
+
'transform transition-all duration-500 ease-out',
|
|
57
|
+
show ? 'opacity-100 scale-100' : 'opacity-0 scale-90'
|
|
58
|
+
)}
|
|
59
|
+
>
|
|
60
|
+
{/* Header */}
|
|
61
|
+
{(Header || title) && (
|
|
62
|
+
<div className='flex items-center justify-between px-6 py-4 border-b border-secondary-300'>
|
|
63
|
+
{Header || (
|
|
64
|
+
<h2 className='text-xl font-semibold text-gray-800'>{title}</h2>
|
|
65
|
+
)}
|
|
66
|
+
|
|
67
|
+
<button
|
|
68
|
+
onClick={() => setShow(false)}
|
|
69
|
+
className='text-gray-800 hover:text-secondary-300 active:animate-spin transition'
|
|
70
|
+
>
|
|
71
|
+
<X className='w-5 h-5' />
|
|
72
|
+
</button>
|
|
73
|
+
</div>
|
|
74
|
+
)}
|
|
75
|
+
|
|
76
|
+
{/* Body */}
|
|
77
|
+
{Body && (
|
|
78
|
+
<div className='px-6 py-4 max-h-96 overflow-y-auto scrollbar-thin scrollbar-thumb-gray-300'>
|
|
79
|
+
{Body}
|
|
80
|
+
</div>
|
|
81
|
+
)}
|
|
82
|
+
|
|
83
|
+
{/* Footer */}
|
|
84
|
+
{footer && (
|
|
85
|
+
<div className='flex items-center justify-end gap-3 px-6 py-4 border-t border-secondary-300'>
|
|
86
|
+
{footer}
|
|
87
|
+
</div>
|
|
88
|
+
)}
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
)
|
|
92
|
+
}
|