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