From 023bb762fc4e25bf71c072bcacf52b84008cb942 Mon Sep 17 00:00:00 2001 From: asajjad2 Date: Tue, 11 Nov 2025 15:13:05 +0500 Subject: [PATCH 01/11] 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 dbc556521f..3c1e1211fb 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 648b4f5fe667e357821cb1fc5e78a4c169625bf0 Mon Sep 17 00:00:00 2001 From: asajjad2 Date: Tue, 11 Nov 2025 16:20:31 +0500 Subject: [PATCH 02/11] 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 3c1e1211fb..d35174e610 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 3f413784fbc81e110ada874332e774f83b166004 Mon Sep 17 00:00:00 2001 From: asajjad2 Date: Thu, 13 Nov 2025 13:27:49 +0500 Subject: [PATCH 03/11] 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 d4532fa7c5da5ea1b305f89cc52e7d21d2ff3fd2 Mon Sep 17 00:00:00 2001 From: asajjad2 Date: Thu, 13 Nov 2025 14:00:44 +0500 Subject: [PATCH 04/11] 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 98c9ff7740e78dd3b790ff20859cb3e4948fd4fd Mon Sep 17 00:00:00 2001 From: asajjad2 Date: Thu, 13 Nov 2025 14:29:31 +0500 Subject: [PATCH 05/11] 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 d35174e610..1d3c9008f5 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 f6c350533e5ca56f31f8b9be9d406e35e0510e21 Mon Sep 17 00:00:00 2001 From: asajjad2 Date: Thu, 13 Nov 2025 16:27:55 +0500 Subject: [PATCH 06/11] 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 1d3c9008f5..52ff942510 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 b2ffbdac23e360b4fad95064bccd351164f46be9 Mon Sep 17 00:00:00 2001 From: asajjad2 Date: Thu, 13 Nov 2025 17:19:16 +0500 Subject: [PATCH 07/11] 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 681939f888057032b07cde9c481b3e016619cb38 Mon Sep 17 00:00:00 2001 From: asajjad2 Date: Thu, 13 Nov 2025 17:30:19 +0500 Subject: [PATCH 08/11] 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); }); }); From d8fd7d9c1cd0a7858bf1ab6064b27234ac95252e Mon Sep 17 00:00:00 2001 From: asajjad2 Date: Tue, 2 Dec 2025 05:02:30 +0500 Subject: [PATCH 09/11] feat: only keep arialabels --- .../datepicker-control/DatepickerControl.jsx | 55 ------------------- .../DatepickerControl.test.jsx | 24 -------- 2 files changed, 79 deletions(-) diff --git a/src/generic/datepicker-control/DatepickerControl.jsx b/src/generic/datepicker-control/DatepickerControl.jsx index 52ff942510..56c5230a56 100644 --- a/src/generic/datepicker-control/DatepickerControl.jsx +++ b/src/generic/datepicker-control/DatepickerControl.jsx @@ -5,26 +5,11 @@ 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', @@ -49,45 +34,6 @@ const DatepickerControl = ({ }; 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`]; @@ -137,7 +83,6 @@ const DatepickerControl = ({ showTimeSelectOnly={type === DATEPICKER_TYPES.time} placeholderText={inputFormat[type].toLocaleUpperCase()} showPopperArrow={false} - onKeyDown={isTimePicker ? handleTimeKeyDown : undefined} ariaLabel={ariaLabel} ariaDescribedBy={describedByIds} onChange={(date) => { diff --git a/src/generic/datepicker-control/DatepickerControl.test.jsx b/src/generic/datepicker-control/DatepickerControl.test.jsx index 20b4614d65..df158cc2b6 100644 --- a/src/generic/datepicker-control/DatepickerControl.test.jsx +++ b/src/generic/datepicker-control/DatepickerControl.test.jsx @@ -28,10 +28,6 @@ describe('', () => { onChange: onChangeMock, }; - beforeEach(() => { - onChangeMock.mockClear(); - }); - it('renders without crashing', () => { const { getByText, queryAllByText, getByPlaceholderText } = render( , @@ -69,24 +65,4 @@ describe('', () => { ).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); - }); }); From f287668eeb4c0dc0d505609e9578d53699b0f15b Mon Sep 17 00:00:00 2001 From: asajjad2 Date: Mon, 15 Dec 2025 17:59:42 +0500 Subject: [PATCH 10/11] fix: timepicker label --- src/generic/datepicker-control/DatepickerControl.jsx | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/generic/datepicker-control/DatepickerControl.jsx b/src/generic/datepicker-control/DatepickerControl.jsx index 56c5230a56..9e5c01d36d 100644 --- a/src/generic/datepicker-control/DatepickerControl.jsx +++ b/src/generic/datepicker-control/DatepickerControl.jsx @@ -45,12 +45,8 @@ const DatepickerControl = ({ describedByIds = `${controlName}-helptext`; } - const ariaLabel = isTimePicker - ? intl.formatMessage(messages.timepickerAriaLabel) - : undefined; - return ( - + {label} {showUTC && ( @@ -68,6 +64,7 @@ const DatepickerControl = ({ /> )} { if (isValidDate(date)) { onChange(convertToStringFromDate(date)); From a3fbc183c6aaaaaaf189c016e0181b40e44bd968 Mon Sep 17 00:00:00 2001 From: asajjad2 Date: Tue, 16 Dec 2025 03:37:07 +0500 Subject: [PATCH 11/11] test: update coverage --- .../DatepickerControl.test.jsx | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/generic/datepicker-control/DatepickerControl.test.jsx b/src/generic/datepicker-control/DatepickerControl.test.jsx index df158cc2b6..77739b52ba 100644 --- a/src/generic/datepicker-control/DatepickerControl.test.jsx +++ b/src/generic/datepicker-control/DatepickerControl.test.jsx @@ -65,4 +65,23 @@ describe('', () => { ).toBeInTheDocument(); expect(input.getAttribute('aria-describedby')).toContain('fooControlName-timehint'); }); + + it('renders time picker with accessibility hint and help text', () => { + 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(getByText('This is help text')).toBeInTheDocument(); + expect(input.getAttribute('aria-describedby')).toContain('fooControlName-timehint'); + expect(input.getAttribute('aria-describedby')).toContain('fooControlName-helptext'); + }); });