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