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 { TimeField } from './time-field'
function getSegments(name: 'hour' | 'minute' | 'second' | 'period') {
return screen.getAllByRole('spinbutton', { name })
}
function getSegment(name: 'hour' | 'minute' | 'second' | 'period') {
return screen.getByRole('spinbutton', { name })
}
function getRoot(container: HTMLElement) {
return container.querySelector('[data-slot="time-field"]') as HTMLDivElement
}
describe('TimeField', () => {
it('renders two segments for the default HH:mm format', () => {
render()
expect(getSegment('hour')).toHaveTextContent('09')
expect(getSegment('minute')).toHaveTextContent('30')
expect(screen.queryAllByRole('spinbutton')).toHaveLength(2)
})
describe('clicking the field chrome', () => {
it('focuses the first segment when the field is empty', () => {
const { container } = render()
fireEvent.mouseDown(getRoot(container))
expect(getSegment('hour')).toHaveFocus()
})
it('focuses the first empty segment when partially filled', async () => {
const user = userEvent.setup()
const { container } = render()
await user.click(getSegment('hour'))
await user.keyboard('09') // fills hour, leaving minute empty
fireEvent.mouseDown(getRoot(container))
expect(getSegment('minute')).toHaveFocus()
})
it('does not steal focus when a slot control is clicked', () => {
render(clear} />)
fireEvent.mouseDown(screen.getByRole('button', { name: 'clear' }))
expect(getSegment('hour')).not.toHaveFocus()
})
})
it('renders three segments including a period for the 12h format', () => {
render()
expect(getSegment('hour')).toHaveTextContent('01')
expect(getSegment('minute')).toHaveTextContent('05')
expect(getSegment('period')).toHaveTextContent('PM')
})
it('renders placeholders when no value is provided', () => {
render()
expect(getSegment('hour')).toHaveTextContent('HH')
expect(getSegment('minute')).toHaveTextContent('mm')
})
it('renders the seconds segment when the format includes ss', () => {
render()
expect(getSegment('second')).toHaveTextContent('45')
})
it('exposes aria spinbutton attributes per segment', () => {
render()
const hour = getSegment('hour')
expect(hour).toHaveAttribute('aria-valuemin', '0')
expect(hour).toHaveAttribute('aria-valuemax', '23')
expect(hour).toHaveAttribute('aria-valuenow', '9')
const minute = getSegment('minute')
expect(minute).toHaveAttribute('aria-valuemax', '59')
})
it('uses the 12h range on hh segments and announces AM/PM via aria-valuetext on the period', () => {
render()
const hour = getSegment('hour')
expect(hour).toHaveAttribute('aria-valuemin', '1')
expect(hour).toHaveAttribute('aria-valuemax', '12')
expect(hour).toHaveAttribute('aria-valuenow', '1')
const period = getSegment('period')
expect(period).toHaveAttribute('aria-valuetext', 'PM')
expect(period).toHaveAttribute('aria-valuemin', '0')
expect(period).toHaveAttribute('aria-valuemax', '1')
expect(period).toHaveAttribute('aria-valuenow', '1')
})
it('increments and decrements with ArrowUp / ArrowDown', async () => {
const user = userEvent.setup()
render()
const hour = getSegment('hour')
hour.focus()
await user.keyboard('{ArrowUp}')
expect(hour).toHaveTextContent('10')
await user.keyboard('{ArrowDown}{ArrowDown}')
expect(hour).toHaveTextContent('08')
})
it('wraps around at min/max for hours', async () => {
const user = userEvent.setup()
render()
const hour = getSegment('hour')
hour.focus()
await user.keyboard('{ArrowUp}')
expect(hour).toHaveTextContent('00')
await user.keyboard('{ArrowDown}')
expect(hour).toHaveTextContent('23')
})
it('wraps around at min/max for minutes', async () => {
const user = userEvent.setup()
render()
const minute = getSegment('minute')
minute.focus()
await user.keyboard('{ArrowUp}')
expect(minute).toHaveTextContent('00')
await user.keyboard('{ArrowDown}')
expect(minute).toHaveTextContent('59')
})
it('types digits and auto-advances when buffer fills', async () => {
const user = userEvent.setup()
render()
const hour = getSegment('hour')
hour.focus()
await user.keyboard('12')
expect(hour).toHaveTextContent('12')
expect(getSegment('minute')).toHaveFocus()
})
it('clamps on overflow and replaces with the new digit', async () => {
const user = userEvent.setup()
render()
const hour = getSegment('hour')
hour.focus()
await user.keyboard('25')
expect(hour).toHaveTextContent('05')
expect(getSegment('minute')).toHaveFocus()
})
it('auto-advances early when the next digit could not fit', async () => {
const user = userEvent.setup()
render()
const hour = getSegment('hour')
hour.focus()
await user.keyboard('3')
expect(hour).toHaveTextContent('03')
expect(getSegment('minute')).toHaveFocus()
})
it('separator keys (: and /) advance focus', async () => {
const user = userEvent.setup()
render()
const hour = getSegment('hour')
hour.focus()
await user.keyboard('9:')
expect(hour).toHaveTextContent('09')
expect(getSegment('minute')).toHaveFocus()
})
it('ArrowLeft / ArrowRight move focus between segments', async () => {
const user = userEvent.setup()
render()
getSegment('hour').focus()
await user.keyboard('{ArrowRight}')
expect(getSegment('minute')).toHaveFocus()
await user.keyboard('{ArrowLeft}')
expect(getSegment('hour')).toHaveFocus()
})
it('Home / End jump to first / last segment', async () => {
const user = userEvent.setup()
render()
getSegment('minute').focus()
await user.keyboard('{End}')
expect(getSegment('period')).toHaveFocus()
await user.keyboard('{Home}')
expect(getSegment('hour')).toHaveFocus()
})
it('Backspace clears the segment, then moves to the previous one', async () => {
const user = userEvent.setup()
render()
const minute = getSegment('minute')
minute.focus()
await user.keyboard('{Backspace}')
expect(minute).toHaveTextContent('mm')
await user.keyboard('{Backspace}')
expect(getSegment('hour')).toHaveFocus()
})
it('period segment toggles AM↔PM with ArrowUp/ArrowDown', async () => {
const user = userEvent.setup()
render()
const period = getSegment('period')
period.focus()
expect(period).toHaveTextContent('AM')
await user.keyboard('{ArrowUp}')
expect(period).toHaveTextContent('PM')
await user.keyboard('{ArrowDown}')
expect(period).toHaveTextContent('AM')
})
it('period segment accepts a/p letter keys', async () => {
const user = userEvent.setup()
render()
const period = getSegment('period')
period.focus()
await user.keyboard('p')
expect(period).toHaveTextContent('PM')
await user.keyboard('a')
expect(period).toHaveTextContent('AM')
})
it('stores 12 AM as 24h 00 in the hidden input', () => {
const { container } = render()
expect(getSegment('hour')).toHaveTextContent('12')
expect(getSegment('period')).toHaveTextContent('AM')
const hidden = container.querySelector('input[type="hidden"]') as HTMLInputElement
expect(hidden.value).toBe('00:15')
})
it('stores 12 PM as 24h 12 in the hidden input', () => {
const { container } = render()
expect(getSegment('hour')).toHaveTextContent('12')
expect(getSegment('period')).toHaveTextContent('PM')
const hidden = container.querySelector('input[type="hidden"]') as HTMLInputElement
expect(hidden.value).toBe('12:00')
})
it('fires onValueChange with an HH:mm string when all parts are valid', async () => {
const user = userEvent.setup()
const onValueChange = vi.fn()
render()
getSegment('minute').focus()
await user.keyboard('{ArrowUp}')
const last = onValueChange.mock.calls.at(-1)?.[0]
expect(last).toBe('09:31')
})
it('fires onValueChange with HH:mm:ss when format includes seconds', async () => {
const user = userEvent.setup()
const onValueChange = vi.fn()
render()
getSegment('second').focus()
await user.keyboard('{ArrowUp}')
const last = onValueChange.mock.calls.at(-1)?.[0]
expect(last).toBe('09:30:01')
})
it('supports controlled value updates from outside', () => {
const { rerender } = render()
expect(getSegment('hour')).toHaveTextContent('09')
expect(getSegment('minute')).toHaveTextContent('30')
rerender()
expect(getSegment('hour')).toHaveTextContent('17')
expect(getSegment('minute')).toHaveTextContent('05')
})
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('t')
expect(hidden.value).toBe('09:30')
})
it('hidden input always uses 24h format even when displayed in 12h', () => {
const { container } = render()
expect(getSegment('hour')).toHaveTextContent('02')
expect(getSegment('period')).toHaveTextContent('PM')
const hidden = container.querySelector('input[type="hidden"]') as HTMLInputElement
expect(hidden.value).toBe('14:30')
})
it('hidden input includes seconds when the format includes seconds', () => {
const { container } = render()
const hidden = container.querySelector('input[type="hidden"]') as HTMLInputElement
expect(hidden.value).toBe('09:30:45')
})
it('prevents mutation when disabled and removes segments from the tab order', async () => {
const user = userEvent.setup()
const onValueChange = vi.fn()
render()
const hour = getSegment('hour')
expect(hour).toHaveAttribute('tabindex', '-1')
hour.focus()
await user.keyboard('{ArrowUp}')
expect(hour).toHaveTextContent('09')
expect(onValueChange).not.toHaveBeenCalled()
})
it('allows navigation but blocks mutation when readOnly', async () => {
const user = userEvent.setup()
render()
getSegment('hour').focus()
await user.keyboard('{ArrowRight}')
expect(getSegment('minute')).toHaveFocus()
await user.keyboard('{ArrowUp}')
expect(getSegment('minute')).toHaveTextContent('30')
})
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="time-field-start"]')).toContainElement(screen.getByTestId('s'))
expect(container.querySelector('[data-slot="time-field-end"]')).toContainElement(screen.getByTestId('e'))
})
it('keeps segments reachable as a single aria group', () => {
render()
expect(getSegments('hour')).toHaveLength(1)
expect(getSegments('period')).toHaveLength(1)
})
it('has no accessibility violations', async () => {
const { container } = render(
<>
>,
)
expect(await axe(container)).toHaveNoViolations()
})
})