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