diff --git a/__tests__/components/common/TimePicker.test.tsx b/__tests__/components/common/TimePicker.test.tsx index bc5c104625..8c259891cb 100644 --- a/__tests__/components/common/TimePicker.test.tsx +++ b/__tests__/components/common/TimePicker.test.tsx @@ -3,25 +3,47 @@ import React from 'react'; import TimePicker from '../../../components/common/TimePicker'; describe('TimePicker', () => { - it('changes hours and minutes via inputs', () => { + it('labels hour and minute inputs for accessibility', () => { const onChange = jest.fn(); render(); - fireEvent.change(screen.getByPlaceholderText('HH'), { target: { value: '10' } }); + + const hoursInput = screen.getByLabelText('Hours'); + const minutesInput = screen.getByLabelText('Minutes'); + + fireEvent.change(hoursInput, { target: { value: '10' } }); expect(onChange).toHaveBeenCalledWith(10, 15); - fireEvent.change(screen.getByPlaceholderText('MM'), { target: { value: '45' } }); + + fireEvent.change(minutesInput, { target: { value: '45' } }); expect(onChange).toHaveBeenCalledWith(9, 45); }); it('toggles am/pm respecting minTime', () => { const onChange = jest.fn(); - render(); - fireEvent.click(screen.getByText('AM')); + render( + + ); + fireEvent.click(screen.getByRole('button', { name: 'Toggle AM/PM' })); expect(onChange).toHaveBeenCalledWith(21, 0); }); - it('disables options before minTime', () => { + it('describes minimum time and disables earlier quick options', () => { const onChange = jest.fn(); - render(); + render( + + ); + + const hoursInput = screen.getByLabelText('Hours'); + const minutesInput = screen.getByLabelText('Minutes'); + + const describedBy = hoursInput.getAttribute('aria-describedby'); + expect(describedBy).toBeTruthy(); + + const description = document.getElementById(describedBy ?? ''); + expect(description).not.toBeNull(); + expect(description).toHaveTextContent('Earliest selectable time is 9:30 AM.'); + + expect(minutesInput.getAttribute('aria-describedby')).toBe(describedBy); + const early = screen.getByText('9 AM') as HTMLButtonElement; const later = screen.getByText('12 PM'); expect(early).toBeDisabled(); diff --git a/components/common/TimePicker.tsx b/components/common/TimePicker.tsx index de2c14712d..67252880b8 100644 --- a/components/common/TimePicker.tsx +++ b/components/common/TimePicker.tsx @@ -1,3 +1,5 @@ +import { useId, type ChangeEvent } from "react"; + interface TimePickerProps { readonly hours: number; readonly minutes: number; @@ -11,12 +13,28 @@ interface TimeOption { minutes: number; } +const formatTime = (timeHours: number, timeMinutes: number) => { + const period = timeHours >= 12 ? "PM" : "AM"; + const hour12 = timeHours % 12 === 0 ? 12 : timeHours % 12; + const minuteLabel = timeMinutes.toString().padStart(2, "0"); + return `${hour12}:${minuteLabel} ${period}`; +}; + export default function TimePicker({ hours, minutes, onTimeChange, minTime = null, }: TimePickerProps) { + const baseId = useId(); + const hoursInputId = `${baseId}-hours`; + const minutesInputId = `${baseId}-minutes`; + const minTimeDescriptionId = minTime ? `${baseId}-min-time` : undefined; + const minTimeDescription = + minTime !== null + ? `Earliest selectable time is ${formatTime(minTime.hours, minTime.minutes)}.` + : undefined; + const timeOptions: TimeOption[] = [ { label: "12 AM", hours: 0, minutes: 0 }, { label: "6 AM", hours: 6, minutes: 0 }, @@ -50,7 +68,7 @@ export default function TimePicker({ return false; }; - const onHoursChange = (e: React.ChangeEvent) => { + const onHoursChange = (e: ChangeEvent) => { const val = parseInt(e.target.value, 10); if (isNaN(val)) return; const newHours = isPm ? (val === 12 ? 12 : val + 12) : val === 12 ? 0 : val; @@ -60,7 +78,7 @@ export default function TimePicker({ } }; - const onMinutesChange = (e: React.ChangeEvent) => { + const onMinutesChange = (e: ChangeEvent) => { const val = parseInt(e.target.value, 10); if (isNaN(val)) return; @@ -75,30 +93,41 @@ export default function TimePicker({
+
:
+
+ {minTimeDescription ? ( +

+ {minTimeDescription} +

+ ) : null} +
{timeOptions.map((option) => { const disabled = isTimeDisabled(option.hours, option.minutes);