import '@testing-library/jest-dom'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useForm } from 'react-hook-form'
import { axe, toHaveNoViolations } from 'jest-axe'
import { vi } from 'vitest'
import { PktButton } from '../button/Button'
import { PktTimepicker } from './Timepicker'
expect.extend(toHaveNoViolations)
const id = 'test-timepicker'
const label = 'Test Timepicker'
describe('PktTimepicker', () => {
describe('Rendering', () => {
test('renders without errors', () => {
const { container } = render()
expect(container.querySelector('.pkt-timepicker')).toBeInTheDocument()
})
test('renders spinbutton inputs and separator', () => {
render()
const spinbuttons = screen.getAllByRole('spinbutton')
expect(spinbuttons).toHaveLength(2)
expect(spinbuttons[0]).toHaveAttribute('id', `${id}-hours`)
expect(spinbuttons[1]).toHaveAttribute('id', `${id}-minutes`)
})
test('adds stepper modifier when stepArrows is set', () => {
const { container } = render(
,
)
expect(container.querySelector('.pkt-timepicker--stepper')).toBeInTheDocument()
})
test('adds fullwidth modifier when fullwidth is set', () => {
const { container } = render(
,
)
expect(container.querySelector('.pkt-timepicker--fullwidth')).toBeInTheDocument()
})
test('renders clock button when picker is visible', () => {
render()
expect(
screen.getByRole('button', { name: /åpne tidspunkt-velger/i }),
).toBeInTheDocument()
})
test('hides clock button when hidePicker is set', () => {
render()
expect(
screen.queryByRole('button', { name: /åpne tidspunkt-velger/i }),
).not.toBeInTheDocument()
})
test('renders decorative clock icon when hidePicker is set', () => {
const { container } = render()
expect(container.querySelector('.pkt-timepicker__icon')).toBeInTheDocument()
})
test('does not render popup when hidePicker is set', () => {
const { container } = render()
expect(container.querySelector('.pkt-timepicker-popup')).not.toBeInTheDocument()
})
test('does not render popup when stepArrows is set', () => {
const { container } = render()
expect(container.querySelector('.pkt-timepicker-popup')).not.toBeInTheDocument()
})
test('renders prev/next buttons when stepArrows is set', () => {
render()
expect(screen.getByRole('button', { name: /forrige tidspunkt/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /neste tidspunkt/i })).toBeInTheDocument()
})
test('popup is hidden by default', () => {
const { container } = render()
const popup = container.querySelector('.pkt-timepicker-popup')
expect(popup).toBeInTheDocument()
expect(popup).toHaveAttribute('hidden')
})
})
describe('Properties', () => {
test('value sets display inputs', () => {
render()
const spinbuttons = screen.getAllByRole('spinbutton')
expect(spinbuttons[0]).toHaveValue('09')
expect(spinbuttons[1]).toHaveValue('30')
})
test('empty value renders empty display inputs', () => {
render()
const spinbuttons = screen.getAllByRole('spinbutton')
expect(spinbuttons[0]).toHaveValue('')
expect(spinbuttons[1]).toHaveValue('')
})
test('disabled disables inputs and clock button', () => {
render()
const spinbuttons = screen.getAllByRole('spinbutton')
expect(spinbuttons[0]).toBeDisabled()
expect(spinbuttons[1]).toBeDisabled()
expect(screen.getByRole('button', { name: /åpne tidspunkt-velger/i })).toBeDisabled()
})
test('min/max/step are not on hidden input (constraints use setCustomValidity on hours for native form validation)', () => {
const { container } = render(
,
)
const hidden = container.querySelector(`#${id}-input`)
expect(hidden?.hasAttribute('min')).toBe(false)
expect(hidden?.hasAttribute('max')).toBe(false)
expect(hidden?.hasAttribute('step')).toBe(false)
expect(hidden?.hasAttribute('required')).toBe(false)
})
test('hidden input reflects value', () => {
const { container } = render(
,
)
const hidden = container.querySelector(`#${id}-input`)
expect(hidden?.value).toBe('09:30')
})
test('native form submit flushes spinbutton digits into the named hidden input before validation', async () => {
const user = userEvent.setup()
const onSubmit = vi.fn()
render(
,
)
const [hoursEl, minutesEl] = screen.getAllByRole('spinbutton')
await user.click(hoursEl)
await user.keyboard('01')
await user.click(minutesEl)
await user.keyboard('02')
await user.click(screen.getByRole('button', { name: /^send$/i }))
expect(onSubmit).toHaveBeenCalledWith({ tid: '01:02' })
})
})
describe('Popup', () => {
test('clock button opens popup', async () => {
const user = userEvent.setup()
const { container } = render()
await user.click(screen.getByRole('button', { name: /åpne tidspunkt-velger/i }))
const popup = container.querySelector('.pkt-timepicker-popup')
expect(popup).not.toHaveAttribute('hidden')
})
test('Escape closes popup', async () => {
const user = userEvent.setup()
render()
const btn = screen.getByRole('button', { name: /åpne tidspunkt-velger/i })
await user.click(btn)
await waitFor(() => expect(btn).toHaveAttribute('aria-expanded', 'true'))
await user.keyboard('{Escape}')
await waitFor(() => expect(btn).toHaveAttribute('aria-expanded', 'false'))
})
test('popup renders hour and minute columns', async () => {
const user = userEvent.setup()
const { container } = render()
await user.click(screen.getByRole('button', { name: /åpne tidspunkt-velger/i }))
const cols = container.querySelectorAll('.pkt-timepicker-popup__col')
expect(cols).toHaveLength(2)
})
})
describe('Stepper', () => {
test('next button increments by one minute step', async () => {
const user = userEvent.setup()
const { container } = render(
,
)
await user.click(screen.getByRole('button', { name: /neste tidspunkt/i }))
const hidden = container.querySelector(`#${id}-input`)
expect(hidden?.value).toBe('09:01')
})
test('prev button decrements by step (5 min)', async () => {
const user = userEvent.setup()
const { container } = render(
,
)
await user.click(screen.getByRole('button', { name: /forrige tidspunkt/i }))
const hidden = container.querySelector(`#${id}-input`)
expect(hidden?.value).toBe('09:00')
})
test('next button at 59 minutes rolls over to next hour', async () => {
const user = userEvent.setup()
const { container } = render(
,
)
await user.click(screen.getByRole('button', { name: /neste tidspunkt/i }))
const hidden = container.querySelector(`#${id}-input`)
expect(hidden?.value).toBe('10:00')
})
})
describe('Events', () => {
test('onFocus fires when a spinbutton is focused (focus must bubble to root handler)', async () => {
const user = userEvent.setup()
const onFocus = vi.fn()
const { container } = render(
,
)
const hoursInput = container.querySelector(`#${id}-hours`)!
await user.click(hoursInput)
expect(onFocus).toHaveBeenCalledTimes(1)
})
test('Backspace removes digits in hours and updates hidden value (controlled inputs)', async () => {
const user = userEvent.setup()
const { container } = render(
,
)
const hoursInput = container.querySelector(`#${id}-hours`)!
const hidden = container.querySelector(`#${id}-input`)
hoursInput.focus()
await user.keyboard('{Backspace}')
expect(hoursInput).toHaveValue('0')
expect(hidden?.value).toBe('00:30')
await user.keyboard('{Backspace}')
expect(hoursInput).toHaveValue('')
expect(hidden?.value).toBe('')
})
test('onValueChange fires when hours change via ArrowUp', async () => {
const handleValueChange = vi.fn()
const { container } = render(
,
)
const hoursInput = container.querySelector(`#${id}-hours`)!
fireEvent.keyDown(hoursInput, { key: 'ArrowUp' })
await waitFor(() => {
expect(handleValueChange).toHaveBeenCalledWith('10:30')
})
})
})
describe('Accessibility', () => {
test('passes axe in default state', async () => {
const { container } = render()
await window.customElements.whenDefined('pkt-icon')
const results = await axe(container)
expect(results).toHaveNoViolations()
})
test('hours input has spinbutton role and bounds', () => {
render()
const hours = screen.getAllByRole('spinbutton')[0]
expect(hours).toHaveAttribute('role', 'spinbutton')
expect(hours).toHaveAttribute('aria-valuemin', '0')
expect(hours).toHaveAttribute('aria-valuemax', '23')
expect(hours).toHaveAttribute('aria-valuenow', '9')
})
})
})
describe('PktTimepicker + React Hook Form', () => {
function WatchForm() {
const { register, watch } = useForm<{ tid: string }>({
defaultValues: { tid: '09:30' },
})
const values = watch()
return (
)
}
test('register() defaultValues are reflected in watch()', async () => {
render()
await waitFor(() => {
expect(screen.getByTestId('watched')).toHaveTextContent('09:30')
})
})
function SubmitForm({
onSubmit,
}: {
onSubmit: (data: { tid: string }) => void
}) {
const { register, handleSubmit } = useForm<{ tid: string }>({
defaultValues: { tid: '14:45' },
})
return (
)
}
test('submit sends current time value from register()', async () => {
const user = userEvent.setup()
const onSubmit = vi.fn()
render()
await user.click(screen.getByRole('button', { name: /send/i }))
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith({ tid: '14:45' }, expect.anything())
})
})
})