From d875ed62f233c5464163691e4d558922cd0a94f5 Mon Sep 17 00:00:00 2001 From: azeeee05 Date: Mon, 6 Jul 2026 16:31:53 +0530 Subject: [PATCH 1/3] feat: implement library of reusable custom UI components and layout structures for the frontend --- src/components/custom/CustomActionMenu.tsx | 128 +++++++++ src/components/custom/CustomAlertBanner.tsx | 85 ++++++ src/components/custom/CustomAppLoader.tsx | 53 ++++ src/components/custom/CustomBackButton.tsx | 51 ++++ src/components/custom/CustomButton.tsx | 69 +++++ src/components/custom/CustomCheckBox.tsx | 57 ++++ .../custom/CustomConfirmationModal.tsx | 74 ++++++ src/components/custom/CustomDatePicker.tsx | 54 ++++ .../custom/CustomDateTimePicker.tsx | 55 ++++ src/components/custom/CustomDropdown.tsx | 98 +++++++ src/components/custom/CustomFileUploader.tsx | 238 +++++++++++++++++ src/components/custom/CustomFullModal.tsx | 77 ++++++ src/components/custom/CustomIncrement.tsx | 80 ++++++ src/components/custom/CustomInput.tsx | 132 ++++++++++ src/components/custom/CustomLoader.tsx | 85 ++++++ src/components/custom/CustomModal.tsx | 149 +++++++++++ src/components/custom/CustomMultiSelect.tsx | 196 ++++++++++++++ src/components/custom/CustomOTPInput.tsx | 101 +++++++ src/components/custom/CustomRadio.tsx | 52 ++++ .../custom/CustomSearchableDropdown.tsx | 249 ++++++++++++++++++ src/components/custom/CustomSkeleton.tsx | 36 +++ src/components/custom/CustomStatus.tsx | 69 +++++ src/components/custom/CustomSuccessModal.tsx | 90 +++++++ src/components/custom/CustomSwitch.tsx | 49 ++++ src/components/custom/CustomTabs.tsx | 131 +++++++++ src/components/custom/CustomTextArea.tsx | 78 ++++++ src/components/custom/CustomTimePicker.tsx | 56 ++++ src/components/custom/index.ts | 54 ++++ src/layout/AppHeader.tsx | 32 ++- src/layout/AppLayout.tsx | 15 +- src/layout/AppSidebar.tsx | 185 ++++++++----- 31 files changed, 2804 insertions(+), 74 deletions(-) create mode 100644 src/components/custom/CustomActionMenu.tsx create mode 100644 src/components/custom/CustomAlertBanner.tsx create mode 100644 src/components/custom/CustomAppLoader.tsx create mode 100644 src/components/custom/CustomBackButton.tsx create mode 100644 src/components/custom/CustomButton.tsx create mode 100644 src/components/custom/CustomCheckBox.tsx create mode 100644 src/components/custom/CustomConfirmationModal.tsx create mode 100644 src/components/custom/CustomDatePicker.tsx create mode 100644 src/components/custom/CustomDateTimePicker.tsx create mode 100644 src/components/custom/CustomDropdown.tsx create mode 100644 src/components/custom/CustomFileUploader.tsx create mode 100644 src/components/custom/CustomFullModal.tsx create mode 100644 src/components/custom/CustomIncrement.tsx create mode 100644 src/components/custom/CustomInput.tsx create mode 100644 src/components/custom/CustomLoader.tsx create mode 100644 src/components/custom/CustomModal.tsx create mode 100644 src/components/custom/CustomMultiSelect.tsx create mode 100644 src/components/custom/CustomOTPInput.tsx create mode 100644 src/components/custom/CustomRadio.tsx create mode 100644 src/components/custom/CustomSearchableDropdown.tsx create mode 100644 src/components/custom/CustomSkeleton.tsx create mode 100644 src/components/custom/CustomStatus.tsx create mode 100644 src/components/custom/CustomSuccessModal.tsx create mode 100644 src/components/custom/CustomSwitch.tsx create mode 100644 src/components/custom/CustomTabs.tsx create mode 100644 src/components/custom/CustomTextArea.tsx create mode 100644 src/components/custom/CustomTimePicker.tsx create mode 100644 src/components/custom/index.ts diff --git a/src/components/custom/CustomActionMenu.tsx b/src/components/custom/CustomActionMenu.tsx new file mode 100644 index 0000000..9fd2575 --- /dev/null +++ b/src/components/custom/CustomActionMenu.tsx @@ -0,0 +1,128 @@ +import React, { useCallback, useEffect, useRef, useState, useLayoutEffect } from "react"; +import { createPortal } from "react-dom"; +import { MoreVertical } from "lucide-react"; + +interface CustomActionMenuProps { + children: React.ReactNode; +} + +export const CustomActionMenu: React.FC = ({ + children, +}) => { + const [isOpen, setIsOpen] = useState(false); + const [position, setPosition] = useState({ top: 0, left: 0 }); + const buttonRef = useRef(null); + const menuRef = useRef(null); + + const updatePosition = useCallback(() => { + if (buttonRef.current && isOpen) { + const buttonRect = buttonRef.current.getBoundingClientRect(); + const menuRect = menuRef.current?.getBoundingClientRect(); + + let top = buttonRect.bottom + 4; + let left = buttonRect.right - (menuRect?.width || 160); // Default items width + + // Check if it fits vertically + if (menuRect && top + menuRect.height > window.innerHeight) { + top = buttonRect.top - menuRect.height - 4; // Flip up + } + + // Check if it fits horizontally + if (left < 0) { + left = buttonRect.left; // Align left if no space on right + } + + setPosition({ top, left }); + } + }, [isOpen]); + + useLayoutEffect(() => { + updatePosition(); + }, [updatePosition]); + + useEffect(() => { + const handleScrollOrResize = () => { + if (isOpen) updatePosition(); + }; + + window.addEventListener("scroll", handleScrollOrResize, true); + window.addEventListener("resize", handleScrollOrResize); + + return () => { + window.removeEventListener("scroll", handleScrollOrResize, true); + window.removeEventListener("resize", handleScrollOrResize); + }; + }, [isOpen, updatePosition]); + + // Handle click outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + const target = event.target as HTMLElement; + if ( + isOpen && + buttonRef.current && + !buttonRef.current.contains(target) && + !target.closest(".custom-action-menu-dropdown") + ) { + setIsOpen(false); + } + }; + + if (isOpen) { + document.addEventListener("mousedown", handleClickOutside); + } + + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [isOpen]); + + return ( + <> + + + {isOpen && + createPortal( +
setIsOpen(false)} + > +
+ {children} +
+
, + document.body + )} + + ); +}; + +export const CustomActionItem: React.FC<{ + children: React.ReactNode; + onClick?: () => void; + className?: string; +}> = ({ children, onClick, className = "" }) => ( +
{ + onClick?.(); + }} + > + {children} +
+); + +export default CustomActionMenu; diff --git a/src/components/custom/CustomAlertBanner.tsx b/src/components/custom/CustomAlertBanner.tsx new file mode 100644 index 0000000..faddcad --- /dev/null +++ b/src/components/custom/CustomAlertBanner.tsx @@ -0,0 +1,85 @@ +import { type FC, useEffect } from "react"; +import { AlertTriangle, CircleCheck, Info, XCircle, X } from 'lucide-react'; + +type AlertType = 'error' | 'success' | 'warning' | 'info'; + +interface AlertBannerProps { + message: string; + type?: AlertType; + onClose?: () => void; + autoClose?: boolean; + duration?: number; +} + +const alertConfig = { + error: { + icon: , + containerClasses: 'bg-red-50 border-red-500 text-red-800', + }, + success: { + icon: , + containerClasses: 'bg-green-50 border-green-500 text-green-800', + }, + warning: { + icon: , + containerClasses: 'bg-yellow-50 border-yellow-500 text-yellow-800', + }, + info: { + icon: , + containerClasses: 'bg-blue-50 border-blue-500 text-blue-800', + }, +}; + +const AlertBanner: FC = ({ + message, + type = 'error', + onClose, + autoClose = true, + duration = 5000 +}) => { + useEffect(() => { + if (autoClose && message && onClose) { + const timer = setTimeout(() => { + onClose(); + }, duration); + return () => clearTimeout(timer); + } + }, [message, autoClose, duration, onClose]); + + // If no message, don't render anything + if (!message) { + return null; + } + + const { icon, containerClasses } = alertConfig[type as AlertType] || alertConfig.error; + + return ( +
+
+
{icon}
+
+ {message} +
+
+ {onClose && ( + + )} +
+ ); +}; + +export default AlertBanner; diff --git a/src/components/custom/CustomAppLoader.tsx b/src/components/custom/CustomAppLoader.tsx new file mode 100644 index 0000000..34ec1ba --- /dev/null +++ b/src/components/custom/CustomAppLoader.tsx @@ -0,0 +1,53 @@ +import { useState, useEffect } from "react"; +import { Users, Briefcase, Building2 } from "lucide-react"; + +const CustomAppLoader = () => { + const [currentIndex, setCurrentIndex] = useState(0); + + // HRM related icons + const icons = [ + { component: Users, key: "users" }, + { component: Briefcase, key: "briefcase" }, + { component: Building2, key: "building" }, + ]; + + useEffect(() => { + const interval = setInterval(() => { + setCurrentIndex((prev) => (prev + 1) % icons.length); + }, 800); // Slower, smoother transition + + return () => clearInterval(interval); + }, [icons.length]); + + const ActiveIcon = icons[currentIndex].component; + + return ( +
+
+ {/* Outer Spinning Ring */} +
+ + {/* Icon Container */} +
+ +
+
+ +
+

+ HRM System +

+ + Loading resources... + +
+
+ ); +}; + +export default CustomAppLoader; \ No newline at end of file diff --git a/src/components/custom/CustomBackButton.tsx b/src/components/custom/CustomBackButton.tsx new file mode 100644 index 0000000..b6c3640 --- /dev/null +++ b/src/components/custom/CustomBackButton.tsx @@ -0,0 +1,51 @@ +import React from "react"; +import { Link, useNavigate } from "react-router-dom"; +import { MoveLeft } from "lucide-react"; + +interface CustomBackButtonProps { + to?: string; + label?: string; + tooltip?: string; + className?: string; +} + +const CustomBackButton: React.FC = ({ + to, + label = "", + tooltip, + className = "", +}) => { + const navigate = useNavigate(); + + const handleBack = (e: React.MouseEvent) => { + if (!to) { + e.preventDefault(); + navigate(-1); + } + }; + + const content = ( +
+
+ +
+ {label && {label}} + + {tooltip && !label && ( +
+ {tooltip} +
+ )} +
+ ); + + if (to) { + return {content}; + } + + return
{content}
; +}; + +export default CustomBackButton; diff --git a/src/components/custom/CustomButton.tsx b/src/components/custom/CustomButton.tsx new file mode 100644 index 0000000..65cd28c --- /dev/null +++ b/src/components/custom/CustomButton.tsx @@ -0,0 +1,69 @@ +import CustomLoader from "./CustomLoader"; + +type ButtonVariant = "primary" | "secondary" | "outlined" | "text" | "link"; +type ButtonSize = "sm" | "md" | "lg"; + +interface CustomButtonProps + extends React.ButtonHTMLAttributes { + variant?: ButtonVariant; + size?: ButtonSize; + leftIcon?: React.ReactNode; + rightIcon?: React.ReactNode; + loading?: boolean; + children: React.ReactNode; +} + +const CustomButton: React.FC = ({ + variant = "primary", + size = "md", + leftIcon, + rightIcon, + loading = false, + disabled, + className = "", + children, + ...props +}) => { + const baseClasses = + "inline-flex items-center justify-center font-medium rounded-lg transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2"; + + const variantClasses = { + primary: + "bg-[#0B3B6A] text-white hover:bg-[#092e53] focus:ring-[#0B3B6A] disabled:bg-gray-300 disabled:text-gray-500", + secondary: + "bg-gray-600 text-white hover:bg-gray-700 focus:ring-gray-500 disabled:bg-gray-300 disabled:text-gray-500", + outlined: + "border-2 border-[#0B3B6A] text-[#0B3B6A] bg-transparent hover:bg-gray-50 focus:ring-[#0B3B6A] disabled:border-gray-300 disabled:text-gray-400 disabled:hover:bg-transparent", + text: "text-[#0B3B6A] bg-transparent hover:bg-gray-50 focus:ring-[#0B3B6A] disabled:text-gray-400 disabled:hover:bg-transparent", + link: "text-[#0B3B6A] bg-transparent hover:underline p-0 h-auto focus:ring-transparent disabled:text-gray-400", + }; + + const sizeClasses = { + sm: "px-3 py-1.5 text-sm gap-1.5", + md: "px-4 py-2 text-sm gap-2", + lg: "px-6 py-3 text-base gap-2", + }; + + const isDisabled = disabled || loading; + + const loaderVariant = variant === "outlined" || variant === "text" ? "primary" : "white"; + + return ( + + ); +}; + +export default CustomButton; diff --git a/src/components/custom/CustomCheckBox.tsx b/src/components/custom/CustomCheckBox.tsx new file mode 100644 index 0000000..cd6820a --- /dev/null +++ b/src/components/custom/CustomCheckBox.tsx @@ -0,0 +1,57 @@ +import React, { forwardRef } from "react"; +import { Check } from "lucide-react"; + +interface CustomCheckBoxProps + extends Omit, "type"> { + label?: string; +} + +const CustomCheckBox = forwardRef( + ({ label, className = "", disabled, ...props }, ref) => { + return ( + + ); + } +); + +export default CustomCheckBox; diff --git a/src/components/custom/CustomConfirmationModal.tsx b/src/components/custom/CustomConfirmationModal.tsx new file mode 100644 index 0000000..7be44fd --- /dev/null +++ b/src/components/custom/CustomConfirmationModal.tsx @@ -0,0 +1,74 @@ +import React from "react"; +import CustomModal from "./CustomModal"; +import CustomButton from "./CustomButton"; +import { AlertTriangle, CheckCircle } from "lucide-react"; + +interface ConfirmationModalProps { + isOpen: boolean; + onClose: () => void; + onConfirm: () => void; + title: string; + description: string; + confirmText?: string; + cancelText?: string; + variant?: "danger" | "primary" | "warning" | "success"; // To style the confirm button + isLoading?: boolean; +} + +const ConfirmationModal: React.FC = ({ + isOpen, + onClose, + onConfirm, + title, + description, + confirmText = "Confirm", + cancelText = "Cancel", + variant = "danger", + isLoading = false, +}) => { + return ( + +
+
+ {variant === 'success' ? : } +
+ +

+ {description} +

+ +
+ + {cancelText} + + + {confirmText} + +
+
+
+ ); +}; + +export default ConfirmationModal; diff --git a/src/components/custom/CustomDatePicker.tsx b/src/components/custom/CustomDatePicker.tsx new file mode 100644 index 0000000..983f65c --- /dev/null +++ b/src/components/custom/CustomDatePicker.tsx @@ -0,0 +1,54 @@ +import React, { forwardRef } from "react"; +import { Calendar } from "lucide-react"; + +interface CustomDatePickerProps + extends Omit, "type"> { + label?: string; + error?: string; +} + +const CustomDatePicker = forwardRef( + ({ label, error, className = "", disabled, required, ...props }, ref) => { + return ( +
+ {label && ( + + )} +
+ + + + +
+ {error &&

{error}

} +
+ ); + } +); + +export default CustomDatePicker; diff --git a/src/components/custom/CustomDateTimePicker.tsx b/src/components/custom/CustomDateTimePicker.tsx new file mode 100644 index 0000000..e8cc43b --- /dev/null +++ b/src/components/custom/CustomDateTimePicker.tsx @@ -0,0 +1,55 @@ +import React, { forwardRef } from "react"; +import { CalendarClock } from "lucide-react"; + +interface CustomDateTimePickerProps + extends Omit, "type"> { + label?: string; + error?: string; +} + +const CustomDateTimePicker = forwardRef< + HTMLInputElement, + CustomDateTimePickerProps +>(({ label, error, className = "", disabled, required, ...props }, ref) => { + return ( +
+ {label && ( + + )} +
+ + + + +
+ {error &&

{error}

} +
+ ); +}); + +export default CustomDateTimePicker; diff --git a/src/components/custom/CustomDropdown.tsx b/src/components/custom/CustomDropdown.tsx new file mode 100644 index 0000000..811e734 --- /dev/null +++ b/src/components/custom/CustomDropdown.tsx @@ -0,0 +1,98 @@ +import React, { forwardRef } from "react"; +import { ChevronDown } from "lucide-react"; + +interface Option { + label: string; + value: string | number; + disabled?: boolean; +} + +interface CustomDropdownProps + extends Omit, "size"> { + label?: string; + options: Option[]; + leftIcon?: React.ReactNode; + placeholder?: string; + size?: "sm" | "md"; +} + +const CustomDropdown = forwardRef( + ( + { + label, + options, + leftIcon, + placeholder, + disabled, + className = "", + size = "md", + ...props + }, + ref + ) => { + const sizeClasses = size === "sm" ? "py-1.5 text-sm border-2 min-h-[36px]" : "py-3 text-sm"; + + return ( +
+ {label && ( + + )} + +
+ {leftIcon && ( + + {leftIcon} + + )} + + + + + + +
+
+ ); + } +); + +export default CustomDropdown; diff --git a/src/components/custom/CustomFileUploader.tsx b/src/components/custom/CustomFileUploader.tsx new file mode 100644 index 0000000..4a878d4 --- /dev/null +++ b/src/components/custom/CustomFileUploader.tsx @@ -0,0 +1,238 @@ +import React, { useRef, useState, useEffect } from "react"; +import { Upload, X, File } from "lucide-react"; + +interface CustomFileUploaderProps { + label?: string; + required?: boolean; + multiple?: boolean; + maxFiles?: number; + accept?: string; + value?: File[]; + onChange?: (files: File[]) => void; + initialUrl?: string | null; + onRemoveInitial?: () => void; + disabled?: boolean; + className?: string; +} + +const CustomFileUploader: React.FC = ({ + label, + required, + multiple = false, + maxFiles, + accept, + value = [], + onChange, + initialUrl, + onRemoveInitial, + disabled, + className = "", +}) => { + const inputRef = useRef(null); + const [isDragging, setIsDragging] = useState(false); + const [previews, setPreviews] = useState<{ [key: string]: string }>({}); + + useEffect(() => { + // Cleanup object URLs to avoid memory leaks + return () => { + Object.values(previews).forEach((url) => URL.revokeObjectURL(url)); + }; + }, [previews]); + + useEffect(() => { + // Generate previews for new files + const newPreviews: { [key: string]: string } = {}; + value.forEach((file) => { + if (file.type.startsWith("image/") && !previews[file.name]) { + newPreviews[file.name] = URL.createObjectURL(file); + } + }); + + if (Object.keys(newPreviews).length > 0) { + setPreviews((prev) => ({ ...prev, ...newPreviews })); + } + }, [value, previews]); + + const handleFileChange = (e: React.ChangeEvent) => { + if (disabled) return; + const files = Array.from(e.target.files || []); + handleFiles(files); + }; + + const handleFiles = (newFiles: File[]) => { + let updatedFiles = multiple ? [...value, ...newFiles] : newFiles; + + if (maxFiles && updatedFiles.length > maxFiles) { + updatedFiles = updatedFiles.slice(0, maxFiles); + } + + onChange?.(updatedFiles); + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + if (!disabled) setIsDragging(true); + }; + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + if (disabled) return; + const files = Array.from(e.dataTransfer.files); + handleFiles(files); + }; + + const removeFile = (e: React.MouseEvent, index: number) => { + e.stopPropagation(); + if (disabled) return; + const fileToRemove = value[index]; + const newFiles = value.filter((_, i) => i !== index); + onChange?.(newFiles); + + // Cleanup preview if it exists + if (previews[fileToRemove.name]) { + URL.revokeObjectURL(previews[fileToRemove.name]); + setPreviews((prev) => { + const newPrev = { ...prev }; + delete newPrev[fileToRemove.name]; + return newPrev; + }); + } + }; + + return ( +
+ {label && ( + + )} + +
!disabled && inputRef.current?.click()} + > + + +
+ + + {isDragging ? "Drop files here" : "Click or drag to upload"} + + {maxFiles && ( + + Max {maxFiles} file{maxFiles > 1 ? "s" : ""} + + )} +
+
+ + { + (value.length > 0 || initialUrl) && ( +
+ {/* Initial Image Preview */} + {initialUrl && value.length === 0 && ( +
+
+
+ Existing Logo +
+
+ + Current Logo + + + Already uploaded + +
+
+ {!disabled && ( + + )} +
+ )} + + {/* New Files Previews */} + {value.map((file, index) => ( +
+
+
+ {file.type.startsWith("image/") && previews[file.name] ? ( + {file.name} + ) : ( + + )} +
+
+ + {file.name} + + + {(file.size / 1024).toFixed(1)} KB + +
+
+ {!disabled && ( + + )} +
+ ))} +
+ ) + } +
+ ); +}; + +export default CustomFileUploader; diff --git a/src/components/custom/CustomFullModal.tsx b/src/components/custom/CustomFullModal.tsx new file mode 100644 index 0000000..e338bf4 --- /dev/null +++ b/src/components/custom/CustomFullModal.tsx @@ -0,0 +1,77 @@ +import React, { useEffect } from "react"; +import { X } from "lucide-react"; + +interface FullModalProps { + isOpen: boolean; + onClose: () => void; + children: React.ReactNode; + showCloseButton?: boolean; + className?: string; // For additional overrides if absolutely needed + contentClassName?: string; +} + +const FullModal: React.FC = ({ + isOpen, + onClose, + children, + showCloseButton = false, // Default false as callers might want custom close buttons + className = "", + contentClassName = "", +}) => { + useEffect(() => { + const handleEsc = (event: KeyboardEvent) => { + if (event.key === "Escape") { + onClose(); + } + }; + + if (isOpen) { + document.addEventListener("keydown", handleEsc); + document.body.style.overflow = "hidden"; + } + + return () => { + document.removeEventListener("keydown", handleEsc); + document.body.style.overflow = ""; + }; + }, [isOpen, onClose]); + + if (!isOpen) return null; + + return ( +
+ {/* + Modal Container: + - Mobile: w-screen h-screen (Full Screen), rounded-none + - Desktop (md): max-w-7xl h-[90vh] rounded-2xl + */} +
+ {/* Close Button (Optional default) */} + {showCloseButton && ( + + )} + + {/* Content Area */} +
+ {children} +
+
+
+ ); +}; + +export default FullModal; diff --git a/src/components/custom/CustomIncrement.tsx b/src/components/custom/CustomIncrement.tsx new file mode 100644 index 0000000..1a39cd2 --- /dev/null +++ b/src/components/custom/CustomIncrement.tsx @@ -0,0 +1,80 @@ +import React from "react"; +import { Minus, Plus } from "lucide-react"; + +interface CustomIncrementProps { + label?: string; + required?: boolean; + value: number; + onChange: (value: number) => void; + min?: number; + max?: number; + step?: number; + disabled?: boolean; + className?: string; +} + +const CustomIncrement: React.FC = ({ + label, + required = false, + value, + onChange, + min = 1, + max = Number.POSITIVE_INFINITY, + step = 1, + disabled = false, + className = "", +}) => { + const handleDecrease = () => { + if (disabled) { + return; + } + onChange(Math.max(min, value - step)); + }; + + const handleIncrease = () => { + if (disabled) { + return; + } + onChange(Math.min(max, value + step)); + }; + + return ( +
+ {label && ( + + )} +
+ +
+ {value} +
+ +
+
+ ); +}; + +export default CustomIncrement; diff --git a/src/components/custom/CustomInput.tsx b/src/components/custom/CustomInput.tsx new file mode 100644 index 0000000..f801b45 --- /dev/null +++ b/src/components/custom/CustomInput.tsx @@ -0,0 +1,132 @@ +import React, { useState, forwardRef } from "react"; +import { Phone, Eye, EyeOff } from "lucide-react"; + +type InputType = "text" | "password" | "number" | "email" | "tel" | "date" | "month" | "datetime-local"; + +interface CustomInputProps extends React.InputHTMLAttributes { + label?: string; + type?: InputType; + leftIcon?: React.ReactNode; + rightIcon?: React.ReactNode; + maxLength?: number; + phonePrefix?: string; + error?: string; + containerClassName?: string; +} + +const CustomInput = forwardRef( + ( + { + label, + type = "text", + leftIcon, + rightIcon, + maxLength, + phonePrefix, + disabled, + className = "", + containerClassName = "", + error, + onInput, + ...props + }, + ref + ) => { + const isPassword = type === "password"; + const isPhone = phonePrefix !== undefined; + const [showPassword, setShowPassword] = useState(false); + + const inputType = isPassword && showPassword ? "text" : type; + + const handleInput = (e: React.InputEvent) => { + if (maxLength && (type === "number" || type === "tel")) { + const target = e.target as HTMLInputElement; + if (target.value.length > maxLength) { + target.value = target.value.slice(0, maxLength); + } + } + onInput?.(e); + }; + + return ( +
+ {label && ( + + )} + +
+ {isPhone && ( +
+ + {phonePrefix} +
+ )} + + {!isPhone && leftIcon && ( + + {leftIcon} + + )} + + + + {!isPhone && isPassword ? ( + setShowPassword(!showPassword)} + className="absolute right-3 top-1/2 -translate-y-1/2 cursor-pointer text-slate-400 select-none hover:text-slate-600" + > + {showPassword ? : } + + ) : ( + !isPhone && + rightIcon && ( + + {rightIcon} + + ) + )} +
+ {error &&

{error}

} +
+ ); + } +); + +export default CustomInput; diff --git a/src/components/custom/CustomLoader.tsx b/src/components/custom/CustomLoader.tsx new file mode 100644 index 0000000..1b3189b --- /dev/null +++ b/src/components/custom/CustomLoader.tsx @@ -0,0 +1,85 @@ +import type { ComponentProps } from "react"; + +type LoaderProps = { + label?: string; + size?: "sm" | "md" | "lg" | "xl"; + variant?: "primary" | "white" | "gray" | "red"; + inline?: boolean; + className?: string; +} & ComponentProps<"div">; + +const variantColorMap = { + primary: "text-[#0B3B6A]", // Brand Dark Blue + white: "text-white", + gray: "text-gray-500", + red: "text-[#0B3B6A]", +}; + +export function CustomLoader({ + label, + size = "md", + variant = "primary", + inline = false, + className, + ...rest +}: LoaderProps) { + const containerClass = [ + "flex flex-col items-center justify-center gap-3", + inline ? "flex-row" : "", + className, + ] + .filter(Boolean) + .join(" "); + + const colorClass = variantColorMap[variant]; + + // Using a modern SVG spinner with a track + // We map sizes to pixel values for the SVG + const pixelSize = { + sm: 20, + md: 32, + lg: 48, + xl: 64, + }[size]; + + return ( +
+
+ {/* Track - subtle opacity */} + + + + +
+ + {label && ( + + {label} + + )} +
+ ); +} + +export default CustomLoader; \ No newline at end of file diff --git a/src/components/custom/CustomModal.tsx b/src/components/custom/CustomModal.tsx new file mode 100644 index 0000000..425e694 --- /dev/null +++ b/src/components/custom/CustomModal.tsx @@ -0,0 +1,149 @@ +import React, { useEffect } from "react"; +import { createPortal } from "react-dom"; + +type ModalSize = "sm" | "md" | "lg" | "xl" | "full"; + +interface CustomModalProps { + isOpen: boolean; + onClose: () => void; + children: React.ReactNode; + + title?: string; + description?: string; + footer?: React.ReactNode; + + size?: ModalSize; + showCloseButton?: boolean; + allowBackdropClose?: boolean; + + className?: string; + contentClassName?: string; + overlayClassName?: string; +} + +const SIZE_CLASS_MAP: Record = { + sm: "max-w-md", + md: "max-w-2xl", + lg: "max-w-4xl", + xl: "max-w-6xl", + full: "max-w-none", +}; + +const CustomModal: React.FC = ({ + isOpen, + onClose, + children, + title, + description, + footer, + size = "lg", + showCloseButton = true, + allowBackdropClose = false, + className = "", + contentClassName = "", + overlayClassName = "", +}) => { + useEffect(() => { + const handleEsc = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + + if (isOpen) { + document.addEventListener("keydown", handleEsc); + document.body.style.overflow = "hidden"; + } + + return () => { + document.removeEventListener("keydown", handleEsc); + document.body.style.overflow = ""; + }; + }, [isOpen, onClose]); + + if (!isOpen) return null; + + const widthClasses = + size === "full" + ? "w-screen h-screen" + : `w-full ${SIZE_CLASS_MAP[size]} mx-4`; + + const roundingClass = size === "full" ? "rounded-none" : "rounded-2xl"; + const heightClass = size === "full" ? "h-full" : "max-h-[85vh]"; + + return createPortal( +
+ {/* Overlay */} +
allowBackdropClose && onClose()} + /> + + {/* Modal */} +
+ {/* Header */} + {(title || showCloseButton) && ( +
+ {title && ( +

+ {title} +

+ )} + {description && ( +

+ {description} +

+ )} + + {showCloseButton && ( + + )} +
+ )} + + {/* Content - Removed heightClass, added min-h-0 for proper flex scrolling */} +
+ {children} +
+ + {/* Footer */} + {footer && ( +
+ {footer} +
+ )} +
+
, + document.body + ); +}; + +export default CustomModal; diff --git a/src/components/custom/CustomMultiSelect.tsx b/src/components/custom/CustomMultiSelect.tsx new file mode 100644 index 0000000..682f976 --- /dev/null +++ b/src/components/custom/CustomMultiSelect.tsx @@ -0,0 +1,196 @@ +import React, { useState, useRef, useEffect } from "react"; +import { ChevronDown, X, Check } from "lucide-react"; + +interface Option { + label: string; + value: string | number; +} + +interface CustomMultiSelectProps { + label?: string; + options: Option[]; + value?: (string | number)[]; + onChange?: (value: (string | number)[]) => void; + placeholder?: string; + leftIcon?: React.ReactNode; + disabled?: boolean; + required?: boolean; + className?: string; + error?: string; +} + +const CustomMultiSelect: React.FC = ({ + label, + options, + value = [], + onChange, + placeholder = "Select options...", + leftIcon, + disabled, + required, + className = "", + error, +}) => { + const [isOpen, setIsOpen] = useState(false); + const [searchTerm, setSearchTerm] = useState(""); + const containerRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + containerRef.current && + !containerRef.current.contains(event.target as Node) + ) { + setIsOpen(false); + setSearchTerm(""); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, []); + + const handleSelect = (optionValue: string | number) => { + if (disabled) return; + const newValue = value.includes(optionValue) + ? value.filter((v) => v !== optionValue) + : [...value, optionValue]; + onChange?.(newValue); + }; + + const removeValue = (e: React.MouseEvent, optionValue: string | number) => { + e.stopPropagation(); + if (disabled) return; + onChange?.(value.filter((v) => v !== optionValue)); + }; + + const selectedOptions = options.filter((opt) => value.includes(opt.value)); + + return ( +
+ {label && ( + + )} + +
+
!disabled && setIsOpen(!isOpen)} + className={` + w-full rounded-lg + bg-white text-gray-900 text-sm + border ${isOpen + ? "border-[#0B3B6A] ring-2 ring-[#0B3B6A]/20" + : error ? "border-red-500" : "border-gray-300" + } + px-3 py-0 h-[46px] + outline-none + transition-all duration-200 + hover:border-[#0B3B6A] + cursor-pointer + flex items-center gap-2 + overflow-hidden + ${disabled + ? "bg-gray-50 text-gray-500 cursor-not-allowed border-gray-300" + : "" + } + ${leftIcon ? "pl-10" : ""} + pr-10 + ${className} + `} + > + {leftIcon && ( + + {leftIcon} + + )} + +
+ {selectedOptions.length === 0 ? ( + {placeholder} + ) : selectedOptions.length === 1 ? ( + + {selectedOptions[0].label} + removeValue(e, selectedOptions[0].value)} + /> + + ) : ( + + {selectedOptions.length} Selected + + )} +
+ + + + +
+ + {isOpen && !disabled && ( +
+
+ e.stopPropagation()} + onChange={(e) => setSearchTerm(e.target.value)} + value={searchTerm} + autoFocus + /> +
+ +
+ {options + .filter(opt => opt.label.toLowerCase().includes(searchTerm.toLowerCase())) + .length === 0 ? ( +
+ No options available +
+ ) : ( + options + .filter(opt => opt.label.toLowerCase().includes(searchTerm.toLowerCase())) + .map((option) => { + const isSelected = value.includes(option.value); + return ( +
handleSelect(option.value)} + className={` + px-3 py-2 text-sm cursor-pointer flex items-center justify-between + ${isSelected + ? "bg-[#0B3B6A]/10 dark:bg-[#0B3B6A]/20 text-[#0B3B6A] dark:text-blue-400" + : "text-gray-900 hover:bg-gray-50" + } + `} + > + {option.label} + {isSelected && } +
+ ); + }) + )} +
+
+ )} +
+ {error &&

{error}

} +
+ ); +}; + +export default CustomMultiSelect; diff --git a/src/components/custom/CustomOTPInput.tsx b/src/components/custom/CustomOTPInput.tsx new file mode 100644 index 0000000..45af317 --- /dev/null +++ b/src/components/custom/CustomOTPInput.tsx @@ -0,0 +1,101 @@ +import React, { useRef, useEffect } from "react"; + +interface CustomOTPInputProps { + length?: number; + value: string; + onChange: (value: string) => void; + label?: string; + error?: string; +} + +const CustomOTPInput: React.FC = ({ + length = 6, + value, + onChange, + label, + error, +}) => { + const inputs = useRef<(HTMLInputElement | null)[]>([]); + + useEffect(() => { + if (inputs.current[0]) { + inputs.current[0].focus(); + } + }, []); + + const handleChange = ( + e: React.ChangeEvent, + index: number + ) => { + const val = e.target.value; + if (isNaN(Number(val))) return; + + const newOtp = value.split(""); + newOtp[index] = val.substring(val.length - 1); + const combinedOtp = newOtp.join(""); + onChange(combinedOtp); + + // Move to next input if value is entered + if (val && index < length - 1 && inputs.current[index + 1]) { + inputs.current[index + 1]?.focus(); + } + }; + + const handleKeyDown = ( + e: React.KeyboardEvent, + index: number + ) => { + if ( + e.key === "Backspace" && + !value[index] && + index > 0 && + inputs.current[index - 1] + ) { + // Move to previous input on backspace if current is empty + inputs.current[index - 1]?.focus(); + } + }; + + const handlePaste = (e: React.ClipboardEvent) => { + e.preventDefault(); + const pastedData = e.clipboardData.getData("text").slice(0, length); + if (/^\d+$/.test(pastedData)) { + onChange(pastedData); + // Focus the last filled input or the next empty one + const nextIndex = Math.min(pastedData.length, length - 1); + inputs.current[nextIndex]?.focus(); + } + }; + + return ( +
+ {label && ( + + )} +
+ {Array.from({ length }).map((_, index) => ( + { inputs.current[index] = el }} + type="text" + maxLength={1} + value={value[index] || ""} + onChange={(e) => handleChange(e, index)} + onKeyDown={(e) => handleKeyDown(e, index)} + onPaste={handlePaste} + className={`w-10 h-12 sm:w-12 sm:h-14 text-center text-xl font-bold rounded-lg border outline-none transition-all duration-200 + bg-white text-gray-800 border-gray-300 focus:border-[#0B3B6A] focus:ring-2 focus:ring-[#0B3B6A]/20 + ${error + ? "border-red-500 focus:border-red-500 focus:ring-red-500/20" + : "" + } + `} + /> + ))} +
+ {error &&

{error}

} +
+ ); +}; + +export default CustomOTPInput; diff --git a/src/components/custom/CustomRadio.tsx b/src/components/custom/CustomRadio.tsx new file mode 100644 index 0000000..9cc9159 --- /dev/null +++ b/src/components/custom/CustomRadio.tsx @@ -0,0 +1,52 @@ +import React, { forwardRef } from "react"; + +interface CustomRadioProps + extends Omit, "type"> { + label?: string; +} + +const CustomRadio = forwardRef( + ({ label, className = "", disabled, ...props }, ref) => { + return ( +