From 8eabbcbcf66115562fd5bb7c326514b571f0bbf3 Mon Sep 17 00:00:00 2001 From: asajjad2 Date: Tue, 11 Nov 2025 15:13:05 +0500 Subject: [PATCH 1/8] feat: add keyboard accessibility for timepicker --- .../datepicker-control/DatepickerControl.jsx | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/src/generic/datepicker-control/DatepickerControl.jsx b/src/generic/datepicker-control/DatepickerControl.jsx index 91306f2f11..7ae134c768 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,46 @@ 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(); + }; return ( @@ -67,6 +122,7 @@ const DatepickerControl = ({ showTimeSelectOnly={type === DATEPICKER_TYPES.time} placeholderText={inputFormat[type].toLocaleUpperCase()} showPopperArrow={false} + onKeyDown={isTimePicker ? handleTimeKeyDown : undefined} onChange={(date) => { if (isValidDate(date)) { onChange(convertToStringFromDate(date)); From 0ae76277247f1e162c0f54cfa675fc2eb3290401 Mon Sep 17 00:00:00 2001 From: asajjad2 Date: Tue, 11 Nov 2025 16:20:31 +0500 Subject: [PATCH 2/8] fix: resolve implied eval lint error --- src/generic/datepicker-control/DatepickerControl.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/generic/datepicker-control/DatepickerControl.jsx b/src/generic/datepicker-control/DatepickerControl.jsx index 7ae134c768..808b4db161 100644 --- a/src/generic/datepicker-control/DatepickerControl.jsx +++ b/src/generic/datepicker-control/DatepickerControl.jsx @@ -17,7 +17,7 @@ const timeStepMinutes = 30; const scrollSelectedTimeIntoView = () => { const schedule = typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function' ? window.requestAnimationFrame - : ((cb) => setTimeout(cb, 0)); + : ((cb) => setTimeout(() => cb(), 0)); schedule(() => { const selectedItem = document.querySelector('.react-datepicker__time-list-item--selected'); From c8b91adc46e6e518fb25c78095f4fd293db9ba7e Mon Sep 17 00:00:00 2001 From: asajjad2 Date: Thu, 13 Nov 2025 13:27:49 +0500 Subject: [PATCH 3/8] test: timepicker keyboard accessibility --- .../DatepickerControl.test.jsx | 41 ++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/src/generic/datepicker-control/DatepickerControl.test.jsx b/src/generic/datepicker-control/DatepickerControl.test.jsx index 509fc0cf8b..47af90b700 100644 --- a/src/generic/datepicker-control/DatepickerControl.test.jsx +++ b/src/generic/datepicker-control/DatepickerControl.test.jsx @@ -16,7 +16,6 @@ describe('', () => { ); const props = { - intl: {}, type: DATEPICKER_TYPES.date, label: 'fooLabel', value: '', @@ -28,6 +27,10 @@ describe('', () => { onChange: onChangeMock, }; + beforeEach(() => { + onChangeMock.mockClear(); + }); + it('renders without crashing', () => { const { getByText, queryAllByText, getByPlaceholderText } = render( , @@ -48,4 +51,40 @@ describe('', () => { convertToStringFromDate('06/16/2023'), ); }); + + it('renders time picker with accessibility hint', () => { + const { getByText, getByLabelText } = render( + , + ); + expect( + getByText('Enter time in HH:MM or twelve-hour format, for example 6:00 PM.'), + ).toBeInTheDocument(); + expect(getByLabelText(messages.timepickerAriaLabel.defaultMessage)) + .toBeInTheDocument(); + }); + + it('increments time value with arrow down and decrements with arrow up', () => { + const incremented = convertToStringFromDate('2025-01-01T10:30:00Z'); + const decremented = convertToStringFromDate('2025-01-01T09:30:00Z'); + const { getByLabelText } = render( + , + ); + const input = getByLabelText(messages.timepickerAriaLabel.defaultMessage); + expect(input).toHaveValue('10:00'); + fireEvent.keyDown(input, { key: 'ArrowDown', target: { value: '10:00' } }); + expect(onChangeMock).toHaveBeenCalledWith(incremented); + fireEvent.keyDown(input, { key: 'ArrowUp', target: { value: '10:30' } }); + expect(onChangeMock).toHaveBeenCalledWith(decremented); + }); + }); From eb225671159e8c526541213be93bfe41375005d9 Mon Sep 17 00:00:00 2001 From: asajjad2 Date: Thu, 13 Nov 2025 14:00:44 +0500 Subject: [PATCH 4/8] style: linting fix --- src/generic/datepicker-control/DatepickerControl.test.jsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/generic/datepicker-control/DatepickerControl.test.jsx b/src/generic/datepicker-control/DatepickerControl.test.jsx index 47af90b700..25a5e918a0 100644 --- a/src/generic/datepicker-control/DatepickerControl.test.jsx +++ b/src/generic/datepicker-control/DatepickerControl.test.jsx @@ -30,7 +30,6 @@ describe('', () => { beforeEach(() => { onChangeMock.mockClear(); }); - it('renders without crashing', () => { const { getByText, queryAllByText, getByPlaceholderText } = render( , @@ -86,5 +85,4 @@ describe('', () => { fireEvent.keyDown(input, { key: 'ArrowUp', target: { value: '10:30' } }); expect(onChangeMock).toHaveBeenCalledWith(decremented); }); - }); From 00f3ce8e665218e8364f7a6c436d4c9bb89dc21e Mon Sep 17 00:00:00 2001 From: asajjad2 Date: Thu, 13 Nov 2025 14:29:31 +0500 Subject: [PATCH 5/8] fix: add accessibility messages/aria labels --- .../datepicker-control/DatepickerControl.jsx | 25 ++++++++++++++++++- src/generic/datepicker-control/messages.js | 8 ++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/generic/datepicker-control/DatepickerControl.jsx b/src/generic/datepicker-control/DatepickerControl.jsx index 808b4db161..4a69df79a2 100644 --- a/src/generic/datepicker-control/DatepickerControl.jsx +++ b/src/generic/datepicker-control/DatepickerControl.jsx @@ -88,6 +88,16 @@ const DatepickerControl = ({ scrollSelectedTimeIntoView(); }; + const describedByIds = isTimePicker + ? [`${controlName}-timehint`, helpText ? `${controlName}-helptext` : null] + .filter(Boolean) + .join(' ') || undefined + : (helpText ? `${controlName}-helptext` : undefined); + + const ariaLabel = isTimePicker + ? intl.formatMessage(messages.timepickerAriaLabel) + : undefined; + return ( @@ -123,6 +133,8 @@ const DatepickerControl = ({ placeholderText={inputFormat[type].toLocaleUpperCase()} showPopperArrow={false} onKeyDown={isTimePicker ? handleTimeKeyDown : undefined} + ariaLabel={ariaLabel} + ariaDescribedBy={describedByIds} onChange={(date) => { if (isValidDate(date)) { onChange(convertToStringFromDate(date)); @@ -130,7 +142,18 @@ const DatepickerControl = ({ }} /> - {helpText && {helpText}} + {isTimePicker && ( + + {intl.formatMessage(messages.timepickerScreenreaderHint, { + timeFormat: inputFormat[type].toLocaleUpperCase(), + })} + + )} + {helpText && ( + + {helpText} + + )} ); }; 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; From 6a06ce97cbf13f1b05980fe4dc968b252e350e4b Mon Sep 17 00:00:00 2001 From: asajjad2 Date: Thu, 13 Nov 2025 16:27:55 +0500 Subject: [PATCH 6/8] fix: linting errors --- .../datepicker-control/DatepickerControl.jsx | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/generic/datepicker-control/DatepickerControl.jsx b/src/generic/datepicker-control/DatepickerControl.jsx index 4a69df79a2..a6db758111 100644 --- a/src/generic/datepicker-control/DatepickerControl.jsx +++ b/src/generic/datepicker-control/DatepickerControl.jsx @@ -88,11 +88,16 @@ const DatepickerControl = ({ scrollSelectedTimeIntoView(); }; - const describedByIds = isTimePicker - ? [`${controlName}-timehint`, helpText ? `${controlName}-helptext` : null] - .filter(Boolean) - .join(' ') || undefined - : (helpText ? `${controlName}-helptext` : undefined); + 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) From c1aca69580249d730190ce870b6a347d107948da Mon Sep 17 00:00:00 2001 From: asajjad2 Date: Thu, 13 Nov 2025 17:19:16 +0500 Subject: [PATCH 7/8] fix: timepicker use placeholder lookup --- .../DatepickerControl.test.jsx | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/generic/datepicker-control/DatepickerControl.test.jsx b/src/generic/datepicker-control/DatepickerControl.test.jsx index 25a5e918a0..46e8c4e99f 100644 --- a/src/generic/datepicker-control/DatepickerControl.test.jsx +++ b/src/generic/datepicker-control/DatepickerControl.test.jsx @@ -16,6 +16,7 @@ describe('', () => { ); const props = { + intl: {}, type: DATEPICKER_TYPES.date, label: 'fooLabel', value: '', @@ -30,6 +31,7 @@ describe('', () => { beforeEach(() => { onChangeMock.mockClear(); }); + it('renders without crashing', () => { const { getByText, queryAllByText, getByPlaceholderText } = render( , @@ -52,7 +54,7 @@ describe('', () => { }); it('renders time picker with accessibility hint', () => { - const { getByText, getByLabelText } = render( + const { getByText, getByPlaceholderText } = render( ', () => { helpText="" />, ); + const input = getByPlaceholderText('HH:MM'); + expect( getByText('Enter time in HH:MM or twelve-hour format, for example 6:00 PM.'), ).toBeInTheDocument(); - expect(getByLabelText(messages.timepickerAriaLabel.defaultMessage)) - .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 decremented = convertToStringFromDate('2025-01-01T09:30:00Z'); - const { getByLabelText } = render( + const { getByPlaceholderText } = render( ', () => { helpText="" />, ); - const input = getByLabelText(messages.timepickerAriaLabel.defaultMessage); - expect(input).toHaveValue('10:00'); + const input = getByPlaceholderText('HH:MM'); + fireEvent.keyDown(input, { key: 'ArrowDown', target: { value: '10:00' } }); expect(onChangeMock).toHaveBeenCalledWith(incremented); + fireEvent.keyDown(input, { key: 'ArrowUp', target: { value: '10:30' } }); expect(onChangeMock).toHaveBeenCalledWith(decremented); }); From 3b6bcc3e60089eac72598b8d94c7f50f4b99517e Mon Sep 17 00:00:00 2001 From: asajjad2 Date: Thu, 13 Nov 2025 17:30:19 +0500 Subject: [PATCH 8/8] test: expected values updation --- src/generic/datepicker-control/DatepickerControl.test.jsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/generic/datepicker-control/DatepickerControl.test.jsx b/src/generic/datepicker-control/DatepickerControl.test.jsx index 46e8c4e99f..20b4614d65 100644 --- a/src/generic/datepicker-control/DatepickerControl.test.jsx +++ b/src/generic/datepicker-control/DatepickerControl.test.jsx @@ -72,7 +72,7 @@ describe('', () => { it('increments time value with arrow down and decrements with arrow up', () => { const incremented = convertToStringFromDate('2025-01-01T10:30:00Z'); - const decremented = convertToStringFromDate('2025-01-01T09: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).toHaveBeenCalledWith(incremented); + expect(onChangeMock).toHaveBeenNthCalledWith(1, incremented); fireEvent.keyDown(input, { key: 'ArrowUp', target: { value: '10:30' } }); - expect(onChangeMock).toHaveBeenCalledWith(decremented); + expect(onChangeMock).toHaveBeenNthCalledWith(2, restored); }); });