import { render, screen, fireEvent } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, it, expect, vi } from 'vitest'
import { axe } from 'vitest-axe'
import { DateField } from './date-field'
function getSegment(name: 'day' | 'month' | 'year') {
return screen.getByRole('spinbutton', { name })
}
function getRoot(container: HTMLElement) {
return container.querySelector('[data-slot="date-field"]') as HTMLDivElement
}
const JUN_23 = new Date(2026, 5, 23)
describe('DateField', () => {
it('renders three segments for the default MM/dd/yyyy format', () => {
render()
expect(getSegment('month')).toHaveTextContent('06')
expect(getSegment('day')).toHaveTextContent('23')
expect(getSegment('year')).toHaveTextContent('2026')
})
it('renders segments in the order of the format string', () => {
render()
const segs = screen.getAllByRole('spinbutton')
expect(segs[0]).toHaveAttribute('aria-label', 'year')
expect(segs[1]).toHaveAttribute('aria-label', 'month')
expect(segs[2]).toHaveAttribute('aria-label', 'day')
})
it('renders placeholders when no value is provided', () => {
render()
expect(getSegment('month')).toHaveTextContent('MM')
expect(getSegment('day')).toHaveTextContent('DD')
expect(getSegment('year')).toHaveTextContent('YYYY')
})
describe('clicking the field chrome', () => {
it('focuses the first segment when the field is empty', () => {
const { container } = render()
fireEvent.mouseDown(getRoot(container))
expect(getSegment('month')).toHaveFocus()
})
it('focuses the first empty segment when partially filled', async () => {
const user = userEvent.setup()
const { container } = render()
await user.click(getSegment('month'))
await user.keyboard('06') // fills month, leaving day + year empty
fireEvent.mouseDown(getRoot(container))
expect(getSegment('day')).toHaveFocus()
})
it('does not steal focus when a slot control is clicked', () => {
render(clear} />)
fireEvent.mouseDown(screen.getByRole('button', { name: 'clear' }))
expect(getSegment('month')).not.toHaveFocus()
})
})
it('renders MMM and MMMM month-name segments', () => {
const { rerender } = render()
expect(getSegment('month')).toHaveTextContent('Jun')
rerender()
expect(getSegment('month')).toHaveTextContent('June')
})
it('exposes aria spinbutton attributes per segment', () => {
render()
const month = getSegment('month')
expect(month).toHaveAttribute('aria-valuemin', '1')
expect(month).toHaveAttribute('aria-valuemax', '12')
expect(month).toHaveAttribute('aria-valuenow', '6')
expect(month).toHaveAttribute('aria-valuetext', '6')
expect(getSegment('day')).toHaveAttribute('aria-valuemax', '30')
expect(getSegment('year')).toHaveAttribute('aria-valuemax', '9999')
})
it('announces month names via aria-valuetext for MMM/MMMM tokens', () => {
render()
expect(getSegment('month')).toHaveAttribute('aria-valuetext', 'June')
})
it('increments and decrements with ArrowUp / ArrowDown', async () => {
const user = userEvent.setup()
render()
const month = getSegment('month')
month.focus()
await user.keyboard('{ArrowUp}')
expect(month).toHaveTextContent('07')
await user.keyboard('{ArrowDown}{ArrowDown}')
expect(month).toHaveTextContent('05')
})
it('wraps around at min/max', async () => {
const user = userEvent.setup()
render()
const month = getSegment('month')
month.focus()
await user.keyboard('{ArrowUp}')
expect(month).toHaveTextContent('01')
await user.keyboard('{ArrowDown}')
expect(month).toHaveTextContent('12')
})
it('clamps day when month switches to a shorter one', async () => {
const user = userEvent.setup()
const onValueChange = vi.fn()
render()
const month = getSegment('month')
month.focus()
await user.keyboard('{ArrowDown}')
expect(getSegment('day')).toHaveTextContent('28')
})
it('types digits and auto-advances when buffer fills', async () => {
const user = userEvent.setup()
render()
const month = getSegment('month')
month.focus()
await user.keyboard('12')
expect(month).toHaveTextContent('12')
expect(getSegment('day')).toHaveFocus()
})
it('clamps on overflow and replaces with the new digit', async () => {
const user = userEvent.setup()
render()
const month = getSegment('month')
month.focus()
await user.keyboard('13')
expect(month).toHaveTextContent('03')
expect(getSegment('day')).toHaveFocus()
})
it('auto-advances early when the next digit could not fit', async () => {
const user = userEvent.setup()
render()
const month = getSegment('month')
month.focus()
await user.keyboard('2')
expect(month).toHaveTextContent('02')
expect(getSegment('day')).toHaveFocus()
})
it('separator keys commit current value and advance focus', async () => {
const user = userEvent.setup()
render()
const month = getSegment('month')
month.focus()
await user.keyboard('1/')
expect(month).toHaveTextContent('01')
expect(getSegment('day')).toHaveFocus()
})
it('ArrowLeft / ArrowRight move focus between segments', async () => {
const user = userEvent.setup()
render()
getSegment('month').focus()
await user.keyboard('{ArrowRight}')
expect(getSegment('day')).toHaveFocus()
await user.keyboard('{ArrowLeft}')
expect(getSegment('month')).toHaveFocus()
})
it('Home / End jump to first / last segment', async () => {
const user = userEvent.setup()
render()
getSegment('day').focus()
await user.keyboard('{End}')
expect(getSegment('year')).toHaveFocus()
await user.keyboard('{Home}')
expect(getSegment('month')).toHaveFocus()
})
it('Backspace clears the segment, then moves to the previous one', async () => {
const user = userEvent.setup()
render()
const day = getSegment('day')
day.focus()
await user.keyboard('{Backspace}')
expect(day).toHaveTextContent('DD')
await user.keyboard('{Backspace}')
expect(getSegment('month')).toHaveFocus()
})
it('letter keys cycle months in a MMMM segment', async () => {
const user = userEvent.setup()
render()
const month = getSegment('month')
month.focus()
await user.keyboard('j')
expect(month).toHaveTextContent('July')
await user.keyboard('j')
expect(month).toHaveTextContent('January')
})
it('ignores printable keys combined with a modifier (Ctrl/Cmd)', async () => {
const user = userEvent.setup()
render()
const month = getSegment('month')
month.focus()
await user.keyboard('{Control>}c{/Control}')
expect(month).toHaveTextContent('06')
await user.keyboard('{Meta>}v{/Meta}')
expect(month).toHaveTextContent('06')
})
it('does not throw when rendered with an invalid Date', () => {
expect(() => render()).not.toThrow()
})
it('fires onValueChange with a Date when all parts are valid', async () => {
const user = userEvent.setup()
const onValueChange = vi.fn()
render()
getSegment('day').focus()
await user.keyboard('{ArrowUp}')
const last = onValueChange.mock.calls.at(-1)?.[0]
expect(last).toBeInstanceOf(Date)
expect(last.getDate()).toBe(24)
expect(last.getMonth()).toBe(5)
expect(last.getFullYear()).toBe(2026)
})
it('supports controlled value updates from outside', () => {
const { rerender } = render()
expect(getSegment('day')).toHaveTextContent('23')
rerender()
expect(getSegment('month')).toHaveTextContent('01')
expect(getSegment('day')).toHaveTextContent('05')
expect(getSegment('year')).toHaveTextContent('2030')
})
it('keeps partial input visible when controlled with null', async () => {
const user = userEvent.setup()
render()
const day = getSegment('day')
await user.click(day)
await user.keyboard('0')
expect(day).toHaveTextContent('00')
const year = getSegment('year')
await user.click(year)
await user.keyboard('2')
expect(year).toHaveTextContent('2')
})
it('does not fire onValueChange(null) for intermediate invalid input', async () => {
const user = userEvent.setup()
const onValueChange = vi.fn()
render()
onValueChange.mockClear()
// Typing '0' in day produces day=0 which is not a real date -> no callback.
await user.click(getSegment('day'))
await user.keyboard('0')
expect(onValueChange).not.toHaveBeenCalled()
})
it('fires onValueChange(null) only when every segment is cleared', async () => {
const user = userEvent.setup()
const onValueChange = vi.fn()
render()
onValueChange.mockClear()
// Backspace just the day -> still has month/year, no clear-all callback.
await user.click(getSegment('day'))
await user.keyboard('{Backspace}')
expect(onValueChange).not.toHaveBeenCalled()
// Clear the rest -> finally fires null.
await user.click(getSegment('month'))
await user.keyboard('{Backspace}')
await user.click(getSegment('year'))
await user.keyboard('{Backspace}')
expect(onValueChange).toHaveBeenLastCalledWith(null)
})
it('renders the hidden input only when name is provided', () => {
const { container, rerender } = render()
expect(container.querySelector('input[type="hidden"]')).toBeNull()
rerender()
const hidden = container.querySelector('input[type="hidden"]') as HTMLInputElement
expect(hidden).not.toBeNull()
expect(hidden.name).toBe('dob')
expect(hidden.value).toBe('2026-06-23')
})
it('prevents mutation when disabled and removes segments from the tab order', async () => {
const user = userEvent.setup()
const onValueChange = vi.fn()
render()
const month = getSegment('month')
expect(month).toHaveAttribute('tabindex', '-1')
month.focus()
await user.keyboard('{ArrowUp}')
expect(month).toHaveTextContent('06')
expect(onValueChange).not.toHaveBeenCalled()
})
it('allows navigation but blocks mutation when readOnly', async () => {
const user = userEvent.setup()
render()
getSegment('month').focus()
await user.keyboard('{ArrowRight}')
expect(getSegment('day')).toHaveFocus()
await user.keyboard('{ArrowUp}')
expect(getSegment('day')).toHaveTextContent('23')
})
it('merges custom className onto the root and applies size / variant classes', () => {
const { container } = render()
const root = getRoot(container)
expect(root.className).toContain('custom-x')
expect(root.className).toContain('h-12')
expect(root.className).toContain('bg-background-muted')
})
it('passes data-invalid through aria-invalid', () => {
const { container } = render()
expect(getRoot(container)).toHaveAttribute('data-invalid')
})
it('inherits invalid and disabled from a surrounding Field', async () => {
const { Field } = await import('@base-ui/react/field')
const { container } = render(
,
)
const root = getRoot(container)
expect(root).toHaveAttribute('data-invalid')
expect(root).toHaveAttribute('data-disabled')
})
it('renders startSlot and endSlot containers', () => {
const { container } = render(
s}
endSlot={e}
/>,
)
expect(container.querySelector('[data-slot="date-field-start"]')).toContainElement(screen.getByTestId('s'))
expect(container.querySelector('[data-slot="date-field-end"]')).toContainElement(screen.getByTestId('e'))
})
it('has no accessibility violations', async () => {
const { container } = render(
<>
>,
)
expect(await axe(container)).toHaveNoViolations()
})
})