chore: update DateInput component

This commit is contained in:
ValdiANS
2025-11-03 10:27:51 +07:00
parent 33c0d5513c
commit f6d4ef4697
+241 -41
View File
@@ -1,14 +1,23 @@
'use client'; 'use client';
import { ChangeEventHandler, FocusEventHandler, ReactNode } from 'react'; import {
ChangeEventHandler,
import { cn } from '@/lib/helper'; FocusEventHandler,
useEffect,
useState,
} from 'react';
import { cn, formatDate } from '@/lib/helper';
import Modal, { useModal } from '../Modal';
import { DateRange, DayPicker, Matcher } from 'react-day-picker';
import 'react-day-picker/dist/style.css';
import Button from '../Button';
import { Icon } from '@iconify/react';
export interface DateInputProps { export interface DateInputProps {
label?: string; label?: string;
bottomLabel?: string; bottomLabel?: string;
name: string; name: string;
value?: string; value?: string | { from?: string; to?: string };
placeholder?: string; placeholder?: string;
min?: string; min?: string;
max?: string; max?: string;
@@ -24,9 +33,8 @@ export interface DateInputProps {
readOnly?: boolean; readOnly?: boolean;
required?: boolean; required?: boolean;
isLoading?: boolean; isLoading?: boolean;
isRange?: boolean;
errorMessage?: string; errorMessage?: string;
startAdornment?: ReactNode;
endAdornment?: ReactNode;
onChange?: ChangeEventHandler<HTMLInputElement>; onChange?: ChangeEventHandler<HTMLInputElement>;
onBlur?: FocusEventHandler<HTMLInputElement>; onBlur?: FocusEventHandler<HTMLInputElement>;
} }
@@ -36,22 +44,144 @@ const DateInput = ({
bottomLabel, bottomLabel,
name, name,
value, value,
placeholder, placeholder = 'dd/mm/yyyy',
min, min,
max, max,
className, className,
isError, isError: externalError,
isValid, isValid: externalValid,
errorMessage, errorMessage: externalErrorMessage,
startAdornment,
endAdornment,
disabled = false, disabled = false,
required = false, required = false,
onChange, onChange,
onBlur, onBlur,
readOnly = false, readOnly = false,
isLoading = false, isLoading = false,
isRange = false,
}: DateInputProps) => { }: DateInputProps) => {
const [internalError, setInternalError] = useState<string | null>(null);
const [selected, setSelected] = useState<Date | undefined>();
const [selectedRange, setSelectedRange] = useState<{
from?: Date;
to?: Date;
}>({});
const [displayValue, setDisplayValue] = useState<string>('');
const minDate = min
? new Date(min.split('/').reverse().join('-'))
: undefined;
const maxDate = max
? new Date(max.split('/').reverse().join('-'))
: undefined;
const calendarModal = useModal();
// --- Sync value props ---
useEffect(() => {
if (!value) return;
if (isRange && typeof value === 'object') {
const from = value.from ? new Date(value.from) : undefined;
const to = value.to ? new Date(value.to) : undefined;
setSelectedRange({ from, to });
setDisplayValue(
`${from ? formatDate(from, 'DD/MM/YYYY') : ''} ${
to ? '- ' + formatDate(to, 'DD/MM/YYYY') : ''
}`
);
} else if (typeof value === 'string') {
const iso = value.includes('/')
? value.split('/').reverse().join('-')
: value;
const date = new Date(iso);
setSelected(date);
setDisplayValue(formatDate(iso, 'DD/MM/YYYY'));
}
}, [value, isRange]);
const handleClick = (e: React.MouseEvent<HTMLInputElement>) => {
e.preventDefault();
if (!disabled && !readOnly) calendarModal.openModal();
};
const handleBlur: FocusEventHandler<HTMLInputElement> = (e) => {
onBlur?.(e);
};
const handleSelectSingle = (selectedDate?: Date) => {
if (!selectedDate) return;
if (minDate && selectedDate < minDate) {
setInternalError(`Tanggal tidak boleh sebelum ${min}`);
return;
}
if (maxDate && selectedDate > maxDate) {
setInternalError(`Tanggal tidak boleh setelah ${max}`);
return;
}
setInternalError(null);
setSelected(selectedDate);
const formattedDisplay = formatDate(selectedDate, 'DD/MM/YYYY');
const formattedISO = formatDate(selectedDate, 'YYYY-MM-DD');
setDisplayValue(formattedDisplay);
const syntheticEvent = {
target: { name, value: formattedISO },
} as unknown as React.ChangeEvent<HTMLInputElement>;
onChange?.(syntheticEvent);
calendarModal.closeModal();
};
const handleSelectRange = (range?: { from?: Date; to?: Date }) => {
if (!range) return;
setSelectedRange(range);
const fromStr = range.from ? formatDate(range.from, 'DD/MM/YYYY') : '';
const toStr = range.to ? formatDate(range.to, 'DD/MM/YYYY') : '';
setDisplayValue(`${fromStr}${toStr ? ' - ' + toStr : ''}`);
// Jika kedua tanggal sudah terpilih
if (range.from && range.to) {
if (minDate && range.from < minDate) {
setInternalError(`Tanggal mulai tidak boleh sebelum ${min}`);
return;
}
if (maxDate && range.to > maxDate) {
setInternalError(`Tanggal akhir tidak boleh setelah ${max}`);
return;
}
setInternalError(null);
const syntheticEvent = {
target: {
name,
value: {
from: formatDate(range.from, 'YYYY-MM-DD'),
to: formatDate(range.to, 'YYYY-MM-DD'),
},
},
} as unknown as React.ChangeEvent<HTMLInputElement>;
onChange?.(syntheticEvent);
}
};
const handleResetDate = () => {
setSelected(undefined);
setSelectedRange({});
setDisplayValue('');
const syntheticEvent = {
target: { name, value: isRange ? { from: '', to: '' } : '' },
} as unknown as React.ChangeEvent<HTMLInputElement>;
onChange?.(syntheticEvent);
calendarModal.closeModal();
};
const handleSaveDate = () => {
if (internalError) return;
calendarModal.closeModal();
};
const finalIsError = externalError || !!internalError;
const finalErrorMessage = internalError || externalErrorMessage;
return ( return (
<div <div
className={cn( className={cn(
@@ -64,65 +194,135 @@ const DateInput = ({
htmlFor={name} htmlFor={name}
className={cn( className={cn(
'w-full text-sm font-normal leading-5', 'w-full text-sm font-normal leading-5',
{ { 'text-error': finalIsError },
'text-error': isError,
},
className?.label className?.label
)} )}
> >
{label} {label}
{required && ( {required && (
<> <span className='text-error' title='required'>
{' '} *
<span className='tooltip tooltip-error' data-tip='required'> </span>
<span className='text-error'>*</span>
</span>
</>
)} )}
</label> </label>
)} )}
<div <div
className={cn( className={cn(
'input h-12 px-4 py-2 text-base font-normal leading-6 w-full rounded outline-none! transition-all duration-200 flex items-center', 'input h-12 px-4 py-2 text-base font-normal leading-6 w-full rounded transition-all duration-200 flex items-center border',
{ {
'border-error': isError, 'border-error': finalIsError,
'border-success!': isValid, 'border-success': externalValid && !finalIsError,
}, },
className?.inputWrapper className?.inputWrapper
)} )}
> >
{startAdornment && startAdornment}
<input <input
type='date' type='text'
id={name} id={name}
name={name} name={name}
placeholder={placeholder} placeholder={isRange ? 'dd/mm/yyyy - dd/mm/yyyy' : placeholder}
value={value} value={displayValue}
onChange={onChange} onBlur={handleBlur}
onBlur={onBlur} onClick={handleClick}
min={min}
max={max}
disabled={disabled} disabled={disabled}
className={cn('grow bg-transparent cursor-pointer', className?.input)} readOnly // ✅ tidak bisa diketik manual
readOnly={readOnly} className={cn(
'grow bg-transparent cursor-pointer focus:outline-none',
className?.input
)}
/> />
{(isLoading || endAdornment) && ( {isLoading && (
<div className='flex flex-row gap-2'> <div className='flex flex-row gap-2'>
{isLoading && <span className='loading loading-spinner' />} <span className='loading loading-spinner' />
{endAdornment && endAdornment}
</div> </div>
)} )}
<Icon
icon='uil:calendar'
width={24}
height={24}
className='cursor-pointer text-dark'
onClick={(e) =>
handleClick(e as unknown as React.MouseEvent<HTMLInputElement>)
}
/>
</div> </div>
{!isError && bottomLabel && ( {!finalIsError && bottomLabel && (
<p className='w-full text-sm opacity-60'>{bottomLabel}</p> <p className='w-full text-sm opacity-60'>{bottomLabel}</p>
)} )}
{isError && errorMessage && ( {finalIsError && finalErrorMessage && (
<p className='w-full text-sm text-error'>{errorMessage}</p> <p className='w-full text-sm text-error'>{finalErrorMessage}</p>
)} )}
<Modal
ref={calendarModal.ref}
className={{
modal: 'rounded',
modalBox: `w-fit min-h-${isRange ? '124' : '110'} flex flex-col`,
}}
closeOnBackdrop
>
{isRange ? (
<DayPicker
required={required}
mode='range'
captionLayout='dropdown-years'
navLayout='around'
reverseYears
defaultMonth={selectedRange.from ?? new Date()}
startMonth={minDate ?? new Date(1999, 1)}
endMonth={maxDate ?? new Date(new Date().getFullYear() + 5, 11)}
selected={selectedRange as DateRange}
onSelect={handleSelectRange}
footer={<div className='text-center mt-3'>{displayValue}</div>}
disabled={
[
minDate ? { before: minDate } : undefined,
maxDate ? { after: maxDate } : undefined,
].filter(Boolean) as Matcher[]
}
/>
) : (
<DayPicker
required={required}
mode='single'
captionLayout='dropdown-years'
navLayout='around'
reverseYears
defaultMonth={selected ?? new Date()}
startMonth={minDate ?? new Date(1999, 1)}
endMonth={maxDate ?? new Date(new Date().getFullYear() + 5, 11)}
selected={selected}
onSelect={handleSelectSingle}
disabled={
[
minDate ? { before: minDate } : undefined,
maxDate ? { after: maxDate } : undefined,
].filter(Boolean) as Matcher[]
}
/>
)}
<div className='mt-auto flex flex-col gap-2'>
{isRange && (
<small className='text-secondary'>
Tekan dua kali untuk memilih tanggal awal
</small>
)}
<div className='flex h-full justify-end items-end gap-2'>
<Button type='button' color='warning' onClick={handleResetDate}>
Reset
</Button>
{isRange && (
<Button type='button' onClick={handleSaveDate}>
Simpan
</Button>
)}
</div>
</div>
</Modal>
</div> </div>
); );
}; };