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;