import { Field } from '../field/Field'; import { mockMatchMedia, mockResizeObserver, render, RenderResult, screen, userEvent, } from '../test-utils'; import DateLookup, { DateLookupProps } from './DateLookup'; import { act } from 'react'; const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTimeAsync }); mockMatchMedia(); mockResizeObserver(); const initialValue = new Date(2000, 0, 1); const labelProp = 'label'; describe('DateLookup', () => { beforeEach(() => { jest.useFakeTimers(); }); afterEach(async () => { await jest.runOnlyPendingTimersAsync(); jest.useRealTimers(); jest.clearAllMocks(); }); it('supports `Field` for labeling', () => { render( {}} /> , ); const button = screen.getByRole('button', { name: /^Date of birth/ }); expect(button).toBeInTheDocument(); expect(button).toHaveAttribute('aria-haspopup'); }); it('focuses trigger and opens panel when `Field` label is clicked', async () => { const label = 'Date of birth'; render( {}} /> , ); const button = screen.getByRole('button', { name: /^Date of birth/ }); await user.click(screen.getByLabelText(label)); expect(button).toHaveAttribute('aria-expanded', 'true'); expect(screen.getByRole('button', { name: /next/iu })).toBeInTheDocument(); }); it.each([' ', '{Enter}', '{ArrowDown}', '{ArrowUp}', '{ArrowRight}', '{ArrowLeft}'] as const)( "opens with '%s' and closes with '{Escape}'", async (text: string) => { render( {}} />); await user.tab(); await user.keyboard(text); expect(screen.getByRole('button', { name: /next/iu })).toBeInTheDocument(); await user.keyboard('{Escape}'); await act(async () => { await jest.runOnlyPendingTimersAsync(); }); expect(screen.queryByRole('button', { name: /next/iu })).not.toBeInTheDocument(); }, ); const setupAndOpenWithMouse = async (props: Partial = {}) => { const view = render( {}} {...props} />); await user.click(screen.getByRole('button')); await act(async () => { await jest.runOnlyPendingTimersAsync(); }); return view; }; it('opens and closes with mouse', async () => { await setupAndOpenWithMouse(); expect(screen.getByRole('button', { name: /next/iu })).toBeInTheDocument(); const dimmerElement = screen.getByRole('dialog').parentElement?.parentElement; if (dimmerElement != null) { await user.click(dimmerElement); } await act(async () => { await jest.runOnlyPendingTimersAsync(); }); expect(screen.queryByRole('button', { name: /next/iu })).not.toBeInTheDocument(); }); describe('in day view', () => { it.each([ ['{ArrowLeft}', -1], ['{ArrowRight}', +1], ['{ArrowUp}', -7], ['{ArrowDown}', +7], ])("handles '%s' to step %d day(s)", async (text: string, step: number) => { const handleChange = jest.fn(); await setupAndOpenWithMouse({ onChange: handleChange }); expect(handleChange).not.toHaveBeenCalled(); await user.keyboard(text); const value = new Date(initialValue); value.setDate(initialValue.getDate() + step); expect(handleChange).toHaveBeenCalledWith(value); await user.keyboard('{Escape}'); await act(async () => { await jest.runOnlyPendingTimersAsync(); }); expect(handleChange).toHaveBeenCalledWith(initialValue); }); }); describe('in year view', () => { it.each([ ['{ArrowLeft}', -1], ['{ArrowRight}', +1], ['{ArrowUp}', -4], ['{ArrowDown}', +4], ])("handles '%s' to step %d year(s)", async (text: string, step: number) => { const handleChange = jest.fn(); await setupAndOpenWithMouse({ onChange: handleChange }); await user.click(screen.getByRole('button', { name: /year view/iu })); await act(async () => { await jest.runOnlyPendingTimersAsync(); }); expect(handleChange).not.toHaveBeenCalled(); await user.keyboard(text); const value = new Date(initialValue); value.setFullYear(initialValue.getFullYear() + step); expect(handleChange).toHaveBeenCalledWith(value); await user.keyboard('{Escape}'); await act(async () => { await jest.runOnlyPendingTimersAsync(); }); expect(handleChange).toHaveBeenCalledWith(initialValue); }); }); describe('in month view', () => { it.each([ ['{ArrowLeft}', -1], ['{ArrowRight}', +1], ['{ArrowUp}', -4], ['{ArrowDown}', +4], ])("handles '%s' to step %d month(s)", async (text: string, step: number) => { const handleChange = jest.fn(); await setupAndOpenWithMouse({ onChange: handleChange }); await user.click(screen.getByRole('button', { name: /year view/iu })); await act(async () => { await jest.runOnlyPendingTimersAsync(); }); await user.keyboard(' '); await act(async () => { await jest.runOnlyPendingTimersAsync(); }); expect(handleChange).not.toHaveBeenCalled(); await user.keyboard(text); const value = new Date(initialValue); value.setMonth(initialValue.getMonth() + step); expect(handleChange).toHaveBeenCalledWith(value); await user.keyboard('{Escape}'); await act(async () => { await jest.runOnlyPendingTimersAsync(); }); expect(handleChange).toHaveBeenCalledWith(initialValue); }); }); it('limits min value', async () => { const min = new Date(initialValue); min.setDate(min.getDate() - 1); const handleChange = jest.fn(); await setupAndOpenWithMouse({ min, onChange: handleChange }); await user.keyboard('{ArrowLeft}{ArrowLeft}'); expect(handleChange).toHaveBeenCalledWith(min); }); it('limits max value', async () => { const max = new Date(initialValue); max.setDate(max.getDate() + 1); const handleChange = jest.fn(); await setupAndOpenWithMouse({ max, onChange: handleChange }); await user.keyboard('{ArrowRight}{ArrowRight}'); expect(handleChange).toHaveBeenCalledWith(max); }); }); describe('DateLookup propTypes', () => { describe('when the value prop is set to null', () => { it('renders without prop type warnings in the console', () => { const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); render(); // eslint-disable-next-line no-console expect(console.error).not.toHaveBeenCalledWith( expect.stringContaining('Warning: Failed %s type'), 'prop', 'The prop `value` is marked as required in `DateLookup`, but its value is `null`.', expect.anything(), ); consoleSpy.mockRestore(); }); }); }); describe('DateLookup (events)', () => { const date = new Date(2018, 11, 27); const min = new Date(2018, 11, 26); const max = new Date(2018, 11, 28); it('focuses on today when value is null', async () => { const today = new Date(); const todayDateLabel = today.toLocaleDateString('en-GB', { day: 'numeric', month: 'numeric', year: 'numeric', }); const expectedHeaderText = today.toLocaleDateString('en-GB', { month: 'long', year: 'numeric', }); render(); await user.click(screen.getByRole('button')); const todayButton = screen.getByLabelText(todayDateLabel); expect(screen.getByRole('dialog')).toBeInTheDocument(); expect(screen.getByText(expectedHeaderText)).toBeInTheDocument(); expect(todayButton).toHaveClass('today'); expect(todayButton).toHaveFocus(); }); describe('when not clearable', () => { let handleChange: jest.Mock; const setup = async (overrides = {}) => { handleChange = jest.fn(); return render( <> {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */} , ); }; it('switches to years', async () => { const view = await setup(); await openDateLookup(); await clickDateButton(); expect(getActiveYearButton(view)).toHaveFocus(); await closeDateLookup(view); }); it('has aria-label for 20 years', async () => { const view = await setup(); await openDateLookup(); await clickDateButton(); expect(getButtonByAriaLabel('next 20 years')).toBeInTheDocument(); expect(getButtonByAriaLabel('previous 20 years')).toBeInTheDocument(); await closeDateLookup(view); }); it('switches to months', async () => { const view = await setup(); await openDateLookup(); await clickDateButton(); await userEvent.click(getActiveYearButton(view)); expect(getActiveMonthButton(view)).toHaveFocus(); await closeDateLookup(view); }); it('has aria label for year', async () => { const view = await setup(); await openDateLookup(); await clickDateButton(); await userEvent.click(getActiveYearButton(view)); expect(getButtonByAriaLabel('next year')).toBeInTheDocument(); expect(getButtonByAriaLabel('previous year')).toBeInTheDocument(); await closeDateLookup(view); }); it('switches to days', async () => { const view = await setup(); await openDateLookup(); await clickDateButton(); await userEvent.click(getActiveYearButton(view)); await userEvent.click(getActiveMonthButton(view)); expect(getActiveDayButton(view)).toHaveFocus(); await closeDateLookup(view); }); it('has aria label for month', async () => { const view = await setup(); await openDateLookup(); await clickDateButton(); await userEvent.click(getActiveYearButton(view)); await userEvent.click(getActiveMonthButton(view)); expect(getButtonByAriaLabel('next month')).toBeInTheDocument(); expect(getButtonByAriaLabel('previous month')).toBeInTheDocument(); await closeDateLookup(view); }); it('has aria label on selected date', async () => { const view = await setup(); await openDateLookup(); const d = new Date(2018, 11, 28); const newDay = screen.getByText(d.getDate().toString()); await userEvent.click(newDay); await openDateLookup(); expect(screen.getByRole('button', { name: /selected day/i })).toBeInTheDocument(); await closeDateLookup(view); }); it('supports custom `aria-labelledby` attribute', async () => { const view = await setup(); expect(screen.getByRole('button', { name: /^Prioritized label/ })).toBeInTheDocument(); await closeDateLookup(view); }); it('reads our the HTML label as well as the input prefix and the value', async () => { const view = await setup(); expect( screen.getByRole('button', { name: 'Prioritized label label 27 December 2018' }), ).toBeInTheDocument(); await closeDateLookup(view); }); it('reads our the HTML label as well as the input prefix and the placeholder', async () => { const view = await setup({ value: undefined }); expect( screen.getByRole('button', { name: 'Prioritized label label Select date' }), ).toBeInTheDocument(); await closeDateLookup(view); }); }); describe('when is clearable', () => { const props: DateLookupProps = { value: date, onChange: jest.fn(), clearable: true, label: labelProp, }; it(`doesn't show clear button if disable is true`, async () => { const view = render(); expect(getClearButton(view)).toBeInTheDocument(); view.rerender(); expect(getClearButton(view)).not.toBeInTheDocument(); }); it('when user clicks on clear the focus returns to btn', async () => { const view = render(); await clickClearButton(view); expect(getOpenButton(labelProp)).toHaveFocus(); }); it('onChange gets called with null when reset button is clicked', async () => { const view = render(); await clickClearButton(view); expect(props.onChange).toHaveBeenCalledWith(null); }); }); const getClearButton = (view: RenderResult) => { return view.container.querySelector('.clear-btn'); }; const getOpenButton = (label: DateLookupProps['label']) => { return screen.getByRole('button', { name: new RegExp(String(label), 'i'), expanded: false, }); }; const getDateButton = () => { return screen.getByRole('button', { name: /Go to 20 year view/i, }); }; const openDateLookup = async () => userEvent.click(getOpenButton(labelProp)); const clickDateButton = async () => userEvent.click(getDateButton()); // Close dateLookup and removes events attached to documents. const closeDateLookup = async (view: RenderResult) => userEvent.click(view.container); // @ts-expect-error getClearButton returns node const clickClearButton = async (view: RenderResult) => userEvent.click(getClearButton(view)); const getActiveYearButton = (view: RenderResult) => { return screen.getByRole('button', { name: /selected year/i, pressed: true, }); }; const getActiveMonthButton = (view: RenderResult) => { return screen.getByRole('button', { name: /selected month/i, pressed: true, }); }; const getActiveDayButton = (view: RenderResult) => { return screen.getByRole('button', { name: /selected day/i, pressed: true, }); }; const getButtonByAriaLabel = (ariaLabel: string) => { return screen.getByRole('button', { name: ariaLabel }); }; });