Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(DateRangeInput3) Add keyboard accessibility #7080

Merged
merged 3 commits into from
Nov 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,13 @@ import type {
DateRangeInput3PropsWithDefaults,
} from "./dateRangeInput3Props";
import type { DateRangeInput3State } from "./dateRangeInput3State";
import {
clampDate,
getTodayAtMidnight,
isEntireInputSelected,
shiftDateByArrowKey,
shiftDateByDays,
} from "./dateRangeInputUilts";

export type { DateRangeInput3Props };

Expand Down Expand Up @@ -465,7 +472,7 @@ export class DateRangeInput3 extends DateFnsLocalizedComponent<DateRangeInput3Pr
break;
case "keydown":
e = e as React.KeyboardEvent<HTMLInputElement>;
this.handleInputKeyDown(e);
this.handleInputKeyDown(e, boundary);
inputProps?.onKeyDown?.(e);
break;
case "mousedown":
Expand All @@ -481,14 +488,21 @@ export class DateRangeInput3 extends DateFnsLocalizedComponent<DateRangeInput3Pr
// add a keydown listener to persistently change focus when tabbing:
// - if focused in start field, Tab moves focus to end field
// - if focused in end field, Shift+Tab moves focus to start field
private handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
private handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>, boundary: Boundary) => {
const isArrowKeyPresssed =
e.key === "ArrowUp" || e.key === "ArrowDown" || e.key === "ArrowLeft" || e.key === "ArrowRight";
const isTabPressed = e.key === "Tab";
const isEnterPressed = e.key === "Enter";
const isEscapeKeyPressed = e.key === "Escape";
const isShiftPressed = e.shiftKey;

const { selectedStart, selectedEnd } = this.state;

if (isArrowKeyPresssed) {
this.handleInputArrowKeyDown(e, boundary);
return;
}

if (isEscapeKeyPressed) {
this.startInputElement?.blur();
this.endInputElement?.blur();
Expand Down Expand Up @@ -545,6 +559,79 @@ export class DateRangeInput3 extends DateFnsLocalizedComponent<DateRangeInput3Pr
}
};

private handleInputArrowKeyDown = (e: React.KeyboardEvent<HTMLInputElement>, boundary: Boundary) => {
const { minDate, maxDate } = this.props;
const { isOpen } = this.state;
const inputElement = boundary === Boundary.START ? this.startInputElement : this.endInputElement;

if (!isOpen || !isEntireInputSelected(inputElement)) {
return;
}

const shiftedDate =
this.getNextDateForArrowKeyNavigation(e.key, boundary) ??
this.getDefaultDateForArrowKeyNavigation(e.key, boundary);

if (shiftedDate == null) {
return;
}

// We've commited to moving the selection, prevent the default arrow key interactions
e.preventDefault();

const clampedDate = clampDate(shiftedDate, minDate, maxDate);
const { keys } = this.getStateKeysAndValuesForBoundary(boundary);
const nextState: Partial<DateRangeInput3State> = {
[keys.inputString]: this.formatDate(clampedDate),
shouldSelectAfterUpdate: true,
};

if (!this.isControlled()) {
nextState[keys.selectedValue] = clampedDate;
}

this.props.onChange?.(this.getDateRangeForCallback(clampedDate, boundary));
this.setState(nextState);
};

private getNextDateForArrowKeyNavigation(arrowKey: string, boundary: Boundary) {
const { allowSingleDayRange } = this.props;
const [selectedStart, selectedEnd] = this.getSelectedRange();
const initialDate = boundary === Boundary.START ? selectedStart : selectedEnd;
if (initialDate == null) {
return undefined;
}

const relativeDate = shiftDateByArrowKey(initialDate, arrowKey);

// Ensure that we don't move onto a single day range selection if that is disallowed
const adjustedStart =
selectedStart == null || allowSingleDayRange ? selectedStart : shiftDateByDays(selectedStart, 1);
const adjustedEnd = selectedEnd == null || allowSingleDayRange ? selectedEnd : shiftDateByDays(selectedEnd, -1);
ggdouglas marked this conversation as resolved.
Show resolved Hide resolved

return boundary === Boundary.START
jscheiny marked this conversation as resolved.
Show resolved Hide resolved
? clampDate(relativeDate, undefined, adjustedEnd)
: clampDate(relativeDate, adjustedStart, undefined);
}

private getDefaultDateForArrowKeyNavigation(arrowKey: string, boundary: Boundary) {
const [selectedStart, selectedEnd] = this.getSelectedRange();
const otherBoundary = boundary === Boundary.START ? selectedEnd : selectedStart;

if (otherBoundary == null) {
return getTodayAtMidnight();
}

const isForwardArrowKey = arrowKey === "ArrowRight" || arrowKey === "ArrowDown";
// If the arrow key direction is in the same direction as the boundary, then moving that way will not create an
// overlapping date range
if (isForwardArrowKey === (boundary === Boundary.END)) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The equivalence of a boolean to a boolean here reads a bit weird. Can we change this to something like:

const isForwardArrowKey = arrowKey === "ArrowRight" || arrowKey === "ArrowDown";
const isEndBoundary = boundary === Boundary.END;
// If the arrow key direction is in the same direction as the boundary, then moving that way will not create an
// overlapping date range
if (isForwardArrowKey && isEndBoundary) {
    return shiftDateByArrowKey(otherBoundary, arrowKey);
}

Copy link
Contributor Author

@jscheiny jscheiny Nov 21, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We still need equality here for this to work properly, isForwardArrowKey && isEndBoundary is not equivalent to isForwardArrowKey === isEndBoundary. I thought about doing something like:

const arrowKeyDirection = arrowKey === "ArrowRight" || arrowKey === "ArrowDown" ? "forward" : "backward";
const boundaryDirection = boundary === Boundary.END ? "forward" : "backward";
if (arrowKeyDirection === boundaryDirection) {

But this is just a lot of extra stuff for the same thing and minimal readability improvement. I'll pull isEndBoundary out I like that, but I'm not sure how to make it much more readable beyond that.

return shiftDateByArrowKey(otherBoundary, arrowKey);
}

return undefined;
}

private handleInputMouseDown = () => {
// clicking in the field constitutes an explicit focus change. we update
// the flag on "mousedown" instead of on "click", because it needs to be
Expand Down Expand Up @@ -692,7 +779,7 @@ export class DateRangeInput3 extends DateFnsLocalizedComponent<DateRangeInput3Pr
return false;
}

const fallbackDate = new Date(new Date().setHours(0, 0, 0, 0));
const fallbackDate = getTodayAtMidnight();
const [selectedStart, selectedEnd] = this.getSelectedRange([fallbackDate, fallbackDate]);

// case to check if the user has changed TimePicker values
Expand Down Expand Up @@ -928,7 +1015,7 @@ export class DateRangeInput3 extends DateFnsLocalizedComponent<DateRangeInput3Pr
newDate = getDateFnsParser(format, locale)(dateString);
}

return newDate === false ? new Date() : newDate;
return newDate === false ? getTodayAtMidnight() : newDate;
};

// called on date hover & selection
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/* !
* (c) Copyright 2024 Palantir Technologies Inc. All rights reserved.
*/

const DAY_IN_MILLIS = 1000 * 60 * 60 * 24;
const WEEK_IN_MILLIS = 7 * DAY_IN_MILLIS;

export function getTodayAtMidnight() {
return new Date(new Date().setHours(0, 0, 0, 0));
}

export function shiftDateByDays(date: Date, days: number): Date {
return new Date(date.valueOf() + days * DAY_IN_MILLIS);
}

export function shiftDateByWeeks(date: Date, weeks: number): Date {
return new Date(date.valueOf() + weeks * WEEK_IN_MILLIS);
}

export function shiftDateByArrowKey(date: Date, key: string): Date {
switch (key) {
case "ArrowUp":
return shiftDateByWeeks(date, -1);
case "ArrowDown":
return shiftDateByWeeks(date, 1);
case "ArrowLeft":
return shiftDateByDays(date, -1);
case "ArrowRight":
return shiftDateByDays(date, 1);
default:
return date;
}
}

export function clampDate(date: Date, minDate: Date | null | undefined, maxDate: Date | null | undefined) {
let result = date;
if (minDate != null && date < minDate) {
result = minDate;
}
if (maxDate != null && date > maxDate) {
result = maxDate;
}
return result;
}

export function isEntireInputSelected(element: HTMLInputElement | null) {
if (element == null) {
return false;
}

return element.selectionStart === 0 && element.selectionEnd === element.value.length;
}
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ function useContiguousCalendarViews(

const nextRangeStart = MonthAndYear.fromDate(selectedRange[0]);
const nextRangeEnd = MonthAndYear.fromDate(selectedRange[1]);
const hasSelectionEndChanged = prevSelectedRange.current[0]?.valueOf() === selectedRange[0]?.valueOf();

if (nextRangeStart == null && nextRangeEnd != null) {
// Only end date selected.
Expand Down Expand Up @@ -175,8 +176,11 @@ function useContiguousCalendarViews(
} else {
newDisplayMonth = nextRangeStart;
}
} else if (hasSelectionEndChanged) {
// If the selection end has changed, adjust the view to show the new end date
newDisplayMonth = singleMonthOnly ? nextRangeEnd : nextRangeEnd.getPreviousMonth();
} else {
// Different start and end date months, adjust display months.
// Otherwise, the selection start must have changed, show that
newDisplayMonth = nextRangeStart;
}
}
Expand Down
Loading