import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { DatePicker, DateRangePicker } from './date-picker';
const at = (year: number, month: number, day: number) => new Date(year, month - 1, day);
const field = () => screen.getByRole('combobox');
const panel = () => screen.getByRole('grid');
const day = (label: RegExp | string) => screen.getByRole('gridcell', { name: label });
describe('DatePicker', () => {
it('shows the pattern as the placeholder by default', () => {
render();
expect(field()).toHaveAttribute('placeholder', 'yyyy-MM-dd');
});
it('renders a selected value through the format', () => {
render();
expect(field()).toHaveValue('2024-03-07');
});
it('honours a custom format', () => {
render();
expect(field()).toHaveValue('07.03.2024');
});
describe('the panel', () => {
it('opens when the field takes focus', async () => {
const user = userEvent.setup();
render();
await user.click(field());
expect(await screen.findByRole('grid')).toBeInTheDocument();
});
it('opens on ArrowDown', async () => {
const user = userEvent.setup();
render();
field().focus();
await user.keyboard('{Escape}');
await user.keyboard('{ArrowDown}');
expect(await screen.findByRole('grid')).toBeInTheDocument();
});
it('opens from the calendar icon too, not only from the input', async () => {
const user = userEvent.setup();
const { container } = render();
/* The icon and the padding around the text are part of the control, but
neither takes focus — without the shell opening the panel they were
dead area. */
await user.click(container.querySelector('[data-slot="date-picker"] svg') as Element);
expect(await screen.findByRole('grid')).toBeInTheDocument();
expect(field()).toHaveFocus();
});
it('leaves focus in the field, so typing still works', async () => {
const user = userEvent.setup();
render();
await user.click(field());
await screen.findByRole('grid');
expect(field()).toHaveFocus();
});
it('shows the month of the selected date', async () => {
const user = userEvent.setup();
render();
await user.click(field());
expect(await screen.findByText('March 2024')).toBeInTheDocument();
});
it('pages between months', async () => {
const user = userEvent.setup();
render();
await user.click(field());
await user.click(screen.getByRole('button', { name: 'Next month' }));
expect(screen.getByText('April 2024')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Previous month' }));
await user.click(screen.getByRole('button', { name: 'Previous month' }));
expect(screen.getByText('February 2024')).toBeInTheDocument();
});
it('draws a full six-week grid', async () => {
const user = userEvent.setup();
render();
await user.click(field());
expect(within(panel()).getAllByRole('gridcell')).toHaveLength(42);
});
it('marks today', async () => {
const user = userEvent.setup();
render();
await user.click(field());
expect(within(panel()).getByRole('gridcell', { current: 'date' })).toBeInTheDocument();
});
});
describe('selecting', () => {
it('reports the picked date and closes', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render();
await user.click(field());
await user.click(day('15 March 2024'));
expect(onChange).toHaveBeenCalledWith(at(2024, 3, 15));
await waitFor(() => expect(screen.queryByRole('grid')).not.toBeInTheDocument());
});
it('holds a controlled value until the parent updates it', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render();
await user.click(field());
await user.click(day('15 March 2024'));
expect(onChange).toHaveBeenCalledWith(at(2024, 3, 15));
expect(field()).toHaveValue('2024-03-07');
});
it('refuses a disabled date', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(
date.getDate() > 10}
/>
);
await user.click(field());
expect(day('15 March 2024')).toBeDisabled();
await user.click(day('15 March 2024'));
expect(onChange).not.toHaveBeenCalled();
});
});
describe('keyboard navigation', () => {
it('moves a day at a time and selects with Enter', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render();
await user.click(field());
day('7 March 2024').focus();
await user.keyboard('{ArrowRight}{Enter}');
expect(onChange).toHaveBeenCalledWith(at(2024, 3, 8));
});
it('moves a week with the vertical arrows', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render();
await user.click(field());
day('7 March 2024').focus();
await user.keyboard('{ArrowDown}{Enter}');
expect(onChange).toHaveBeenCalledWith(at(2024, 3, 14));
});
it('crosses into the previous month, bringing the panel with it', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render();
await user.click(field());
day('1 March 2024').focus();
await user.keyboard('{ArrowLeft}');
await waitFor(() => expect(screen.getByText('February 2024')).toBeInTheDocument());
await user.keyboard('{Enter}');
expect(onChange).toHaveBeenCalledWith(at(2024, 2, 29));
});
it('pages a month with PageUp and PageDown', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render();
await user.click(field());
day('7 March 2024').focus();
await user.keyboard('{PageDown}{Enter}');
expect(onChange).toHaveBeenCalledWith(at(2024, 4, 7));
});
it('steps into the grid from the field', async () => {
const user = userEvent.setup();
render();
field().focus();
await screen.findByRole('grid');
/* The grid owns the arrow keys, so an open panel is unreachable until
focus crosses into it. */
await user.keyboard('{ArrowDown}');
expect(day('7 March 2024')).toHaveFocus();
});
it('skips a disabled cell on the way in', async () => {
const user = userEvent.setup();
render( date.getDate() < 10} />);
field().focus();
await screen.findByRole('grid');
await user.keyboard('{ArrowDown}');
/* The roving cell is the selected one, which the caller has rejected —
a disabled button cannot take focus and would strand the keyboard. */
expect(document.activeElement).toHaveAttribute('role', 'gridcell');
expect(document.activeElement).not.toBeDisabled();
});
it('hands focus back to the field after a pick', async () => {
const user = userEvent.setup();
render();
await user.click(field());
await user.click(day('15 March 2024'));
/* Radix returns focus to the trigger, and an anchor-only Popover has
none, so without this focus lands on the document. */
await waitFor(() => expect(field()).toHaveFocus());
expect(screen.queryByRole('grid')).not.toBeInTheDocument();
});
it('keeps only one cell in the tab order', async () => {
const user = userEvent.setup();
render();
await user.click(field());
const tabbable = within(panel())
.getAllByRole('gridcell')
.filter((cell) => cell.getAttribute('tabindex') === '0');
expect(tabbable).toHaveLength(1);
});
});
describe('typing', () => {
it('accepts a fully typed date', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render();
await user.type(field(), '2024-03-07');
expect(onChange).toHaveBeenLastCalledWith(at(2024, 3, 7));
});
it('does not clear the selection while a date is half-typed', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render();
await user.clear(field());
onChange.mockClear();
await user.type(field(), '2024-0');
expect(onChange).not.toHaveBeenCalled();
});
it('marks unparseable text invalid rather than swallowing it', async () => {
const user = userEvent.setup();
render();
await user.type(field(), '2024-99-99');
expect(field()).toHaveAttribute('aria-invalid', 'true');
});
it('clears the value when the field is emptied', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render();
await user.clear(field());
expect(onChange).toHaveBeenLastCalledWith(null);
});
});
describe('granularity', () => {
it('shows months for picker="month"', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render();
await user.click(field());
await user.click(screen.getByRole('gridcell', { name: 'Jun' }));
expect(onChange).toHaveBeenCalledWith(at(2024, 6, 1));
});
it('shows years for picker="year"', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render();
await user.click(field());
await user.click(screen.getByRole('gridcell', { name: '2020' }));
expect(onChange).toHaveBeenCalledWith(at(2020, 1, 1));
});
it('picks a matching default format per granularity', () => {
const { rerender } = render();
expect(field()).toHaveValue('2024-03');
rerender();
expect(field()).toHaveValue('2024');
});
});
describe('clearing', () => {
it('offers a clear button once something is selected', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render();
await user.click(screen.getByRole('button', { name: 'Clear date' }));
expect(onChange).toHaveBeenCalledWith(null);
});
it('does not open the panel on the way past', async () => {
const user = userEvent.setup();
render();
await user.click(screen.getByRole('button', { name: 'Clear date' }));
expect(screen.queryByRole('grid')).not.toBeInTheDocument();
});
it('hides it while nothing is selected', () => {
render();
expect(screen.queryByRole('button', { name: 'Clear date' })).not.toBeInTheDocument();
});
it('can be turned off', () => {
render();
expect(screen.queryByRole('button', { name: 'Clear date' })).not.toBeInTheDocument();
});
});
it('does not open when disabled', async () => {
const user = userEvent.setup();
render();
await user.click(field());
expect(screen.queryByRole('grid')).not.toBeInTheDocument();
});
it('follows the locale for month and weekday names', async () => {
const user = userEvent.setup();
render();
await user.click(field());
expect(screen.getByText('März 2024')).toBeInTheDocument();
});
});
describe('DateRangePicker', () => {
const fields = () => screen.getAllByRole('combobox');
it('renders a labelled field for each end', () => {
render();
expect(screen.getByRole('combobox', { name: 'Start date' })).toBeInTheDocument();
expect(screen.getByRole('combobox', { name: 'End date' })).toBeInTheDocument();
});
it('takes custom field names', () => {
render();
expect(screen.getByRole('combobox', { name: 'From' })).toBeInTheDocument();
});
it('renders both ends of a value', () => {
render();
expect(fields()[0]).toHaveValue('2024-03-01');
expect(fields()[1]).toHaveValue('2024-03-15');
});
it('fills the start, then the end', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render();
await user.click(fields()[0]);
await user.click(day('5 March 2024'));
expect(onChange).toHaveBeenLastCalledWith([at(2024, 3, 5), null]);
await user.click(day('12 March 2024'));
expect(onChange).toHaveBeenLastCalledWith([at(2024, 3, 5), at(2024, 3, 12)]);
});
it('re-anchors when the second pick precedes the first', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render();
await user.click(fields()[1]);
await user.click(day('5 March 2024'));
/* Rather than committing a backwards range, the earlier date becomes the
new start and the end is left open. */
expect(onChange).toHaveBeenLastCalledWith([at(2024, 3, 5), null]);
});
it('drops an end that the new start would overtake', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(
);
await user.click(fields()[0]);
await user.click(day('20 March 2024'));
expect(onChange).toHaveBeenLastCalledWith([at(2024, 3, 20), null]);
});
it('anchors the range even when the end field is focused first', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render();
await user.click(fields()[1]);
await user.click(screen.getAllByRole('gridcell', { current: 'date' })[0]);
/* With no start there is nothing to close, so the first pick is a start —
committing it as an end left the range permanently half-empty. */
expect(onChange).toHaveBeenLastCalledWith([expect.any(Date), null]);
});
it('shows two months at once', async () => {
const user = userEvent.setup();
render();
await user.click(fields()[0]);
expect(screen.getByText('March 2024')).toBeInTheDocument();
expect(screen.getByText('April 2024')).toBeInTheDocument();
});
it('clears both ends at once', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(
);
await user.click(screen.getByRole('button', { name: 'Clear date' }));
expect(onChange).toHaveBeenCalledWith([null, null]);
});
});