Skip to content
46 changes: 29 additions & 17 deletions src/lib/holocene/date-picker.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) => {
Comment thread
KATIETOLER marked this conversation as resolved.
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 = () => {
Expand All @@ -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');
</script>

<div class="relative" use:clickoutside={() => (showDatePicker = false)}>
Expand All @@ -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
Comment thread
KATIETOLER marked this conversation as resolved.
clearButtonLabel={clearLabel}
{disabled}
Expand Down Expand Up @@ -136,8 +148,8 @@
<div class="my-1 flex justify-between px-2">
<button
type="button"
class="cursor-pointer text-xs"
onclick={() => (selected = new Date())}
class="cursor-pointer text-[12px]"
onclick={() => commitDate(new Date())}
>
{todayLabel}
</button>
Expand Down
1 change: 1 addition & 0 deletions src/lib/i18n/locales/en/date-picker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
122 changes: 122 additions & 0 deletions src/lib/utilities/date-picker-input.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
31 changes: 31 additions & 0 deletions src/lib/utilities/date-picker-input.ts
Comment thread
KATIETOLER marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -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 };
};
Loading