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:
Kyle 2026-03-03 02:30:52 +08:00
parent da61676fef
commit 013f9ec010
12 changed files with 522 additions and 30 deletions

View File

@ -6,6 +6,7 @@ import { useAuth } from '@/hooks/useAuth';
import api, { getErrorMessage } from '@/lib/api'; import api, { getErrorMessage } from '@/lib/api';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { DatePicker } from '@/components/ui/date-picker';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@ -641,13 +642,14 @@ export default function LockScreen() {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="reg-dob" required>Date of Birth</Label> <Label htmlFor="reg-dob" required>Date of Birth</Label>
<Input <DatePicker
id="reg-dob" id="reg-dob"
type="date"
value={regDateOfBirth} value={regDateOfBirth}
onChange={(e) => setRegDateOfBirth(e.target.value)} onChange={(v) => setRegDateOfBirth(v)}
required required
name="bday"
autoComplete="bday" autoComplete="bday"
max={new Date().toISOString().slice(0, 10)}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">

View File

@ -13,6 +13,7 @@ import {
SheetFooter, SheetFooter,
} from '@/components/ui/sheet'; } from '@/components/ui/sheet';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { DatePicker } from '@/components/ui/date-picker';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button'; 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="grid grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="birthday">Birthday</Label> <Label htmlFor="birthday">Birthday</Label>
<Input <DatePicker
id="birthday" id="birthday"
type="date"
value={formData.birthday} value={formData.birthday}
onChange={(e) => set('birthday', e.target.value)} onChange={(v) => set('birthday', v)}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">

View File

@ -12,6 +12,7 @@ import {
SheetClose, SheetClose,
} from '@/components/ui/sheet'; } from '@/components/ui/sheet';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { DatePicker } from '@/components/ui/date-picker';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Select } from '@/components/ui/select'; import { Select } from '@/components/ui/select';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
@ -121,11 +122,10 @@ export default function ProjectForm({ project, onClose }: ProjectFormProps) {
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="due_date">Due Date</Label> <Label htmlFor="due_date">Due Date</Label>
<Input <DatePicker
id="due_date" id="due_date"
type="date"
value={formData.due_date} value={formData.due_date}
onChange={(e) => setFormData({ ...formData, due_date: e.target.value })} onChange={(v) => setFormData({ ...formData, due_date: v })}
/> />
</div> </div>
</div> </div>

View File

@ -14,6 +14,7 @@ import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { DatePicker } from '@/components/ui/date-picker';
import { Select } from '@/components/ui/select'; import { Select } from '@/components/ui/select';
const taskStatusColors: Record<string, string> = { const taskStatusColors: Record<string, string> = {
@ -350,10 +351,9 @@ export default function TaskDetailPanel({
Due Date Due Date
</div> </div>
{isEditing ? ( {isEditing ? (
<Input <DatePicker
type="date"
value={editState.due_date} 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" className="h-8 text-xs"
/> />
) : ( ) : (

View File

@ -12,6 +12,7 @@ import {
SheetClose, SheetClose,
} from '@/components/ui/sheet'; } from '@/components/ui/sheet';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { DatePicker } from '@/components/ui/date-picker';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Select } from '@/components/ui/select'; import { Select } from '@/components/ui/select';
import { Label } from '@/components/ui/label'; 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="grid grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="due_date">Due Date</Label> <Label htmlFor="due_date">Due Date</Label>
<Input <DatePicker
id="due_date" id="due_date"
type="date"
value={formData.due_date} value={formData.due_date}
onChange={(e) => setFormData({ ...formData, due_date: e.target.value })} onChange={(v) => setFormData({ ...formData, due_date: v })}
/> />
</div> </div>

View File

@ -12,6 +12,7 @@ import { formatUpdatedAt } from '@/components/shared/utils';
import CopyableField from '@/components/shared/CopyableField'; import CopyableField from '@/components/shared/CopyableField';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { DatePicker } from '@/components/ui/date-picker';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Select } from '@/components/ui/select'; import { Select } from '@/components/ui/select';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
@ -340,11 +341,11 @@ export default function ReminderDetailPanel({
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div className="space-y-1"> <div className="space-y-1">
<Label htmlFor="reminder-at">Remind At</Label> <Label htmlFor="reminder-at">Remind At</Label>
<Input <DatePicker
id="reminder-at" id="reminder-at"
type="datetime-local" mode="datetime"
value={editState.remind_at} value={editState.remind_at}
onChange={(e) => updateField('remind_at', e.target.value)} onChange={(v) => updateField('remind_at', v)}
className="text-xs" className="text-xs"
/> />
</div> </div>

View File

@ -12,6 +12,7 @@ import {
SheetClose, SheetClose,
} from '@/components/ui/sheet'; } from '@/components/ui/sheet';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { DatePicker } from '@/components/ui/date-picker';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Select } from '@/components/ui/select'; import { Select } from '@/components/ui/select';
import { Label } from '@/components/ui/label'; 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="grid grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="remind_at">Remind At</Label> <Label htmlFor="remind_at">Remind At</Label>
<Input <DatePicker
id="remind_at" id="remind_at"
type="datetime-local" mode="datetime"
value={formData.remind_at} value={formData.remind_at}
onChange={(e) => setFormData({ ...formData, remind_at: e.target.value })} onChange={(v) => setFormData({ ...formData, remind_at: v })}
/> />
</div> </div>

View File

@ -18,6 +18,7 @@ import {
import { useSettings } from '@/hooks/useSettings'; import { useSettings } from '@/hooks/useSettings';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { DatePicker } from '@/components/ui/date-picker';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import api from '@/lib/api'; import api from '@/lib/api';
@ -353,11 +354,10 @@ export default function SettingsPage() {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="date_of_birth">Date of Birth</Label> <Label htmlFor="date_of_birth">Date of Birth</Label>
<Input <DatePicker
id="date_of_birth" id="date_of_birth"
type="date"
value={dateOfBirth} value={dateOfBirth}
onChange={(e) => setDateOfBirth(e.target.value)} onChange={(v) => setDateOfBirth(v)}
onBlur={() => handleProfileSave('date_of_birth')} onBlur={() => handleProfileSave('date_of_birth')}
onKeyDown={(e) => { if (e.key === 'Enter') handleProfileSave('date_of_birth'); }} onKeyDown={(e) => { if (e.key === 'Enter') handleProfileSave('date_of_birth'); }}
/> />

View File

@ -13,6 +13,7 @@ import { formatUpdatedAt } from '@/components/shared/utils';
import CopyableField from '@/components/shared/CopyableField'; import CopyableField from '@/components/shared/CopyableField';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { DatePicker } from '@/components/ui/date-picker';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Select } from '@/components/ui/select'; import { Select } from '@/components/ui/select';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
@ -385,11 +386,10 @@ export default function TodoDetailPanel({
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div className="space-y-1"> <div className="space-y-1">
<Label htmlFor="todo-due-date">Due Date</Label> <Label htmlFor="todo-due-date">Due Date</Label>
<Input <DatePicker
id="todo-due-date" id="todo-due-date"
type="date"
value={editState.due_date} value={editState.due_date}
onChange={(e) => updateField('due_date', e.target.value)} onChange={(v) => updateField('due_date', v)}
className="text-xs" className="text-xs"
/> />
</div> </div>

View File

@ -12,6 +12,7 @@ import {
SheetClose, SheetClose,
} from '@/components/ui/sheet'; } from '@/components/ui/sheet';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { DatePicker } from '@/components/ui/date-picker';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Select } from '@/components/ui/select'; import { Select } from '@/components/ui/select';
import { Label } from '@/components/ui/label'; 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="grid grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="due_date">Due Date</Label> <Label htmlFor="due_date">Due Date</Label>
<Input <DatePicker
id="due_date" id="due_date"
type="date"
value={formData.due_date} value={formData.due_date}
onChange={(e) => setFormData({ ...formData, due_date: e.target.value })} onChange={(v) => setFormData({ ...formData, due_date: v })}
/> />
</div> </div>

View 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 };

View File

@ -193,6 +193,13 @@
font-weight: 600; 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 validation — red outline only after submit attempt ── */
form[data-submitted] input:invalid, form[data-submitted] input:invalid,
form[data-submitted] select: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); 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 ── */ /* ── Ambient background animations ── */
@keyframes drift-1 { @keyframes drift-1 {