From 9f903d69e47f238e6f7de6f404626c177a7e6135 Mon Sep 17 00:00:00 2001 From: David Roper Date: Fri, 2 Oct 2026 16:53:21 -0400 Subject: [PATCH 1/9] feat: add datetime picker component --- .../Form/DateTimeField/DateTimeField.tsx | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 src/components/Form/DateTimeField/DateTimeField.tsx diff --git a/src/components/Form/DateTimeField/DateTimeField.tsx b/src/components/Form/DateTimeField/DateTimeField.tsx new file mode 100644 index 0000000..97b2823 --- /dev/null +++ b/src/components/Form/DateTimeField/DateTimeField.tsx @@ -0,0 +1,116 @@ +import { useEffect, useState } from 'react'; + +import { toBasicISOString } from '@douglasneuroinformatics/libjs'; +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 toTimeString = (date: Date) => { + return [date.getHours(), date.getMinutes(), date.getSeconds()].map((n) => String(n).padStart(2, '0')).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); +}; + +export type DateTimeFieldProps = Simplify & Omit>; + +export const DateTimeField = ({ + description, + disabled, + error, + label, + name, + readOnly, + setValue, + 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`; + + return ( + + + + + +
+
+ + + + + + + { + setValue(combineDateAndTime(date, isValidTimeString(timeValue) ? timeValue : DEFAULT_TIME)); + setIsDatePickerOpen(false); + }} + /> + + +
+
+ + { + const time = event.target.value; + setTimeValue(time); + if (value && isValidTimeString(time)) { + setValue(combineDateAndTime(value, time)); + } + }} + /> +
+
+ +
+ ); +}; From dd47c3118583e77fc94662f9f2a291c8fa9e379d Mon Sep 17 00:00:00 2001 From: David Roper Date: Fri, 2 Oct 2026 16:53:47 -0400 Subject: [PATCH 2/9] test: add tests and story for datetime picker component --- .../Form/DateTimeField/DateTimeField.spec.tsx | 126 ++++++++++++++++++ .../DateTimeField/DateTimeField.stories.tsx | 75 +++++++++++ 2 files changed, 201 insertions(+) create mode 100644 src/components/Form/DateTimeField/DateTimeField.spec.tsx create mode 100644 src/components/Form/DateTimeField/DateTimeField.stories.tsx diff --git a/src/components/Form/DateTimeField/DateTimeField.spec.tsx b/src/components/Form/DateTimeField/DateTimeField.spec.tsx new file mode 100644 index 0000000..7cfc5dd --- /dev/null +++ b/src/components/Form/DateTimeField/DateTimeField.spec.tsx @@ -0,0 +1,126 @@ +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('2025-01-15'); + 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(); + }); +}); diff --git a/src/components/Form/DateTimeField/DateTimeField.stories.tsx b/src/components/Form/DateTimeField/DateTimeField.stories.tsx new file mode 100644 index 0000000..08d3012 --- /dev/null +++ b/src/components/Form/DateTimeField/DateTimeField.stories.tsx @@ -0,0 +1,75 @@ +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 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 ( + + ); + } + ] +}; From c76206672e90cf80d64a5fc2b434cc2a354976a0 Mon Sep 17 00:00:00 2001 From: David Roper Date: Fri, 2 Oct 2026 16:54:09 -0400 Subject: [PATCH 3/9] chore: add translations for component --- src/i18n/translations/libui.json | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/i18n/translations/libui.json b/src/i18n/translations/libui.json index 450935a..0ad39d3 100644 --- a/src/i18n/translations/libui.json +++ b/src/i18n/translations/libui.json @@ -42,6 +42,11 @@ "es": "Agregar", "fr": "Ajouter" }, + "date": { + "en": "Date", + "es": "Fecha", + "fr": "Date" + }, "radioLabels": { "false": { "en": "False", @@ -69,10 +74,20 @@ "es": "Restablecer", "fr": "Réinitialiser" }, + "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": { From 8565e8113e43e0c920cb0ba9d4e71640f6972af1 Mon Sep 17 00:00:00 2001 From: David Roper Date: Mon, 5 Oct 2026 13:01:19 -0400 Subject: [PATCH 4/9] feat: make timebox bigger so am/pm is visible --- src/components/Form/DateTimeField/DateTimeField.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Form/DateTimeField/DateTimeField.tsx b/src/components/Form/DateTimeField/DateTimeField.tsx index 97b2823..d6aaee9 100644 --- a/src/components/Form/DateTimeField/DateTimeField.tsx +++ b/src/components/Form/DateTimeField/DateTimeField.tsx @@ -92,7 +92,7 @@ export const DateTimeField = ({ Date: Mon, 5 Oct 2026 14:53:32 -0400 Subject: [PATCH 5/9] feat: add segemented time component for 24 hr time editing --- .../DateTimeField/DateTimeField.stories.tsx | 19 +++ .../Form/DateTimeField/DateTimeField.tsx | 157 +++++++++++++++--- 2 files changed, 155 insertions(+), 21 deletions(-) diff --git a/src/components/Form/DateTimeField/DateTimeField.stories.tsx b/src/components/Form/DateTimeField/DateTimeField.stories.tsx index 08d3012..26c9677 100644 --- a/src/components/Form/DateTimeField/DateTimeField.stories.tsx +++ b/src/components/Form/DateTimeField/DateTimeField.stories.tsx @@ -45,6 +45,25 @@ export const WithInitialValue: Story = { ] }; +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, diff --git a/src/components/Form/DateTimeField/DateTimeField.tsx b/src/components/Form/DateTimeField/DateTimeField.tsx index d6aaee9..e529fa5 100644 --- a/src/components/Form/DateTimeField/DateTimeField.tsx +++ b/src/components/Form/DateTimeField/DateTimeField.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react'; +import type { ChangeEvent, FocusEvent } from 'react'; import { toBasicISOString } from '@douglasneuroinformatics/libjs'; import type { BaseFormField } from '@douglasneuroinformatics/libui-form-types'; @@ -16,8 +17,10 @@ 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((n) => String(n).padStart(2, '0')).join(':'); + 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) */ @@ -26,7 +29,107 @@ const combineDateAndTime = (date: Date, time: string) => { return new Date(date.getFullYear(), date.getMonth(), date.getDate(), hours, minutes, seconds); }; -export type DateTimeFieldProps = Simplify & Omit>; +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, @@ -36,6 +139,7 @@ export const DateTimeField = ({ name, readOnly, setValue, + timeFormat, value }: DateTimeFieldProps) => { const [isDatePickerOpen, setIsDatePickerOpen] = useState(false); @@ -49,6 +153,13 @@ export const DateTimeField = ({ const dateInputId = `${name}-date`; const timeInputId = `${name}-time`; + const handleTimeChange = (time: string) => { + setTimeValue(time); + if (value && isValidTimeString(time)) { + setValue(combineDateAndTime(value, time)); + } + }; + return ( @@ -71,7 +182,7 @@ export const DateTimeField = ({ variant="outline" > - {value ? toBasicISOString(value) : t('form.selectDate')} + {value ? value.toDateString() : t('form.selectDate')} @@ -90,24 +201,28 @@ export const DateTimeField = ({ - { - const time = event.target.value; - setTimeValue(time); - if (value && isValidTimeString(time)) { - setValue(combineDateAndTime(value, time)); - } - }} - /> + {timeFormat ? ( + + ) : ( + handleTimeChange(event.target.value)} + /> + )} From 45b3c84063498f72e02644a8950099b9da910c97 Mon Sep 17 00:00:00 2001 From: David Roper Date: Mon, 5 Oct 2026 14:53:59 -0400 Subject: [PATCH 6/9] test: add tests for the new 24hr component --- .../Form/DateTimeField/DateTimeField.spec.tsx | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/src/components/Form/DateTimeField/DateTimeField.spec.tsx b/src/components/Form/DateTimeField/DateTimeField.spec.tsx index 7cfc5dd..346fde7 100644 --- a/src/components/Form/DateTimeField/DateTimeField.spec.tsx +++ b/src/components/Form/DateTimeField/DateTimeField.spec.tsx @@ -123,4 +123,75 @@ describe('DateTimeField', () => { 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(); + }); + }); }); From 671deaf58f56bb7132eeefa9fbb3b6b31affcaa4 Mon Sep 17 00:00:00 2001 From: David Roper Date: Mon, 5 Oct 2026 14:54:35 -0400 Subject: [PATCH 7/9] chore: add translations for time labels (hr, second, ms) --- src/i18n/translations/libui.json | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/i18n/translations/libui.json b/src/i18n/translations/libui.json index 0ad39d3..5f5ddb7 100644 --- a/src/i18n/translations/libui.json +++ b/src/i18n/translations/libui.json @@ -47,6 +47,16 @@ "es": "Fecha", "fr": "Date" }, + "hours": { + "en": "Hours", + "es": "Horas", + "fr": "Heures" + }, + "minutes": { + "en": "Minutes", + "es": "Minutos", + "fr": "Minutes" + }, "radioLabels": { "false": { "en": "False", @@ -74,6 +84,11 @@ "es": "Restablecer", "fr": "Réinitialiser" }, + "seconds": { + "en": "Seconds", + "es": "Segundos", + "fr": "Secondes" + }, "selectDate": { "en": "Select date", "es": "Seleccionar fecha", From c11114fbded9419274ffb0e8e3fbe250a2d94bda Mon Sep 17 00:00:00 2001 From: David Roper Date: Mon, 5 Oct 2026 14:57:24 -0400 Subject: [PATCH 8/9] feat: use toDate string instead of toIsoString function --- src/components/Form/DateTimeField/DateTimeField.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/Form/DateTimeField/DateTimeField.tsx b/src/components/Form/DateTimeField/DateTimeField.tsx index e529fa5..65ca37c 100644 --- a/src/components/Form/DateTimeField/DateTimeField.tsx +++ b/src/components/Form/DateTimeField/DateTimeField.tsx @@ -1,7 +1,6 @@ import { useEffect, useState } from 'react'; import type { ChangeEvent, FocusEvent } from 'react'; -import { toBasicISOString } from '@douglasneuroinformatics/libjs'; import type { BaseFormField } from '@douglasneuroinformatics/libui-form-types'; import { ChevronDownIcon } from 'lucide-react'; import type { Simplify } from 'type-fest'; From 5a10df3cc58c7a722927827f991cabafa3136e91 Mon Sep 17 00:00:00 2001 From: David Roper Date: Mon, 5 Oct 2026 14:58:01 -0400 Subject: [PATCH 9/9] test: remove to use of toIsoStringFormat method in test --- src/components/Form/DateTimeField/DateTimeField.spec.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Form/DateTimeField/DateTimeField.spec.tsx b/src/components/Form/DateTimeField/DateTimeField.spec.tsx index 346fde7..3c98466 100644 --- a/src/components/Form/DateTimeField/DateTimeField.spec.tsx +++ b/src/components/Form/DateTimeField/DateTimeField.spec.tsx @@ -88,7 +88,7 @@ describe('DateTimeField', () => { render( ); - expect(screen.getByTestId('datetime-date-trigger')).toHaveTextContent('2025-01-15'); + expect(screen.getByTestId('datetime-date-trigger')).toHaveTextContent('Wed Jan 15 2025'); expect(screen.getByTestId('datetime-time-input').value).toBe('10:30:05'); });