@mci-ui/mci-ui 0.0.0 → 0.0.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.
@@ -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 '../index'
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
+ }
@@ -0,0 +1,141 @@
1
+ import { ChevronLeft, ChevronRight } from 'lucide-react'
2
+
3
+ interface PaginationProps {
4
+ totalItems: number
5
+ currentPage: number
6
+ perPage: number
7
+ onPageChange: (page: number) => void
8
+ onPerPageChange?: (value: number) => void
9
+ siblingCount?: number
10
+ perPageOptions?: number[]
11
+ showPerPage?: boolean
12
+ }
13
+
14
+ export default function Pagination({
15
+ totalItems,
16
+ currentPage,
17
+ perPage,
18
+ onPageChange,
19
+ onPerPageChange,
20
+ siblingCount = 1,
21
+ perPageOptions = [10, 20, 30],
22
+ showPerPage = true,
23
+ }: PaginationProps) {
24
+ const totalPages = Math.ceil(totalItems / perPage)
25
+
26
+ const createPageRange = () => {
27
+ const totalPageNumbers = siblingCount * 2 + 5
28
+ if (totalPages <= totalPageNumbers) {
29
+ return Array.from({ length: totalPages }, (_, i) => i + 1)
30
+ }
31
+
32
+ const leftSiblingIndex = Math.max(currentPage - siblingCount, 2)
33
+ const rightSiblingIndex = Math.min(
34
+ currentPage + siblingCount,
35
+ totalPages - 1
36
+ )
37
+
38
+ const showLeftDots = leftSiblingIndex > 2
39
+ const showRightDots = rightSiblingIndex < totalPages - 1
40
+
41
+ const range: (number | string)[] = [1]
42
+
43
+ if (showLeftDots) range.push('...')
44
+
45
+ for (let i = leftSiblingIndex; i <= rightSiblingIndex; i++) {
46
+ range.push(i)
47
+ }
48
+
49
+ if (showRightDots) range.push('...')
50
+
51
+ range.push(totalPages)
52
+
53
+ return range
54
+ }
55
+
56
+ const pages = createPageRange()
57
+
58
+ const handlePrev = () => {
59
+ if (currentPage > 1) onPageChange(currentPage - 1)
60
+ }
61
+
62
+ const handleNext = () => {
63
+ if (currentPage < totalPages) onPageChange(currentPage + 1)
64
+ }
65
+
66
+ return (
67
+ <div className='w-full flex flex-col md:flex-row md:justify-between md:items-center gap-4 py-4'>
68
+ {/* Per Page Select */}
69
+ {showPerPage && onPerPageChange && (
70
+ <div className='flex justify-center md:justify-start w-full md:w-auto'>
71
+ <select
72
+ value={perPage}
73
+ onChange={e => onPerPageChange(Number(e.target.value))}
74
+ className='border rounded-lg px-3 py-2 text-sm bg-white
75
+ hover:border-secondary-500
76
+ focus:outline-none focus:ring-2 focus:ring-secondary-500
77
+ transition-all duration-200'
78
+ >
79
+ {perPageOptions.map(option => (
80
+ <option key={option} value={option}>
81
+ {option} / page
82
+ </option>
83
+ ))}
84
+ </select>
85
+ </div>
86
+ )}
87
+
88
+ {/* Pagination */}
89
+ <div className='flex items-center justify-center md:justify-end w-full'>
90
+ {/* Prev */}
91
+ <button
92
+ onClick={handlePrev}
93
+ disabled={currentPage === 1}
94
+ className={`flex items-center justify-center rounded-xl border px-3 py-2 transition-all duration-200
95
+ ${
96
+ currentPage === 1
97
+ ? 'opacity-40 cursor-not-allowed'
98
+ : 'hover:bg-secondary-100 hover:text-secondary-700'
99
+ }`}
100
+ >
101
+ <ChevronLeft size={18} />
102
+ </button>
103
+
104
+ {/* Page numbers */}
105
+ <div className='flex items-center gap-1 mx-2'>
106
+ {pages.map((page, idx) => (
107
+ <button
108
+ key={idx}
109
+ onClick={() => typeof page === 'number' && onPageChange(page)}
110
+ disabled={page === '...'}
111
+ className={`min-w-[40px] rounded-xl border px-3 py-2 text-sm font-medium transition-all duration-200
112
+ ${
113
+ page === currentPage
114
+ ? 'bg-secondary-500 text-white shadow-md'
115
+ : page === '...'
116
+ ? 'cursor-default border-none text-gray-400'
117
+ : 'bg-white text-secondary-700 hover:bg-secondary-100'
118
+ }`}
119
+ >
120
+ {page}
121
+ </button>
122
+ ))}
123
+ </div>
124
+
125
+ {/* Next */}
126
+ <button
127
+ onClick={handleNext}
128
+ disabled={currentPage === totalPages}
129
+ className={`flex items-center justify-center rounded-xl border px-3 py-2 transition-all duration-200
130
+ ${
131
+ currentPage === totalPages
132
+ ? 'opacity-40 cursor-not-allowed'
133
+ : 'hover:bg-secondary-100 hover:text-secondary-700'
134
+ }`}
135
+ >
136
+ <ChevronRight size={18} />
137
+ </button>
138
+ </div>
139
+ </div>
140
+ )
141
+ }
@@ -0,0 +1,33 @@
1
+ import {cn} from '../../lib/utils.ts'
2
+
3
+ type SkeletonProps = {
4
+ className?: string
5
+ variant?: 'default' | 'rounded' | 'circle'
6
+ width?: string | number
7
+ height?: string | number
8
+ }
9
+
10
+ export default function Skeleton({
11
+ className,
12
+ variant = 'default',
13
+ width = '100%',
14
+ height = '1rem',
15
+ }: SkeletonProps) {
16
+ return (
17
+ <div
18
+ className={cn(
19
+ 'relative overflow-hidden bg-secondary-200 dark:bg-secondary-800',
20
+ 'rounded-sm',
21
+ variant === 'circle' && 'rounded-full',
22
+ variant === 'rounded' && 'rounded-md',
23
+ className
24
+ )}
25
+ style={{
26
+ width: typeof width === 'number' ? `${width}px` : width,
27
+ height: typeof height === 'number' ? `${height}px` : height,
28
+ }}
29
+ >
30
+ <div className='absolute inset-0 shimmer-mask'/>
31
+ </div>
32
+ )
33
+ }
@@ -0,0 +1,192 @@
1
+ import React, { useEffect, useRef, useState } from 'react'
2
+ import { cn } from '../../lib/utils.ts'
3
+
4
+ type Tab = {
5
+ id: string
6
+ label: string
7
+ icon?: React.ReactNode
8
+ content: React.ReactNode
9
+ disabled?: boolean
10
+ }
11
+
12
+ type TabsProps = {
13
+ tabs: Tab[]
14
+ defaultTab?: string
15
+ position?: 'top' | 'bottom' | 'left' | 'right'
16
+ variant?: 'primary' | 'secondary' | 'accent'
17
+ className?: string
18
+ onChange?: (tabId: string | number) => void
19
+ }
20
+
21
+ export default function Tabs({
22
+ tabs,
23
+ defaultTab,
24
+ position = 'top',
25
+ variant = 'primary',
26
+ className,
27
+ onChange,
28
+ }: TabsProps) {
29
+ const [activeTab, setActiveTab] = useState(defaultTab || tabs[0]?.id)
30
+ const [indicatorStyle, setIndicatorStyle] = useState({})
31
+ const tabsRef = useRef<(HTMLButtonElement | null)[]>([])
32
+
33
+ useEffect(() => {
34
+ const activeIndex = tabs.findIndex(tab => tab.id === activeTab)
35
+ const activeTabElement = tabsRef.current[activeIndex]
36
+
37
+ if (activeTabElement) {
38
+ const { offsetLeft, offsetTop, offsetWidth, offsetHeight } =
39
+ activeTabElement
40
+
41
+ setIndicatorStyle({
42
+ left: offsetLeft,
43
+ top: offsetTop,
44
+ width: offsetWidth,
45
+ height: offsetHeight,
46
+ transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
47
+ })
48
+ }
49
+ }, [activeTab, position, tabs])
50
+
51
+ const containerClasses = {
52
+ top: 'flex-col',
53
+ bottom: 'flex-col-reverse',
54
+ left: 'flex-row',
55
+ right: 'flex-row-reverse',
56
+ }
57
+
58
+ const tabsContainerClasses = {
59
+ top: 'flex-row',
60
+ bottom: 'flex-row',
61
+ left: 'flex-col',
62
+ right: 'flex-col',
63
+ }
64
+
65
+ const contentClasses = {
66
+ top: 'mt-6',
67
+ bottom: 'mb-6',
68
+ left: 'ml-6',
69
+ right: 'mr-6',
70
+ }
71
+
72
+ const variantClasses = {
73
+ primary: {
74
+ active: 'text-secondary-600',
75
+ inactive:
76
+ 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300',
77
+ disabled: 'text-gray-300 dark:text-gray-600 cursor-not-allowed',
78
+ bg: 'bg-secondary-100 dark:bg-secondary-900',
79
+ },
80
+ secondary: {
81
+ active: 'text-accent-600',
82
+ inactive:
83
+ 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300',
84
+ disabled: 'text-gray-300 dark:text-gray-600 cursor-not-allowed',
85
+ bg: 'bg-accent-100 dark:bg-accent-900',
86
+ },
87
+ accent: {
88
+ active: 'text-gray-800 dark:text-gray-200',
89
+ inactive:
90
+ 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300',
91
+ disabled: 'text-gray-300 dark:text-gray-600 cursor-not-allowed',
92
+ bg: 'bg-gray-100 dark:bg-gray-800',
93
+ },
94
+ }
95
+
96
+ const isVertical = position === 'left' || position === 'right'
97
+
98
+ return (
99
+ <div className={cn('flex w-full', containerClasses[position], className)}>
100
+ {/* Tabs Container */}
101
+ <div
102
+ className={cn(
103
+ 'flex relative bg-gray-50 dark:bg-gray-900 rounded-lg p-1',
104
+ tabsContainerClasses[position],
105
+ isVertical ? 'min-w-48' : 'w-full'
106
+ )}
107
+ >
108
+ {/* Animated Background Indicator */}
109
+ <div
110
+ className={cn(
111
+ 'absolute rounded-md transition-all duration-300 ease-out',
112
+ variantClasses[variant].bg,
113
+ isVertical ? 'w-full' : 'h-full'
114
+ )}
115
+ style={indicatorStyle}
116
+ />
117
+
118
+ {tabs.map((tab, index) => {
119
+ const isActive = activeTab === tab.id
120
+ const variantConfig = variantClasses[variant]
121
+
122
+ return (
123
+ <button
124
+ key={tab.id}
125
+ ref={(el) => {
126
+ tabsRef.current[index] = el
127
+ }}
128
+ onClick={() => {
129
+ if (!tab.disabled) {
130
+ setActiveTab(tab.id)
131
+ onChange?.(tab.id)
132
+ }
133
+ }}
134
+ disabled={tab.disabled}
135
+ className={cn(
136
+ 'relative flex items-center justify-center gap-2 px-4 py-3 font-medium',
137
+ 'text-sm whitespace-nowrap transition-all duration-200 z-10',
138
+ 'focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-blue-500',
139
+ 'rounded-md',
140
+ isActive
141
+ ? [variantConfig.active, 'font-semibold']
142
+ : [
143
+ variantConfig.inactive,
144
+ 'hover:bg-white/50 dark:hover:bg-gray-800/50',
145
+ ],
146
+ tab.disabled && variantConfig.disabled,
147
+ isVertical ? 'w-full justify-start' : 'flex-1'
148
+ )}
149
+ >
150
+ {tab.icon && (
151
+ <span
152
+ className={cn(
153
+ 'flex-shrink-0 transition-transform duration-200',
154
+ isActive && 'scale-110'
155
+ )}
156
+ >
157
+ {tab.icon}
158
+ </span>
159
+ )}
160
+ <span className='relative z-10'>{tab.label}</span>
161
+ </button>
162
+ )
163
+ })}
164
+ </div>
165
+
166
+ {/* Tab Content with Smooth Transition */}
167
+ <div className={cn('flex-1 overflow-hidden', contentClasses[position])}>
168
+ <div key={activeTab} className='animate-fade-in'>
169
+ {tabs.find(tab => tab.id === activeTab)?.content}
170
+ </div>
171
+ </div>
172
+ </div>
173
+ )
174
+ }
175
+
176
+ type TabPanelProps = {
177
+ children: React.ReactNode
178
+ className?: string
179
+ }
180
+
181
+ export function TabPanel({ children, className }: TabPanelProps) {
182
+ return (
183
+ <div
184
+ className={cn(
185
+ 'p-6 rounded-lg bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700',
186
+ className
187
+ )}
188
+ >
189
+ {children}
190
+ </div>
191
+ )
192
+ }