import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { createRef, type FocusEvent as ReactFocusEvent, useState } from 'react'; import { describe, expect, it, vi } from 'vitest'; import { DatePicker } from '../../src/components/DatePicker'; type ControlledProps = { type: 'date' | 'time' | 'date-time'; initialValue: string; format?: string; min?: string; max?: string; onValueChange?: (value: string) => void; onBlur?: (event: ReactFocusEvent) => void; disabled?: boolean; }; function ControlledDatePicker({ type, initialValue, format, min, max, onValueChange, onBlur, disabled = false, }: Readonly) { const [value, setValue] = useState(initialValue); return ( { setValue(event.target.value); onValueChange?.(event.target.value); }} /> ); } function pickCurrentMonthDay(label: string): void { const dayButtons = Array.from(document.querySelectorAll('.datepicker-day')) as HTMLButtonElement[]; const targetButton = dayButtons.find( (button) => button.textContent === label && !button.classList.contains('is-outside-month'), ); expect(targetButton).toBeDefined(); fireEvent.click(targetButton as HTMLButtonElement); } function getCurrentMonthDayButton(label: string): HTMLButtonElement { const dayButtons = Array.from(document.querySelectorAll('.datepicker-day')) as HTMLButtonElement[]; const targetButton = dayButtons.find( (button) => button.textContent === label && !button.classList.contains('is-outside-month'), ); if (!targetButton) { throw new Error(`Could not find day button for ${label}`); } return targetButton; } function createPasteEvent(text: string): Event { const event = new Event('paste', { bubbles: true, cancelable: true }); Object.defineProperty(event, 'clipboardData', { value: { getData: () => text, }, }); return event; } function createRect(left: number, top: number, width: number, height: number): DOMRect { return { x: left, y: top, left, top, width, height, right: left + width, bottom: top + height, toJSON: () => ({}), } as DOMRect; } describe('DatePicker', () => { it('opens popup from icon button and closes with Escape', () => { render( {}} />); const input = screen.getByLabelText('Schedule') as HTMLInputElement; expect(input.type).toBe('text'); fireEvent.click(screen.getByRole('button', { name: 'Open date picker' })); expect( screen.getByRole('dialog', { name: 'Date and time picker popup' }), ).toBeInTheDocument(); fireEvent.keyDown(document, { key: 'Escape' }); expect( screen.queryByRole('dialog', { name: 'Date and time picker popup' }), ).not.toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: 'Open date picker' })); fireEvent.click(screen.getByRole('button', { name: 'Close date picker' })); expect( screen.queryByRole('dialog', { name: 'Date and time picker popup' }), ).not.toBeInTheDocument(); }); it('supports segment-by-segment editing with custom format and auto-advance', async () => { const onValueChange = vi.fn(); render( , ); const input = screen.getByLabelText('Schedule') as HTMLInputElement; fireEvent.focus(input); await waitFor(() => { expect(input.selectionStart).toBe(0); expect(input.selectionEnd).toBe(2); }); fireEvent.keyDown(input, { key: '1' }); await waitFor(() => { expect(input.value).toBe('01/02/2026 14:30'); expect(input.selectionStart).toBe(0); expect(input.selectionEnd).toBe(2); }); fireEvent.keyDown(input, { key: '1' }); await waitFor(() => { expect(input.value).toBe('11/02/2026 14:30'); expect(input.selectionStart).toBe(3); expect(input.selectionEnd).toBe(5); }); fireEvent.keyDown(input, { key: '0' }); fireEvent.keyDown(input, { key: '3' }); await waitFor(() => { expect(input.value).toBe('11/03/2026 14:30'); expect(input.selectionStart).toBe(6); expect(input.selectionEnd).toBe(10); }); fireEvent.keyDown(input, { key: '2' }); fireEvent.keyDown(input, { key: '0' }); fireEvent.keyDown(input, { key: '2' }); fireEvent.keyDown(input, { key: '7' }); await waitFor(() => { expect(input.value).toBe('11/03/2027 14:30'); expect(input.selectionStart).toBe(11); expect(input.selectionEnd).toBe(13); }); fireEvent.keyDown(input, { key: '1' }); fireEvent.keyDown(input, { key: '6' }); await waitFor(() => { expect(input.value).toBe('11/03/2027 16:30'); expect(input.selectionStart).toBe(14); expect(input.selectionEnd).toBe(16); }); fireEvent.keyDown(input, { key: '4' }); fireEvent.keyDown(input, { key: '5' }); await waitFor(() => { expect(input.value).toBe('11/03/2027 16:45'); }); expect(onValueChange).toHaveBeenCalled(); }); it('preserves year 0000 while editing and does not coerce to 19xx', async () => { render( , ); const input = screen.getByLabelText('Schedule') as HTMLInputElement; fireEvent.focus(input); await waitFor(() => { expect(input.selectionStart).toBe(0); expect(input.selectionEnd).toBe(2); }); fireEvent.keyDown(input, { key: '/' }); await waitFor(() => { expect(input.selectionStart).toBe(3); expect(input.selectionEnd).toBe(5); }); fireEvent.keyDown(input, { key: '/' }); await waitFor(() => { expect(input.selectionStart).toBe(6); expect(input.selectionEnd).toBe(10); }); fireEvent.keyDown(input, { key: '0' }); await waitFor(() => { expect(input.value).toBe('22/02/0000 14:30'); expect(input.selectionStart).toBe(6); expect(input.selectionEnd).toBe(10); }); fireEvent.keyDown(input, { key: '0' }); fireEvent.keyDown(input, { key: '0' }); fireEvent.keyDown(input, { key: '0' }); await waitFor(() => { expect(input.value).toBe('22/02/0000 14:30'); expect(input.selectionStart).toBe(11); expect(input.selectionEnd).toBe(13); }); }); it('uses separator keys to move between editable segments', async () => { render( , ); const input = screen.getByLabelText('Schedule') as HTMLInputElement; fireEvent.focus(input); await waitFor(() => { expect(input.selectionStart).toBe(0); expect(input.selectionEnd).toBe(2); }); fireEvent.keyDown(input, { key: '/' }); await waitFor(() => { expect(input.selectionStart).toBe(3); expect(input.selectionEnd).toBe(5); }); fireEvent.keyDown(input, { key: '/' }); await waitFor(() => { expect(input.selectionStart).toBe(6); expect(input.selectionEnd).toBe(10); }); fireEvent.keyDown(input, { key: ' ' }); await waitFor(() => { expect(input.selectionStart).toBe(11); expect(input.selectionEnd).toBe(13); }); fireEvent.keyDown(input, { key: ':' }); await waitFor(() => { expect(input.selectionStart).toBe(14); expect(input.selectionEnd).toBe(16); }); }); it('applies min/max constraints to calendar and time options', () => { render( , ); fireEvent.click(screen.getByRole('button', { name: 'Open date picker' })); expect(getCurrentMonthDayButton('9')).toBeDisabled(); expect(getCurrentMonthDayButton('10')).toBeEnabled(); expect(getCurrentMonthDayButton('11')).toBeDisabled(); const hoursList = screen.getByRole('listbox', { name: 'Hours' }); expect(within(hoursList).getByRole('button', { name: '08' })).toBeDisabled(); expect(within(hoursList).getByRole('button', { name: '09' })).toBeEnabled(); expect(within(hoursList).getByRole('button', { name: '10' })).toBeEnabled(); expect(within(hoursList).getByRole('button', { name: '11' })).toBeDisabled(); const minutesList = screen.getByRole('listbox', { name: 'Minutes' }); expect(within(minutesList).getByRole('button', { name: '15' })).toBeEnabled(); expect(within(minutesList).getByRole('button', { name: '20' })).toBeDisabled(); }); it('renders date mode calendar only and auto-closes after day selection', () => { const onValueChange = vi.fn(); render( , ); fireEvent.click(screen.getByRole('button', { name: 'Open date picker' })); expect(screen.getByRole('dialog', { name: 'Date picker popup' })).toBeInTheDocument(); expect(screen.queryByText('Hours')).not.toBeInTheDocument(); expect(screen.queryByText('Minutes')).not.toBeInTheDocument(); pickCurrentMonthDay('15'); const input = screen.getByLabelText('Schedule') as HTMLInputElement; expect(input.value).toMatch(/^\d{4}\/\d{2}\/15$/); expect(onValueChange).toHaveBeenCalled(); expect(screen.queryByRole('dialog', { name: 'Date picker popup' })).not.toBeInTheDocument(); }); it('commits calendar date changes in date-time mode without closing the popup', () => { const onValueChange = vi.fn(); render( , ); fireEvent.click(screen.getByRole('button', { name: 'Open date picker' })); pickCurrentMonthDay('15'); const input = screen.getByLabelText('Schedule') as HTMLInputElement; expect(input.value).toMatch(/^\d{4}\/\d{2}\/15 14:30$/); expect(onValueChange).toHaveBeenCalled(); expect(screen.getByRole('dialog', { name: 'Date and time picker popup' })).toBeInTheDocument(); }); it('renders time mode selectors only and keeps popup open after selection', () => { const onValueChange = vi.fn(); render(); fireEvent.click(screen.getByRole('button', { name: 'Open date picker' })); expect(screen.getByRole('dialog', { name: 'Time picker popup' })).toBeInTheDocument(); expect(screen.queryByRole('grid')).not.toBeInTheDocument(); const hoursList = screen.getByRole('listbox', { name: 'Hours' }); const minutesList = screen.getByRole('listbox', { name: 'Minutes' }); fireEvent.click(within(hoursList).getByRole('button', { name: '13' })); fireEvent.click(within(minutesList).getByRole('button', { name: '45' })); const input = screen.getByLabelText('Schedule') as HTMLInputElement; expect(input.value).toBe('13:45'); expect(onValueChange).toHaveBeenCalledTimes(2); expect(screen.getByRole('dialog', { name: 'Time picker popup' })).toBeInTheDocument(); }); it('blocks popup interactions while disabled', () => { render(); const input = screen.getByLabelText('Schedule') as HTMLInputElement; const iconButton = screen.getByRole('button', { name: 'Open date picker' }); expect(input).toBeDisabled(); expect(iconButton).toBeDisabled(); fireEvent.click(iconButton); expect(screen.queryByRole('dialog', { name: 'Date picker popup' })).not.toBeInTheDocument(); }); it('renders right icon and error message', () => { const { container } = render( {}} rightIcon={R} error="Invalid date" inputClassName="custom-input" />, ); const input = container.querySelector('input'); expect(input).toBeInstanceOf(HTMLInputElement); expect(screen.getByTestId('right-icon')).toBeInTheDocument(); expect(input).toHaveClass('pr-10'); expect(input).toHaveClass('custom-input'); expect(screen.getByText('Invalid date')).toBeInTheDocument(); }); it('forwards inputRef for callback and object refs', () => { const callbackRef = vi.fn(); const objectRef = createRef(); const { rerender } = render( {}} inputRef={callbackRef} />, ); expect(callbackRef).toHaveBeenCalled(); const callbackNode = callbackRef.mock.calls.find(([node]) => node instanceof HTMLInputElement)?.[0]; expect(callbackNode).toBeInstanceOf(HTMLInputElement); rerender( {}} inputRef={objectRef} />, ); expect(objectRef.current).toBeInstanceOf(HTMLInputElement); }); it('supports inline layout', () => { const { container } = render( {}} layout="inline" />, ); expect(container.querySelector('label')).toHaveClass('inline-flex'); expect(container.querySelector('label > div')).not.toHaveClass('mt-1'); }); it('handles outside vs inside pointer interactions for popup close behavior', () => { render( {}} />); fireEvent.click(screen.getByRole('button', { name: 'Open date picker' })); const dialog = screen.getByRole('dialog', { name: 'Date and time picker popup' }); fireEvent.mouseDown(dialog); expect(screen.getByRole('dialog', { name: 'Date and time picker popup' })).toBeInTheDocument(); fireEvent.mouseDown(document.body); expect( screen.queryByRole('dialog', { name: 'Date and time picker popup' }), ).not.toBeInTheDocument(); }); it('supports month/year chooser interactions and month navigation buttons', async () => { render( {}} />); fireEvent.click(screen.getByRole('button', { name: 'Open date picker' })); fireEvent.click(screen.getByRole('button', { name: 'Previous month' })); fireEvent.click(screen.getByRole('button', { name: 'Next month' })); const chooserButtons = document.querySelectorAll('.datepicker-chooser-btn'); const monthChooserButton = chooserButtons[0] as HTMLButtonElement; const yearChooserButton = chooserButtons[1] as HTMLButtonElement; const initialMonthText = monthChooserButton.textContent; fireEvent.click(monthChooserButton); const monthList = screen.getByRole('listbox', { name: 'Choose month' }); const monthOptions = within(monthList).getAllByRole('button'); const nextMonth = monthOptions.find((option) => option.textContent !== initialMonthText) ?? monthOptions[0]; fireEvent.click(nextMonth); await waitFor(() => { expect(screen.queryByRole('listbox', { name: 'Choose month' })).not.toBeInTheDocument(); }); expect((document.querySelectorAll('.datepicker-chooser-btn')[0] as HTMLButtonElement).textContent).toBe( nextMonth.textContent, ); const initialYearText = yearChooserButton.textContent; fireEvent.click(yearChooserButton); const yearList = screen.getByRole('listbox', { name: 'Choose year' }); const yearOptions = within(yearList).getAllByRole('button'); const nextYear = yearOptions.find((option) => option.textContent !== initialYearText) ?? yearOptions[0]; fireEvent.click(nextYear); await waitFor(() => { expect(screen.queryByRole('listbox', { name: 'Choose year' })).not.toBeInTheDocument(); }); expect((document.querySelectorAll('.datepicker-chooser-btn')[1] as HTMLButtonElement).textContent).toBe( nextYear.textContent, ); }); it('guards month navigation at absolute calendar boundaries', () => { const first = render( {}} />, ); fireEvent.click(screen.getByRole('button', { name: 'Open date picker' })); fireEvent.click(screen.getByRole('button', { name: 'Previous month' })); const lowerYearButton = document.querySelectorAll('.datepicker-chooser-btn')[1] as HTMLButtonElement; expect(lowerYearButton.textContent).toBe('0'); first.unmount(); render( {}} />); fireEvent.click(screen.getByRole('button', { name: 'Open date picker' })); fireEvent.click(screen.getByRole('button', { name: 'Next month' })); const upperYearButton = document.querySelectorAll('.datepicker-chooser-btn')[1] as HTMLButtonElement; expect(upperYearButton.textContent).toBe('9999'); }); it('supports keyboard navigation commands and segment reset shortcuts', async () => { const onBlur = vi.fn(); render( , ); const input = screen.getByLabelText('Schedule') as HTMLInputElement; fireEvent.focus(input); await waitFor(() => { expect(input.selectionStart).toBe(0); expect(input.selectionEnd).toBe(2); }); fireEvent.keyDown(input, { key: '1' }); fireEvent.keyDown(input, { key: 'ArrowRight' }); await waitFor(() => { expect(input.value.startsWith('01/')).toBe(true); expect(input.selectionStart).toBe(3); }); fireEvent.mouseUp(input); fireEvent.click(input); fireEvent.keyDown(input, { key: 'ArrowDown' }); expect(screen.getByRole('dialog', { name: 'Date and time picker popup' })).toBeInTheDocument(); fireEvent.keyDown(input, { key: 'ArrowRight' }); await waitFor(() => expect(input.selectionStart).toBe(3)); fireEvent.keyDown(input, { key: 'ArrowLeft' }); await waitFor(() => expect((input.selectionStart ?? 0) <= 3).toBe(true)); fireEvent.keyDown(input, { key: 'Tab' }); await waitFor(() => expect((input.selectionStart ?? 0) >= 0).toBe(true)); fireEvent.keyDown(input, { key: 'Tab', shiftKey: true }); await waitFor(() => expect((input.selectionStart ?? 0) >= 0).toBe(true)); fireEvent.keyDown(input, { key: 'Backspace' }); await waitFor(() => { expect(input.value.startsWith('01/')).toBe(true); }); fireEvent.keyDown(input, { key: '/' }); await waitFor(() => expect(input.selectionStart).toBe(3)); fireEvent.keyDown(input, { key: 'Delete' }); await waitFor(() => { expect(input.value.slice(3, 5)).toBe('01'); }); fireEvent.keyDown(input, { key: '/' }); await waitFor(() => expect(input.selectionStart).toBe(6)); fireEvent.keyDown(input, { key: 'Delete' }); fireEvent.keyDown(input, { key: ' ' }); fireEvent.keyDown(input, { key: 'Delete' }); await waitFor(() => { expect(/\s00:|:00$/.test(input.value)).toBe(true); }); fireEvent.keyDown(input, { key: 'Enter' }); fireEvent.keyDown(input, { key: 'x' }); fireEvent.blur(input); expect(onBlur).toHaveBeenCalledTimes(1); }); it('handles paste validation and value commits', async () => { render( , ); const input = screen.getByLabelText('Schedule') as HTMLInputElement; await act(async () => { input.dispatchEvent(createPasteEvent('invalid value')); }); expect(input.value).toBe('22/02/2026 14:30'); await act(async () => { input.dispatchEvent(createPasteEvent('11/03/2027 16:45')); }); expect(input.value).toBe('11/03/2027 16:45'); }); it('returns early from interaction handlers when disabled', () => { render( {}} />, ); const input = screen.getByLabelText('Schedule') as HTMLInputElement; fireEvent.focus(input); fireEvent.mouseUp(input); fireEvent.click(input); fireEvent.keyDown(input, { key: 'ArrowDown' }); const disabledPaste = createPasteEvent('11/03/2027 16:45'); input.dispatchEvent(disabledPaste); expect(disabledPaste.defaultPrevented).toBe(false); expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); }); it('falls back to direct assignment and synthetic onChange when native dispatch is stubbed', async () => { const onChange = vi.fn(); const originalGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; render( , ); const input = screen.getByLabelText('Schedule') as HTMLInputElement; const nativeDispatchEvent = input.dispatchEvent.bind(input); vi.spyOn(input, 'dispatchEvent').mockImplementation((event: Event) => { if (event.type === 'change') { return true; } return nativeDispatchEvent(event); }); vi.spyOn(Object, 'getOwnPropertyDescriptor').mockImplementation((target, property) => { if (target === HTMLInputElement.prototype && property === 'value') { return { configurable: true, enumerable: true, get: originalGetOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.get, }; } return originalGetOwnPropertyDescriptor(target, property); }); fireEvent.focus(input); input.setSelectionRange(0, 2); fireEvent.keyDown(input, { key: '1' }); await waitFor(() => { expect(onChange).toHaveBeenCalled(); }); }); it('safely handles pending segment selection during unmount', () => { vi.useFakeTimers(); const { unmount } = render( {}} />, ); const input = screen.getByLabelText('Schedule') as HTMLInputElement; fireEvent.focus(input); unmount(); expect(() => { vi.runAllTimers(); }).not.toThrow(); }); it('recalculates popup position on window resize and scroll', async () => { const originalInnerWidth = Object.getOwnPropertyDescriptor(window, 'innerWidth'); const originalInnerHeight = Object.getOwnPropertyDescriptor(window, 'innerHeight'); const rectSpy = vi .spyOn(HTMLElement.prototype, 'getBoundingClientRect') .mockImplementation(function mockRect(this: HTMLElement) { if (this.classList.contains('datepicker-popup')) { return createRect(0, 0, 300, 200); } if (this.classList.contains('relative') && this.querySelector('input')) { return createRect(500, 80, 120, 40); } return createRect(0, 0, 100, 30); }); Object.defineProperty(window, 'innerWidth', { configurable: true, value: 600 }); Object.defineProperty(window, 'innerHeight', { configurable: true, value: 480 }); render( {}} />); fireEvent.click(screen.getByRole('button', { name: 'Open date picker' })); const dialog = screen.getByRole('dialog', { name: 'Date and time picker popup' }); expect(dialog).toBeInTheDocument(); await waitFor(() => { expect(dialog.style.left).toBe('292px'); }); fireEvent(window, new Event('resize')); fireEvent(window, new Event('scroll')); expect(screen.getByRole('dialog', { name: 'Date and time picker popup' })).toBeInTheDocument(); expect(dialog.style.left).toBe('292px'); rectSpy.mockRestore(); if (originalInnerWidth) { Object.defineProperty(window, 'innerWidth', originalInnerWidth); } if (originalInnerHeight) { Object.defineProperty(window, 'innerHeight', originalInnerHeight); } }); });