Replace native datetime inputs with a calendar date picker

This commit is contained in:
Steven RYDELL
2026-07-29 07:50:55 +02:00
parent 3f31a115c9
commit 0fb734a6c7
5 changed files with 282 additions and 29 deletions
+57 -28
View File
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { useState, useEffect, type KeyboardEvent } from 'react';
import { useState, useEffect, useMemo, type KeyboardEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { useBufferedValue, useResetOnChange } from '@/hooks/useBufferedValue';
import ReactMarkdown from 'react-markdown';
@@ -21,8 +21,9 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Combobox, type ComboboxOption } from '@/components/ui/combobox';
import { Calendar } from '@/components/ui/calendar';
import { Plus, X, Eye, EyeOff, Loader2, Search, Check, ChevronRight } from 'lucide-react';
import { Plus, X, Eye, EyeOff, Loader2, Search, Check, ChevronRight, Calendar as CalendarIcon } from 'lucide-react';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
@@ -40,6 +41,7 @@ import {
DURATION_UNITS,
} from '@/lib/durationFormat';
import { resolveSchema, resolveVariantForm, resolveObject, buildEmbeddedDefaults } from '@/lib/schemaResolver';
import { cn } from '@/lib/utils';
import { useAccountStore } from '@/stores/accountStore';
import { useEffectiveEdition } from '@/components/forms/FormEditionContext';
import { useObjectList, useObjectLabel, useNoPermissionMessage, type ObjectOption } from '@/lib/objectOptions';
@@ -835,28 +837,25 @@ interface DateTimeFieldProps {
function DateTimeField({ value, onChange, readOnly, nullable }: DateTimeFieldProps) {
const { t } = useTranslation();
const strValue = typeof value === 'string' ? value : '';
const [open, setOpen] = useState(false);
const toLocal = (iso: string): string => {
if (!iso) return '';
try {
const d = new Date(iso);
if (isNaN(d.getTime())) return '';
return d.toISOString().slice(0, 16);
} catch {
return '';
}
};
const parsed = useMemo(() => {
if (!strValue) return null;
const d = new Date(strValue);
return isNaN(d.getTime()) ? null : d;
}, [strValue]);
const toIso = (local: string): string | null => {
if (!local) return nullable ? null : '';
return new Date(local).toISOString();
};
const timeValue = parsed
? `${String(parsed.getHours()).padStart(2, '0')}:${String(parsed.getMinutes()).padStart(2, '0')}`
: '';
const [local, setLocal] = useBufferedValue(strValue, toLocal);
const commit = () => {
const iso = toIso(local);
if (iso !== strValue) onChange(iso);
// Date and time are committed together so the field never holds a
// half-entered value the way a free-form datetime input does.
const commit = (date: Date, time: string) => {
const [hours, minutes] = time.split(':').map(Number);
const next = new Date(date);
next.setHours(Number.isFinite(hours) ? hours : 0, Number.isFinite(minutes) ? minutes : 0, 0, 0);
onChange(next.toISOString());
};
if (readOnly) {
@@ -879,13 +878,43 @@ function DateTimeField({ value, onChange, readOnly, nullable }: DateTimeFieldPro
return (
<div className="flex items-center gap-2">
<Input
type="datetime-local"
value={local}
onChange={(e) => setLocal(e.target.value)}
onBlur={commit}
className="flex-1"
/>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
className={cn('flex-1 justify-start text-left font-normal', !parsed && 'text-muted-foreground')}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{parsed
? new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(parsed)
: t('field.pickDate', 'Pick a date')}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={parsed ?? undefined}
defaultMonth={parsed ?? undefined}
onSelect={(day) => {
if (!day) return;
commit(day, timeValue || '00:00');
setOpen(false);
}}
autoFocus
/>
<div className="border-t p-3">
<Input
type="time"
value={timeValue}
onChange={(e) => {
if (parsed && e.target.value) commit(parsed, e.target.value);
}}
aria-label={t('field.time', 'Time')}
/>
</div>
</PopoverContent>
</Popover>
{nullable && strValue && (
<Button
type="button"