tududi/frontend/components/Shared/DatePicker.tsx
Antonis Anastasiadis 220bc92b4a
Lint frontend (#131)
* Add lint-fix npm target

* Sync eslint+plugins with backend

* Add prettier

* Ignore no-explicit-any lint rule for now

* Silence eslint react warning

* Format frontend via prettier

* Lint frontend.

---------

Co-authored-by: antanst <>
2025-07-09 12:23:55 +03:00

367 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useRef, useEffect } from 'react';
import { createPortal } from 'react-dom';
import {
ChevronLeftIcon,
ChevronRightIcon,
CalendarDaysIcon,
} from '@heroicons/react/24/outline';
interface DatePickerProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
disabled?: boolean;
className?: string;
}
const DatePicker: React.FC<DatePickerProps> = ({
value,
onChange,
placeholder = 'Select date',
disabled = false,
className = '',
}) => {
const [isOpen, setIsOpen] = useState(false);
const [position, setPosition] = useState({
top: 0,
left: 0,
width: 0,
openUpward: false,
});
const [currentMonth, setCurrentMonth] = useState(new Date());
const dropdownRef = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const months = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
];
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const formatDate = (date: Date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
const parseDate = (dateString: string) => {
return dateString ? new Date(dateString + 'T00:00:00') : null;
};
const formatDisplayDate = (dateString: string) => {
if (!dateString) return placeholder;
const date = parseDate(dateString);
if (!date || isNaN(date.getTime())) return placeholder;
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
};
const handleToggle = () => {
if (disabled) return;
if (!isOpen && dropdownRef.current) {
const rect = dropdownRef.current.getBoundingClientRect();
const spaceBelow = window.innerHeight - rect.bottom;
const spaceAbove = rect.top;
const menuHeight = 320; // Calendar height
const padding = 16; // Extra padding from viewport edges
// Determine if we should open upward
const wouldFitBelow = spaceBelow >= menuHeight + padding;
const wouldFitAbove = spaceAbove >= menuHeight + padding;
let openUpward = false;
let top = rect.bottom + 8;
if (!wouldFitBelow && wouldFitAbove) {
// Open upward if it fits above but not below
openUpward = true;
top = rect.top - menuHeight - 8;
} else if (!wouldFitBelow && !wouldFitAbove) {
// If it doesn't fit in either direction, choose the side with more space
if (spaceAbove > spaceBelow) {
openUpward = true;
top = Math.max(padding, rect.top - menuHeight - 8);
} else {
top = Math.min(
window.innerHeight - menuHeight - padding,
rect.bottom + 8
);
}
}
// Ensure left position doesn't go off screen
const left = Math.min(
Math.max(padding, rect.left),
window.innerWidth - Math.max(rect.width, 280) - padding
);
setPosition({
top,
left,
width: Math.max(rect.width, 280), // Minimum width for calendar
openUpward,
});
// Set current month based on selected date or today
if (value) {
const selectedDate = parseDate(value);
if (selectedDate && !isNaN(selectedDate.getTime())) {
setCurrentMonth(
new Date(
selectedDate.getFullYear(),
selectedDate.getMonth(),
1
)
);
}
} else {
setCurrentMonth(
new Date(new Date().getFullYear(), new Date().getMonth(), 1)
);
}
}
setIsOpen(!isOpen);
};
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node) &&
menuRef.current &&
!menuRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
};
const handleDateSelect = (date: Date) => {
try {
onChange(formatDate(date));
setIsOpen(false);
} catch (error) {
console.error('Error in date selection:', error);
setIsOpen(false);
}
};
const handleClear = (e: React.MouseEvent) => {
e.stopPropagation();
onChange('');
setIsOpen(false);
};
const navigateMonth = (direction: 'prev' | 'next') => {
setCurrentMonth((prev) => {
const newMonth = new Date(prev);
if (direction === 'prev') {
newMonth.setMonth(newMonth.getMonth() - 1);
} else {
newMonth.setMonth(newMonth.getMonth() + 1);
}
return newMonth;
});
};
const getDaysInMonth = () => {
const year = currentMonth.getFullYear();
const month = currentMonth.getMonth();
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
const daysInMonth = lastDay.getDate();
const startingDayOfWeek = firstDay.getDay();
const days = [];
// Add empty cells for days before the first day of the month
for (let i = 0; i < startingDayOfWeek; i++) {
days.push(null);
}
// Add days of the month
for (let day = 1; day <= daysInMonth; day++) {
days.push(new Date(year, month, day));
}
return days;
};
const isToday = (date: Date) => {
const today = new Date();
return date.toDateString() === today.toDateString();
};
const isSelected = (date: Date) => {
if (!value) return false;
const selectedDate = parseDate(value);
return (
selectedDate && date.toDateString() === selectedDate.toDateString()
);
};
useEffect(() => {
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
} else {
document.removeEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]);
return (
<div
ref={dropdownRef}
className={`relative inline-block text-left w-full ${className}`}
>
<button
type="button"
className={`inline-flex justify-between w-full px-3 py-2 bg-white dark:bg-gray-900 text-sm text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-900 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors ${
disabled
? 'opacity-50 cursor-not-allowed'
: 'hover:bg-gray-50 dark:hover:bg-gray-800'
}`}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleToggle();
}}
disabled={disabled}
>
<span
className={`truncate ${!value ? 'text-gray-500 dark:text-gray-400' : ''}`}
>
{formatDisplayDate(value)}
</span>
<div className="flex items-center space-x-1">
{value && (
<button
type="button"
onClick={handleClear}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-xs"
>
×
</button>
)}
<CalendarDaysIcon className="w-5 h-5 text-gray-500 dark:text-gray-300" />
</div>
</button>
{isOpen &&
createPortal(
<div
ref={menuRef}
className="fixed z-50 bg-white dark:bg-gray-700 shadow-lg rounded-md border border-gray-200 dark:border-gray-600 date-picker-menu"
style={{
top: `${position.top}px`,
left: `${position.left}px`,
width: `${position.width}px`,
}}
onClick={(e) => e.stopPropagation()}
>
{/* Calendar Header */}
<div className="flex items-center justify-between p-3 border-b border-gray-200 dark:border-gray-600">
<button
type="button"
onClick={() => navigateMonth('prev')}
className="p-1 hover:bg-gray-100 dark:hover:bg-gray-600 rounded"
>
<ChevronLeftIcon className="w-5 h-5 text-gray-600 dark:text-gray-300" />
</button>
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
{months[currentMonth.getMonth()]}{' '}
{currentMonth.getFullYear()}
</span>
<button
type="button"
onClick={() => navigateMonth('next')}
className="p-1 hover:bg-gray-100 dark:hover:bg-gray-600 rounded"
>
<ChevronRightIcon className="w-5 h-5 text-gray-600 dark:text-gray-300" />
</button>
</div>
{/* Calendar Grid */}
<div className="p-3">
{/* Day Headers */}
<div className="grid grid-cols-7 gap-1 mb-2">
{days.map((day) => (
<div
key={day}
className="text-xs font-medium text-gray-500 dark:text-gray-400 text-center py-1"
>
{day}
</div>
))}
</div>
{/* Calendar Days */}
<div className="grid grid-cols-7 gap-1">
{getDaysInMonth().map((date, index) => (
<div key={index} className="aspect-square">
{date && (
<button
type="button"
onClick={() =>
handleDateSelect(date)
}
className={`w-full h-full text-xs rounded transition-colors ${
isSelected(date)
? 'bg-blue-600 text-white'
: isToday(date)
? 'bg-blue-100 dark:bg-blue-900 text-blue-900 dark:text-blue-100'
: 'hover:bg-gray-100 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100'
}`}
>
{date.getDate()}
</button>
)}
</div>
))}
</div>
</div>
{/* Footer */}
<div className="border-t border-gray-200 dark:border-gray-600 p-3 flex justify-between">
<button
type="button"
onClick={() => handleDateSelect(new Date())}
className="text-xs text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300"
>
Today
</button>
{value && (
<button
type="button"
onClick={handleClear}
className="text-xs text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
>
Clear
</button>
)}
</div>
</div>,
document.body
)}
</div>
);
};
export default DatePicker;