first commit

This commit is contained in:
TuanVT
2026-05-23 09:48:07 +07:00
parent 03b9101bd5
commit b6ccde2c74
134 changed files with 7178 additions and 110 deletions
@@ -0,0 +1,70 @@
"use client";
import React, { useCallback, useMemo, useState } from "react";
import { PropsFormCustom } from "./interface";
import { ContextFormCustom } from "./contexts";
function FormCustom<T extends Record<string, any>>({
form,
setForm,
onSubmit,
children,
}: PropsFormCustom<T>) {
// 👉 tạo object error ban đầu
const initialError = useMemo(() => {
return Object.fromEntries(
Object.keys(form).map((key) => [key, null]),
) as Record<keyof T, string | null>;
}, [form]);
const [countValidate, setCountValidate] = useState(0);
const [errorText, setErrorText] =
useState<Record<keyof T, string | null>>(initialError);
const [validate, setValidate] = useState<Record<keyof T, boolean> | null>(
null,
);
// 👉 check form hợp lệ
const isDone = useMemo(() => {
if (!validate) return false;
return Object.values(validate).every(Boolean);
}, [validate]);
// 👉 submit form
const handleSubmit = useCallback(
(e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (isDone) {
onSubmit?.();
} else {
setCountValidate((prev) => prev + 1);
}
},
[isDone, onSubmit],
);
return (
<ContextFormCustom.Provider
value={{
form,
setForm,
errorText,
setErrorText,
setValidate,
countValidate,
setCountValidate,
isDone,
}}
>
<form onSubmit={handleSubmit} className="w-full">
{children}
</form>
</ContextFormCustom.Provider>
);
}
export default FormCustom;
@@ -0,0 +1,401 @@
"use client";
import React, { useContext, useMemo, useState } from "react";
import clsx from "clsx";
import { Check, CircleX, Eye, EyeOff } from "lucide-react";
import { validateEmail, validatePhoneNumber } from "@/common/funcs/validate";
import { convertCoin, price } from "@/common/funcs/convertCoin";
import { ContextFormCustom, IContextFormCustom } from "../../contexts";
import { PropsInputForm } from "./interface";
function InputForm({
type,
name,
placeholder,
icon,
label,
value,
unit,
note,
textRequired,
textConfirm,
valueConfirm,
isBlur = true,
onClean,
showDone,
readOnly,
max,
min,
isRequired,
isNumber,
isUppercase,
isPhone,
isEmail,
isMoney,
onBlur,
onChangeValue,
}: PropsInputForm) {
const isPassword = type === "password";
const [showPass, setShowPass] = useState(false);
const [isFocus, setIsFocus] = useState(false);
const {
form,
countValidate,
errorText,
isDone,
setForm,
setValidate,
setErrorText,
} = useContext<IContextFormCustom<any>>(ContextFormCustom);
// =========================
// VALUE
// =========================
const currentValue = useMemo(() => {
return value ?? form?.[name] ?? "";
}, [value, form, name]);
// =========================
// VALIDATE
// =========================
const handleValidate = (inputValue?: string | number) => {
const finalValue = String(inputValue ?? currentValue ?? "");
if (isRequired && finalValue.trim() === "") {
return {
valid: false,
message: textRequired || "Vui lòng nhập trường này",
};
}
if (finalValue) {
if (isNumber && isNaN(Number(finalValue))) {
return {
valid: false,
message: "Vui lòng chỉ nhập số",
};
}
if (isPhone && !validatePhoneNumber(finalValue)) {
return {
valid: false,
message: "Định dạng số điện thoại không đúng",
};
}
if (isEmail && !validateEmail(finalValue)) {
return {
valid: false,
message: "Định dạng email không chính xác",
};
}
if (valueConfirm && finalValue !== valueConfirm) {
return {
valid: false,
message: textConfirm || "Mật khẩu không trùng khớp",
};
}
if (max && finalValue.length > max) {
return {
valid: false,
message: `Nhập tối đa ${max} kí tự`,
};
}
if (min && finalValue.length < min) {
return {
valid: false,
message: `Nhập tối thiểu ${min} kí tự`,
};
}
}
return {
valid: true,
message: null,
};
};
// =========================
// ERROR
// =========================
const handleSetMessage = (inputValue?: string | number) => {
const result = handleValidate(inputValue);
setErrorText((prev) => ({
...prev,
[name]: result.message,
}));
setValidate((prev) => ({
...prev,
[name]: result.valid,
}));
};
// =========================
// HANDLERS
// =========================
const handleFocus = () => {
setIsFocus(true);
setErrorText((prev) => ({
...prev,
[name]: null,
}));
};
const handleBlur = () => {
setIsFocus(false);
onBlur?.();
if (isBlur) {
handleSetMessage();
}
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
let inputValue = e.target.value;
if (isUppercase) {
inputValue = inputValue.toUpperCase();
}
// MONEY
if (isMoney) {
const numericValue = Number(price(inputValue));
inputValue = numericValue ? convertCoin(numericValue) : "0";
}
// CUSTOM CHANGE
if (onChangeValue) {
onChangeValue(inputValue);
return;
}
// UPDATE FORM
setForm((prev: any) => ({
...prev,
[name]: inputValue,
}));
// VALIDATE LIVE
if (countValidate > 0) {
handleSetMessage(inputValue);
}
};
const handleClean = () => {
setForm((prev: any) => ({
...prev,
[name]: "",
}));
setErrorText((prev) => ({
...prev,
[name]: null,
}));
setValidate((prev) => ({
...prev,
[name]: !isRequired,
}));
};
// =========================
// RENDER
// =========================
return (
<div className="w-full">
{/* LABEL */}
{label && (
<label
className="
mb-2
block
text-[16px]
font-medium
text-[#171717]
"
>
{label}
</label>
)}
{/* INPUT WRAPPER */}
<div
className={clsx(
`
flex
h-12
items-center
gap-2
rounded-full
border
bg-white
px-4
transition-all
`,
"border-[#CDD5DF]",
"hover:border-[#0011AB]",
isFocus && "border-[#0011AB]",
errorText?.[name] && "border-red-500",
showDone && isDone && !errorText?.[name] && "border-green-600",
readOnly &&
`
cursor-not-allowed
border-gray-200
bg-gray-100
`,
)}
>
{/* ICON */}
{icon && (
<div
className={clsx(
"transition-all",
isFocus && "text-[#0011AB]",
errorText?.[name] && "text-red-500",
showDone && isDone && !errorText?.[name] && "text-green-600",
!isFocus && !errorText?.[name] && "text-gray-400",
)}
>
{icon}
</div>
)}
{/* INPUT */}
<input
name={name}
type={isPassword && showPass ? "text" : type}
value={String(currentValue)}
placeholder={placeholder}
autoComplete="off"
disabled={readOnly}
readOnly={readOnly}
onFocus={handleFocus}
onBlur={handleBlur}
onChange={handleChange}
className="
flex-1
bg-transparent
text-[16px]
font-medium
outline-none
placeholder:text-gray-400
"
/>
{/* UNIT */}
{unit ? (
<div
className="
border-l
border-gray-300
pl-3
text-[16px]
font-medium
"
>
{unit}
</div>
) : (
<div className="flex items-center gap-2">
{/* CLEAN */}
{onClean && !!currentValue && (
<button
type="button"
onClick={handleClean}
className="
cursor-pointer
transition-all
hover:opacity-70
"
>
<CircleX size={18} />
</button>
)}
{/* DONE */}
{!isPassword &&
showDone &&
isDone &&
!errorText?.[name] &&
!!currentValue && <Check size={18} className="text-green-600" />}
{/* PASSWORD */}
{isPassword && (
<button
type="button"
onClick={() => setShowPass(!showPass)}
className="
cursor-pointer
transition-all
hover:opacity-70
"
>
{showPass ? <Eye size={18} /> : <EyeOff size={18} />}
</button>
)}
</div>
)}
</div>
{/* ERROR */}
{errorText?.[name] && (
<p
className="
mt-1
text-xs
font-medium
text-red-500
"
>
{errorText[name]}
</p>
)}
{/* NOTE */}
{note && (
<p
className="
mt-1
text-xs
font-medium
text-gray-500
"
>
{note}
</p>
)}
</div>
);
}
export default InputForm;
@@ -0,0 +1 @@
export { default } from "./InputForm";
@@ -0,0 +1,35 @@
import { Dispatch, SetStateAction } from "react";
export interface PropsInputForm {
type: string;
name: string;
placeholder: string;
icon?: React.ReactNode;
label?: string | React.ReactNode;
value?: string | number;
unit?: string;
note?: string;
textRequired?: string;
valueConfirm?: string;
textConfirm?: string;
isBlur?: boolean;
onClean?: boolean;
showDone?: boolean;
readOnly?: boolean;
max?: number;
min?: number;
isRequired?: boolean;
isNumber?: boolean;
isPhone?: boolean;
isEmail?: boolean;
isMoney?: boolean;
isUppercase?: boolean;
onBlur?: () => void;
onChangeValue?: (val: string | number) => void;
}
@@ -0,0 +1,175 @@
"use client";
import React, { useEffect, useRef, useState } from "react";
import clsx from "clsx";
import { ChevronDown, Search, CircleX } from "lucide-react";
import Image from "next/image";
import { removeVietnameseTones } from "@/common/funcs/optionConvert";
import icons from "@/constant/images/icons";
import { PropsSelectForm } from "./interface";
function SelectForm<OptionType>({
label,
placeholder = "Chọn...",
isSearch = true,
readOnly,
value,
options = [], // ✅ FIX NULL
onClean,
onSelect,
getOptionLabel,
getOptionValue,
}: PropsSelectForm<OptionType>) {
const refWrapper = useRef<HTMLDivElement>(null);
const refInput = useRef<HTMLInputElement>(null);
const [open, setOpen] = useState(false);
const [keyword, setKeyword] = useState("");
/* ================= CLICK OUTSIDE ================= */
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (!refWrapper.current?.contains(e.target as Node)) {
setOpen(false);
setKeyword("");
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
/* ================= FOCUS SEARCH ================= */
useEffect(() => {
if (open && isSearch) {
setTimeout(() => {
refInput.current?.focus();
}, 0);
}
}, [open, isSearch]);
/* ================= DATA ================= */
const safeOptions = options ?? [];
const selectedOption = safeOptions.find(
(opt) => getOptionValue(opt) === value,
);
const filteredOptions = safeOptions.filter((opt) => {
const label = removeVietnameseTones(getOptionLabel(opt) || "");
const key = removeVietnameseTones(keyword || "");
return label.includes(key);
});
/* ================= HANDLER ================= */
const handleSelect = (opt: OptionType) => {
onSelect?.(opt);
setOpen(false);
setKeyword("");
};
return (
<div className="relative w-full" ref={refWrapper}>
{/* LABEL */}
{label && (
<label className="block mb-2 text-[16px] font-medium">{label}</label>
)}
{/* SELECT BOX */}
<div
onClick={() => !readOnly && setOpen((prev) => !prev)}
className={clsx(
"flex items-center justify-between px-4 h-12 border rounded-full cursor-pointer transition",
"border-gray-300 bg-white hover:border-blue-600",
open && "border-blue-600",
readOnly &&
"bg-gray-100 border-gray-200 cursor-not-allowed hover:border-gray-200",
)}
>
{/* VALUE */}
<p
className={clsx(
"flex-1 text-[16px] font-medium",
!selectedOption && "text-gray-400",
)}
>
{selectedOption ? getOptionLabel(selectedOption) : placeholder}
</p>
{/* CLEAR */}
{onClean && !!value && !readOnly && (
<div
onClick={(e) => {
e.stopPropagation();
onClean();
}}
className="mr-2 hover:opacity-70"
>
<CircleX size={18} className="text-gray-400" />
</div>
)}
{/* ARROW */}
<ChevronDown
size={18}
className={clsx(
"transition",
open && "rotate-180 text-blue-600",
readOnly && "text-gray-400",
)}
/>
</div>
{/* DROPDOWN */}
{open && (
<div className="mt-2 w-full bg-white border border-blue-600 rounded-2xl p-3 shadow-lg z-50 absolute">
{/* SEARCH */}
{isSearch && (
<div className="flex items-center gap-2 px-3 h-[42px] border border-gray-300 rounded-full mb-3">
<Search size={18} className="text-blue-600" />
<input
ref={refInput}
value={keyword}
placeholder="Tìm kiếm..."
onChange={(e) => setKeyword(e.target.value)}
className="flex-1 bg-transparent outline-none text-sm"
/>
</div>
)}
{/* LIST */}
{filteredOptions.length > 0 ? (
<div className="max-h-[300px] overflow-y-auto space-y-1">
{filteredOptions.map((opt) => {
const active = getOptionValue(opt) === value;
return (
<div
key={String(getOptionValue(opt))}
onClick={() => handleSelect(opt)}
className={clsx(
"px-4 py-2 text-sm font-medium rounded-2xl cursor-pointer transition",
active
? "bg-blue-700 text-white"
: "hover:bg-blue-700 hover:text-white",
)}
>
{getOptionLabel(opt)}
</div>
);
})}
</div>
) : (
<div className="h-[200px] flex flex-col items-center justify-center">
<Image src={icons.emptyFile} alt="empty" />
<p className="mt-2 font-semibold">Danh sách lựa chọn rỗng!</p>
</div>
)}
</div>
)}
</div>
);
}
export default SelectForm;
@@ -0,0 +1 @@
export { default } from "./SelectForm";
@@ -0,0 +1,15 @@
export interface PropsSelectForm<OptionType> {
placeholder: string;
label?: string | React.ReactNode;
isSearch?: boolean;
readOnly?: boolean;
value: string | number;
options: OptionType[];
onClean?: () => void;
onSelect: (option: OptionType) => void;
getOptionLabel: (option: OptionType) => string;
getOptionValue: (option: OptionType) => string | number;
}
@@ -0,0 +1,261 @@
"use client";
import React, { Fragment, useEffect, useRef, useState } from "react";
import clsx from "clsx";
import { CirclePlus, Trash, Search, X } from "lucide-react";
import { removeVietnameseTones } from "@/common/funcs/optionConvert";
import Image from "next/image";
import icons from "@/constant/images/icons";
import Popup from "@/components/customs/custom-popup";
import CustomButton from "@/components/customs/custom-button";
import { PropsSelectMany } from "./interface";
function SelectMany<OptionType>({
text,
label,
placeholder,
isSearch = true,
showSelectedItems = false,
readOnly,
disabledItems = [],
selectedItems = [],
options = [],
title,
onClickSelect,
setSelectedItems,
getOptionLabel,
getOptionValue,
onRemove,
action,
renderOption,
selectedItemFields,
selectedItemFieldLabels,
}: PropsSelectMany<OptionType>) {
const refInputSearch = useRef<HTMLInputElement>(null);
const [keyword, setKeyword] = useState("");
const [isFocus, setIsFocus] = useState(false);
const [listDataTerm, setListDataTerm] =
useState<Array<string | number>>(selectedItems);
useEffect(() => {
if (isFocus) {
setTimeout(() => refInputSearch.current?.focus(), 0);
}
}, [isFocus]);
const filteredOptions = options.filter((opt) =>
removeVietnameseTones(String(getOptionLabel(opt) || "")).includes(
removeVietnameseTones(keyword),
),
);
const handleOptionClick = (option: OptionType) => {
const value = getOptionValue(option);
if (disabledItems.includes(value)) return;
setListDataTerm((prev) =>
prev.includes(value) ? prev.filter((v) => v !== value) : [...prev, value],
);
setKeyword("");
};
const handleRemoveSelected = (value: string | number) => {
const updated = listDataTerm.filter((v) => v !== value);
setListDataTerm(updated);
onRemove ? onRemove(value) : setSelectedItems?.(updated);
};
const handleClose = () => {
setKeyword("");
setIsFocus(false);
// reset lại data cũ
setTimeout(() => {
setListDataTerm(selectedItems);
}, 0);
};
const handleSaveData = () => {
setIsFocus(false);
setSelectedItems?.(listDataTerm);
};
return (
<Fragment>
<div className="w-full">
{label && (
<label className="block mb-2 text-base font-medium">{label}</label>
)}
{/* Select box */}
<div
onClick={() => !readOnly && setIsFocus(true)}
className={clsx(
"flex items-center justify-between h-12 px-4 rounded-full border cursor-pointer transition",
"border-gray-300 bg-white hover:border-blue-700",
isFocus && "border-blue-700",
readOnly && "bg-gray-100 border-gray-200 cursor-not-allowed",
)}
>
<p
className={clsx(
"flex-1 font-semibold",
selectedItems.length === 0 && "text-gray-400 font-medium",
)}
>
{selectedItems.length > 0
? `Đã chọn ${selectedItems.length} ${text}`
: placeholder}
</p>
<CirclePlus className="text-blue-700" size={20} />
</div>
{/* Selected chips */}
{showSelectedItems === "default" && (
<div className="flex flex-wrap gap-1 mt-2">
{selectedItems
.map((v) => options.find((o) => getOptionValue(o) === v))
.filter(Boolean)
.map((item) => {
const value = getOptionValue(item!);
const disabled = disabledItems.includes(value);
return (
<div
key={value}
className={clsx(
"flex items-center gap-2 px-3 py-1 rounded-full border text-sm",
disabled
? "bg-gray-100 border-gray-200 text-gray-400"
: "bg-blue-50 border-blue-200 text-blue-700",
)}
>
<span>{getOptionLabel(item!)}</span>
<X
size={16}
className={clsx(
"cursor-pointer",
disabled && "pointer-events-none opacity-50",
)}
onClick={() => handleRemoveSelected(value)}
/>
</div>
);
})}
</div>
)}
</div>
{/* Popup */}
<Popup open={isFocus} onClose={handleClose}>
<div className="relative w-[540px] max-h-[640px] bg-white rounded-2xl p-6 shadow-xl flex flex-col">
{/* Title */}
<h4 className="text-2xl font-semibold">{title || "Danh sách"}</h4>
{/* Search */}
{isSearch && (
<div className="flex items-center gap-2 px-3 h-[42px] border border-gray-300 rounded-full mt-3">
<Search className="text-blue-700" size={18} />
<input
ref={refInputSearch}
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder="Tìm kiếm..."
className="flex-1 outline-none bg-transparent text-sm"
/>
</div>
)}
{/* Select all */}
{filteredOptions.length > 0 && (
<div className="flex items-center justify-between mt-3 mb-3">
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={listDataTerm.length === options.length}
onChange={(e) =>
setListDataTerm(
e.target.checked ? options.map(getOptionValue) : [],
)
}
className="w-5 h-5 accent-blue-500"
/>
<span className="text-gray-500 font-medium">Chọn tất cả</span>
</div>
<p className="text-sm">
Đã chọn:{" "}
<span className="font-semibold">{listDataTerm.length}</span>
</p>
</div>
)}
{/* Options */}
{filteredOptions.length > 0 ? (
<div className="max-h-[320px] overflow-y-auto border-t">
{filteredOptions.map((opt) => {
const value = getOptionValue(opt);
const checked = listDataTerm.includes(value);
return (
<div
key={String(value)}
className="flex items-center gap-3 px-3 py-2 border-b"
>
<input
type="checkbox"
checked={checked}
onChange={() => handleOptionClick(opt)}
disabled={disabledItems.includes(value)}
className="w-5 h-5 accent-blue-500"
/>
<label className="flex-1 cursor-pointer text-base font-medium">
{getOptionLabel(opt)}
{renderOption?.(opt)}
</label>
</div>
);
})}
</div>
) : (
<div className="h-[280px] flex flex-col items-center justify-center">
<Image src={icons.emptyFile} alt="empty" />
<p className="mt-2 font-semibold">Danh sách lựa chọn rỗng!</p>
</div>
)}
{/* Buttons */}
<div className="flex justify-end gap-2 mt-4">
<CustomButton variant="grey" rounded="full" onClick={handleClose}>
Hủy bỏ
</CustomButton>
<CustomButton
disabled={listDataTerm.length === 0}
variant="midnightBlue"
rounded="full"
onClick={handleSaveData}
>
Xác nhận
</CustomButton>
</div>
{/* Close */}
<div
onClick={handleClose}
className="absolute top-6 right-6 cursor-pointer active:scale-90"
>
<X size={24} />
</div>
</div>
</Popup>
</Fragment>
);
}
export default SelectMany;
@@ -0,0 +1 @@
export { default } from "./SelectMany";
@@ -0,0 +1,27 @@
import { Dispatch, SetStateAction } from "react";
export interface PropsSelectMany<OptionType> {
text: string;
placeholder: string;
label?: string | React.ReactNode;
isSearch?: boolean;
readOnly?: boolean;
showSelectedItems?: false | "default" | "input";
disabledItems?: Array<string | number>;
selectedItems: Array<string | number>;
options: OptionType[];
title?: string;
onClickSelect?: () => void;
setSelectedItems?: Dispatch<SetStateAction<Array<string | number>>>;
getOptionLabel: (option: OptionType) => string | React.ReactNode;
getOptionValue: (option: OptionType) => string | number;
onRemove?: (item: string | number) => void;
action?: React.ReactNode;
renderOption?: (option: OptionType) => React.ReactNode;
selectedItemFields?: (keyof OptionType)[];
selectedItemFieldLabels?: Record<string, string>;
}
@@ -0,0 +1,206 @@
"use client";
import React, { useEffect, useRef, useState } from "react";
import clsx from "clsx";
import { X, Search } from "lucide-react";
import Image from "next/image";
import { removeVietnameseTones } from "@/common/funcs/optionConvert";
import icons from "@/constant/images/icons";
import Popup from "@/components/customs/custom-popup";
import CustomButton from "@/components/customs/custom-button";
import { PropsSelectSingle } from "./interface";
function SelectSingle<OptionType>({
text = "",
label,
placeholder = "Chọn 1 giá trị",
isSearch = true,
readOnly,
disabledItems = [],
selectedItem,
options,
title,
action,
getOptionLabel,
getOptionValue,
setSelectedItem,
onClickSelect,
renderOption,
}: PropsSelectSingle<OptionType>) {
const refInputSearch = useRef<HTMLInputElement>(null);
const [keyword, setKeyword] = useState("");
const [isFocus, setIsFocus] = useState(false);
const [selectedTemp, setSelectedTemp] = useState<string | number | null>(
null,
);
useEffect(() => {
if (isFocus && refInputSearch.current) {
setTimeout(() => refInputSearch.current?.focus(), 0);
}
}, [isFocus]);
const handlerFocused = () => {
if (onClickSelect) {
onClickSelect();
return;
}
if (readOnly) return;
// sync dữ liệu tại đây
setSelectedTemp(selectedItem ?? null);
setIsFocus(true);
};
const handleSelect = (value: string | number) => {
if (!disabledItems.includes(value)) {
setSelectedTemp(value);
}
};
const handleSave = () => {
setSelectedItem?.(selectedTemp);
setIsFocus(false);
};
const handleClose = () => {
setIsFocus(false);
setKeyword("");
setSelectedTemp(selectedItem ?? null);
};
const selectedLabel = (() => {
const selectedOption = options.find(
(opt) => getOptionValue(opt) === selectedItem,
);
return selectedOption
? getOptionLabel(selectedOption)
: `Đã chọn 1 ${text}`;
})();
const filteredOptions = options.filter((opt) =>
removeVietnameseTones(getOptionLabel(opt)).includes(
removeVietnameseTones(keyword),
),
);
return (
<div className="w-full">
{label && (
<label className="block mb-2 text-[16px] font-medium">{label}</label>
)}
{/* Select box */}
<div
onClick={handlerFocused}
className={clsx(
"h-12 px-4 flex items-center rounded-full border transition cursor-pointer",
isFocus ? "border-[#0011AB]" : "border-[#CDD5DF]",
readOnly &&
"bg-gray-100 border-gray-200 cursor-default pointer-events-none",
)}
>
<p
className={clsx(
"flex-1 text-[16px] font-semibold",
!selectedItem && "text-gray-400 font-medium",
)}
>
{selectedItem ? selectedLabel : placeholder}
</p>
</div>
{/* Popup */}
<Popup open={isFocus} onClose={handleClose}>
<div className="relative w-[640px] max-w-[90vw] max-h-[740px] bg-white rounded-2xl shadow-xl flex flex-col">
{/* Header */}
<div className="px-6 pt-4 border-b shadow-sm">
<h4 className="text-2xl font-semibold">
{title || "Chọn 1 giá trị"}
</h4>
{isSearch && (
<div className="flex items-center gap-4 mt-3 mb-3 flex-wrap">
<div className="flex items-center w-full h-[42px] px-3 border rounded-full border-gray-300">
<Search color="#0011AB" size={20} />
<input
ref={refInputSearch}
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder="Tìm kiếm..."
className="flex-1 ml-2 outline-none bg-transparent"
/>
</div>
{action && <div>{action}</div>}
</div>
)}
</div>
{/* List */}
{filteredOptions.length > 0 ? (
<div className="px-6 max-h-[420px] min-h-[280px] overflow-auto">
{filteredOptions.map((opt) => {
const value = getOptionValue(opt);
return (
<div
key={value}
className="flex items-center gap-3 py-3 border-b"
>
<input
type="radio"
checked={selectedTemp === value}
onChange={() => handleSelect(value)}
disabled={disabledItems.includes(value)}
className="w-5 h-5 accent-[#0011AB]"
/>
<label className="w-full text-[16px] font-medium cursor-pointer">
{getOptionLabel(opt)}
{renderOption && renderOption(opt)}
</label>
</div>
);
})}
</div>
) : (
<div className="h-[280px] flex flex-col items-center justify-center">
<Image src={icons.emptyFile} alt="empty" />
<p className="mt-2 font-semibold">Danh sách lựa chọn rỗng!</p>
</div>
)}
{/* Footer */}
<div className="px-6 py-4 border-t flex justify-end gap-3 shadow-inner">
<CustomButton variant="grey" rounded="full" onClick={handleClose}>
Hủy bỏ
</CustomButton>
<CustomButton
variant="midnightBlue"
rounded="full"
disabled={selectedTemp === null}
onClick={handleSave}
>
Xác nhận
</CustomButton>
</div>
{/* Close */}
<div
onClick={handleClose}
className="absolute top-6 right-6 cursor-pointer active:scale-90"
>
<X size={24} />
</div>
</div>
</Popup>
</div>
);
}
export default SelectSingle;
@@ -0,0 +1 @@
export { default } from "./SelectSingle";
@@ -0,0 +1,17 @@
export interface PropsSelectSingle<OptionType> {
text?: string;
label?: React.ReactNode;
placeholder?: string;
isSearch?: boolean;
readOnly?: boolean;
disabledItems?: (string | number)[];
selectedItem?: string | number | null;
options: OptionType[];
title?: string;
action?: React.ReactNode;
getOptionLabel: (opt: OptionType) => string;
getOptionValue: (opt: OptionType) => string | number;
setSelectedItem?: (value: string | number | null) => void;
renderOption?: (option: OptionType) => React.ReactNode;
onClickSelect?: () => void;
}
@@ -0,0 +1,192 @@
"use client";
import React, { useContext, useEffect, useState } from "react";
import clsx from "clsx";
import { PropsTextArea } from "./interface";
import { ContextFormCustom, IContextFormCustom } from "../../contexts";
function TextArea({
name,
value,
placeholder,
label,
isBlur = true,
showDone,
readOnly,
max,
min,
isRequired,
textRequired,
}: PropsTextArea) {
const [isFocus, setIsFocus] = useState(false);
const {
form,
countValidate,
errorText,
isDone,
setForm,
setValidate,
setErrorText,
} = useContext<IContextFormCustom<any>>(ContextFormCustom);
// =========================
// VALIDATE
// =========================
const handleValidate = (): boolean => {
const currentValue = `${form[name] || ""}`.trim();
if (isRequired && currentValue === "") return false;
if (min && currentValue.length < min) return false;
if (max && currentValue.length > max) return false;
return true;
};
// =========================
// ERROR MESSAGE
// =========================
const handleSetMessage = () => {
const currentValue = `${form[name] || ""}`.trim();
setErrorText((prev) => ({
...prev,
[name]: null,
}));
if (isRequired && currentValue === "") {
return setErrorText((prev) => ({
...prev,
[name]: textRequired || "Vui lòng nhập trường này",
}));
}
if (max && currentValue.length > max) {
return setErrorText((prev) => ({
...prev,
[name]: `Nhập tối đa ${max} kí tự`,
}));
}
if (min && currentValue.length < min) {
return setErrorText((prev) => ({
...prev,
[name]: `Nhập tối thiểu ${min} kí tự`,
}));
}
};
// =========================
// EFFECTS
// =========================
useEffect(() => {
if (countValidate > 0) {
handleSetMessage();
}
}, [countValidate]);
useEffect(() => {
setValidate((prev) => ({
...prev,
[name]: handleValidate(),
}));
}, [form[name]]);
// =========================
// EVENTS
// =========================
const handlerFocused = () => {
setIsFocus(true);
setErrorText((prev) => ({
...prev,
[name]: null,
}));
};
const handlerBlur = () => {
setIsFocus(false);
if (isBlur) {
handleSetMessage();
setValidate((prev) => ({
...prev,
[name]: handleValidate(),
}));
}
};
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const { value } = e.target;
setForm((prev: any) => ({
...prev,
[name]: value,
}));
};
return (
<div className="w-full space-y-1">
{/* LABEL */}
{label && (
<label
htmlFor={`textarea_${name}`}
className="block text-[16px] font-medium"
>
{label}
</label>
)}
{/* TEXTAREA */}
<textarea
id={`textarea_${name}`}
name={name}
value={value ?? form[name] ?? ""}
placeholder={placeholder}
autoComplete="off"
readOnly={readOnly}
disabled={readOnly}
onChange={handleChange}
onFocus={handlerFocused}
onBlur={handlerBlur}
className={clsx(
"w-full min-h-[140px] resize-none rounded-[20px] border px-4 py-3 text-[16px] font-medium outline-none transition",
// default
"border-[#CDD5DF] bg-white",
// hover
!readOnly && "hover:border-[#0019F6]",
// focus
isFocus && "border-[#0019F6]",
// error
errorText[name] && "border-[#E5493F]",
// done
showDone && isDone && "border-[#1CAD4C]",
// readonly
readOnly &&
"cursor-not-allowed border-gray-200 bg-gray-100 hover:border-gray-200",
)}
/>
{/* ERROR */}
{errorText[name] && (
<p className="text-[12px] text-red-500">{errorText[name]}</p>
)}
</div>
);
}
export default TextArea;
@@ -0,0 +1 @@
export { default } from "./TextArea";
@@ -0,0 +1,17 @@
export interface PropsTextArea {
name: string;
value?: string;
placeholder: string;
label?: string | React.ReactNode;
isBlur?: boolean;
showDone?: boolean;
readOnly?: boolean;
textRequired?: string;
max?: number;
min?: number;
isRequired?: boolean;
}
@@ -0,0 +1,23 @@
import { createContext, Dispatch, SetStateAction } from "react";
export interface IContextFormCustom<T> {
form: T;
setForm: Dispatch<SetStateAction<T>>;
errorText: Record<keyof T, string | null>;
setErrorText: Dispatch<SetStateAction<Record<keyof T, string | null>>>;
setValidate: Dispatch<SetStateAction<Record<keyof T, boolean> | null>>;
countValidate: number;
setCountValidate: Dispatch<SetStateAction<number>>;
isDone: boolean;
}
export const ContextFormCustom = createContext<IContextFormCustom<any>>({
form: {} as any,
setForm: () => {},
errorText: {},
setErrorText: () => {},
setValidate: () => {},
countValidate: 0,
setCountValidate: () => {},
isDone: false,
});
+1
View File
@@ -0,0 +1 @@
export { default } from "./FormCustom";
@@ -0,0 +1,8 @@
import { Dispatch, SetStateAction } from "react";
export interface PropsFormCustom<T extends Record<string, any>> {
children: React.ReactNode;
form: T;
setForm: Dispatch<SetStateAction<T>>;
onSubmit?: () => void;
}