Replace native datetime inputs with a calendar date picker
This commit is contained in:
Generated
+54
@@ -31,6 +31,7 @@
|
||||
"otpauth": "^9.5.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.2.8",
|
||||
"react-day-picker": "^9.14.0",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-i18next": "^17.0.11",
|
||||
"react-markdown": "^10.1.0",
|
||||
@@ -309,6 +310,12 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@date-fns/tz": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.5.0.tgz",
|
||||
"integrity": "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
|
||||
@@ -1922,6 +1929,15 @@
|
||||
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tabby_ai/hijri-converter": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@tabby_ai/hijri-converter/-/hijri-converter-1.0.5.tgz",
|
||||
"integrity": "sha512-r5bClKrcIusDoo049dSL8CawnHR6mRdDwhlQuIgZRNty68q0x8k3Lf1BtPAMxRf/GgnHBnIO4ujd3+GQdLWzxQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/node": {
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz",
|
||||
@@ -3321,6 +3337,22 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/date-fns": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz",
|
||||
"integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/kossnocorp"
|
||||
}
|
||||
},
|
||||
"node_modules/date-fns-jalali": {
|
||||
"version": "4.1.0-0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns-jalali/-/date-fns-jalali-4.1.0-0.tgz",
|
||||
"integrity": "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -5501,6 +5533,28 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-day-picker": {
|
||||
"version": "9.14.0",
|
||||
"resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-9.14.0.tgz",
|
||||
"integrity": "sha512-tBaoDWjPwe0M5pGrum4H0SR6Lyk+BO9oHnp9JbKpGKW2mlraNPgP9BMfsg5pWpwrssARmeqk7YBl2oXutZTaHA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@date-fns/tz": "^1.4.1",
|
||||
"@tabby_ai/hijri-converter": "1.0.5",
|
||||
"date-fns": "^4.1.0",
|
||||
"date-fns-jalali": "4.1.0-0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/gpbl"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "19.2.8",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
|
||||
|
||||
+2
-1
@@ -39,6 +39,7 @@
|
||||
"otpauth": "^9.5.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.2.8",
|
||||
"react-day-picker": "^9.14.0",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-i18next": "^17.0.11",
|
||||
"react-markdown": "^10.1.0",
|
||||
@@ -67,4 +68,4 @@
|
||||
"vite": "^8.1.5",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
import * as React from 'react';
|
||||
import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
|
||||
import { DayPicker, getDefaultClassNames, type DayButton } from 'react-day-picker';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button, buttonVariants } from '@/components/ui/button';
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
captionLayout = 'label',
|
||||
buttonVariant = 'ghost',
|
||||
formatters,
|
||||
components,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayPicker> & {
|
||||
buttonVariant?: React.ComponentProps<typeof Button>['variant'];
|
||||
}) {
|
||||
const defaultClassNames = getDefaultClassNames();
|
||||
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn(
|
||||
'group/calendar bg-background p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent',
|
||||
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
||||
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
||||
className,
|
||||
)}
|
||||
captionLayout={captionLayout}
|
||||
formatters={{
|
||||
formatMonthDropdown: (date) => date.toLocaleString('default', { month: 'short' }),
|
||||
...formatters,
|
||||
}}
|
||||
classNames={{
|
||||
root: cn('w-fit', defaultClassNames.root),
|
||||
months: cn('relative flex flex-col gap-4 md:flex-row', defaultClassNames.months),
|
||||
month: cn('flex w-full flex-col gap-4', defaultClassNames.month),
|
||||
nav: cn('absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1', defaultClassNames.nav),
|
||||
button_previous: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
'size-(--cell-size) p-0 select-none aria-disabled:opacity-50',
|
||||
defaultClassNames.button_previous,
|
||||
),
|
||||
button_next: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
'size-(--cell-size) p-0 select-none aria-disabled:opacity-50',
|
||||
defaultClassNames.button_next,
|
||||
),
|
||||
month_caption: cn(
|
||||
'flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)',
|
||||
defaultClassNames.month_caption,
|
||||
),
|
||||
dropdowns: cn(
|
||||
'flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium',
|
||||
defaultClassNames.dropdowns,
|
||||
),
|
||||
dropdown_root: cn(
|
||||
'relative rounded-md border border-input shadow-xs has-focus:border-ring has-focus:ring-[3px] has-focus:ring-ring/50',
|
||||
defaultClassNames.dropdown_root,
|
||||
),
|
||||
dropdown: cn('absolute inset-0 bg-popover opacity-0', defaultClassNames.dropdown),
|
||||
caption_label: cn(
|
||||
'font-medium select-none',
|
||||
captionLayout === 'label'
|
||||
? 'text-sm'
|
||||
: 'flex h-8 items-center gap-1 rounded-md pr-1 pl-2 text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground',
|
||||
defaultClassNames.caption_label,
|
||||
),
|
||||
month_grid: cn('w-full border-collapse', defaultClassNames.month_grid),
|
||||
weekdays: cn('flex', defaultClassNames.weekdays),
|
||||
weekday: cn(
|
||||
'flex-1 rounded-md text-[0.8rem] font-normal text-muted-foreground select-none',
|
||||
defaultClassNames.weekday,
|
||||
),
|
||||
week: cn('mt-2 flex w-full', defaultClassNames.week),
|
||||
week_number_header: cn('w-(--cell-size) select-none', defaultClassNames.week_number_header),
|
||||
week_number: cn('text-[0.8rem] text-muted-foreground select-none', defaultClassNames.week_number),
|
||||
day: cn(
|
||||
'group/day relative aspect-square h-full w-full p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-r-md',
|
||||
props.showWeekNumber
|
||||
? '[&:nth-child(2)[data-selected=true]_button]:rounded-l-md'
|
||||
: '[&:first-child[data-selected=true]_button]:rounded-l-md',
|
||||
defaultClassNames.day,
|
||||
),
|
||||
range_start: cn('rounded-l-md bg-accent', defaultClassNames.range_start),
|
||||
range_middle: cn('rounded-none', defaultClassNames.range_middle),
|
||||
range_end: cn('rounded-r-md bg-accent', defaultClassNames.range_end),
|
||||
today: cn('rounded-md bg-accent text-accent-foreground data-[selected=true]:rounded-none', defaultClassNames.today),
|
||||
outside: cn('text-muted-foreground aria-selected:text-muted-foreground', defaultClassNames.outside),
|
||||
disabled: cn('text-muted-foreground opacity-50', defaultClassNames.disabled),
|
||||
hidden: cn('invisible', defaultClassNames.hidden),
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
Root: ({ className, rootRef, ...props }) => {
|
||||
return <div data-slot="calendar" ref={rootRef} className={cn(className)} {...props} />;
|
||||
},
|
||||
Chevron: ({ className, orientation, ...props }) => {
|
||||
if (orientation === 'left') {
|
||||
return <ChevronLeftIcon className={cn('size-4', className)} {...props} />;
|
||||
}
|
||||
|
||||
if (orientation === 'right') {
|
||||
return <ChevronRightIcon className={cn('size-4', className)} {...props} />;
|
||||
}
|
||||
|
||||
return <ChevronDownIcon className={cn('size-4', className)} {...props} />;
|
||||
},
|
||||
DayButton: CalendarDayButton,
|
||||
WeekNumber: ({ children, ...props }) => {
|
||||
return (
|
||||
<td {...props}>
|
||||
<div className="flex size-(--cell-size) items-center justify-center text-center">{children}</div>
|
||||
</td>
|
||||
);
|
||||
},
|
||||
...components,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarDayButton({
|
||||
className,
|
||||
day,
|
||||
modifiers,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayButton>) {
|
||||
const defaultClassNames = getDefaultClassNames();
|
||||
|
||||
const ref = React.useRef<HTMLButtonElement>(null);
|
||||
React.useEffect(() => {
|
||||
if (modifiers.focused) ref.current?.focus();
|
||||
}, [modifiers.focused]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-day={day.date.toLocaleDateString()}
|
||||
data-selected-single={
|
||||
modifiers.selected && !modifiers.range_start && !modifiers.range_end && !modifiers.range_middle
|
||||
}
|
||||
data-range-start={modifiers.range_start}
|
||||
data-range-end={modifiers.range_end}
|
||||
data-range-middle={modifiers.range_middle}
|
||||
className={cn(
|
||||
'flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-[3px] group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:rounded-none data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:text-accent-foreground [&>span]:text-xs [&>span]:opacity-70',
|
||||
defaultClassNames.day,
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Calendar, CalendarDayButton };
|
||||
@@ -169,10 +169,12 @@
|
||||
"none": "None",
|
||||
"notSet": "Not set",
|
||||
"optional": "(optional)",
|
||||
"pickDate": "Pick a date",
|
||||
"required": "required",
|
||||
"selectEllipsis": "Select...",
|
||||
"selectKey": "Select key...",
|
||||
"selectOptions": "Select options...",
|
||||
"time": "Time",
|
||||
"type": "Type",
|
||||
"typeAndPressEnter": "Type and press Enter...",
|
||||
"unknownObjectType": "Unknown object type: {{name}}"
|
||||
|
||||
Reference in New Issue
Block a user