timepicker-ui
4.7.0GitHub
timepicker-ui
Advanced / Accessibility

Accessibility

Built with WCAG 2.2 AA compliance and full keyboard support

ARIA Support

#

Timepicker-UI includes ARIA roles, live-region announcements and focus management for screen reader compatibility:

  • role="dialog" with aria-modal="true" for modal timepicker
  • aria-labelledby connecting dialog to title
  • aria-label on all interactive elements (inputs, buttons)
  • role="spinbutton" with aria-valuenow for the hour and minute inputs, and the seconds input when clock.showSeconds is on (dynamically updated). With aria-valuetext from labels.valueText, a screen reader reads "Hour, 7 of 12" instead of a bare number
  • role="group" around the analog clock, named by labels.clock (default "Clock")
  • role="slider" with tabindex="0" on the clock face. Its aria-label, aria-valuemin, aria-valuemax, aria-valuenow and aria-valuetext follow the active hour, minute or second field
  • role="listbox" for the wheel columns (wheel and compact-wheel modes) and the timezone list
  • role="option" with aria-selected for the values in those lists, with aria-activedescendant pointing to the current one
  • aria-disabled on disabled wheel items, on the OK button while the time can't be confirmed (a disabled time, a time that was just cleared, or a range that isn't valid yet), on the Clear button when there is nothing to clear, and on the range To tab until a From time is set. Disabled numbers on the clock face are dimmed, can't be clicked and are skipped by the keyboard. They have no aria-disabled because the numbers are aria-hidden
  • role="radiogroup" around the AM and PM buttons, named by labels.period. Each button is a role="radio" with aria-checked (dynamically updated). Before 4.7.0 they used aria-pressed
  • role="tablist" with two role="tab" buttons for the range From and To ends, and a role="tabpanel" (tp-ui-range-panel) around the time fields and the dial. Each tab has aria-controls pointing to the panel, and the panel is named by the active tab
  • The selected timezone is a select-only role="combobox" with aria-expanded, aria-haspopup="listbox" and aria-activedescendant. It is the element with the class tp-ui-timezone-selected
  • role="status" + aria-live="polite" + aria-atomic="true" for time change announcements
  • aria-hidden="true" for decorative elements (clock numbers, dots, clock hand). The clock numbers are not focusable

Keyboard Navigation

#

Complete keyboard support for users who cannot or prefer not to use a mouse:

Keyboard Shortcuts

Enter Space
Open timepicker
Esc
Close / Cancel (not in inline mode)
Tab
Move between controls: hour, minute, second (when shown), AM, PM, clock face, then the footer buttons
Up arrow Right arrow
Clock face: one step up (1 hour, or the minute / second increment)
Down arrow Left arrow
Clock face: one step down
PgUp PgDn
Clock face: ±3 hours, or ±5 steps on minutes and seconds
Home End
Clock face: first / last value
Enter Space
Clock face: go to the next face (hour, then minute, then second). Off when clock.autoSwitchToMinutes / autoSwitchToSeconds is false
Up arrow Down arrow
Hour, minute or second field: one step up / down
Home End
Hour, minute or second field: first / last value
PgUp PgDn
Hour, minute or second field: ±3 hours, or ±5 steps on minutes and seconds
Enter Space
Select AM or PM (Tab to the button first)
Arrow keys
AM / PM group: move to the other button and select it. The group is one Tab stop, like an APG radio group
Arrow keys
Range tabs: move focus between From and To. Enter or Space on the To tab activates it and focus stays on the tab, as in an APG tab list. Tab then moves into the panel
Enter
Time field: apply the typed value, like leaving the field
Letters
Timezone combobox: jump to the next timezone that starts with the typed letters (type-ahead)

Note: The clock face is a single Tab stop and works like a slider. The arrow keys change the value of the active face (hour, minute or second). The clock hand and the matching field update as you press, and focus stays on the face. Disabled values are skipped on the clock face and in the time fields.

Navigation Flow

  1. Tab through: Hour input, then Minute input, then Second input (when shown), then AM, then PM, then Clock face, then Cancel, then OK
  2. On the clock face, press Enter or Space to go to the next face (hour, then minute, then second). Clicking a time field also switches the face
  3. When the clock face has focus, the arrow keys change the active value and focus stays on the face
  4. Disabled times (if configured) are skipped during arrow key navigation
  5. Focus trap prevents tabbing outside the modal while open

Enabling Keyboard Navigation

index.ts
const picker = new TimepickerUI(input, {
// Keyboard navigation is enabled by default
// All interactive elements are focusable
});
picker.create();
// Arrow keys work on the time fields and on the clock face
// - Hour/minute/second fields: Up/Down step by 1 hour, or by
// clock.incrementMinutes / clock.incrementSeconds
// - Clock face: Up/Right and Down/Left step the active face the same way
// - Disabled times are automatically skipped
// AM and PM are buttons: Tab to one and press Enter or Space

Screen Reader Support

#

Optimized for screen readers like NVDA, JAWS, and VoiceOver:

ARIA Labels

index.ts
const picker = new TimepickerUI(input, {
labels: {
// Visible button text (also announced by screen readers)
ok: 'Confirm',
cancel: 'Cancel',
// Accessible names for the picker controls
clock: 'Clock',
period: 'Period',
timeFields: 'Select appointment time',
// Field caption, accessible name and announcement prefix in one key
hour: 'Hour',
minute: 'Minute'
}
});
// Add a custom aria-label / hint directly on the input element
<input
type="text"
id="timepicker"
aria-label="Select appointment time"
aria-describedby="time-help"
/>
<span id="time-help" class="sr-only">
Press Enter or Space to open the time picker.
</span>

Live Regions

index.html
<!-- Live region for screen reader announcements -->
<!-- Rendered as the last child of .tp-ui-modal; sr-only hides it visually -->
<div
class="timepicker-announcer sr-only"
role="status"
aria-live="polite"
aria-atomic="true"
>
<!-- Dynamically updated by the timepicker -->
<!-- Announces: "Hour: 14", "Minute: 30", etc. -->
</div>
<script>
// Timepicker automatically announces changes via live region
// No manual setup required
// Example announcements with the default labels:
// - "Hour: 14"
// - "Minute: 30" (said "Minutes: 30" before 4.7.0)
// - "Second: 15"
// - "AM selected"
// - "PM selected"
// - "Time cleared"
// Live region updates happen on:
// - Clock hand drag
// - Clock number click
// - Arrow keys, Home/End and PageUp/PageDown on the time fields
// - Enter/Space on the clock face (announces the new face and its value)
// - Wheel column scroll (wheel and compact-wheel modes)
// - AM/PM toggle
// - Clear
// Arrow steps on the focused clock face are not announced here: the
// clock face is a slider, so the screen reader reads its new value itself
</script>

Descriptive Labels

index.html
<label for="meeting-time" class="form-label">
Meeting Time
<span class="text-muted">(Required)</span>
</label>
<input
type="text"
id="meeting-time"
required
aria-required="true"
aria-describedby="meeting-time-hint"
/>
<div id="meeting-time-hint" class="form-hint">
Select a time between 9:00 AM and 5:00 PM
</div>

Value text and sizes

#

The hour, minute and second fields and the clock dial expose their value through aria-valuetext. The text comes from labels.valueText, "{value} of {count}" by default, where {value} has no leading zero and {count} is 12 or 24 hours, or 60 minutes or seconds. With the field name a screen reader reads "Hour, 7 of 12". Set '{value}' for the bare number. New in 4.7.0.

For Material 3 target sizes, set these variables. The hand circle grows to 48 px, and the AM / PM buttons get 48 px with a taller time field next to them.

index.css
.tp-ui-wrapper {
--tp-size-hand-circle: 48px;
--tp-size-input-height: 96px;
--tp-size-am-pm-height: 48px;
--tp-size-am-pm-height-landscape: 48px;
}

Focus Management

#

Proper focus handling for keyboard and screen reader users:

Focus Trapping

index.ts
// Focus is automatically trapped within the modal
// Users cannot tab outside the timepicker when open
const picker = new TimepickerUI(input, {
behavior: {
// Modal automatically manages focus
focusTrap: true, // Default behavior
returnFocus: true // Recommended, default: false (true in 5.0)
}
});
picker.create();
// When modal opens:
// 1. Focus moves to the wrapper element
// 2. Tab cycles through all focusable elements in order:
// - Hour input
// - Minute input
// - Second input (when clock.showSeconds is true)
// - AM/PM radio group, one stop on the checked one (in 12h mode)
// - Clock face (one Tab stop for the whole dial)
// - Cancel button
// - OK button
// 3. Shift+Tab cycles backwards
// 4. When last element is focused and Tab is pressed, focus returns to first element
// 5. Cannot tab to elements behind modal
// When modal closes:
// - Focus returns to the input field that triggered the modal
// when behavior.returnFocus is true (default: false)
// Note: The numbers on the clock face are not in the Tab order. The clock
// face itself works like a slider: the arrow keys change the active value.

Visible Focus Indicators

index.css
/* Built-in focus ring (simplified) */
.tp-ui-ok-btn:focus-visible,
.tp-ui-cancel-btn:focus-visible,
.tp-ui-clear-btn:focus-visible,
.tp-ui-keyboard-icon:focus-visible {
outline: 3px solid var(--tp-focus-ring, var(--tp-primary));
outline-offset: 2px;
box-shadow: var(--tp-shadow-focus-ring, none);
}
/* AM/PM, the time fields and the clock face draw the ring inside their edge */
.tp-ui-hour:focus-visible,
.tp-ui-minutes:focus-visible,
.tp-ui-seconds:focus-visible,
.tp-ui-am:focus-visible,
.tp-ui-pm:focus-visible,
.tp-ui-clock-face:focus-visible {
outline-offset: -3px;
}
/* Thicker ring when the user asks for more contrast */
@media (prefers-contrast: more) {
.tp-ui-ok-btn:focus-visible,
.tp-ui-cancel-btn:focus-visible {
outline-width: 4px;
}
}
/* The ring shows only for keyboard focus, not after a mouse click or tap */
/* Set --tp-focus-ring to change its color */

Disabled Time Handling

index.ts
// Disabled times are properly marked and skipped
// Lists: disable specific hours / minutes
const picker = new TimepickerUI(input, {
clock: {
type: '24h',
disabledTime: {
hours: [0, 1, 2],
minutes: [15, 45]
}
}
});
// Ranges: disable one or more time ranges (string or array of strings).
// When ranges is set, the hours, minutes and seconds lists are ignored
const rangesPicker = new TimepickerUI(input, {
clock: {
disabledTime: {
ranges: ['9:00 AM - 12:00 PM', '6:00 PM - 11:59 PM']
}
}
});
// On the clock face, disabled numbers:
// - are dimmed and can't be picked by click or drag
// - are skipped by the arrow keys, Home/End and PageUp/PageDown
// - have no aria-disabled, because the numbers are aria-hidden and not
// focusable (the clock face itself is the focusable slider)
// In wheel and compact-wheel modes, disabled items get aria-disabled="true",
// or are left out with wheel.hideDisabled: true
// OK gets aria-disabled="true" while the shown time is disabled
// The keyboard skips disabled values in the time fields too
// If the ranges cover every minute of an hour, that hour is also skipped

Color Contrast

#

Ensure WCAG AA compliance with proper color contrast:

Contrast Requirements

WCAG AA:
Text contrast ratio of at least 4.5:1
WCAG AAA:
Text contrast ratio of at least 7:1
Large Text:
18px or 14px bold requires 3:1 contrast

High Contrast Theme

index.css
/* High contrast accessible override (CSS variables use the --tp- prefix) */
.tp-ui-modal[data-theme="dark"] {
--tp-bg: #000000;
--tp-text: #ffffff;
--tp-primary: #ffff00;
--tp-border: #ffffff;
/* Ensure all text meets 7:1 contrast ratio */
}
/* Respect user's contrast preference */
@media (prefers-contrast: more) {
.tp-ui-wrapper {
border: 2px solid currentColor;
}
.tp-ui-clock-face {
border: 2px solid currentColor;
}
}
@media (prefers-contrast: more) {
/* Higher contrast for users who ask for it */
.tp-ui-wrapper {
background: #000000;
color: #ffffff;
}
}

Reduced Motion

#

Respect user preferences for reduced motion:

Disable Animations

index.ts
/* CSS: Respect prefers-reduced-motion */
@media (prefers-reduced-motion: reduce) {
.tp-ui-wrapper {
animation: none !important;
transition: none !important;
}
.tp-ui-clock-hand {
transition: none !important;
}
.tp-ui-modal {
animation: none !important;
}
}
// JavaScript: Detect user preference
const prefersReducedMotion = window.matchMedia(
'(prefers-reduced-motion: reduce)'
).matches;
const picker = new TimepickerUI(input, {
ui: {
animation: !prefersReducedMotion
}
});
picker.create();

Testing Accessibility

#

Tools and techniques for testing accessibility:

Automated Testing

index.ts
// Using axe-core for accessibility testing
import { axe } from 'jest-axe';
test('timepicker should have no accessibility violations', async () => {
const { container } = render(
<TimepickerComponent />
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
// Using @testing-library for keyboard testing
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
test('can navigate with keyboard', async () => {
const user = userEvent.setup();
render(<TimepickerComponent />);
const input = screen.getByRole('textbox');
await user.click(input);
await user.keyboard('{Enter}');
// Verify modal opened
expect(screen.getByRole('dialog')).toBeInTheDocument();
// Test arrow key navigation
await user.keyboard('{ArrowUp}');
await user.keyboard('{Enter}');
});

Manual Testing Checklist

Accessibility Resources
Learn more about web accessibility standards and best practices:
Esc