diff --git a/src/components/Form/DateTimeField/DateTimeField.spec.tsx b/src/components/Form/DateTimeField/DateTimeField.spec.tsx new file mode 100644 index 0000000..3c98466 --- /dev/null +++ b/src/components/Form/DateTimeField/DateTimeField.spec.tsx @@ -0,0 +1,197 @@ +import { fireEvent, getByText, render, screen } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { DateTimeField } from './DateTimeField.tsx'; + +describe('DateTimeField', () => { + const setError = vi.fn(); + const setValue = vi.fn(); + + beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date(2025, 0, 1, 22)); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + it('should render a date trigger with a placeholder and a time input with the default time', () => { + render( + + ); + expect(screen.getByTestId('datetime-date-trigger')).toHaveTextContent('Select date'); + const input: HTMLInputElement = screen.getByTestId('datetime-time-input'); + expect(input.type).toBe('time'); + expect(input.value).toMatch(/^00:00(:00)?$/); + }); + + it('should not initially show the datepicker', () => { + render( + + ); + expect(() => screen.getByTestId('datepicker')).toThrow(); + }); + + it('should show the datepicker when the date trigger is clicked', async () => { + render( + + ); + await userEvent.click(screen.getByTestId('datetime-date-trigger')); + expect(screen.getByTestId('datepicker')).toBeInTheDocument(); + }); + + it('should set the value to the selected date at the default time, and close the datepicker', async () => { + render( + + ); + await userEvent.click(screen.getByTestId('datetime-date-trigger')); + await userEvent.click(getByText(screen.getByTestId('datepicker'), '15')); + expect(setValue).toHaveBeenCalledOnce(); + expect(setValue).toHaveBeenCalledWith(new Date(2025, 0, 15, 0, 0, 0)); + expect(() => screen.getByTestId('datepicker')).toThrow(); + }); + + it('should use a time entered before a date is selected', async () => { + render( + + ); + fireEvent.change(screen.getByTestId('datetime-time-input'), { target: { value: '14:30:15' } }); + expect(setValue).not.toHaveBeenCalled(); + await userEvent.click(screen.getByTestId('datetime-date-trigger')); + await userEvent.click(getByText(screen.getByTestId('datepicker'), '15')); + expect(setValue).toHaveBeenCalledWith(new Date(2025, 0, 15, 14, 30, 15)); + }); + + it('should update the time of the current value when the time is changed', () => { + const value = new Date(2025, 0, 15, 10, 30, 0); + render( + + ); + fireEvent.change(screen.getByTestId('datetime-time-input'), { target: { value: '18:45:30' } }); + expect(setValue).toHaveBeenCalledWith(new Date(2025, 0, 15, 18, 45, 30)); + }); + + it('should not set the value if the time is cleared', () => { + const value = new Date(2025, 0, 15, 10, 30, 0); + render( + + ); + fireEvent.change(screen.getByTestId('datetime-time-input'), { target: { value: '' } }); + expect(setValue).not.toHaveBeenCalled(); + }); + + it('should render the value provided as a prop', () => { + const value = new Date(2025, 0, 15, 10, 30, 5); + render( + + ); + expect(screen.getByTestId('datetime-date-trigger')).toHaveTextContent('Wed Jan 15 2025'); + expect(screen.getByTestId('datetime-time-input').value).toBe('10:30:05'); + }); + + it('should reset the inputs when the value is cleared', () => { + const { rerender } = render( + + ); + rerender( + + ); + expect(screen.getByTestId('datetime-date-trigger')).toHaveTextContent('Select date'); + expect(screen.getByTestId('datetime-time-input').value).toMatch(/^00:00(:00)?$/); + }); + + it('should disable both inputs when disabled', () => { + render( + + ); + expect(screen.getByTestId('datetime-date-trigger')).toBeDisabled(); + expect(screen.getByTestId('datetime-time-input')).toBeDisabled(); + }); + + describe('timeFormat="24h"', () => { + it('should display the time in 24-hour format', () => { + render( + + ); + expect(screen.getByTestId('datetime-time-hours').value).toBe('18'); + expect(screen.getByTestId('datetime-time-minutes').value).toBe('45'); + expect(screen.getByTestId('datetime-time-seconds').value).toBe('30'); + }); + + it('should update the value when a segment is changed', () => { + render( + + ); + fireEvent.change(screen.getByTestId('datetime-time-hours'), { target: { value: '21' } }); + expect(setValue).toHaveBeenCalledWith(new Date(2025, 0, 15, 21, 30, 0)); + }); + + it('should clamp segments to their maximum value', () => { + render( + + ); + const hours = screen.getByTestId('datetime-time-hours'); + const minutes = screen.getByTestId('datetime-time-minutes'); + const seconds = screen.getByTestId('datetime-time-seconds'); + fireEvent.change(hours, { target: { value: '24' } }); + fireEvent.change(minutes, { target: { value: '60' } }); + fireEvent.change(seconds, { target: { value: '99' } }); + expect(hours.value).toBe('23'); + expect(minutes.value).toBe('59'); + expect(seconds.value).toBe('59'); + expect(setValue).toHaveBeenLastCalledWith(new Date(2025, 0, 15, 23, 59, 59)); + }); + + it('should not set the value if a segment is cleared', () => { + render( + + ); + fireEvent.change(screen.getByTestId('datetime-time-minutes'), { target: { value: '' } }); + expect(setValue).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/components/Form/DateTimeField/DateTimeField.stories.tsx b/src/components/Form/DateTimeField/DateTimeField.stories.tsx new file mode 100644 index 0000000..26c9677 --- /dev/null +++ b/src/components/Form/DateTimeField/DateTimeField.stories.tsx @@ -0,0 +1,94 @@ +import { useState } from 'react'; + +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { DateTimeField } from './DateTimeField.tsx'; + +type Story = StoryObj; + +export default { component: DateTimeField } as Meta; + +export const Default: Story = { + decorators: [ + (Story) => { + const [value, setValue] = useState(); + return ( + + ); + } + ] +}; + +export const WithInitialValue: Story = { + decorators: [ + (Story) => { + const [value, setValue] = useState(new Date(2025, 0, 1, 10, 30)); + return ( + + ); + } + ] +}; + +export const With24HourTime: Story = { + decorators: [ + (Story) => { + const [value, setValue] = useState(new Date(2025, 0, 1, 18, 30)); + return ( + + ); + } + ] +}; + +export const Disabled: Story = { + args: { + disabled: true, + label: 'Appointment', + name: 'appointment', + setValue: () => undefined, + value: new Date(2025, 0, 1, 10, 30) + } +}; + +export const WithError: Story = { + decorators: [ + (Story) => { + const [value, setValue] = useState(); + return ( + + ); + } + ] +}; diff --git a/src/components/Form/DateTimeField/DateTimeField.tsx b/src/components/Form/DateTimeField/DateTimeField.tsx new file mode 100644 index 0000000..65ca37c --- /dev/null +++ b/src/components/Form/DateTimeField/DateTimeField.tsx @@ -0,0 +1,230 @@ +import { useEffect, useState } from 'react'; +import type { ChangeEvent, FocusEvent } from 'react'; + +import type { BaseFormField } from '@douglasneuroinformatics/libui-form-types'; +import { ChevronDownIcon } from 'lucide-react'; +import type { Simplify } from 'type-fest'; + +import { Button, DatePicker, Input, Label, Popover } from '#components'; +import { useTranslation } from '#hooks'; + +import { FieldGroup } from '../FieldGroup/FieldGroup.tsx'; + +import type { BaseFieldComponentProps } from '../types.ts'; + +const DEFAULT_TIME = '00:00:00'; + +const isValidTimeString = (s: string) => /^([01][0-9]|2[0-3]):[0-5][0-9](:[0-5][0-9])?$/.test(s); + +const pad = (n: number) => String(n).padStart(2, '0'); + +const toTimeString = (date: Date) => { + return [date.getHours(), date.getMinutes(), date.getSeconds()].map(pad).join(':'); +}; + +/** Returns a new date with the calendar day of `date` and the time of day described by `time` (HH:MM or HH:MM:SS) */ +const combineDateAndTime = (date: Date, time: string) => { + const [hours = 0, minutes = 0, seconds = 0] = time.split(':').map(Number); + return new Date(date.getFullYear(), date.getMonth(), date.getDate(), hours, minutes, seconds); +}; + +type TimeSegments = { + hours: string; + minutes: string; + seconds: string; +}; + +const SEGMENT_MAX: { [K in keyof TimeSegments]: number } = { hours: 23, minutes: 59, seconds: 59 }; + +/** Strips non-digits from `input` and clamps it to the maximum for the segment, e.g., '60' -> '59' for minutes */ +const sanitizeSegment = (key: keyof TimeSegments, input: string) => { + const digits = input.replace(/\D/g, '').slice(0, 2); + return digits && Number(digits) > SEGMENT_MAX[key] ? String(SEGMENT_MAX[key]) : digits; +}; + +/** Splits a 24-hour time string (HH:MM or HH:MM:SS) into its segments */ +const toSegments = (time: string): TimeSegments => { + const [h = 0, m = 0, s = 0] = (isValidTimeString(time) ? time : DEFAULT_TIME).split(':').map(Number); + return { hours: pad(h), minutes: pad(m), seconds: pad(s) }; +}; + +/** Converts segments back to a 24-hour time string (HH:MM:SS), or an empty string if any segment is invalid */ +const fromSegments = ({ hours, minutes, seconds }: TimeSegments) => { + if (![hours, minutes, seconds].every((segment) => /^\d{1,2}$/.test(segment))) { + return ''; + } + const [h, m, s] = [hours, minutes, seconds].map(Number) as [number, number, number]; + if (h > 23 || m > 59 || s > 59) { + return ''; + } + return [h, m, s].map(pad).join(':'); +}; + +type SegmentedTimeInputProps = { + disabled?: boolean; + id: string; + name: string; + onChange: (time: string) => void; + value: string; +}; + +/** A time input that always displays in 24-hour format, regardless of the browser locale */ +const SegmentedTimeInput = ({ disabled, id, name, onChange, value }: SegmentedTimeInputProps) => { + const [segments, setSegments] = useState(() => toSegments(value)); + const { t } = useTranslation('libui'); + + // only resync from the value when it was changed externally, so partially typed segments are not overwritten + useEffect(() => { + if (fromSegments(segments) !== value) { + setSegments(toSegments(value)); + } + }, [value]); + + const update = (changes: Partial) => { + const updated = { ...segments, ...changes }; + setSegments(updated); + onChange(fromSegments(updated)); + }; + + const segmentProps = (key: keyof TimeSegments) => ({ + 'aria-label': t(`form.${key}`), + className: + 'w-6 bg-transparent text-center tabular-nums outline-hidden placeholder:text-muted-foreground disabled:cursor-not-allowed', + 'data-testid': `datetime-time-${key}`, + disabled, + inputMode: 'numeric' as const, + maxLength: 2, + onBlur: () => { + if (/^\d$/.test(segments[key])) { + setSegments({ ...segments, [key]: pad(Number(segments[key])) }); + } + }, + onChange: (event: ChangeEvent) => { + update({ [key]: sanitizeSegment(key, event.target.value) }); + }, + onFocus: (event: FocusEvent) => event.target.select(), + placeholder: '--', + value: segments[key] + }); + + return ( + + + : + + : + + + + ); +}; + +export type DateTimeFieldProps = Simplify< + BaseFieldComponentProps & + Omit & { + /** Force a 24-hour time display. If omitted, the native time input is used, which follows the browser locale. */ + timeFormat?: '24h'; + } +>; + +export const DateTimeField = ({ + description, + disabled, + error, + label, + name, + readOnly, + setValue, + timeFormat, + value +}: DateTimeFieldProps) => { + const [isDatePickerOpen, setIsDatePickerOpen] = useState(false); + const [timeValue, setTimeValue] = useState(value ? toTimeString(value) : DEFAULT_TIME); + const { t } = useTranslation('libui'); + + useEffect(() => { + setTimeValue(value ? toTimeString(value) : DEFAULT_TIME); + }, [value]); + + const dateInputId = `${name}-date`; + const timeInputId = `${name}-time`; + + const handleTimeChange = (time: string) => { + setTimeValue(time); + if (value && isValidTimeString(time)) { + setValue(combineDateAndTime(value, time)); + } + }; + + return ( + + + {label} + + + + + + {t('form.date')} + + + + + + {value ? value.toDateString() : t('form.selectDate')} + + + + + + { + setValue(combineDateAndTime(date, isValidTimeString(timeValue) ? timeValue : DEFAULT_TIME)); + setIsDatePickerOpen(false); + }} + /> + + + + + + {t('form.time')} + + {timeFormat ? ( + + ) : ( + handleTimeChange(event.target.value)} + /> + )} + + + + + ); +}; diff --git a/src/i18n/translations/libui.json b/src/i18n/translations/libui.json index 450935a..5f5ddb7 100644 --- a/src/i18n/translations/libui.json +++ b/src/i18n/translations/libui.json @@ -42,6 +42,21 @@ "es": "Agregar", "fr": "Ajouter" }, + "date": { + "en": "Date", + "es": "Fecha", + "fr": "Date" + }, + "hours": { + "en": "Hours", + "es": "Horas", + "fr": "Heures" + }, + "minutes": { + "en": "Minutes", + "es": "Minutos", + "fr": "Minutes" + }, "radioLabels": { "false": { "en": "False", @@ -69,10 +84,25 @@ "es": "Restablecer", "fr": "Réinitialiser" }, + "seconds": { + "en": "Seconds", + "es": "Segundos", + "fr": "Secondes" + }, + "selectDate": { + "en": "Select date", + "es": "Seleccionar fecha", + "fr": "Sélectionner une date" + }, "submit": { "en": "Submit", "es": "Enviar", "fr": "Soumettre" + }, + "time": { + "en": "Time", + "es": "Hora", + "fr": "Heure" } }, "months": {