@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.
@@ -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
+ }