Skip to content

Commit 0b9c1e0

Browse files
Merge pull request #6039 from asajjad2/areeb/add-keyboard-accessibility-timepicker
feat: add keyboard accessibility for timepicker
2 parents 1e39490 + 5517e0a commit 0b9c1e0

File tree

2 files changed

+770
-10
lines changed

2 files changed

+770
-10
lines changed

src/index.tsx

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
getMinutes,
1616
getHours,
1717
addDays,
18+
addMinutes,
1819
addMonths,
1920
addWeeks,
2021
subDays,
@@ -27,6 +28,7 @@ import {
2728
getEffectiveMinDate,
2829
getEffectiveMaxDate,
2930
parseDate,
31+
formatDate,
3032
safeDateFormat,
3133
safeDateRangeFormat,
3234
getHighLightDaysMap,
@@ -46,6 +48,7 @@ import {
4648
isDateBefore,
4749
getStartOfDay,
4850
getEndOfDay,
51+
isSameMinute,
4952
type HighlightDate,
5053
type HolidayItem,
5154
KeyType,
@@ -978,6 +981,165 @@ export class DatePicker extends Component<DatePickerProps, DatePickerState> {
978981
this.props.onInputClick?.();
979982
};
980983

984+
handleTimeOnlyArrowKey = (eventKey: string): void => {
985+
const currentTime =
986+
this.props.selected || this.state.preSelection || newDate();
987+
const timeIntervals = this.props.timeIntervals ?? 30;
988+
const dateFormat =
989+
this.props.dateFormat ?? DatePicker.defaultProps.dateFormat;
990+
const formatStr = Array.isArray(dateFormat) ? dateFormat[0] : dateFormat;
991+
992+
const baseDate = getStartOfDay(currentTime);
993+
const currentMinutes = getHours(currentTime) * 60 + getMinutes(currentTime);
994+
995+
const maxMinutes = 23 * 60 + 60 - timeIntervals; // Cap at last valid interval of the day
996+
let newTime: Date;
997+
if (eventKey === KeyType.ArrowUp) {
998+
const newMinutes = Math.max(0, currentMinutes - timeIntervals);
999+
newTime = addMinutes(baseDate, newMinutes);
1000+
} else {
1001+
const newMinutes = Math.min(maxMinutes, currentMinutes + timeIntervals);
1002+
newTime = addMinutes(baseDate, newMinutes);
1003+
}
1004+
1005+
const formattedTime = formatDate(
1006+
newTime,
1007+
formatStr || DatePicker.defaultProps.dateFormat,
1008+
this.props.locale,
1009+
);
1010+
this.setState({
1011+
preSelection: newTime,
1012+
inputValue: formattedTime,
1013+
});
1014+
1015+
if (this.props.selectsRange || this.props.selectsMultiple) {
1016+
return;
1017+
}
1018+
1019+
const selected = this.props.selected
1020+
? this.props.selected
1021+
: this.getPreSelection();
1022+
const changedDate = this.props.selected
1023+
? newTime
1024+
: setTime(selected, {
1025+
hour: getHours(newTime),
1026+
minute: getMinutes(newTime),
1027+
});
1028+
1029+
this.props.onChange?.(changedDate);
1030+
1031+
if (this.props.showTimeSelectOnly || this.props.showTimeSelect) {
1032+
this.setState({ isRenderAriaLiveMessage: true });
1033+
}
1034+
1035+
requestAnimationFrame(() => {
1036+
this.scrollToTimeOption(newTime);
1037+
});
1038+
};
1039+
1040+
handleTimeOnlyEnterKey = (event: React.KeyboardEvent<HTMLElement>): void => {
1041+
const inputElement = event.target as HTMLInputElement;
1042+
const inputValue = inputElement.value;
1043+
const dateFormat =
1044+
this.props.dateFormat ?? DatePicker.defaultProps.dateFormat;
1045+
const timeFormat = this.props.timeFormat || "p";
1046+
1047+
const defaultTime =
1048+
this.state.preSelection || this.props.selected || newDate();
1049+
const parsedDate = parseDate(
1050+
inputValue,
1051+
dateFormat,
1052+
this.props.locale,
1053+
this.props.strictParsing ?? false,
1054+
defaultTime,
1055+
);
1056+
1057+
let timeToCommit: Date = defaultTime;
1058+
1059+
if (parsedDate && isValid(parsedDate)) {
1060+
timeToCommit = parsedDate;
1061+
} else {
1062+
const highlightedItem =
1063+
this.calendar?.containerRef.current instanceof Element &&
1064+
this.calendar.containerRef.current.querySelector(
1065+
".react-datepicker__time-list-item[tabindex='0']",
1066+
);
1067+
1068+
if (highlightedItem instanceof HTMLElement) {
1069+
const itemText = highlightedItem.textContent?.trim();
1070+
if (itemText) {
1071+
const itemTime = parseDate(
1072+
itemText,
1073+
timeFormat,
1074+
this.props.locale,
1075+
false,
1076+
defaultTime,
1077+
);
1078+
if (itemTime && isValid(itemTime)) {
1079+
timeToCommit = itemTime;
1080+
}
1081+
}
1082+
}
1083+
}
1084+
1085+
this.handleTimeChange(timeToCommit);
1086+
this.setOpen(false);
1087+
this.sendFocusBackToInput();
1088+
};
1089+
1090+
scrollToTimeOption = (time: Date): void => {
1091+
if (!this.calendar?.containerRef.current) {
1092+
return;
1093+
}
1094+
1095+
const container = this.calendar.containerRef.current;
1096+
const timeListItems = Array.from(
1097+
container.querySelectorAll<HTMLLIElement>(
1098+
".react-datepicker__time-list-item",
1099+
),
1100+
);
1101+
1102+
let targetItem: HTMLLIElement | null = null;
1103+
let closestTimeDiff = Infinity;
1104+
const timeFormat = this.props.timeFormat || "p";
1105+
1106+
for (const item of timeListItems) {
1107+
const itemText = item.textContent?.trim();
1108+
if (itemText) {
1109+
const itemTime = parseDate(
1110+
itemText,
1111+
timeFormat,
1112+
this.props.locale,
1113+
false,
1114+
time,
1115+
);
1116+
if (itemTime && isValid(itemTime)) {
1117+
if (isSameMinute(itemTime, time)) {
1118+
targetItem = item;
1119+
break;
1120+
}
1121+
const timeDiff = Math.abs(itemTime.getTime() - time.getTime());
1122+
if (timeDiff < closestTimeDiff) {
1123+
closestTimeDiff = timeDiff;
1124+
targetItem = item;
1125+
}
1126+
}
1127+
}
1128+
}
1129+
1130+
if (targetItem) {
1131+
timeListItems.forEach((item) => {
1132+
item.setAttribute("tabindex", "-1");
1133+
});
1134+
targetItem.setAttribute("tabindex", "0");
1135+
1136+
targetItem.scrollIntoView({
1137+
behavior: "smooth",
1138+
block: "center",
1139+
});
1140+
}
1141+
};
1142+
9811143
onInputKeyDown = (event: React.KeyboardEvent<HTMLElement>): void => {
9821144
this.props.onKeyDown?.(event);
9831145
const eventKey = event.key;
@@ -997,6 +1159,20 @@ export class DatePicker extends Component<DatePickerProps, DatePickerState> {
9971159
return;
9981160
}
9991161

1162+
if (this.state.open && this.props.showTimeSelectOnly) {
1163+
if (eventKey === KeyType.ArrowDown || eventKey === KeyType.ArrowUp) {
1164+
event.preventDefault();
1165+
this.handleTimeOnlyArrowKey(eventKey);
1166+
return;
1167+
}
1168+
1169+
if (eventKey === KeyType.Enter) {
1170+
event.preventDefault();
1171+
this.handleTimeOnlyEnterKey(event);
1172+
return;
1173+
}
1174+
}
1175+
10001176
// if calendar is open, these keys will focus the selected item
10011177
if (this.state.open) {
10021178
if (eventKey === KeyType.ArrowDown || eventKey === KeyType.ArrowUp) {

0 commit comments

Comments
 (0)