@apptimate/ui 1.0.0
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/CHANGELOG.md +7 -0
- package/package.json +18 -0
- package/src/Accordion.tsx +561 -0
- package/src/Badge.tsx +191 -0
- package/src/Button.tsx +353 -0
- package/src/ButtonGroup.tsx +149 -0
- package/src/Card.tsx +250 -0
- package/src/Checkbox.tsx +49 -0
- package/src/ChipInput.tsx +203 -0
- package/src/Divider.tsx +82 -0
- package/src/Dropdown.tsx +128 -0
- package/src/EmptyState.tsx +18 -0
- package/src/HintIcon.tsx +49 -0
- package/src/Input.tsx +60 -0
- package/src/Modal.tsx +98 -0
- package/src/Pagination.tsx +51 -0
- package/src/PopConfirm.tsx +350 -0
- package/src/SearchableSelect.tsx +735 -0
- package/src/Select.tsx +49 -0
- package/src/Table.tsx +78 -0
- package/src/index.tsx +20 -0
package/src/Dropdown.tsx
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useState, useRef, useEffect, ReactNode, useCallback } from 'react';
|
|
4
|
+
import { createPortal } from 'react-dom';
|
|
5
|
+
import { cn } from '@apptimate/core-lib';
|
|
6
|
+
|
|
7
|
+
interface DropdownProps {
|
|
8
|
+
trigger: ReactNode;
|
|
9
|
+
children: ReactNode;
|
|
10
|
+
align?: 'left' | 'right';
|
|
11
|
+
className?: string;
|
|
12
|
+
contentClassName?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function Dropdown({ trigger, children, align = 'left', className, contentClassName }: DropdownProps) {
|
|
16
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
17
|
+
const [coords, setCoords] = useState({ top: 0, left: 0, right: 0 });
|
|
18
|
+
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
19
|
+
const contentRef = useRef<HTMLDivElement>(null);
|
|
20
|
+
|
|
21
|
+
const updatePosition = useCallback(() => {
|
|
22
|
+
if (dropdownRef.current) {
|
|
23
|
+
const rect = dropdownRef.current.getBoundingClientRect();
|
|
24
|
+
setCoords({
|
|
25
|
+
top: rect.bottom + window.scrollY,
|
|
26
|
+
left: rect.left + window.scrollX,
|
|
27
|
+
right: window.innerWidth - rect.right - window.scrollX
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
}, []);
|
|
31
|
+
|
|
32
|
+
const toggleDropdown = () => {
|
|
33
|
+
if (!isOpen) {
|
|
34
|
+
updatePosition();
|
|
35
|
+
}
|
|
36
|
+
setIsOpen((prev) => !prev);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const closeDropdown = () => setIsOpen(false);
|
|
40
|
+
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
const handleClickOutside = (event: MouseEvent) => {
|
|
43
|
+
const target = event.target as Element;
|
|
44
|
+
if (target?.closest?.('.aceui-pop-confirm') || target?.closest?.('[role="dialog"]')) {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const clickedTrigger = dropdownRef.current && dropdownRef.current.contains(target as Node);
|
|
49
|
+
const clickedContent = contentRef.current && contentRef.current.contains(target as Node);
|
|
50
|
+
|
|
51
|
+
if (!clickedTrigger && !clickedContent) {
|
|
52
|
+
setIsOpen(false);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const handleScroll = () => {
|
|
57
|
+
if (isOpen) updatePosition();
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const handleResize = () => {
|
|
61
|
+
if (isOpen) updatePosition();
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
if (isOpen) {
|
|
65
|
+
document.addEventListener('mousedown', handleClickOutside);
|
|
66
|
+
window.addEventListener('scroll', handleScroll, true);
|
|
67
|
+
window.addEventListener('resize', handleResize);
|
|
68
|
+
}
|
|
69
|
+
return () => {
|
|
70
|
+
document.removeEventListener('mousedown', handleClickOutside);
|
|
71
|
+
window.removeEventListener('scroll', handleScroll, true);
|
|
72
|
+
window.removeEventListener('resize', handleResize);
|
|
73
|
+
};
|
|
74
|
+
}, [isOpen, updatePosition]);
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<>
|
|
78
|
+
<div ref={dropdownRef} className={cn("relative inline-block text-left", className)}>
|
|
79
|
+
<div onClick={toggleDropdown} className="cursor-pointer">
|
|
80
|
+
{trigger}
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
|
|
84
|
+
{isOpen && typeof document !== 'undefined' && createPortal(
|
|
85
|
+
<div
|
|
86
|
+
ref={contentRef}
|
|
87
|
+
style={{
|
|
88
|
+
position: 'absolute',
|
|
89
|
+
top: `${coords.top}px`,
|
|
90
|
+
...(align === 'left' ? { left: `${coords.left}px` } : { right: `${coords.right}px` })
|
|
91
|
+
}}
|
|
92
|
+
className={cn(
|
|
93
|
+
"z-[9999] mt-1.5 min-w-[160px] rounded-[10px] bg-surface-0 border border-border-default shadow-[0_4px_12px_rgba(0,0,0,0.05)] p-1.5 animate-in fade-in zoom-in-95 duration-200",
|
|
94
|
+
contentClassName
|
|
95
|
+
)}
|
|
96
|
+
onClick={closeDropdown}
|
|
97
|
+
>
|
|
98
|
+
{children}
|
|
99
|
+
</div>,
|
|
100
|
+
document.body
|
|
101
|
+
)}
|
|
102
|
+
</>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
interface DropdownItemProps {
|
|
107
|
+
children: ReactNode;
|
|
108
|
+
onClick?: () => void;
|
|
109
|
+
className?: string;
|
|
110
|
+
isActive?: boolean;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function DropdownItem({ children, onClick, className, isActive }: DropdownItemProps) {
|
|
114
|
+
return (
|
|
115
|
+
<div
|
|
116
|
+
onClick={onClick}
|
|
117
|
+
className={cn(
|
|
118
|
+
"px-3 py-2 text-[13px] rounded-[6px] cursor-pointer transition-colors flex items-center gap-2",
|
|
119
|
+
isActive
|
|
120
|
+
? "bg-primary-100 text-primary-700 font-medium"
|
|
121
|
+
: "text-foreground-1 hover:bg-surface-hover hover:text-foreground-0",
|
|
122
|
+
className
|
|
123
|
+
)}
|
|
124
|
+
>
|
|
125
|
+
{children}
|
|
126
|
+
</div>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import { Inbox } from 'lucide-react';
|
|
5
|
+
|
|
6
|
+
export interface EmptyStateProps {
|
|
7
|
+
message?: string;
|
|
8
|
+
icon?: React.ReactNode;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const EmptyState = ({ message = "No records found.", icon }: EmptyStateProps) => {
|
|
12
|
+
return (
|
|
13
|
+
<div className="flex flex-col items-center justify-center opacity-40">
|
|
14
|
+
{icon || <Inbox size={42} strokeWidth={1.5} className="mb-3 text-foreground-disabled" />}
|
|
15
|
+
<p className="text-[14px] font-medium text-foreground-disabled">{message}</p>
|
|
16
|
+
</div>
|
|
17
|
+
);
|
|
18
|
+
};
|
package/src/HintIcon.tsx
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useState } from 'react';
|
|
4
|
+
import ReactDOM from 'react-dom';
|
|
5
|
+
import { Info } from 'lucide-react';
|
|
6
|
+
|
|
7
|
+
interface HintIconProps {
|
|
8
|
+
text: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function HintIcon({ text }: HintIconProps) {
|
|
12
|
+
const [show, setShow] = useState(false);
|
|
13
|
+
const [pos, setPos] = useState({ top: 0, left: 0 });
|
|
14
|
+
const iconRef = React.useRef<HTMLSpanElement>(null);
|
|
15
|
+
|
|
16
|
+
const handleMouseEnter = () => {
|
|
17
|
+
if (iconRef.current) {
|
|
18
|
+
const rect = iconRef.current.getBoundingClientRect();
|
|
19
|
+
// Try to keep tooltip within upper bounds if possible
|
|
20
|
+
const y = rect.top - 6;
|
|
21
|
+
setPos({ top: y, left: rect.left + rect.width / 2 });
|
|
22
|
+
}
|
|
23
|
+
setShow(true);
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
if (!text) return null;
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<>
|
|
30
|
+
<span
|
|
31
|
+
ref={iconRef}
|
|
32
|
+
className="inline-flex ml-1 align-middle"
|
|
33
|
+
onMouseEnter={handleMouseEnter}
|
|
34
|
+
onMouseLeave={() => setShow(false)}
|
|
35
|
+
>
|
|
36
|
+
<Info size={14} className="text-foreground-muted hover:text-foreground-1 transition-colors cursor-help" />
|
|
37
|
+
</span>
|
|
38
|
+
{show && typeof document !== 'undefined' && ReactDOM.createPortal(
|
|
39
|
+
<div
|
|
40
|
+
className="fixed z-[9999] px-3 py-2 text-[12px] leading-relaxed text-white bg-foreground-0 rounded-[6px] shadow-lg max-w-[320px] whitespace-pre-wrap pointer-events-none"
|
|
41
|
+
style={{ top: pos.top, left: pos.left, transform: 'translate(-50%, -100%)' }}
|
|
42
|
+
>
|
|
43
|
+
{text}
|
|
44
|
+
</div>,
|
|
45
|
+
document.body
|
|
46
|
+
)}
|
|
47
|
+
</>
|
|
48
|
+
);
|
|
49
|
+
}
|
package/src/Input.tsx
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useState } from 'react';
|
|
4
|
+
import { cn } from '@apptimate/core-lib';
|
|
5
|
+
import { Eye, EyeOff } from 'lucide-react';
|
|
6
|
+
|
|
7
|
+
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
|
8
|
+
label?: string;
|
|
9
|
+
error?: string;
|
|
10
|
+
icon?: React.ReactNode;
|
|
11
|
+
isRequired?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|
15
|
+
({ label, error, icon, isRequired, className, type, ...props }, ref) => {
|
|
16
|
+
const [showPassword, setShowPassword] = useState(false);
|
|
17
|
+
const isPassword = type === 'password';
|
|
18
|
+
const inputType = isPassword ? (showPassword ? 'text' : 'password') : type;
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<div className={cn("flex flex-col gap-1.5 w-full", className)}>
|
|
22
|
+
{label && (
|
|
23
|
+
<label className="text-[12px] font-semibold text-foreground-subtle tracking-[0.3px] uppercase">
|
|
24
|
+
{label} {isRequired && <span className="text-danger-alt ml-0.5">*</span>}
|
|
25
|
+
</label>
|
|
26
|
+
)}
|
|
27
|
+
<div className="relative group">
|
|
28
|
+
{icon && (
|
|
29
|
+
<div className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-foreground-disabled group-focus-within:text-primary transition-colors">
|
|
30
|
+
{icon}
|
|
31
|
+
</div>
|
|
32
|
+
)}
|
|
33
|
+
<input
|
|
34
|
+
ref={ref}
|
|
35
|
+
type={inputType}
|
|
36
|
+
className={cn(
|
|
37
|
+
"w-full bg-surface-0 border-1.5 border-border-subtle rounded-[10px] px-3.5 py-2.5 text-[13.5px] text-foreground-1 outline-none transition-all focus:border-primary focus:bg-surface-1 placeholder:text-foreground-disabled",
|
|
38
|
+
icon && "pl-10",
|
|
39
|
+
isPassword && "pr-10",
|
|
40
|
+
error && "border-danger-alt focus:border-danger-alt"
|
|
41
|
+
)}
|
|
42
|
+
{...props}
|
|
43
|
+
/>
|
|
44
|
+
{isPassword && (
|
|
45
|
+
<button
|
|
46
|
+
type="button"
|
|
47
|
+
onClick={() => setShowPassword(!showPassword)}
|
|
48
|
+
className="absolute right-3 top-1/2 -translate-y-1/2 text-foreground-disabled hover:text-primary transition-colors"
|
|
49
|
+
>
|
|
50
|
+
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
|
51
|
+
</button>
|
|
52
|
+
)}
|
|
53
|
+
</div>
|
|
54
|
+
{error && <span className="text-[11px] text-danger-alt font-medium">{error}</span>}
|
|
55
|
+
</div>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
Input.displayName = 'Input';
|
package/src/Modal.tsx
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useEffect } from 'react';
|
|
4
|
+
import { motion, AnimatePresence } from 'framer-motion';
|
|
5
|
+
import { X } from 'lucide-react';
|
|
6
|
+
import { cn } from '@apptimate/core-lib';
|
|
7
|
+
|
|
8
|
+
export interface ModalProps {
|
|
9
|
+
isOpen: boolean;
|
|
10
|
+
onClose: () => void;
|
|
11
|
+
title?: string | React.ReactNode;
|
|
12
|
+
children: React.ReactNode;
|
|
13
|
+
className?: string;
|
|
14
|
+
footer?: React.ReactNode;
|
|
15
|
+
backdrop?: 'transparent' | 'opaque' | 'blur';
|
|
16
|
+
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'full';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const Modal = ({ isOpen, onClose, title, children, className, footer, backdrop = 'opaque', size = 'sm' }: ModalProps) => {
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
if (isOpen) {
|
|
22
|
+
document.body.style.overflow = 'hidden';
|
|
23
|
+
} else {
|
|
24
|
+
document.body.style.overflow = 'unset';
|
|
25
|
+
}
|
|
26
|
+
return () => { document.body.style.overflow = 'unset'; };
|
|
27
|
+
}, [isOpen]);
|
|
28
|
+
|
|
29
|
+
const backdropClasses = cn(
|
|
30
|
+
"absolute inset-0 transition-all duration-300",
|
|
31
|
+
backdrop === 'transparent' && "bg-transparent",
|
|
32
|
+
backdrop === 'opaque' && "bg-foreground-0/60",
|
|
33
|
+
backdrop === 'blur' && "bg-foreground-0/40 backdrop-blur-sm"
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
const sizeClasses = {
|
|
37
|
+
xs: 'max-w-[400px]',
|
|
38
|
+
sm: 'max-w-[480px]',
|
|
39
|
+
md: 'max-w-[600px]',
|
|
40
|
+
lg: 'max-w-[800px]',
|
|
41
|
+
xl: 'max-w-[1140px]',
|
|
42
|
+
'2xl': 'max-w-[1400px]',
|
|
43
|
+
full: 'max-w-[100vw]',
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<AnimatePresence>
|
|
48
|
+
{isOpen && (
|
|
49
|
+
<div className={cn("fixed inset-0 z-50 flex items-center justify-center", size === 'full' ? 'p-0' : 'p-2 sm:p-4')}>
|
|
50
|
+
<motion.div
|
|
51
|
+
initial={{ opacity: 0 }}
|
|
52
|
+
animate={{ opacity: 1 }}
|
|
53
|
+
exit={{ opacity: 0 }}
|
|
54
|
+
onClick={onClose}
|
|
55
|
+
className={backdropClasses}
|
|
56
|
+
/>
|
|
57
|
+
<motion.div
|
|
58
|
+
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
|
59
|
+
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
60
|
+
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
|
61
|
+
className={cn(
|
|
62
|
+
"relative w-full bg-surface-1 shadow-[0_8px_40px_rgba(0,0,0,0.2)] flex flex-col overflow-hidden",
|
|
63
|
+
size === 'full' ? "rounded-none h-screen max-h-screen" : "rounded-[16px] sm:rounded-[24px] max-h-[90vh]",
|
|
64
|
+
sizeClasses[size],
|
|
65
|
+
className
|
|
66
|
+
)}
|
|
67
|
+
>
|
|
68
|
+
{/* Header */}
|
|
69
|
+
<div className="flex items-center justify-between px-5 sm:px-8 py-4 border-b border-surface-0">
|
|
70
|
+
<h3 className="text-[16px] sm:text-[18px] font-bold text-foreground-0 tracking-tight">{title}</h3>
|
|
71
|
+
<button
|
|
72
|
+
onClick={onClose}
|
|
73
|
+
className="w-8 h-8 rounded-full flex items-center justify-center text-foreground-disabled hover:bg-surface-0 hover:text-foreground-0 transition-all"
|
|
74
|
+
>
|
|
75
|
+
<X size={20} />
|
|
76
|
+
</button>
|
|
77
|
+
</div>
|
|
78
|
+
|
|
79
|
+
{/* Body */}
|
|
80
|
+
<div className={cn(
|
|
81
|
+
"flex-1",
|
|
82
|
+
size === 'full' ? "overflow-hidden p-0" : "overflow-y-auto px-5 sm:px-8 py-5 sm:py-7"
|
|
83
|
+
)}>
|
|
84
|
+
{children}
|
|
85
|
+
</div>
|
|
86
|
+
|
|
87
|
+
{/* Footer */}
|
|
88
|
+
{footer && (
|
|
89
|
+
<div className="px-5 sm:px-8 py-4 sm:py-6 bg-surface-subtle border-t border-surface-0 flex flex-col-reverse sm:flex-row justify-end gap-2 sm:gap-3">
|
|
90
|
+
{footer}
|
|
91
|
+
</div>
|
|
92
|
+
)}
|
|
93
|
+
</motion.div>
|
|
94
|
+
</div>
|
|
95
|
+
)}
|
|
96
|
+
</AnimatePresence>
|
|
97
|
+
);
|
|
98
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import { cn } from '@apptimate/core-lib';
|
|
5
|
+
|
|
6
|
+
export interface PaginationProps {
|
|
7
|
+
currentPage: number;
|
|
8
|
+
totalItems: number;
|
|
9
|
+
itemsPerPage: number;
|
|
10
|
+
onPageChange: (page: number) => void;
|
|
11
|
+
className?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const Pagination = ({
|
|
15
|
+
currentPage,
|
|
16
|
+
totalItems,
|
|
17
|
+
itemsPerPage,
|
|
18
|
+
onPageChange,
|
|
19
|
+
className
|
|
20
|
+
}: PaginationProps) => {
|
|
21
|
+
const totalPages = Math.ceil(totalItems / itemsPerPage);
|
|
22
|
+
|
|
23
|
+
if (totalPages === 0) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const startItem = totalItems === 0 ? 0 : (currentPage - 1) * itemsPerPage + 1;
|
|
28
|
+
const endItem = Math.min(startItem + itemsPerPage - 1, totalItems);
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<div className={cn("px-4 sm:px-6 py-3 sm:py-4 flex items-center justify-end gap-2", className)}>
|
|
32
|
+
<span className="text-[12px] sm:text-[13px] font-semibold text-primary mr-1 sm:mr-2">
|
|
33
|
+
{totalItems > 0 ? `${startItem}–${endItem} of ${totalItems}` : '0 of 0'}
|
|
34
|
+
</span>
|
|
35
|
+
<button
|
|
36
|
+
className={`w-8 h-8 rounded-full border border-border-subtle flex items-center justify-center text-foreground-muted transition-colors ${currentPage <= 1 || totalPages === 0 ? 'cursor-not-allowed' : 'cursor-pointer bg-primary text-white hover:bg-primary/90'}`}
|
|
37
|
+
disabled={currentPage <= 1 || totalPages === 0}
|
|
38
|
+
onClick={() => onPageChange(currentPage - 1)}
|
|
39
|
+
>
|
|
40
|
+
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="15 18 9 12 15 6" /></svg>
|
|
41
|
+
</button>
|
|
42
|
+
<button
|
|
43
|
+
className={`w-8 h-8 rounded-full border border-border-subtle flex items-center justify-center text-foreground-muted transition-colors ${currentPage >= totalPages || totalPages === 0 ? 'cursor-not-allowed' : 'cursor-pointer bg-primary text-white hover:bg-primary/90'}`}
|
|
44
|
+
disabled={currentPage >= totalPages || totalPages === 0}
|
|
45
|
+
onClick={() => onPageChange(currentPage + 1)}
|
|
46
|
+
>
|
|
47
|
+
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="9 18 15 12 9 6" /></svg>
|
|
48
|
+
</button>
|
|
49
|
+
</div>
|
|
50
|
+
);
|
|
51
|
+
};
|