diff --git a/packages/x-date-pickers/src/DigitalClock/DigitalClock.tsx b/packages/x-date-pickers/src/DigitalClock/DigitalClock.tsx index b9c0e5bd1941..d66141043dc6 100644 --- a/packages/x-date-pickers/src/DigitalClock/DigitalClock.tsx +++ b/packages/x-date-pickers/src/DigitalClock/DigitalClock.tsx @@ -21,6 +21,7 @@ import { DIGITAL_CLOCK_VIEW_HEIGHT } from '../internals/constants/dimensions'; import { useControlledValueWithTimezone } from '../internals/hooks/useValueWithTimezone'; import { singleItemValueManager } from '../internals/utils/valueManagers'; import { useClockReferenceDate } from '../internals/hooks/useClockReferenceDate'; +import { getFocusedListItemIndex } from '../internals/utils/utils'; const useUtilityClasses = (ownerState: DigitalClockProps) => { const { classes } = ownerState; @@ -115,6 +116,7 @@ export const DigitalClock = React.forwardRef(function DigitalClock(null); const handleRef = useForkRef(ref, containerRef); + const listRef = React.useRef(null); const props = useThemeProps({ props: inProps, @@ -294,6 +296,42 @@ export const DigitalClock = React.forwardRef(function DigitalClock { + switch (event.key) { + case 'PageUp': { + if (!listRef.current) { + return; + } + const newIndex = getFocusedListItemIndex(listRef.current) - 5; + const children = listRef.current?.children; + const newFocusedIndex = Math.max(0, newIndex); + + const childToFocus = children[newFocusedIndex]; + if (childToFocus) { + (childToFocus as HTMLElement).focus(); + } + event.preventDefault(); + break; + } + case 'PageDown': { + if (!listRef.current) { + return; + } + const newIndex = getFocusedListItemIndex(listRef.current) + 5; + const children = listRef.current?.children; + const newFocusedIndex = Math.min(children.length - 1, newIndex); + + const childToFocus = children[newFocusedIndex]; + if (childToFocus) { + (childToFocus as HTMLElement).focus(); + } + event.preventDefault(); + break; + } + default: + } + }; + return ( {timeOptions.map((option, index) => { if (skipDisabled && isTimeDisabled(option)) { diff --git a/packages/x-date-pickers/src/DigitalClock/tests/DigitalClock.test.tsx b/packages/x-date-pickers/src/DigitalClock/tests/DigitalClock.test.tsx index 96fb9bd96fa6..2358722b10bd 100644 --- a/packages/x-date-pickers/src/DigitalClock/tests/DigitalClock.test.tsx +++ b/packages/x-date-pickers/src/DigitalClock/tests/DigitalClock.test.tsx @@ -1,3 +1,4 @@ +/* eslint-disable material-ui/disallow-active-element-as-key-event-target */ import * as React from 'react'; import { expect } from 'chai'; import { spy } from 'sinon'; @@ -8,7 +9,7 @@ import { digitalClockHandler, formatFullTimeValue, } from 'test/utils/pickers'; -import { screen } from '@mui/internal-test-utils'; +import { fireEvent, screen } from '@mui/internal-test-utils'; describe('', () => { const { render } = createPickerRenderer(); @@ -92,6 +93,75 @@ describe('', () => { }); }); + describe('Keyboard support', () => { + it('should move focus up by 5 on PageUp press', () => { + const handleChange = spy(); + render(); + const options = screen.getAllByRole('option'); + const lastOptionIndex = options.length - 1; + + fireEvent.keyDown(document.activeElement!, { key: 'End' }); // moves focus to last element + fireEvent.keyDown(document.activeElement!, { key: 'PageUp' }); + + expect(handleChange.callCount).to.equal(0); + expect(document.activeElement).to.equal(options[lastOptionIndex - 5]); + + fireEvent.keyDown(options[lastOptionIndex - 5], { key: 'PageUp' }); + expect(handleChange.callCount).to.equal(0); + expect(document.activeElement).to.equal(options[lastOptionIndex - 10]); + }); + + it('should move focus to first item on PageUp press when current focused item index is among the first 5 items', () => { + const handleChange = spy(); + render(); + const options = screen.getAllByRole('option'); + + // moves focus to 4th element using arrow down + [0, 1, 2].forEach((index) => { + fireEvent.keyDown(options[index], { key: 'ArrowDown' }); + }); + + fireEvent.keyDown(options[3], { key: 'PageUp' }); + expect(handleChange.callCount).to.equal(0); + expect(document.activeElement).to.equal(options[0]); + }); + + it('should move focus down by 5 on PageDown press', () => { + const handleChange = spy(); + render(); + const options = screen.getAllByRole('option'); + + fireEvent.keyDown(options[0], { key: 'PageDown' }); + + expect(handleChange.callCount).to.equal(0); + expect(document.activeElement).to.equal(options[5]); + + fireEvent.keyDown(options[5], { key: 'PageDown' }); + + expect(handleChange.callCount).to.equal(0); + expect(document.activeElement).to.equal(options[10]); + }); + + it('should move focus to last item on PageDown press when current focused item index is among the last 5 items', () => { + const handleChange = spy(); + render(); + const options = screen.getAllByRole('option'); + const lastOptionIndex = options.length - 1; + + const lastElement = options[lastOptionIndex]; + + fireEvent.keyDown(document.activeElement!, { key: 'End' }); // moves focus to last element + // moves focus 4 steps above last item using arrow up + [0, 1, 2].forEach((index) => { + fireEvent.keyDown(options[lastOptionIndex - index], { key: 'ArrowUp' }); + }); + fireEvent.keyDown(options[lastOptionIndex - 3], { key: 'PageDown' }); + + expect(handleChange.callCount).to.equal(0); + expect(document.activeElement).to.equal(lastElement); + }); + }); + it('forwards list class to MenuList', () => { render(); diff --git a/packages/x-date-pickers/src/MultiSectionDigitalClock/MultiSectionDigitalClockSection.tsx b/packages/x-date-pickers/src/MultiSectionDigitalClock/MultiSectionDigitalClockSection.tsx index cbc6a60df7a0..f0311659b99f 100644 --- a/packages/x-date-pickers/src/MultiSectionDigitalClock/MultiSectionDigitalClockSection.tsx +++ b/packages/x-date-pickers/src/MultiSectionDigitalClock/MultiSectionDigitalClockSection.tsx @@ -18,6 +18,7 @@ import { DIGITAL_CLOCK_VIEW_HEIGHT, MULTI_SECTION_CLOCK_SECTION_WIDTH, } from '../internals/constants/dimensions'; +import { getFocusedListItemIndex } from '../internals/utils/utils'; export interface ExportedMultiSectionDigitalClockSectionProps { className?: string; @@ -187,6 +188,42 @@ export const MultiSectionDigitalClockSection = React.forwardRef( const focusedOptionIndex = items.findIndex((item) => item.isFocused(item.value)); + const handleKeyDown = (event: React.KeyboardEvent) => { + switch (event.key) { + case 'PageUp': { + if (!containerRef.current) { + return; + } + const newIndex = getFocusedListItemIndex(containerRef.current) - 5; + const children = containerRef.current?.children; + const newFocusedIndex = Math.max(0, newIndex); + + const childToFocus = children[newFocusedIndex]; + if (childToFocus) { + (childToFocus as HTMLElement).focus(); + } + event.preventDefault(); + break; + } + case 'PageDown': { + if (!containerRef.current) { + return; + } + const newIndex = getFocusedListItemIndex(containerRef.current) + 5; + const children = containerRef.current?.children; + const newFocusedIndex = Math.min(children.length - 1, newIndex); + + const childToFocus = children[newFocusedIndex]; + if (childToFocus) { + (childToFocus as HTMLElement).focus(); + } + event.preventDefault(); + break; + } + default: + } + }; + return ( {items.map((option, index) => { diff --git a/packages/x-date-pickers/src/MultiSectionDigitalClock/tests/MultiSectionDigitalClock.test.tsx b/packages/x-date-pickers/src/MultiSectionDigitalClock/tests/MultiSectionDigitalClock.test.tsx index 6229a42f0e22..f1701f1ba588 100644 --- a/packages/x-date-pickers/src/MultiSectionDigitalClock/tests/MultiSectionDigitalClock.test.tsx +++ b/packages/x-date-pickers/src/MultiSectionDigitalClock/tests/MultiSectionDigitalClock.test.tsx @@ -1,3 +1,4 @@ +/* eslint-disable material-ui/disallow-active-element-as-key-event-target */ import * as React from 'react'; import { expect } from 'chai'; import { spy } from 'sinon'; @@ -10,7 +11,7 @@ import { adapterToUse, multiSectionDigitalClockHandler, } from 'test/utils/pickers'; -import { screen } from '@mui/internal-test-utils'; +import { fireEvent, screen, within } from '@mui/internal-test-utils'; describe('', () => { const { render } = createPickerRenderer(); @@ -105,4 +106,78 @@ describe('', () => { expect(onChange.lastCall.firstArg).toEqualDateTime(new Date(2019, 0, 1, 15, 30)); }); }); + + describe('Keyboard support', () => { + it('should move item focus up by 5 on PageUp press', () => { + const handleChange = spy(); + render(); + const hoursSectionListbox = screen.getAllByRole('listbox')[0]; // get only hour section + const hoursOptions = within(hoursSectionListbox).getAllByRole('option'); + const lastOptionIndex = hoursOptions.length - 1; + + fireEvent.keyDown(document.activeElement!, { key: 'End' }); // moves focus to last element + fireEvent.keyDown(document.activeElement!, { key: 'PageUp' }); + + expect(handleChange.callCount).to.equal(0); + expect(document.activeElement).to.equal(hoursOptions[lastOptionIndex - 5]); + + fireEvent.keyDown(hoursOptions[lastOptionIndex - 5], { key: 'PageUp' }); + + expect(handleChange.callCount).to.equal(0); + expect(document.activeElement).to.equal(hoursOptions[lastOptionIndex - 10]); + }); + + it('should move focus to first item on PageUp press when current focused item index is among the first 5 items', () => { + const handleChange = spy(); + render(); + const hoursSectionListbox = screen.getAllByRole('listbox')[0]; // get only hour section + const hoursOptions = within(hoursSectionListbox).getAllByRole('option'); + + // moves focus to 4th element using arrow down + [0, 1, 2].forEach((index) => { + fireEvent.keyDown(hoursOptions[index], { key: 'ArrowDown' }); + }); + + fireEvent.keyDown(hoursOptions[3], { key: 'PageUp' }); + expect(handleChange.callCount).to.equal(0); + expect(document.activeElement).to.equal(hoursOptions[0]); + }); + + it('should move item focus down by 5 on PageDown press', () => { + const handleChange = spy(); + render(); + const hoursSectionListbox = screen.getAllByRole('listbox')[0]; // get only hour section + const hoursOptions = within(hoursSectionListbox).getAllByRole('option'); + + fireEvent.keyDown(hoursOptions[0], { key: 'PageDown' }); + + expect(handleChange.callCount).to.equal(0); + expect(document.activeElement).to.equal(hoursOptions[5]); + + fireEvent.keyDown(hoursOptions[5], { key: 'PageDown' }); + + expect(handleChange.callCount).to.equal(0); + expect(document.activeElement).to.equal(hoursOptions[10]); + }); + + it('should move focus to last item on PageDown press when current focused item index is among the last 5 items', () => { + const handleChange = spy(); + render(); + const hoursSectionListbox = screen.getAllByRole('listbox')[0]; // get only hour section + const hoursOptions = within(hoursSectionListbox).getAllByRole('option'); + const lastOptionIndex = hoursOptions.length - 1; + + const lastElement = hoursOptions[lastOptionIndex]; + + fireEvent.keyDown(document.activeElement!, { key: 'End' }); // moves focus to last element + // moves focus 4 steps above last item using arrow up + [0, 1, 2].forEach((index) => { + fireEvent.keyDown(hoursOptions[lastOptionIndex - index], { key: 'ArrowUp' }); + }); + + fireEvent.keyDown(hoursOptions[lastOptionIndex - 3], { key: 'PageDown' }); + expect(handleChange.callCount).to.equal(0); + expect(document.activeElement).to.equal(lastElement); + }); + }); }); diff --git a/packages/x-date-pickers/src/TimeClock/Clock.tsx b/packages/x-date-pickers/src/TimeClock/Clock.tsx index 3d12f498d668..4e7c3f48ba23 100644 --- a/packages/x-date-pickers/src/TimeClock/Clock.tsx +++ b/packages/x-date-pickers/src/TimeClock/Clock.tsx @@ -332,6 +332,14 @@ export function Clock(inProps: ClockProps) handleValueChange(viewValue - keyboardControlStep, 'partial'); event.preventDefault(); break; + case 'PageUp': + handleValueChange(viewValue + 5, 'partial'); + event.preventDefault(); + break; + case 'PageDown': + handleValueChange(viewValue - 5, 'partial'); + event.preventDefault(); + break; case 'Enter': case ' ': handleValueChange(viewValue, 'finish'); diff --git a/packages/x-date-pickers/src/TimeClock/TimeClock.tsx b/packages/x-date-pickers/src/TimeClock/TimeClock.tsx index 699514c015e4..df5213454067 100644 --- a/packages/x-date-pickers/src/TimeClock/TimeClock.tsx +++ b/packages/x-date-pickers/src/TimeClock/TimeClock.tsx @@ -185,13 +185,11 @@ export const TimeClock = React.forwardRef(function TimeClock', () => { expect(reason).to.equal('partial'); }); + it('should increase hour selection by 5 on PageUp press', () => { + const handleChange = spy(); + render( + , + ); + const listbox = screen.getByRole('listbox'); + + fireEvent.keyDown(listbox, { key: 'PageUp' }); + + expect(handleChange.callCount).to.equal(1); + const [newDate, reason] = handleChange.firstCall.args; + expect(adapterToUse.getHours(newDate)).to.equal(3); + expect(adapterToUse.getMinutes(newDate)).to.equal(20); + expect(reason).to.equal('partial'); + }); + + it('should decrease hour selection by 5 on PageDown press', () => { + const handleChange = spy(); + render( + , + ); + const listbox = screen.getByRole('listbox'); + + fireEvent.keyDown(listbox, { key: 'PageDown' }); + + expect(handleChange.callCount).to.equal(1); + const [newDate, reason] = handleChange.firstCall.args; + expect(adapterToUse.getHours(newDate)).to.equal(21); + expect(adapterToUse.getMinutes(newDate)).to.equal(20); + expect(reason).to.equal('partial'); + }); + [ { keyName: 'Enter', diff --git a/packages/x-date-pickers/src/internals/utils/utils.ts b/packages/x-date-pickers/src/internals/utils/utils.ts index 6923ac9aeef0..56d7b2240630 100644 --- a/packages/x-date-pickers/src/internals/utils/utils.ts +++ b/packages/x-date-pickers/src/internals/utils/utils.ts @@ -47,4 +47,15 @@ export const getActiveElement = (root: Document | ShadowRoot = document): Elemen return activeEl; }; +/** + * Gets the index of the focused list item in a given ul list element. + * + * @param {HTMLUListElement} listElement - The list element to search within. + * @returns {number} The index of the focused list item, or -1 if none is focused. + */ +export const getFocusedListItemIndex = (listElement: HTMLUListElement): number => { + const children = listElement.children; + return Array.from(children).findIndex((child) => child === getActiveElement(document)); +}; + export const DEFAULT_DESKTOP_MODE_MEDIA_QUERY = '@media (pointer: fine)';