diff --git a/src/lib/holocene/date-picker.svelte b/src/lib/holocene/date-picker.svelte index 05e63ec43f..3363d9ff3f 100644 --- a/src/lib/holocene/date-picker.svelte +++ b/src/lib/holocene/date-picker.svelte @@ -4,6 +4,11 @@ import { clickoutside } from '$lib/holocene/outside-click'; import { translate } from '$lib/i18n/translate'; import { getMonthName } from '$lib/utilities/calendar'; + import { + DATE_PICKER_INPUT_FORMAT, + evaluateDatePickerInput, + formatDatePickerInput, + } from '$lib/utilities/date-picker-input'; import Calender from './calendar.svelte'; import Icon from './icon/icon.svelte'; @@ -40,24 +45,27 @@ let month = $derived(selected.getMonth()); let year = $derived(selected.getFullYear()); let showDatePicker = $state(false); + // resets whenever selected changes; can be overridden until then + let inputError = $derived.by(() => { + void selected; + return false; + }); // handlers + const commitDate = (date: Date) => { + selected = date; + onDateChange?.(date); + }; + const onFocus = () => { showDatePicker = true; }; - const onInput = (e: Event) => { + const onBlur = (e: FocusEvent) => { const target = e.target as HTMLInputElement; - const inputDate = target.value; - if (inputDate.length === 8) { - const inputDateSplit = inputDate?.split('/'); - const yearEnd = parseInt(inputDateSplit[2]); - const year = 2000 + yearEnd; - const month = parseInt(inputDateSplit[0]) - 1; - const date = parseInt(inputDateSplit[1]); - const newDate = new Date(year, month, date); - onDateChange?.(newDate); - } + const { date, error } = evaluateDatePickerInput(target.value, isAllowed); + inputError = error; + if (date) commitDate(date); }; const next = () => { @@ -80,11 +88,12 @@ const handleDateChange = (d: Date) => { showDatePicker = false; - onDateChange?.(d); + commitDate(d); }; const previousMonth = translate('date-picker.previous-month'); const nextMonth = translate('date-picker.next-month'); + const invalidDate = translate('date-picker.invalid-date');
(showDatePicker = false)}> @@ -96,9 +105,12 @@ icon="calendar-plus" type="text" onfocus={onFocus} - oninput={onInput} - placeholder="MM/DD/YY" - value={selected.toDateString()} + onblur={onBlur} + placeholder={DATE_PICKER_INPUT_FORMAT} + value={formatDatePickerInput(selected)} + error={inputError} + hintText={inputError ? invalidDate : ''} + onClear={() => (inputError = false)} clearable clearButtonLabel={clearLabel} {disabled} @@ -136,8 +148,8 @@
diff --git a/src/lib/i18n/locales/en/date-picker.ts b/src/lib/i18n/locales/en/date-picker.ts index 7578101d0c..8d219915bc 100644 --- a/src/lib/i18n/locales/en/date-picker.ts +++ b/src/lib/i18n/locales/en/date-picker.ts @@ -3,4 +3,5 @@ export const Namespace = 'date-picker' as const; export const Strings = { 'next-month': 'Next Month', 'previous-month': 'Previous Month', + 'invalid-date': 'Enter a valid date as MM/DD/YYYY', } as const; diff --git a/src/lib/utilities/date-picker-input.test.ts b/src/lib/utilities/date-picker-input.test.ts new file mode 100644 index 0000000000..c270b23092 --- /dev/null +++ b/src/lib/utilities/date-picker-input.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, test } from 'vitest'; + +import { + evaluateDatePickerInput, + formatDatePickerInput, + parseDatePickerInput, +} from './date-picker-input'; + +describe('formatDatePickerInput', () => { + test('formats a date as zero-padded MM/DD/YYYY', () => { + expect(formatDatePickerInput(new Date(2026, 5, 5))).toBe('06/05/2026'); + }); + + test('keeps two-digit months and days without padding changes', () => { + expect(formatDatePickerInput(new Date(2026, 10, 25))).toBe('11/25/2026'); + }); + + test('round-trips with parseDatePickerInput', () => { + const date = new Date(2027, 0, 1); + const parsed = parseDatePickerInput(formatDatePickerInput(date)); + expect(parsed?.getFullYear()).toBe(2027); + expect(parsed?.getMonth()).toBe(0); + expect(parsed?.getDate()).toBe(1); + }); +}); + +describe('parseDatePickerInput', () => { + test('parses a complete, valid MM/DD/YYYY date', () => { + const date = parseDatePickerInput('06/05/2026'); + expect(date?.getFullYear()).toBe(2026); + expect(date?.getMonth()).toBe(5); + expect(date?.getDate()).toBe(5); + }); + + test('parses single-digit month and day without padding', () => { + const date = parseDatePickerInput('6/5/2026'); + expect(date?.getFullYear()).toBe(2026); + expect(date?.getMonth()).toBe(5); + expect(date?.getDate()).toBe(5); + }); + + test('returns null for incomplete input (mid-typing)', () => { + expect(parseDatePickerInput('06/05')).toBeNull(); + expect(parseDatePickerInput('06')).toBeNull(); + }); + + test('leniently parses a short year as entered', () => { + expect(parseDatePickerInput('06/05/26')?.getFullYear()).toBe(26); + }); + + test('returns null for an out-of-range month', () => { + expect(parseDatePickerInput('13/01/2026')).toBeNull(); + expect(parseDatePickerInput('00/01/2026')).toBeNull(); + }); + + test('returns null for an invalid day that does not round-trip', () => { + expect(parseDatePickerInput('02/30/2026')).toBeNull(); + expect(parseDatePickerInput('04/31/2026')).toBeNull(); + }); + + test('returns null for non-date garbage', () => { + expect(parseDatePickerInput('not a date')).toBeNull(); + expect(parseDatePickerInput('Fri Jun 05 2026')).toBeNull(); + expect(parseDatePickerInput('')).toBeNull(); + }); +}); + +describe('evaluateDatePickerInput', () => { + test('returns the date with no error for valid full-date entry', () => { + const { date, error } = evaluateDatePickerInput('06/05/2026'); + expect(error).toBe(false); + expect(date?.getFullYear()).toBe(2026); + }); + + test('returns no date and no error for empty input', () => { + expect(evaluateDatePickerInput('')).toEqual({ date: null, error: false }); + expect(evaluateDatePickerInput(' ')).toEqual({ + date: null, + error: false, + }); + }); + + test('flags incomplete entry as an error without a date', () => { + expect(evaluateDatePickerInput('06/05')).toEqual({ + date: null, + error: true, + }); + }); + + test('flags an invalid date like 02/30/2026 as an error', () => { + expect(evaluateDatePickerInput('02/30/2026')).toEqual({ + date: null, + error: true, + }); + }); + + test('rejects an out-of-range date per isAllowed', () => { + const noPastDates = (d: Date) => d >= new Date(2026, 0, 1); + const { date, error } = evaluateDatePickerInput('06/05/2020', noPastDates); + expect(date).toBeNull(); + expect(error).toBe(true); + }); + + test('accepts an in-range date per isAllowed', () => { + const noPastDates = (d: Date) => d >= new Date(2026, 0, 1); + const { date, error } = evaluateDatePickerInput('06/05/2027', noPastDates); + expect(date?.getFullYear()).toBe(2027); + expect(error).toBe(false); + }); + + test('supports inline editing of the year', () => { + const prefilled = formatDatePickerInput(new Date(2026, 5, 5)); + expect(prefilled).toBe('06/05/2026'); + + const edited = prefilled.replace('2026', '2027'); + const { date, error } = evaluateDatePickerInput(edited); + expect(error).toBe(false); + expect(date?.getFullYear()).toBe(2027); + expect(date?.getMonth()).toBe(5); + expect(date?.getDate()).toBe(5); + }); +}); diff --git a/src/lib/utilities/date-picker-input.ts b/src/lib/utilities/date-picker-input.ts new file mode 100644 index 0000000000..353cdb4972 --- /dev/null +++ b/src/lib/utilities/date-picker-input.ts @@ -0,0 +1,31 @@ +import { format, isValid, parse } from 'date-fns'; + +export const DATE_PICKER_INPUT_FORMAT = 'MM/DD/YYYY'; + +const DATE_FNS_FORMAT = 'MM/dd/yyyy'; +const REFERENCE_DATE = new Date(0); + +export const formatDatePickerInput = (date: Date): string => + format(date, DATE_FNS_FORMAT); + +export const parseDatePickerInput = (value: string): Date | null => { + const date = parse(value.trim(), DATE_FNS_FORMAT, REFERENCE_DATE); + return isValid(date) ? date : null; +}; + +export type DatePickerInputResult = { + date: Date | null; + error: boolean; +}; + +export const evaluateDatePickerInput = ( + value: string, + isAllowed: (date: Date) => boolean = () => true, +): DatePickerInputResult => { + if (!value.trim()) return { date: null, error: false }; + + const date = parseDatePickerInput(value); + if (!date || !isAllowed(date)) return { date: null, error: true }; + + return { date, error: false }; +};