Skip to content

Commit 033d014

Browse files
committed
feat: add keyboard accessibility for timepicker
1 parent 1122aba commit 033d014

File tree

1 file changed

+174
-0
lines changed

1 file changed

+174
-0
lines changed

src/index.tsx

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

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

0 commit comments

Comments
 (0)