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 };
+};