diff --git a/src/generic/datepicker-control/DatepickerControl.jsx b/src/generic/datepicker-control/DatepickerControl.jsx index 91306f2f11..a6db758111 100644 --- a/src/generic/datepicker-control/DatepickerControl.jsx +++ b/src/generic/datepicker-control/DatepickerControl.jsx @@ -5,11 +5,26 @@ import classNames from 'classnames'; import { Form, Icon } from '@openedx/paragon'; import { Calendar } from '@openedx/paragon/icons'; import { useIntl } from '@edx/frontend-platform/i18n'; +import moment from 'moment'; import { convertToDateFromString, convertToStringFromDate, isValidDate } from '../../utils'; import { DATE_FORMAT, TIME_FORMAT } from '../../constants'; import messages from './messages'; +const timeFormats = ['HH:mm', 'H:mm', 'hh:mm A', 'h:mm A', 'hh:mm a', 'h:mm a']; +const timeStepMinutes = 30; + +const scrollSelectedTimeIntoView = () => { + const schedule = typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function' + ? window.requestAnimationFrame + : ((cb) => setTimeout(() => cb(), 0)); + + schedule(() => { + const selectedItem = document.querySelector('.react-datepicker__time-list-item--selected'); + selectedItem?.scrollIntoView({ block: 'nearest' }); + }); +}; + export const DATEPICKER_TYPES = { date: 'date', time: 'time', @@ -32,6 +47,61 @@ const DatepickerControl = ({ [DATEPICKER_TYPES.date]: DATE_FORMAT, [DATEPICKER_TYPES.time]: TIME_FORMAT, }; + const isTimePicker = type === DATEPICKER_TYPES.time; + + const parseTimeValue = (rawValue) => { + if (!rawValue) { + return null; + } + const sanitized = rawValue.trim().replace(/\s+/g, ' '); + const parsed = moment(sanitized, timeFormats, true); + if (!parsed.isValid()) { + return null; + } + return parsed; + }; + + const handleTimeKeyDown = (event) => { + if (!isTimePicker || readonly) { + return; + } + if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') { + return; + } + + event.preventDefault(); + const direction = event.key === 'ArrowUp' ? -1 : 1; + const parsedTime = parseTimeValue(event.target.value); + const baseMoment = formattedDate ? moment(formattedDate) : moment().startOf('day'); + const workingMoment = parsedTime + ? baseMoment.clone().hours(parsedTime.hours()).minutes(parsedTime.minutes()) + : baseMoment.clone(); + + workingMoment.seconds(0); + workingMoment.milliseconds(0); + + const roundedMinutes = Math.floor(workingMoment.minutes() / timeStepMinutes) * timeStepMinutes; + workingMoment.minutes(roundedMinutes); + + const adjustedTime = workingMoment.add(direction * timeStepMinutes, 'minutes'); + onChange(convertToStringFromDate(adjustedTime.toDate())); + scrollSelectedTimeIntoView(); + }; + + let describedByIds; + if (isTimePicker) { + const ids = [`${controlName}-timehint`]; + if (helpText) { + ids.push(`${controlName}-helptext`); + } + describedByIds = ids.filter(Boolean).join(' ') || undefined; + } else if (helpText) { + describedByIds = `${controlName}-helptext`; + } + + const ariaLabel = isTimePicker + ? intl.formatMessage(messages.timepickerAriaLabel) + : undefined; return ( @@ -67,6 +137,9 @@ const DatepickerControl = ({ showTimeSelectOnly={type === DATEPICKER_TYPES.time} placeholderText={inputFormat[type].toLocaleUpperCase()} showPopperArrow={false} + onKeyDown={isTimePicker ? handleTimeKeyDown : undefined} + ariaLabel={ariaLabel} + ariaDescribedBy={describedByIds} onChange={(date) => { if (isValidDate(date)) { onChange(convertToStringFromDate(date)); @@ -74,7 +147,18 @@ const DatepickerControl = ({ }} /> - {helpText && {helpText}} + {isTimePicker && ( + + {intl.formatMessage(messages.timepickerScreenreaderHint, { + timeFormat: inputFormat[type].toLocaleUpperCase(), + })} + + )} + {helpText && ( + + {helpText} + + )} ); }; diff --git a/src/generic/datepicker-control/DatepickerControl.test.jsx b/src/generic/datepicker-control/DatepickerControl.test.jsx index 509fc0cf8b..20b4614d65 100644 --- a/src/generic/datepicker-control/DatepickerControl.test.jsx +++ b/src/generic/datepicker-control/DatepickerControl.test.jsx @@ -28,6 +28,10 @@ describe('', () => { onChange: onChangeMock, }; + beforeEach(() => { + onChangeMock.mockClear(); + }); + it('renders without crashing', () => { const { getByText, queryAllByText, getByPlaceholderText } = render( , @@ -48,4 +52,41 @@ describe('', () => { convertToStringFromDate('06/16/2023'), ); }); + + it('renders time picker with accessibility hint', () => { + const { getByText, getByPlaceholderText } = render( + , + ); + const input = getByPlaceholderText('HH:MM'); + + expect( + getByText('Enter time in HH:MM or twelve-hour format, for example 6:00 PM.'), + ).toBeInTheDocument(); + expect(input.getAttribute('aria-describedby')).toContain('fooControlName-timehint'); + }); + + it('increments time value with arrow down and decrements with arrow up', () => { + const incremented = convertToStringFromDate('2025-01-01T10:30:00Z'); + const restored = convertToStringFromDate('2025-01-01T10:00:00Z'); + const { getByPlaceholderText } = render( + , + ); + const input = getByPlaceholderText('HH:MM'); + + fireEvent.keyDown(input, { key: 'ArrowDown', target: { value: '10:00' } }); + expect(onChangeMock).toHaveBeenNthCalledWith(1, incremented); + + fireEvent.keyDown(input, { key: 'ArrowUp', target: { value: '10:30' } }); + expect(onChangeMock).toHaveBeenNthCalledWith(2, restored); + }); }); diff --git a/src/generic/datepicker-control/messages.js b/src/generic/datepicker-control/messages.js index b6139f7b57..38446a69ac 100644 --- a/src/generic/datepicker-control/messages.js +++ b/src/generic/datepicker-control/messages.js @@ -9,6 +9,14 @@ const messages = defineMessages({ id: 'course-authoring.schedule.schedule-section.datepicker.utc', defaultMessage: 'UTC', }, + timepickerAriaLabel: { + id: 'course-authoring.schedule.schedule-section.timepicker.aria-label', + defaultMessage: 'Time input field. Enter a time or use the arrow keys to adjust.', + }, + timepickerScreenreaderHint: { + id: 'course-authoring.schedule.schedule-section.timepicker.screenreader-hint', + defaultMessage: 'Enter time in {timeFormat} or twelve-hour format, for example 6:00 PM.', + }, }); export default messages;