Skip to content

Accessibility ​

The timepicker is designed to be usable without a mouse. It follows WAI-ARIA patterns for comboboxes and dialogs.

ARIA roles and attributes ​

Input element ​

When the picker is initialized, the following attributes are added to the <input>:

AttributeValuePurpose
rolecomboboxIdentifies the input as a combobox that opens a popup.
aria-haspopupdialogIndicates the popup type.
aria-expandedfalse / trueUpdated when the dropdown opens and closes.
autocompleteoffPrevents browser autocomplete from overlapping the picker.

These attributes are removed by destroy().

The dropdown container carries role="dialog" implicitly through its structure. Each spinner column has:

AttributeValue
rolespinbutton
aria-labelLocale-resolved label (e.g. "Hours", "Minutes")
aria-valuenowCurrent numeric value

The up/down arrow buttons have aria-label="Increase hours" / "Decrease minutes" etc. The time value buttons (click to open the grid view) have aria-label="hours, click to select from list".

Grid view cells ​

Each clickable cell in the hour/minute/second grid has:

AttributeValue
rolebutton (via <button> element)
aria-selected"true" on the currently selected cell
aria-disabled"true" on disabled cells

Live region ​

An aria-live="polite" region is injected visually off-screen. It announces selection changes to screen readers without moving focus.

Invalid input ​

When a value is rejected, the class vtp-invalid is added to the input. You can connect this to a visible error message by linking the message with aria-describedby:

html
<input id="start" aria-describedby="start-error" />
<span id="start-error" class="error" hidden></span>
ts
new Timepicker('#start', {
  onInvalid: (err) => {
    const msg = document.getElementById('start-error')!
    msg.textContent = err.message
    msg.hidden = false
  },
  onChange: () => {
    document.getElementById('start-error')!.hidden = true
  },
})

Keyboard navigation ​

On the input ​

KeyAction
EnterTriggers autofill (normalises the typed value).
EscapeCloses the dropdown (reason: 'escape').

In the spinner (picker) view ​

KeyAction
ArrowUpIncrement the focused column by one step.
ArrowDownDecrement the focused column by one step.
PageUpIncrement by a large step (currently same as ArrowUp).
PageDownDecrement by a large step.
HomeSet the column to its minimum value (0 for all columns).
EndSet the column to its maximum value (23 for hours, 59 for minutes/seconds).
ArrowRightMove focus to the next column (minute → second).
ArrowLeftMove focus to the previous column.
EnterConfirm the selection (same as clicking "Confirm" when shown).
EscapeClose the dropdown.

In the grid view ​

KeyAction
Arrow keysNavigate between cells.
Enter / SpaceSelect the focused cell.
EscapeReturn to the spinner view.

Focus trap ​

When the dropdown is open, focus is trapped inside it. Pressing Tab cycles through the interactive elements (up arrows, time values, down arrows, footer buttons). Pressing Escape or selecting a value releases the trap and returns focus to the input.

Reduced motion ​

The CSS file includes:

css
@media (prefers-reduced-motion: reduce) {
  .vtp-dropdown {
    animation: none !important;
    transition: none !important;
  }
}

Users who have opted into reduced motion see no entry animation, regardless of the animation option.

Testing accessibility ​

The picker works with VoiceOver (macOS / iOS), NVDA (Windows), and JAWS. Screen readers announce:

  • "Select time, collapsed" on the input (from role="combobox" and aria-expanded="false").
  • "expanded" when the dropdown opens.
  • The new time value via the live region after each change.

Use axe-core or @axe-core/playwright to run automated accessibility checks on your integration:

ts
import { checkA11y } from 'axe-playwright'

await checkA11y(page, '#departure')

Released under the MIT License.