Add custom DatePicker component, replace all native date inputs
Custom date-picker.tsx with date/datetime modes, portal popup with month/year dropdowns, min/max constraints, and hidden input for form validation. Replaces all 10 native <input type="date"> and <input type="datetime-local"> across LockScreen, SettingsPage, PersonForm, TodoForm, TodoDetailPanel, TaskForm, TaskDetailPanel, ProjectForm, ReminderForm, and ReminderDetailPanel. Adds Chromium calendar icon invert CSS fallback. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
da61676fef
commit
013f9ec010
@ -6,6 +6,7 @@ import { useAuth } from '@/hooks/useAuth';
|
||||
import api, { getErrorMessage } from '@/lib/api';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { cn } from '@/lib/utils';
|
||||
@ -641,13 +642,14 @@ export default function LockScreen() {
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="reg-dob" required>Date of Birth</Label>
|
||||
<Input
|
||||
<DatePicker
|
||||
id="reg-dob"
|
||||
type="date"
|
||||
value={regDateOfBirth}
|
||||
onChange={(e) => setRegDateOfBirth(e.target.value)}
|
||||
onChange={(v) => setRegDateOfBirth(v)}
|
||||
required
|
||||
name="bday"
|
||||
autoComplete="bday"
|
||||
max={new Date().toISOString().slice(0, 10)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
|
||||
@ -13,6 +13,7 @@ import {
|
||||
SheetFooter,
|
||||
} from '@/components/ui/sheet';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@ -165,11 +166,10 @@ export default function PersonForm({ person, categories, onClose }: PersonFormPr
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="birthday">Birthday</Label>
|
||||
<Input
|
||||
<DatePicker
|
||||
id="birthday"
|
||||
type="date"
|
||||
value={formData.birthday}
|
||||
onChange={(e) => set('birthday', e.target.value)}
|
||||
onChange={(v) => set('birthday', v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
|
||||
@ -12,6 +12,7 @@ import {
|
||||
SheetClose,
|
||||
} from '@/components/ui/sheet';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Select } from '@/components/ui/select';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@ -121,11 +122,10 @@ export default function ProjectForm({ project, onClose }: ProjectFormProps) {
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="due_date">Due Date</Label>
|
||||
<Input
|
||||
<DatePicker
|
||||
id="due_date"
|
||||
type="date"
|
||||
value={formData.due_date}
|
||||
onChange={(e) => setFormData({ ...formData, due_date: e.target.value })}
|
||||
onChange={(v) => setFormData({ ...formData, due_date: v })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -14,6 +14,7 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Select } from '@/components/ui/select';
|
||||
|
||||
const taskStatusColors: Record<string, string> = {
|
||||
@ -350,10 +351,9 @@ export default function TaskDetailPanel({
|
||||
Due Date
|
||||
</div>
|
||||
{isEditing ? (
|
||||
<Input
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={editState.due_date}
|
||||
onChange={(e) => setEditState((s) => ({ ...s, due_date: e.target.value }))}
|
||||
onChange={(v) => setEditState((s) => ({ ...s, due_date: v }))}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
) : (
|
||||
|
||||
@ -12,6 +12,7 @@ import {
|
||||
SheetClose,
|
||||
} from '@/components/ui/sheet';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Select } from '@/components/ui/select';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@ -154,11 +155,10 @@ export default function TaskForm({ projectId, task, parentTaskId, defaultDueDate
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="due_date">Due Date</Label>
|
||||
<Input
|
||||
<DatePicker
|
||||
id="due_date"
|
||||
type="date"
|
||||
value={formData.due_date}
|
||||
onChange={(e) => setFormData({ ...formData, due_date: e.target.value })}
|
||||
onChange={(v) => setFormData({ ...formData, due_date: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@ -12,6 +12,7 @@ import { formatUpdatedAt } from '@/components/shared/utils';
|
||||
import CopyableField from '@/components/shared/CopyableField';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Select } from '@/components/ui/select';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@ -340,11 +341,11 @@ export default function ReminderDetailPanel({
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="reminder-at">Remind At</Label>
|
||||
<Input
|
||||
<DatePicker
|
||||
id="reminder-at"
|
||||
type="datetime-local"
|
||||
mode="datetime"
|
||||
value={editState.remind_at}
|
||||
onChange={(e) => updateField('remind_at', e.target.value)}
|
||||
onChange={(v) => updateField('remind_at', v)}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -12,6 +12,7 @@ import {
|
||||
SheetClose,
|
||||
} from '@/components/ui/sheet';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Select } from '@/components/ui/select';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@ -96,11 +97,11 @@ export default function ReminderForm({ reminder, onClose }: ReminderFormProps) {
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="remind_at">Remind At</Label>
|
||||
<Input
|
||||
<DatePicker
|
||||
id="remind_at"
|
||||
type="datetime-local"
|
||||
mode="datetime"
|
||||
value={formData.remind_at}
|
||||
onChange={(e) => setFormData({ ...formData, remind_at: e.target.value })}
|
||||
onChange={(v) => setFormData({ ...formData, remind_at: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@ -18,6 +18,7 @@ import {
|
||||
import { useSettings } from '@/hooks/useSettings';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { cn } from '@/lib/utils';
|
||||
import api from '@/lib/api';
|
||||
@ -353,11 +354,10 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="date_of_birth">Date of Birth</Label>
|
||||
<Input
|
||||
<DatePicker
|
||||
id="date_of_birth"
|
||||
type="date"
|
||||
value={dateOfBirth}
|
||||
onChange={(e) => setDateOfBirth(e.target.value)}
|
||||
onChange={(v) => setDateOfBirth(v)}
|
||||
onBlur={() => handleProfileSave('date_of_birth')}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleProfileSave('date_of_birth'); }}
|
||||
/>
|
||||
|
||||
@ -13,6 +13,7 @@ import { formatUpdatedAt } from '@/components/shared/utils';
|
||||
import CopyableField from '@/components/shared/CopyableField';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Select } from '@/components/ui/select';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
@ -385,11 +386,10 @@ export default function TodoDetailPanel({
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="todo-due-date">Due Date</Label>
|
||||
<Input
|
||||
<DatePicker
|
||||
id="todo-due-date"
|
||||
type="date"
|
||||
value={editState.due_date}
|
||||
onChange={(e) => updateField('due_date', e.target.value)}
|
||||
onChange={(v) => updateField('due_date', v)}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -12,6 +12,7 @@ import {
|
||||
SheetClose,
|
||||
} from '@/components/ui/sheet';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Select } from '@/components/ui/select';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@ -129,11 +130,10 @@ export default function TodoForm({ todo, onClose }: TodoFormProps) {
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="due_date">Due Date</Label>
|
||||
<Input
|
||||
<DatePicker
|
||||
id="due_date"
|
||||
type="date"
|
||||
value={formData.due_date}
|
||||
onChange={(e) => setFormData({ ...formData, due_date: e.target.value })}
|
||||
onChange={(v) => setFormData({ ...formData, due_date: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
475
frontend/src/components/ui/date-picker.tsx
Normal file
475
frontend/src/components/ui/date-picker.tsx
Normal file
@ -0,0 +1,475 @@
|
||||
import * as React from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Calendar, ChevronLeft, ChevronRight, Clock } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
function getDaysInMonth(year: number, month: number): number {
|
||||
return new Date(year, month + 1, 0).getDate();
|
||||
}
|
||||
|
||||
function getFirstDayOfWeek(year: number, month: number): number {
|
||||
return new Date(year, month, 1).getDay();
|
||||
}
|
||||
|
||||
function pad(n: number): string {
|
||||
return n.toString().padStart(2, '0');
|
||||
}
|
||||
|
||||
function formatDate(y: number, m: number, d: number): string {
|
||||
return `${y}-${pad(m + 1)}-${pad(d)}`;
|
||||
}
|
||||
|
||||
const MONTH_NAMES = [
|
||||
'January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December',
|
||||
];
|
||||
|
||||
const DAY_HEADERS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];
|
||||
|
||||
// ── Props ──
|
||||
|
||||
export interface DatePickerProps {
|
||||
mode?: 'date' | 'datetime';
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onBlur?: () => void;
|
||||
onKeyDown?: (e: React.KeyboardEvent) => void;
|
||||
id?: string;
|
||||
name?: string;
|
||||
autoComplete?: string;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
placeholder?: string;
|
||||
min?: string;
|
||||
max?: string;
|
||||
yearRange?: [number, number];
|
||||
}
|
||||
|
||||
// ── Component ──
|
||||
|
||||
const DatePicker = React.forwardRef<HTMLButtonElement, DatePickerProps>(
|
||||
(
|
||||
{
|
||||
mode = 'date',
|
||||
value,
|
||||
onChange,
|
||||
onBlur,
|
||||
onKeyDown,
|
||||
id,
|
||||
name,
|
||||
autoComplete,
|
||||
required,
|
||||
disabled,
|
||||
className,
|
||||
placeholder,
|
||||
min,
|
||||
max,
|
||||
yearRange,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const currentYear = new Date().getFullYear();
|
||||
const [startYear, endYear] = yearRange ?? [1900, currentYear + 20];
|
||||
|
||||
// Parse current value
|
||||
const parseDateValue = () => {
|
||||
if (!value) return null;
|
||||
const parts = value.split('T');
|
||||
const dateParts = parts[0]?.split('-');
|
||||
if (!dateParts || dateParts.length !== 3) return null;
|
||||
const y = parseInt(dateParts[0], 10);
|
||||
const m = parseInt(dateParts[1], 10) - 1;
|
||||
const d = parseInt(dateParts[2], 10);
|
||||
if (isNaN(y) || isNaN(m) || isNaN(d)) return null;
|
||||
const timeParts = parts[1]?.split(':');
|
||||
const hour = timeParts ? parseInt(timeParts[0], 10) : 0;
|
||||
const minute = timeParts ? parseInt(timeParts[1], 10) : 0;
|
||||
return { year: y, month: m, day: d, hour, minute };
|
||||
};
|
||||
|
||||
const parsed = parseDateValue();
|
||||
const today = new Date();
|
||||
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [viewYear, setViewYear] = React.useState(parsed?.year ?? today.getFullYear());
|
||||
const [viewMonth, setViewMonth] = React.useState(parsed?.month ?? today.getMonth());
|
||||
const [hour, setHour] = React.useState(parsed?.hour ?? 0);
|
||||
const [minute, setMinute] = React.useState(parsed?.minute ?? 0);
|
||||
|
||||
const triggerRef = React.useRef<HTMLButtonElement>(null);
|
||||
const popupRef = React.useRef<HTMLDivElement>(null);
|
||||
const [pos, setPos] = React.useState<{ top: number; left: number; flipped: boolean }>({
|
||||
top: 0,
|
||||
left: 0,
|
||||
flipped: false,
|
||||
});
|
||||
|
||||
// Merge forwarded ref with internal ref
|
||||
React.useImperativeHandle(ref, () => triggerRef.current!);
|
||||
|
||||
// Sync internal state when value changes externally
|
||||
React.useEffect(() => {
|
||||
const p = parseDateValue();
|
||||
if (p) {
|
||||
setViewYear(p.year);
|
||||
setViewMonth(p.month);
|
||||
setHour(p.hour);
|
||||
setMinute(p.minute);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [value]);
|
||||
|
||||
// Position popup
|
||||
const updatePosition = React.useCallback(() => {
|
||||
if (!triggerRef.current) return;
|
||||
const rect = triggerRef.current.getBoundingClientRect();
|
||||
const popupHeight = mode === 'datetime' ? 370 : 320;
|
||||
const spaceBelow = window.innerHeight - rect.bottom;
|
||||
const flipped = spaceBelow < popupHeight && rect.top > popupHeight;
|
||||
|
||||
setPos({
|
||||
top: flipped ? rect.top - popupHeight - 4 : rect.bottom + 4,
|
||||
left: Math.min(rect.left, window.innerWidth - 290),
|
||||
flipped,
|
||||
});
|
||||
}, [mode]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
updatePosition();
|
||||
window.addEventListener('scroll', updatePosition, true);
|
||||
window.addEventListener('resize', updatePosition);
|
||||
return () => {
|
||||
window.removeEventListener('scroll', updatePosition, true);
|
||||
window.removeEventListener('resize', updatePosition);
|
||||
};
|
||||
}, [open, updatePosition]);
|
||||
|
||||
// Dismiss on click outside
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (
|
||||
popupRef.current?.contains(e.target as Node) ||
|
||||
triggerRef.current?.contains(e.target as Node)
|
||||
)
|
||||
return;
|
||||
closePopup();
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
// Dismiss on Escape
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.stopPropagation();
|
||||
closePopup();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handler);
|
||||
return () => document.removeEventListener('keydown', handler);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
const closePopup = () => {
|
||||
setOpen(false);
|
||||
// Fire onBlur once when popup closes
|
||||
onBlur?.();
|
||||
};
|
||||
|
||||
const openPopup = () => {
|
||||
if (disabled) return;
|
||||
// Re-sync view to current value when opening
|
||||
const p = parseDateValue();
|
||||
if (p) {
|
||||
setViewYear(p.year);
|
||||
setViewMonth(p.month);
|
||||
setHour(p.hour);
|
||||
setMinute(p.minute);
|
||||
}
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const togglePopup = () => {
|
||||
if (open) closePopup();
|
||||
else openPopup();
|
||||
};
|
||||
|
||||
// Min/Max date boundaries
|
||||
const minDate = min ? new Date(min + 'T00:00:00') : null;
|
||||
const maxDate = max ? new Date(max + 'T00:00:00') : null;
|
||||
|
||||
const isDayDisabled = (y: number, m: number, d: number) => {
|
||||
const date = new Date(y, m, d);
|
||||
if (minDate && date < minDate) return true;
|
||||
if (maxDate && date > maxDate) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
// Select a day
|
||||
const selectDay = (day: number) => {
|
||||
if (isDayDisabled(viewYear, viewMonth, day)) return;
|
||||
const dateStr = formatDate(viewYear, viewMonth, day);
|
||||
if (mode === 'datetime') {
|
||||
onChange(`${dateStr}T${pad(hour)}:${pad(minute)}`);
|
||||
} else {
|
||||
onChange(dateStr);
|
||||
closePopup();
|
||||
}
|
||||
};
|
||||
|
||||
const handleTimeChange = (newHour: number, newMinute: number) => {
|
||||
setHour(newHour);
|
||||
setMinute(newMinute);
|
||||
if (parsed) {
|
||||
const dateStr = formatDate(parsed.year, parsed.month, parsed.day);
|
||||
onChange(`${dateStr}T${pad(newHour)}:${pad(newMinute)}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Navigation
|
||||
const prevMonth = () => {
|
||||
if (viewMonth === 0) {
|
||||
setViewMonth(11);
|
||||
setViewYear((y) => y - 1);
|
||||
} else {
|
||||
setViewMonth((m) => m - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const nextMonth = () => {
|
||||
if (viewMonth === 11) {
|
||||
setViewMonth(0);
|
||||
setViewYear((y) => y + 1);
|
||||
} else {
|
||||
setViewMonth((m) => m + 1);
|
||||
}
|
||||
};
|
||||
|
||||
// Build calendar grid
|
||||
const daysInMonth = getDaysInMonth(viewYear, viewMonth);
|
||||
const firstDay = getFirstDayOfWeek(viewYear, viewMonth);
|
||||
const cells: (number | null)[] = [];
|
||||
for (let i = 0; i < firstDay; i++) cells.push(null);
|
||||
for (let d = 1; d <= daysInMonth; d++) cells.push(d);
|
||||
|
||||
const isToday = (d: number) =>
|
||||
d === today.getDate() && viewMonth === today.getMonth() && viewYear === today.getFullYear();
|
||||
|
||||
const isSelected = (d: number) =>
|
||||
parsed !== null && d === parsed.day && viewMonth === parsed.month && viewYear === parsed.year;
|
||||
|
||||
// Display text
|
||||
const displayText = (() => {
|
||||
if (!parsed) return '';
|
||||
const monthName = MONTH_NAMES[parsed.month];
|
||||
const base = `${monthName} ${parsed.day}, ${parsed.year}`;
|
||||
if (mode === 'datetime') {
|
||||
return `${base} ${pad(parsed.hour)}:${pad(parsed.minute)}`;
|
||||
}
|
||||
return base;
|
||||
})();
|
||||
|
||||
// Year options
|
||||
const yearOptions: number[] = [];
|
||||
for (let y = startYear; y <= endYear; y++) yearOptions.push(y);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Hidden input for native form validation + autofill */}
|
||||
<input
|
||||
type="hidden"
|
||||
name={name}
|
||||
autoComplete={autoComplete}
|
||||
value={value}
|
||||
required={required}
|
||||
/>
|
||||
{required && (
|
||||
<input
|
||||
tabIndex={-1}
|
||||
aria-hidden
|
||||
className="absolute w-0 h-0 opacity-0 pointer-events-none"
|
||||
value={value}
|
||||
required
|
||||
onChange={() => {}}
|
||||
style={{ position: 'absolute' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Trigger button */}
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
id={id}
|
||||
disabled={disabled}
|
||||
onClick={togglePopup}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
togglePopup();
|
||||
}
|
||||
// Don't forward Enter to parent when popup is open
|
||||
if (open) return;
|
||||
onKeyDown?.(e);
|
||||
}}
|
||||
className={cn(
|
||||
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
!value && 'text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span className="truncate">
|
||||
{displayText || placeholder || (mode === 'datetime' ? 'Pick date & time' : 'Pick a date')}
|
||||
</span>
|
||||
<Calendar className="h-4 w-4 shrink-0 opacity-70" />
|
||||
</button>
|
||||
|
||||
{/* Popup (portalled) */}
|
||||
{open &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={popupRef}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: pos.top,
|
||||
left: pos.left,
|
||||
zIndex: 60,
|
||||
}}
|
||||
className="w-[280px] rounded-lg border border-input bg-card shadow-lg animate-fade-in"
|
||||
>
|
||||
{/* Month/Year Nav */}
|
||||
<div className="flex items-center justify-between px-3 pt-3 pb-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={prevMonth}
|
||||
className="p-1 rounded-md hover:bg-accent/10 transition-colors"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<select
|
||||
value={viewMonth}
|
||||
onChange={(e) => setViewMonth(parseInt(e.target.value, 10))}
|
||||
className="appearance-none bg-transparent text-sm font-medium cursor-pointer hover:text-accent focus:outline-none pr-1"
|
||||
>
|
||||
{MONTH_NAMES.map((name, i) => (
|
||||
<option key={i} value={i} className="bg-card text-foreground">
|
||||
{name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={viewYear}
|
||||
onChange={(e) => setViewYear(parseInt(e.target.value, 10))}
|
||||
className="appearance-none bg-transparent text-sm font-medium cursor-pointer hover:text-accent focus:outline-none"
|
||||
>
|
||||
{yearOptions.map((y) => (
|
||||
<option key={y} value={y} className="bg-card text-foreground">
|
||||
{y}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={nextMonth}
|
||||
className="p-1 rounded-md hover:bg-accent/10 transition-colors"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Day headers */}
|
||||
<div className="grid grid-cols-7 px-3 pb-1">
|
||||
{DAY_HEADERS.map((d) => (
|
||||
<div
|
||||
key={d}
|
||||
className="text-center text-[11px] font-medium text-muted-foreground py-1"
|
||||
>
|
||||
{d}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Day grid */}
|
||||
<div className="grid grid-cols-7 px-3 pb-3">
|
||||
{cells.map((day, i) =>
|
||||
day === null ? (
|
||||
<div key={`empty-${i}`} />
|
||||
) : (
|
||||
<button
|
||||
key={day}
|
||||
type="button"
|
||||
disabled={isDayDisabled(viewYear, viewMonth, day)}
|
||||
onClick={() => selectDay(day)}
|
||||
className={cn(
|
||||
'h-8 w-full rounded-md text-sm transition-colors',
|
||||
'hover:bg-accent/10 focus:outline-none focus-visible:ring-1 focus-visible:ring-ring',
|
||||
isSelected(day) && 'bg-accent text-accent-foreground font-medium',
|
||||
isToday(day) && !isSelected(day) && 'border border-accent/50 text-accent',
|
||||
isDayDisabled(viewYear, viewMonth, day) &&
|
||||
'opacity-30 cursor-not-allowed hover:bg-transparent'
|
||||
)}
|
||||
>
|
||||
{day}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Time selectors (datetime mode only) */}
|
||||
{mode === 'datetime' && (
|
||||
<div className="flex items-center gap-2 px-3 pb-3 border-t border-border pt-2">
|
||||
<Clock className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||
<select
|
||||
value={hour}
|
||||
onChange={(e) => handleTimeChange(parseInt(e.target.value, 10), minute)}
|
||||
className="flex-1 appearance-none bg-secondary rounded-md px-2 py-1 text-sm focus:outline-none focus-visible:ring-1 focus-visible:ring-ring cursor-pointer"
|
||||
>
|
||||
{Array.from({ length: 24 }, (_, i) => (
|
||||
<option key={i} value={i} className="bg-card text-foreground">
|
||||
{pad(i)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="text-muted-foreground font-medium">:</span>
|
||||
<select
|
||||
value={minute}
|
||||
onChange={(e) => handleTimeChange(hour, parseInt(e.target.value, 10))}
|
||||
className="flex-1 appearance-none bg-secondary rounded-md px-2 py-1 text-sm focus:outline-none focus-visible:ring-1 focus-visible:ring-ring cursor-pointer"
|
||||
>
|
||||
{Array.from({ length: 60 }, (_, i) => (
|
||||
<option key={i} value={i} className="bg-card text-foreground">
|
||||
{pad(i)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closePopup}
|
||||
className="ml-auto px-2 py-1 text-xs font-medium rounded-md bg-accent text-accent-foreground hover:bg-accent/90 transition-colors"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
DatePicker.displayName = 'DatePicker';
|
||||
|
||||
export { DatePicker };
|
||||
@ -193,6 +193,13 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── Chromium native date picker icon fix (safety net) ── */
|
||||
input[type="date"]::-webkit-calendar-picker-indicator,
|
||||
input[type="datetime-local"]::-webkit-calendar-picker-indicator {
|
||||
filter: invert(1);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ── Form validation — red outline only after submit attempt ── */
|
||||
form[data-submitted] input:invalid,
|
||||
form[data-submitted] select:invalid,
|
||||
@ -201,6 +208,12 @@ form[data-submitted] textarea:invalid {
|
||||
box-shadow: 0 0 0 2px hsl(0 62.8% 50% / 0.25);
|
||||
}
|
||||
|
||||
/* DatePicker trigger inherits red border from its hidden required sibling */
|
||||
form[data-submitted] input:invalid + button {
|
||||
border-color: hsl(0 62.8% 50%);
|
||||
box-shadow: 0 0 0 2px hsl(0 62.8% 50% / 0.25);
|
||||
}
|
||||
|
||||
/* ── Ambient background animations ── */
|
||||
|
||||
@keyframes drift-1 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user