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]); }); });