Skip to content

Commit 4bc5acc

Browse files
[pickers] Add PageUp and PageDown support for time components (#14812)
1 parent ab03165 commit 4bc5acc

File tree

8 files changed

+284
-4
lines changed

8 files changed

+284
-4
lines changed

packages/x-date-pickers/src/DigitalClock/DigitalClock.tsx

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { DIGITAL_CLOCK_VIEW_HEIGHT } from '../internals/constants/dimensions';
2121
import { useControlledValueWithTimezone } from '../internals/hooks/useValueWithTimezone';
2222
import { singleItemValueManager } from '../internals/utils/valueManagers';
2323
import { useClockReferenceDate } from '../internals/hooks/useClockReferenceDate';
24+
import { getFocusedListItemIndex } from '../internals/utils/utils';
2425

2526
const useUtilityClasses = (ownerState: DigitalClockProps<any>) => {
2627
const { classes } = ownerState;
@@ -115,6 +116,7 @@ export const DigitalClock = React.forwardRef(function DigitalClock<TDate extends
115116

116117
const containerRef = React.useRef<HTMLDivElement>(null);
117118
const handleRef = useForkRef(ref, containerRef);
119+
const listRef = React.useRef<HTMLUListElement>(null);
118120

119121
const props = useThemeProps({
120122
props: inProps,
@@ -294,6 +296,42 @@ export const DigitalClock = React.forwardRef(function DigitalClock<TDate extends
294296
utils.isEqual(option, valueOrReferenceDate),
295297
);
296298

299+
const handleKeyDown = (event: React.KeyboardEvent) => {
300+
switch (event.key) {
301+
case 'PageUp': {
302+
if (!listRef.current) {
303+
return;
304+
}
305+
const newIndex = getFocusedListItemIndex(listRef.current) - 5;
306+
const children = listRef.current?.children;
307+
const newFocusedIndex = Math.max(0, newIndex);
308+
309+
const childToFocus = children[newFocusedIndex];
310+
if (childToFocus) {
311+
(childToFocus as HTMLElement).focus();
312+
}
313+
event.preventDefault();
314+
break;
315+
}
316+
case 'PageDown': {
317+
if (!listRef.current) {
318+
return;
319+
}
320+
const newIndex = getFocusedListItemIndex(listRef.current) + 5;
321+
const children = listRef.current?.children;
322+
const newFocusedIndex = Math.min(children.length - 1, newIndex);
323+
324+
const childToFocus = children[newFocusedIndex];
325+
if (childToFocus) {
326+
(childToFocus as HTMLElement).focus();
327+
}
328+
event.preventDefault();
329+
break;
330+
}
331+
default:
332+
}
333+
};
334+
297335
return (
298336
<DigitalClockRoot
299337
ref={handleRef}
@@ -302,9 +340,11 @@ export const DigitalClock = React.forwardRef(function DigitalClock<TDate extends
302340
{...other}
303341
>
304342
<DigitalClockList
343+
ref={listRef}
305344
role="listbox"
306345
aria-label={translations.timePickerToolbarTitle}
307346
className={classes.list}
347+
onKeyDown={handleKeyDown}
308348
>
309349
{timeOptions.map((option, index) => {
310350
if (skipDisabled && isTimeDisabled(option)) {

packages/x-date-pickers/src/DigitalClock/tests/DigitalClock.test.tsx

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
/* eslint-disable material-ui/disallow-active-element-as-key-event-target */
12
import * as React from 'react';
23
import { expect } from 'chai';
34
import { spy } from 'sinon';
@@ -8,7 +9,7 @@ import {
89
digitalClockHandler,
910
formatFullTimeValue,
1011
} from 'test/utils/pickers';
11-
import { screen } from '@mui/internal-test-utils';
12+
import { fireEvent, screen } from '@mui/internal-test-utils';
1213

1314
describe('<DigitalClock />', () => {
1415
const { render } = createPickerRenderer();
@@ -92,6 +93,75 @@ describe('<DigitalClock />', () => {
9293
});
9394
});
9495

96+
describe('Keyboard support', () => {
97+
it('should move focus up by 5 on PageUp press', () => {
98+
const handleChange = spy();
99+
render(<DigitalClock autoFocus onChange={handleChange} />);
100+
const options = screen.getAllByRole('option');
101+
const lastOptionIndex = options.length - 1;
102+
103+
fireEvent.keyDown(document.activeElement!, { key: 'End' }); // moves focus to last element
104+
fireEvent.keyDown(document.activeElement!, { key: 'PageUp' });
105+
106+
expect(handleChange.callCount).to.equal(0);
107+
expect(document.activeElement).to.equal(options[lastOptionIndex - 5]);
108+
109+
fireEvent.keyDown(options[lastOptionIndex - 5], { key: 'PageUp' });
110+
expect(handleChange.callCount).to.equal(0);
111+
expect(document.activeElement).to.equal(options[lastOptionIndex - 10]);
112+
});
113+
114+
it('should move focus to first item on PageUp press when current focused item index is among the first 5 items', () => {
115+
const handleChange = spy();
116+
render(<DigitalClock autoFocus onChange={handleChange} />);
117+
const options = screen.getAllByRole('option');
118+
119+
// moves focus to 4th element using arrow down
120+
[0, 1, 2].forEach((index) => {
121+
fireEvent.keyDown(options[index], { key: 'ArrowDown' });
122+
});
123+
124+
fireEvent.keyDown(options[3], { key: 'PageUp' });
125+
expect(handleChange.callCount).to.equal(0);
126+
expect(document.activeElement).to.equal(options[0]);
127+
});
128+
129+
it('should move focus down by 5 on PageDown press', () => {
130+
const handleChange = spy();
131+
render(<DigitalClock autoFocus onChange={handleChange} />);
132+
const options = screen.getAllByRole('option');
133+
134+
fireEvent.keyDown(options[0], { key: 'PageDown' });
135+
136+
expect(handleChange.callCount).to.equal(0);
137+
expect(document.activeElement).to.equal(options[5]);
138+
139+
fireEvent.keyDown(options[5], { key: 'PageDown' });
140+
141+
expect(handleChange.callCount).to.equal(0);
142+
expect(document.activeElement).to.equal(options[10]);
143+
});
144+
145+
it('should move focus to last item on PageDown press when current focused item index is among the last 5 items', () => {
146+
const handleChange = spy();
147+
render(<DigitalClock autoFocus onChange={handleChange} />);
148+
const options = screen.getAllByRole('option');
149+
const lastOptionIndex = options.length - 1;
150+
151+
const lastElement = options[lastOptionIndex];
152+
153+
fireEvent.keyDown(document.activeElement!, { key: 'End' }); // moves focus to last element
154+
// moves focus 4 steps above last item using arrow up
155+
[0, 1, 2].forEach((index) => {
156+
fireEvent.keyDown(options[lastOptionIndex - index], { key: 'ArrowUp' });
157+
});
158+
fireEvent.keyDown(options[lastOptionIndex - 3], { key: 'PageDown' });
159+
160+
expect(handleChange.callCount).to.equal(0);
161+
expect(document.activeElement).to.equal(lastElement);
162+
});
163+
});
164+
95165
it('forwards list class to MenuList', () => {
96166
render(<DigitalClock classes={{ list: 'foo' }} />);
97167

packages/x-date-pickers/src/MultiSectionDigitalClock/MultiSectionDigitalClockSection.tsx

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
DIGITAL_CLOCK_VIEW_HEIGHT,
1919
MULTI_SECTION_CLOCK_SECTION_WIDTH,
2020
} from '../internals/constants/dimensions';
21+
import { getFocusedListItemIndex } from '../internals/utils/utils';
2122

2223
export interface ExportedMultiSectionDigitalClockSectionProps {
2324
className?: string;
@@ -187,13 +188,50 @@ export const MultiSectionDigitalClockSection = React.forwardRef(
187188

188189
const focusedOptionIndex = items.findIndex((item) => item.isFocused(item.value));
189190

191+
const handleKeyDown = (event: React.KeyboardEvent) => {
192+
switch (event.key) {
193+
case 'PageUp': {
194+
if (!containerRef.current) {
195+
return;
196+
}
197+
const newIndex = getFocusedListItemIndex(containerRef.current) - 5;
198+
const children = containerRef.current?.children;
199+
const newFocusedIndex = Math.max(0, newIndex);
200+
201+
const childToFocus = children[newFocusedIndex];
202+
if (childToFocus) {
203+
(childToFocus as HTMLElement).focus();
204+
}
205+
event.preventDefault();
206+
break;
207+
}
208+
case 'PageDown': {
209+
if (!containerRef.current) {
210+
return;
211+
}
212+
const newIndex = getFocusedListItemIndex(containerRef.current) + 5;
213+
const children = containerRef.current?.children;
214+
const newFocusedIndex = Math.min(children.length - 1, newIndex);
215+
216+
const childToFocus = children[newFocusedIndex];
217+
if (childToFocus) {
218+
(childToFocus as HTMLElement).focus();
219+
}
220+
event.preventDefault();
221+
break;
222+
}
223+
default:
224+
}
225+
};
226+
190227
return (
191228
<MultiSectionDigitalClockSectionRoot
192229
ref={handleRef}
193230
className={clsx(classes.root, className)}
194231
ownerState={ownerState}
195232
autoFocusItem={autoFocus && active}
196233
role="listbox"
234+
onKeyDown={handleKeyDown}
197235
{...other}
198236
>
199237
{items.map((option, index) => {

packages/x-date-pickers/src/MultiSectionDigitalClock/tests/MultiSectionDigitalClock.test.tsx

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
/* eslint-disable material-ui/disallow-active-element-as-key-event-target */
12
import * as React from 'react';
23
import { expect } from 'chai';
34
import { spy } from 'sinon';
@@ -10,7 +11,7 @@ import {
1011
adapterToUse,
1112
multiSectionDigitalClockHandler,
1213
} from 'test/utils/pickers';
13-
import { screen } from '@mui/internal-test-utils';
14+
import { fireEvent, screen, within } from '@mui/internal-test-utils';
1415

1516
describe('<MultiSectionDigitalClock />', () => {
1617
const { render } = createPickerRenderer();
@@ -105,4 +106,78 @@ describe('<MultiSectionDigitalClock />', () => {
105106
expect(onChange.lastCall.firstArg).toEqualDateTime(new Date(2019, 0, 1, 15, 30));
106107
});
107108
});
109+
110+
describe('Keyboard support', () => {
111+
it('should move item focus up by 5 on PageUp press', () => {
112+
const handleChange = spy();
113+
render(<MultiSectionDigitalClock autoFocus onChange={handleChange} />);
114+
const hoursSectionListbox = screen.getAllByRole('listbox')[0]; // get only hour section
115+
const hoursOptions = within(hoursSectionListbox).getAllByRole('option');
116+
const lastOptionIndex = hoursOptions.length - 1;
117+
118+
fireEvent.keyDown(document.activeElement!, { key: 'End' }); // moves focus to last element
119+
fireEvent.keyDown(document.activeElement!, { key: 'PageUp' });
120+
121+
expect(handleChange.callCount).to.equal(0);
122+
expect(document.activeElement).to.equal(hoursOptions[lastOptionIndex - 5]);
123+
124+
fireEvent.keyDown(hoursOptions[lastOptionIndex - 5], { key: 'PageUp' });
125+
126+
expect(handleChange.callCount).to.equal(0);
127+
expect(document.activeElement).to.equal(hoursOptions[lastOptionIndex - 10]);
128+
});
129+
130+
it('should move focus to first item on PageUp press when current focused item index is among the first 5 items', () => {
131+
const handleChange = spy();
132+
render(<MultiSectionDigitalClock autoFocus onChange={handleChange} />);
133+
const hoursSectionListbox = screen.getAllByRole('listbox')[0]; // get only hour section
134+
const hoursOptions = within(hoursSectionListbox).getAllByRole('option');
135+
136+
// moves focus to 4th element using arrow down
137+
[0, 1, 2].forEach((index) => {
138+
fireEvent.keyDown(hoursOptions[index], { key: 'ArrowDown' });
139+
});
140+
141+
fireEvent.keyDown(hoursOptions[3], { key: 'PageUp' });
142+
expect(handleChange.callCount).to.equal(0);
143+
expect(document.activeElement).to.equal(hoursOptions[0]);
144+
});
145+
146+
it('should move item focus down by 5 on PageDown press', () => {
147+
const handleChange = spy();
148+
render(<MultiSectionDigitalClock autoFocus onChange={handleChange} />);
149+
const hoursSectionListbox = screen.getAllByRole('listbox')[0]; // get only hour section
150+
const hoursOptions = within(hoursSectionListbox).getAllByRole('option');
151+
152+
fireEvent.keyDown(hoursOptions[0], { key: 'PageDown' });
153+
154+
expect(handleChange.callCount).to.equal(0);
155+
expect(document.activeElement).to.equal(hoursOptions[5]);
156+
157+
fireEvent.keyDown(hoursOptions[5], { key: 'PageDown' });
158+
159+
expect(handleChange.callCount).to.equal(0);
160+
expect(document.activeElement).to.equal(hoursOptions[10]);
161+
});
162+
163+
it('should move focus to last item on PageDown press when current focused item index is among the last 5 items', () => {
164+
const handleChange = spy();
165+
render(<MultiSectionDigitalClock autoFocus onChange={handleChange} />);
166+
const hoursSectionListbox = screen.getAllByRole('listbox')[0]; // get only hour section
167+
const hoursOptions = within(hoursSectionListbox).getAllByRole('option');
168+
const lastOptionIndex = hoursOptions.length - 1;
169+
170+
const lastElement = hoursOptions[lastOptionIndex];
171+
172+
fireEvent.keyDown(document.activeElement!, { key: 'End' }); // moves focus to last element
173+
// moves focus 4 steps above last item using arrow up
174+
[0, 1, 2].forEach((index) => {
175+
fireEvent.keyDown(hoursOptions[lastOptionIndex - index], { key: 'ArrowUp' });
176+
});
177+
178+
fireEvent.keyDown(hoursOptions[lastOptionIndex - 3], { key: 'PageDown' });
179+
expect(handleChange.callCount).to.equal(0);
180+
expect(document.activeElement).to.equal(lastElement);
181+
});
182+
});
108183
});

packages/x-date-pickers/src/TimeClock/Clock.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,14 @@ export function Clock<TDate extends PickerValidDate>(inProps: ClockProps<TDate>)
332332
handleValueChange(viewValue - keyboardControlStep, 'partial');
333333
event.preventDefault();
334334
break;
335+
case 'PageUp':
336+
handleValueChange(viewValue + 5, 'partial');
337+
event.preventDefault();
338+
break;
339+
case 'PageDown':
340+
handleValueChange(viewValue - 5, 'partial');
341+
event.preventDefault();
342+
break;
335343
case 'Enter':
336344
case ' ':
337345
handleValueChange(viewValue, 'finish');

packages/x-date-pickers/src/TimeClock/TimeClock.tsx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -185,13 +185,11 @@ export const TimeClock = React.forwardRef(function TimeClock<TDate extends Picke
185185
utils.setMinutes(valueOrReferenceDate, timeValue),
186186
'minutes',
187187
);
188-
189188
case 'seconds':
190189
return !shouldDisableTime(
191190
utils.setSeconds(valueOrReferenceDate, timeValue),
192191
'seconds',
193192
);
194-
195193
default:
196194
return false;
197195
}

packages/x-date-pickers/src/TimeClock/tests/TimeClock.test.tsx

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,46 @@ describe('<TimeClock />', () => {
139139
expect(reason).to.equal('partial');
140140
});
141141

142+
it('should increase hour selection by 5 on PageUp press', () => {
143+
const handleChange = spy();
144+
render(
145+
<TimeClock
146+
autoFocus
147+
value={adapterToUse.date('2019-01-01T22:20:00')}
148+
onChange={handleChange}
149+
/>,
150+
);
151+
const listbox = screen.getByRole('listbox');
152+
153+
fireEvent.keyDown(listbox, { key: 'PageUp' });
154+
155+
expect(handleChange.callCount).to.equal(1);
156+
const [newDate, reason] = handleChange.firstCall.args;
157+
expect(adapterToUse.getHours(newDate)).to.equal(3);
158+
expect(adapterToUse.getMinutes(newDate)).to.equal(20);
159+
expect(reason).to.equal('partial');
160+
});
161+
162+
it('should decrease hour selection by 5 on PageDown press', () => {
163+
const handleChange = spy();
164+
render(
165+
<TimeClock
166+
autoFocus
167+
value={adapterToUse.date('2019-01-01T02:20:00')}
168+
onChange={handleChange}
169+
/>,
170+
);
171+
const listbox = screen.getByRole('listbox');
172+
173+
fireEvent.keyDown(listbox, { key: 'PageDown' });
174+
175+
expect(handleChange.callCount).to.equal(1);
176+
const [newDate, reason] = handleChange.firstCall.args;
177+
expect(adapterToUse.getHours(newDate)).to.equal(21);
178+
expect(adapterToUse.getMinutes(newDate)).to.equal(20);
179+
expect(reason).to.equal('partial');
180+
});
181+
142182
[
143183
{
144184
keyName: 'Enter',

0 commit comments

Comments
 (0)