Testing
Find the picker's controls by role and name, and check the value the input ends up with.
Test the picker from the user's side: click the input to open it, then reach the controls by role and accessible name. Assert on the value the input shows at the end. Testing Library's guiding principle is that a test earns more trust the closer it stays to how people use the page. A selector built on a tp-ui-* class tests the markup rather than what the user sees, so keep classes for the few parts that have no role.
Roles and names
#The table shows what a 12-hour picker exposes with the default English labels. Every name comes from the labels option, so a test for a translated picker uses your strings.
| Part | Role | Default name | Set with |
|---|---|---|---|
| Picker | dialog | Select time. Enter Time in the keyboard view | labels.clockTitle, labels.keyboardTitle |
| Hour field | spinbutton | Hour | labels.hour |
| Minute field | spinbutton | Minute | labels.minute |
Second field, with clock.showSeconds | spinbutton | Second | labels.second |
| AM and PM | button with aria-pressed | AM, PM | labels.am, labels.pm |
| Clock face | slider | Same as the active field: Hour, Minute or Second | labels.hour, labels.minute, labels.second |
| OK and Cancel | button | OK, Cancel | labels.ok, labels.cancel |
Clear, with ui.clearButton | button | Clear | labels.clear |
View switch, with ui.viewSwitch | button | Switch to keyboard input, Switch to clock | labels.switchToKeyboard, labels.switchToClock |
| Groups around the fields, AM/PM and the clock face | group | Time, Period, Clock | labels.timeFields, labels.period, labels.clock |
| Range tabs (range plugin) | tablist with two tab elements | Range selection. Tabs: From, To | labels.range, labels.rangeFrom, labels.rangeTo |
| Timezone selector (timezone plugin) | combobox with a listbox of option elements | Timezone | labels.timezone |
| Wheel columns (wheel plugin) | listbox of option elements | Hour and Minute, plus Period in compact-wheel. Options: Hour 02, Minute 30 | labels.hour, labels.minute, labels.period |
| Invalid input message, next to the input | alert | Text: Invalid time format | labels.invalidTimeFormat |
The hour, minute and second fields are <input type="number">. On the clock face view they are read-only unless ui.editable is true, so typing into them changes nothing. In the keyboard view (ui.initialView: 'keyboard') they accept typing. These keys work on the fields in both views:
- Up and Down arrows: one step. They wrap around at the ends.
- Home and End: the first and the last value.
- Page Up and Page Down: 3 hours, or 5 steps on minutes and seconds.
The clock face takes the same keys for the active field. Enter or Space on it moves to the next field.
Unit tests (Jest and Testing Library)
#The picker needs a DOM. In Jest's default node environment the constructor throws TimepickerUI requires browser environment, so run these tests in jsdom:
npm install --save-dev jest-environment-jsdom @testing-library/dom @testing-library/user-event @testing-library/jest-dom
module.exports = {testEnvironment: 'jsdom',setupFilesAfterEnv: ['@testing-library/jest-dom'],// Only needed when the code under test imports timepicker-ui CSSmoduleNameMapper: { '\\.css$': '<rootDir>/test/style-stub.js' },};
test/style-stub.js holds one line, module.exports = {};. The examples are TypeScript, so Jest also needs the transform your project already uses, such as ts-jest or babel-jest. In Vitest, set environment: 'jsdom' and use vi.fn() where the example uses jest.fn().
import { screen, within } from '@testing-library/dom';import userEvent from '@testing-library/user-event';import { TimepickerUI, type TimepickerOptions } from 'timepicker-ui';function renderPicker(options: TimepickerOptions = {}) {document.body.innerHTML ='<label for="start">Start time</label><input id="start" type="text" />';const input = screen.getByLabelText('Start time');const picker = new TimepickerUI(input, {...options,ui: { animation: false, ...options.ui },});picker.create();return { input, picker };}afterEach(() => {TimepickerUI.destroyAll();document.body.innerHTML = '';});test('picks 2:30 PM with the keyboard', async () => {const user = userEvent.setup();const onConfirm = jest.fn();const { input } = renderPicker({ callbacks: { onConfirm } });await user.click(input);const dialog = screen.getByRole('dialog', { name: 'Select time' });await user.click(within(dialog).getByRole('spinbutton', { name: 'Hour' }));await user.keyboard('{Home}{ArrowUp}'); // 01, then 02await user.click(within(dialog).getByRole('spinbutton', { name: 'Minute' }));await user.keyboard('{Home}' + '{PageUp}'.repeat(6)); // 00, then 05, 10 ... 30const pm = within(dialog).getByRole('button', { name: 'PM' });await user.click(pm);expect(pm).toHaveAttribute('aria-pressed', 'true');await user.click(within(dialog).getByRole('button', { name: 'OK' }));expect(input).toHaveValue('02:30 PM');expect(onConfirm).toHaveBeenCalledWith(expect.objectContaining({ hour: '02', minutes: '30', type: 'PM' }),);});test('setValue() converts a 24-hour time', () => {const { input, picker } = renderPicker();picker.setValue('14:30');expect(input).toHaveValue('02:30 PM');expect(picker.getValue().time).toBe('02:30 PM');});
The first test opens the picker, sets 2:30 PM with the keys and confirms with OK. It checks the input and the confirm payload. The second test calls setValue(), which converts a 24-hour time for a 12-hour picker.
jsdom has no layout
Every element in jsdom has a size of zero, so a click on the clock face cannot be turned into an hour or a minute. Set the time with the keys or with setValue().
Turn off animations in jsdom
renderPicker() passes ui: { animation: false }. With animations on, the switch from the hour face to the minute face animates the clock hand and reads its angle with DOMMatrix. jsdom does not have DOMMatrix, so the animation frame throws ReferenceError: DOMMatrix is not defined. With animations off, the picker skips that step and removes the modal without waiting for the close transition.
setValue()does not emitconfirm. Testcallbacks.onConfirmthrough the OK button.- In a 12-hour picker,
setValue('14:30')writes02:30 PM, the format OK writes.setValue('2:30 PM')is already in 12-hour format, and the input gets the string as you passed it:2:30 PM. TimepickerUI.destroyAll()destroys every picker with its modal and listeners. It leaves thediv.tp-uiwrapper that the picker put around the input, so the example also emptiesdocument.body.
End-to-end tests (Playwright)
#In a browser the clock face has a size, so clicks on it work. The keys still make the steadier test, because they do not depend on the viewport. The example follows the Playwright best practices: locators by role, and assertions that wait.
import { test, expect } from '@playwright/test';test('picks 2:30 PM', async ({ page }) => {await page.goto('/booking');const input = page.getByLabel('Start time');await input.click();const dialog = page.getByRole('dialog', { name: 'Select time' });await expect(dialog).toBeVisible();const hour = dialog.getByRole('spinbutton', { name: 'Hour' });await hour.press('Home');await hour.press('ArrowUp');const minute = dialog.getByRole('spinbutton', { name: 'Minute' });await minute.press('Home');for (let step = 0; step < 6; step++) {await minute.press('PageUp');}await dialog.getByRole('button', { name: 'PM' }).click();await dialog.getByRole('button', { name: 'OK' }).click();await expect(dialog).toBeHidden();await expect(input).toHaveValue('02:30 PM');});
The page under test has <label for="start">Start time</label> for the input. 02:30 PM is the default 12-hour format: a two-digit hour and minutes, a space, then AM or PM. With clock.type: '24h' the same time is 14:30.
fill() waits for an editable element, so on the read-only fields it times out. Use press(), or turn on ui.editable in the page under test. Editable fields pad and clamp the value when they lose focus, so fill('2') on the hour field becomes 02.
When a class is the only option
#A few parts have no role, or a name that changes while the test runs. Use a tp-ui-* class for these, scoped to the dialog locator. Keep such locators in one helper, so a markup change means one edit. CSS Classes lists the classes.
- Numbers on the clock face. They are
aria-hidden, so role queries skip them. In a browser you can click one through.tp-ui-value-tips, or.tp-ui-value-tips-24hon the inner ring of a 24-hour clock. A disabled number also has.tp-ui-tips-disabled. - The selected timezone. The combobox takes its name from
labels.timezone, and the option list sits inside it, so its text contains every timezone. Read the selected one from.tp-ui-timezone-selected. Option names include the current UTC offset, for exampleParis (+01:00), and the offset changes with daylight saving time. Match options by city with a regular expression. - The time on a range tab. Find the tabs with
getByRole('tab', { name: /^From/ })and{ name: /^To/ }. Once a tab shows a time, the time becomes part of its name, so match the start of the name. For the time alone, use.tp-ui-range-from-timeor.tp-ui-range-to-time.
const dialog = page.getByRole('dialog', { name: 'Select time' });// Clock face numbers are aria-hidden: click the 3 by classawait dialog.locator('.tp-ui-value-tips', { hasText: /^3$/ }).click();// Timezone plugin: pick an option by city, read the selection by classawait dialog.getByRole('combobox', { name: 'Timezone' }).click();await dialog.getByRole('option', { name: /^Paris / }).click();await expect(dialog.locator('.tp-ui-timezone-selected')).toHaveText(/^Paris /);// Range plugin, after a From time is set: the tab by role, its time by classconst fromTab = dialog.getByRole('tab', { name: /^From/ });await expect(fromTab.locator('.tp-ui-range-from-time')).not.toHaveText('--:--');