From 1dba1664adc09886a08011910695294dd7a9abee Mon Sep 17 00:00:00 2001 From: Jonah Scheinerman Date: Tue, 19 Nov 2024 15:09:18 -0500 Subject: [PATCH 1/3] feat(DateRangeInput3) Add keyboard accessibility --- .../date-range-input3/dateRangeInput3.tsx | 70 ++++- .../date-range-input3/dateRangeInputUilts.ts | 48 +++ .../contiguousDayRangePicker.tsx | 6 +- .../test/components/dateRangeInput3Tests.tsx | 284 +++++++++++++++++- 4 files changed, 401 insertions(+), 7 deletions(-) create mode 100644 packages/datetime2/src/components/date-range-input3/dateRangeInputUilts.ts diff --git a/packages/datetime2/src/components/date-range-input3/dateRangeInput3.tsx b/packages/datetime2/src/components/date-range-input3/dateRangeInput3.tsx index 655112a80b8..cb541902cf1 100644 --- a/packages/datetime2/src/components/date-range-input3/dateRangeInput3.tsx +++ b/packages/datetime2/src/components/date-range-input3/dateRangeInput3.tsx @@ -52,6 +52,7 @@ import type { DateRangeInput3PropsWithDefaults, } from "./dateRangeInput3Props"; import type { DateRangeInput3State } from "./dateRangeInput3State"; +import { clampDate, isEntireInputSelected, shiftDateByArrowKey, shiftDateByDays } from "./dateRangeInputUilts"; export type { DateRangeInput3Props }; @@ -465,7 +466,7 @@ export class DateRangeInput3 extends DateFnsLocalizedComponent; - this.handleInputKeyDown(e); + this.handleInputKeyDown(e, boundary); inputProps?.onKeyDown?.(e); break; case "mousedown": @@ -481,7 +482,9 @@ export class DateRangeInput3 extends DateFnsLocalizedComponent) => { + private handleInputKeyDown = (e: React.KeyboardEvent, 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"; @@ -489,6 +492,11 @@ export class DateRangeInput3 extends DateFnsLocalizedComponent, boundary: Boundary) => { + const { isOpen } = this.state; + const inputElement = boundary === Boundary.START ? this.startInputElement : this.endInputElement; + + if (!isOpen || !isEntireInputSelected(inputElement)) { + return; + } + + // We've commited to moving the selection, prevent the default arrow key interactions + e.preventDefault(); + + const newDate = + this.getNextDateForArrowKeyNavigation(e.key, boundary) ?? + this.getDefaultDateForArrowKeyNavigation(e.key, boundary); + + const { keys } = this.getStateKeysAndValuesForBoundary(boundary); + const nextState: Partial = { + [keys.inputString]: this.formatDate(newDate), + shouldSelectAfterUpdate: true, + }; + + if (!this.isControlled()) { + nextState[keys.selectedValue] = newDate; + } + + this.props.onChange?.(this.getDateRangeForCallback(newDate, boundary)); + this.setState(nextState); + }; + + private getNextDateForArrowKeyNavigation(arrowKey: string, boundary: Boundary) { + const { allowSingleDayRange, maxDate, minDate } = 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); + + return boundary === Boundary.START + ? clampDate(relativeDate, minDate, adjustedEnd) + : clampDate(relativeDate, adjustedStart, maxDate); + } + + private getDefaultDateForArrowKeyNavigation(arrowKey: string, boundary: Boundary) { + const { maxDate, minDate } = this.props; + const [selectedStart, selectedEnd] = this.getSelectedRange(); + const otherBoundary = boundary === Boundary.START ? selectedEnd : selectedStart; + + const selectedDate = otherBoundary == null ? new Date() : shiftDateByArrowKey(otherBoundary, arrowKey); + return clampDate(selectedDate, minDate, maxDate); + } + 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 diff --git a/packages/datetime2/src/components/date-range-input3/dateRangeInputUilts.ts b/packages/datetime2/src/components/date-range-input3/dateRangeInputUilts.ts new file mode 100644 index 00000000000..7df2b58fbdf --- /dev/null +++ b/packages/datetime2/src/components/date-range-input3/dateRangeInputUilts.ts @@ -0,0 +1,48 @@ +/* ! + * (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 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; +} diff --git a/packages/datetime2/src/components/date-range-picker3/contiguousDayRangePicker.tsx b/packages/datetime2/src/components/date-range-picker3/contiguousDayRangePicker.tsx index e50cfd13848..5f6e90897ad 100644 --- a/packages/datetime2/src/components/date-range-picker3/contiguousDayRangePicker.tsx +++ b/packages/datetime2/src/components/date-range-picker3/contiguousDayRangePicker.tsx @@ -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. @@ -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; } } diff --git a/packages/datetime2/test/components/dateRangeInput3Tests.tsx b/packages/datetime2/test/components/dateRangeInput3Tests.tsx index c28e1c03efc..1353dba23e2 100644 --- a/packages/datetime2/test/components/dateRangeInput3Tests.tsx +++ b/packages/datetime2/test/components/dateRangeInput3Tests.tsx @@ -93,18 +93,19 @@ describe("", () => { } }); + const YEAR = 2022; const START_DAY = 22; - const START_DATE = new Date(2022, Months.JANUARY, START_DAY); + const START_DATE = new Date(YEAR, Months.JANUARY, START_DAY); const START_STR = DATE_FORMAT.formatDate(START_DATE); const END_DAY = 24; - const END_DATE = new Date(2022, Months.JANUARY, END_DAY); + const END_DATE = new Date(YEAR, Months.JANUARY, END_DAY); const END_STR = DATE_FORMAT.formatDate(END_DATE); const DATE_RANGE = [START_DATE, END_DATE] as DateRange; - const START_DATE_2 = new Date(2022, Months.JANUARY, 1); + const START_DATE_2 = new Date(YEAR, Months.JANUARY, 1); const START_STR_2 = DATE_FORMAT.formatDate(START_DATE_2); const START_STR_2_ES_LOCALE = "1 de enero de 2022"; - const END_DATE_2 = new Date(2022, Months.JANUARY, 31); + const END_DATE_2 = new Date(YEAR, Months.JANUARY, 31); const END_STR_2 = DATE_FORMAT.formatDate(END_DATE_2); const END_STR_2_ES_LOCALE = "31 de enero de 2022"; const DATE_RANGE_2 = [START_DATE_2, END_DATE_2] as DateRange; @@ -1020,6 +1021,265 @@ describe("", () => { }); }); + describe("Arrow key navigation", () => { + it("Pressing an arrow key has no effect when the input is not fully selected", () => { + const onChange = sinon.spy(); + const { root } = wrap( + , + ); + + getStartInput(root).simulate("keydown", { key: "ArrowDown" }); + getEndInput(root).simulate("keydown", { key: "ArrowDown" }); + expect(onChange.called).to.be.false; + }); + + it("Pressing the left arrow key moves the date back by a day", () => { + const onChange = sinon.spy(); + const { root } = wrap( + , + ); + + const expectedStartDate1 = DATE_FORMAT.formatDate(new Date(YEAR, Months.JANUARY, START_DAY - 1)); + const expectedStartDate2 = DATE_FORMAT.formatDate(new Date(YEAR, Months.JANUARY, START_DAY - 2)); + + getStartInput(root).simulate("focus"); + getStartInput(root).simulate("keydown", { key: "ArrowLeft" }); + assertInputValueEquals(getStartInput(root), expectedStartDate1); + assertDateRangesEqual(onChange.getCall(0).args[0], [expectedStartDate1, END_STR]); + + getStartInput(root).simulate("keydown", { key: "ArrowLeft" }); + assertInputValueEquals(getStartInput(root), expectedStartDate2); + assertDateRangesEqual(onChange.getCall(1).args[0], [expectedStartDate2, END_STR]); + }); + + it("Pressing the right arrow key moves the date forward by a day", () => { + const onChange = sinon.spy(); + const { root } = wrap( + , + ); + + const expectedEndDate1 = DATE_FORMAT.formatDate(new Date(YEAR, Months.JANUARY, END_DAY + 1)); + const expectedEndDate2 = DATE_FORMAT.formatDate(new Date(YEAR, Months.JANUARY, END_DAY + 2)); + + getEndInput(root).simulate("focus"); + getEndInput(root).simulate("keydown", { key: "ArrowRight" }); + assertInputValueEquals(getEndInput(root), expectedEndDate1); + assertDateRangesEqual(onChange.getCall(0).args[0], [START_STR, expectedEndDate1]); + + getEndInput(root).simulate("keydown", { key: "ArrowRight" }); + assertInputValueEquals(getEndInput(root), expectedEndDate2); + assertDateRangesEqual(onChange.getCall(1).args[0], [START_STR, expectedEndDate2]); + }); + + it("Pressing the up arrow key moves the date back by a week", () => { + const onChange = sinon.spy(); + const { root } = wrap( + , + ); + + const expectedStartDate1 = DATE_FORMAT.formatDate(new Date(YEAR, Months.JANUARY, START_DAY - 7)); + const expectedStartDate2 = DATE_FORMAT.formatDate(new Date(YEAR, Months.JANUARY, START_DAY - 14)); + + getStartInput(root).simulate("focus"); + getStartInput(root).simulate("keydown", { key: "ArrowUp" }); + assertInputValueEquals(getStartInput(root), expectedStartDate1); + assertDateRangesEqual(onChange.getCall(0).args[0], [expectedStartDate1, END_STR]); + + getStartInput(root).simulate("keydown", { key: "ArrowUp" }); + assertInputValueEquals(getStartInput(root), expectedStartDate2); + assertDateRangesEqual(onChange.getCall(1).args[0], [expectedStartDate2, END_STR]); + }); + + it("Pressing the down arrow key moves the date forward by a week", () => { + const onChange = sinon.spy(); + const { root } = wrap( + , + ); + + const expectedEndDate1 = DATE_FORMAT.formatDate(new Date(YEAR, Months.JANUARY, END_DAY + 7)); + const expectedEndDate2 = DATE_FORMAT.formatDate(new Date(YEAR, Months.FEBRUARY, 7)); + + getEndInput(root).simulate("focus"); + getEndInput(root).simulate("keydown", { key: "ArrowDown" }); + assertInputValueEquals(getEndInput(root), expectedEndDate1); + assertDateRangesEqual(onChange.getCall(0).args[0], [START_STR, expectedEndDate1]); + + getEndInput(root).simulate("keydown", { key: "ArrowDown" }); + assertInputValueEquals(getEndInput(root), expectedEndDate2); + assertDateRangesEqual(onChange.getCall(1).args[0], [START_STR, expectedEndDate2]); + }); + + it("Will not move past the end boundary", () => { + const onChange = sinon.spy(); + const { root } = wrap( + , + ); + + const expectedStartDate = DATE_FORMAT.formatDate(new Date(YEAR, Months.JANUARY, END_DAY - 1)); + + getStartInput(root).simulate("focus"); + getStartInput(root).simulate("keydown", { key: "ArrowDown" }); + assertInputValueEquals(getStartInput(root), expectedStartDate); + assertDateRangesEqual(onChange.getCall(0).args[0], [expectedStartDate, END_STR]); + }); + + it("Will not move past the end boundary when allowSingleDayRange={true}", () => { + const onChange = sinon.spy(); + const { root } = wrap( + , + ); + + getStartInput(root).simulate("focus"); + getStartInput(root).simulate("keydown", { key: "ArrowDown" }); + assertInputValueEquals(getStartInput(root), END_STR); + assertDateRangesEqual(onChange.getCall(0).args[0], [END_STR, END_STR]); + }); + + it("Will not move past the start boundary", () => { + const onChange = sinon.spy(); + const { root } = wrap( + , + ); + + const expectedEndDate = DATE_FORMAT.formatDate(new Date(YEAR, Months.JANUARY, START_DAY + 1)); + + getEndInput(root).simulate("focus"); + getEndInput(root).simulate("keydown", { key: "ArrowUp" }); + assertInputValueEquals(getEndInput(root), expectedEndDate); + assertDateRangesEqual(onChange.getCall(0).args[0], [START_STR, expectedEndDate]); + }); + + it("Will not move past the start boundary when allowSingleDayRange={true}", () => { + const onChange = sinon.spy(); + const { root } = wrap( + , + ); + + getEndInput(root).simulate("focus"); + getEndInput(root).simulate("keydown", { key: "ArrowUp" }); + assertInputValueEquals(getEndInput(root), START_STR); + assertDateRangesEqual(onChange.getCall(0).args[0], [START_STR, START_STR]); + }); + + it("Will not move past the min date", () => { + const minDate = new Date(YEAR, Months.JANUARY, START_DAY - 3); + const minDateStr = DATE_FORMAT.formatDate(minDate); + + const onChange = sinon.spy(); + const { root } = wrap( + , + ); + + getStartInput(root).simulate("focus"); + getStartInput(root).simulate("keydown", { key: "ArrowUp" }); + assertInputValueEquals(getStartInput(root), minDateStr); + assertDateRangesEqual(onChange.getCall(0).args[0], [minDateStr, END_STR]); + }); + + it("Will not move past the max date", () => { + const maxDate = new Date(YEAR, Months.JANUARY, END_DAY + 3); + const maxDateStr = DATE_FORMAT.formatDate(maxDate); + + const onChange = sinon.spy(); + const { root } = wrap( + , + ); + + getEndInput(root).simulate("focus"); + getEndInput(root).simulate("keydown", { key: "ArrowDown" }); + assertInputValueEquals(getEndInput(root), maxDateStr); + assertDateRangesEqual(onChange.getCall(0).args[0], [START_STR, maxDateStr]); + }); + + it("Will select today's date by default", () => { + const onChange = sinon.spy(); + const { root } = wrap(); + + const today = DATE_FORMAT.formatDate(new Date()); + getStartInput(root).simulate("focus"); + getStartInput(root).simulate("keydown", { key: "ArrowDown" }); + assertInputValueEquals(getStartInput(root), today); + }); + + it("Will choose a reasonable end date when only the start is selected", () => { + const onChange = sinon.spy(); + const { root } = wrap( + , + ); + + const expectedEndDate = DATE_FORMAT.formatDate(new Date(YEAR, Months.JANUARY, START_DAY + 1)); + getEndInput(root).simulate("focus"); + getEndInput(root).simulate("keydown", { key: "ArrowRight" }); + assertInputValueEquals(getEndInput(root), expectedEndDate); + }); + + it("Will choose a reasonable start date when only the end is selected", () => { + const onChange = sinon.spy(); + const { root } = wrap( + , + ); + + const expectedEndDate = DATE_FORMAT.formatDate(new Date(YEAR, Months.JANUARY, END_DAY - 7)); + getStartInput(root).simulate("focus"); + getStartInput(root).simulate("keydown", { key: "ArrowUp" }); + assertInputValueEquals(getStartInput(root), expectedEndDate); + }); + }); + describe("Hovering over dates", () => { // define new constants to clarify chronological ordering of dates // TODO: rename all date constants in this file to use a similar @@ -2591,6 +2851,22 @@ describe("", () => { }); }); + describe("Arrow key navigation", () => { + it("Pressing the left arrow key moves the date back by a day", () => { + const onChange = sinon.spy(); + const { root } = wrap( + , + ); + + const expectedStartDate1 = DATE_FORMAT.formatDate(new Date(YEAR, Months.JANUARY, START_DAY - 1)); + + getStartInput(root).simulate("focus"); + getStartInput(root).simulate("keydown", { key: "ArrowLeft" }); + assertInputValueEquals(getStartInput(root), expectedStartDate1); + assertDateRangesEqual(onChange.getCall(0).args[0], [expectedStartDate1, END_STR]); + }); + }); + it("Clearing the dates in the picker invokes onChange with [null, null] and updates input fields", () => { const onChange = sinon.spy(); const value = [START_DATE, null] as DateRange; From aed2dd96cd821d3a4f820f1c08f785c145ddd81f Mon Sep 17 00:00:00 2001 From: Jonah Scheinerman Date: Thu, 21 Nov 2024 10:07:55 -0500 Subject: [PATCH 2/3] Fix overlapping dates --- .../date-range-input3/dateRangeInput3.tsx | 41 +++++++++++++------ .../test/components/dateRangeInput3Tests.tsx | 26 ++++++++++++ 2 files changed, 54 insertions(+), 13 deletions(-) diff --git a/packages/datetime2/src/components/date-range-input3/dateRangeInput3.tsx b/packages/datetime2/src/components/date-range-input3/dateRangeInput3.tsx index cb541902cf1..b4e95e4fe6d 100644 --- a/packages/datetime2/src/components/date-range-input3/dateRangeInput3.tsx +++ b/packages/datetime2/src/components/date-range-input3/dateRangeInput3.tsx @@ -554,6 +554,7 @@ export class DateRangeInput3 extends DateFnsLocalizedComponent, boundary: Boundary) => { + const { minDate, maxDate } = this.props; const { isOpen } = this.state; const inputElement = boundary === Boundary.START ? this.startInputElement : this.endInputElement; @@ -561,29 +562,34 @@ export class DateRangeInput3 extends DateFnsLocalizedComponent = { - [keys.inputString]: this.formatDate(newDate), + [keys.inputString]: this.formatDate(clampedDate), shouldSelectAfterUpdate: true, }; if (!this.isControlled()) { - nextState[keys.selectedValue] = newDate; + nextState[keys.selectedValue] = clampedDate; } - this.props.onChange?.(this.getDateRangeForCallback(newDate, boundary)); + this.props.onChange?.(this.getDateRangeForCallback(clampedDate, boundary)); this.setState(nextState); }; private getNextDateForArrowKeyNavigation(arrowKey: string, boundary: Boundary) { - const { allowSingleDayRange, maxDate, minDate } = this.props; + const { allowSingleDayRange } = this.props; const [selectedStart, selectedEnd] = this.getSelectedRange(); const initialDate = boundary === Boundary.START ? selectedStart : selectedEnd; if (initialDate == null) { @@ -598,17 +604,26 @@ export class DateRangeInput3 extends DateFnsLocalizedComponent { diff --git a/packages/datetime2/test/components/dateRangeInput3Tests.tsx b/packages/datetime2/test/components/dateRangeInput3Tests.tsx index 1353dba23e2..67331a0e246 100644 --- a/packages/datetime2/test/components/dateRangeInput3Tests.tsx +++ b/packages/datetime2/test/components/dateRangeInput3Tests.tsx @@ -1278,6 +1278,32 @@ describe("", () => { getStartInput(root).simulate("keydown", { key: "ArrowUp" }); assertInputValueEquals(getStartInput(root), expectedEndDate); }); + + it("Will not make a selection when trying to move backward and only the start is selected", () => { + const onChange = sinon.spy(); + const { root } = wrap( + , + ); + + getEndInput(root).simulate("focus"); + getEndInput(root).simulate("keydown", { key: "ArrowLeft" }); + getEndInput(root).simulate("keydown", { key: "ArrowUp" }); + assertInputValueEquals(getEndInput(root), ""); + expect(onChange.called).to.be.false; + }); + + it("Will not make a selection when trying to move forward and only the end is selected", () => { + const onChange = sinon.spy(); + const { root } = wrap( + , + ); + + getStartInput(root).simulate("focus"); + getStartInput(root).simulate("keydown", { key: "ArrowRight" }); + getStartInput(root).simulate("keydown", { key: "ArrowDown" }); + assertInputValueEquals(getStartInput(root), ""); + expect(onChange.called).to.be.false; + }); }); describe("Hovering over dates", () => { From 46fbe38eef8bc88486f164386b761e47e688be24 Mon Sep 17 00:00:00 2001 From: Jonah Scheinerman Date: Thu, 21 Nov 2024 10:44:38 -0500 Subject: [PATCH 3/3] Fix same date overlap --- .../date-range-input3/dateRangeInput3.tsx | 14 ++++++++++---- .../date-range-input3/dateRangeInputUilts.ts | 4 ++++ 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/datetime2/src/components/date-range-input3/dateRangeInput3.tsx b/packages/datetime2/src/components/date-range-input3/dateRangeInput3.tsx index b4e95e4fe6d..653d7edc39d 100644 --- a/packages/datetime2/src/components/date-range-input3/dateRangeInput3.tsx +++ b/packages/datetime2/src/components/date-range-input3/dateRangeInput3.tsx @@ -52,7 +52,13 @@ import type { DateRangeInput3PropsWithDefaults, } from "./dateRangeInput3Props"; import type { DateRangeInput3State } from "./dateRangeInput3State"; -import { clampDate, isEntireInputSelected, shiftDateByArrowKey, shiftDateByDays } from "./dateRangeInputUilts"; +import { + clampDate, + getTodayAtMidnight, + isEntireInputSelected, + shiftDateByArrowKey, + shiftDateByDays, +} from "./dateRangeInputUilts"; export type { DateRangeInput3Props }; @@ -613,7 +619,7 @@ export class DateRangeInput3 extends DateFnsLocalizedComponent