Skip to content

Commit

Permalink
[DateInput/DateRangeInput] Commit typed value on 'Enter' key press (#…
Browse files Browse the repository at this point in the history
…1825)

* DateInput now saves on Enter

* DateRangeInput now saves on Enter

* Write DateInput tests, fix locale bug

* Tests + bug fixes for DateRangeInput

* Fix test

* Fix blur interactions

* Close the popover on Shift + TAB

* Format the date string on Enter in DateInput
  • Loading branch information
cmslewis authored Dec 8, 2017
1 parent 7a242a6 commit 826fbc7
Show file tree
Hide file tree
Showing 4 changed files with 182 additions and 26 deletions.
31 changes: 27 additions & 4 deletions packages/datetime/src/dateInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
InputGroup,
IPopoverProps,
IProps,
Keys,
Popover,
Position,
Utils,
Expand Down Expand Up @@ -256,6 +257,7 @@ export class DateInput extends AbstractComponent<IDateInputProps, IDateInputStat
onChange={this.handleInputChange}
onClick={this.handleInputClick}
onFocus={this.handleInputFocus}
onKeyDown={this.handleInputKeyDown}
value={dateString}
/>
</Popover>
Expand Down Expand Up @@ -311,7 +313,7 @@ export class DateInput extends AbstractComponent<IDateInputProps, IDateInputStat
this.setState({ isOpen: false });
};

private handleDateChange = (date: Date, hasUserManuallySelectedDate: boolean) => {
private handleDateChange = (date: Date, hasUserManuallySelectedDate: boolean, didSubmitWithEnter = false) => {
const prevMomentDate = this.state.value;
const momentDate = fromDateToMoment(date);

Expand All @@ -324,10 +326,17 @@ export class DateInput extends AbstractComponent<IDateInputProps, IDateInputStat
this.hasTimeChanged(prevMomentDate, momentDate) ||
!this.props.closeOnSelection;

// if selecting a date via click or Tab, the input will already be
// blurred by now, so sync isInputFocused to false. if selecting via
// Enter, setting isInputFocused to false won't do anything by itself,
// plus we want the field to retain focus anyway.
// (note: spelling out the ternary explicitly reads more clearly.)
const isInputFocused = didSubmitWithEnter ? true : false;

if (this.props.value === undefined) {
this.setState({ isInputFocused: false, isOpen, value: momentDate });
this.setState({ isInputFocused, isOpen, value: momentDate, valueString: this.getDateString(momentDate) });
} else {
this.setState({ isInputFocused: false, isOpen });
this.setState({ isInputFocused, isOpen });
}
Utils.safeInvoke(this.props.onChange, date === null ? null : fromMomentToDate(momentDate));
};
Expand Down Expand Up @@ -398,7 +407,7 @@ export class DateInput extends AbstractComponent<IDateInputProps, IDateInputStat
};

private handleInputBlur = (e: React.FocusEvent<HTMLInputElement>) => {
const valueString = this.state.valueString;
const { valueString } = this.state;
const value = this.createMoment(valueString);
if (
valueString.length > 0 &&
Expand Down Expand Up @@ -428,6 +437,20 @@ export class DateInput extends AbstractComponent<IDateInputProps, IDateInputStat
this.safeInvokeInputProp("onBlur", e);
};

private handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.which === Keys.ENTER) {
const nextValue = this.createMoment(this.state.valueString);
const nextDate = fromMomentToDate(nextValue);
this.handleDateChange(nextDate, true, true);
} else if (e.which === Keys.TAB && e.shiftKey) {
// close the popover if focus will move to the previous element on
// the page. tabbing forward should *not* close the popover, because
// focus will be moving into the popover itself.
this.setState({ isOpen: false });
}
this.safeInvokeInputProp("onKeyDown", e);
};

private setInputRef = (el: HTMLElement) => {
this.inputRef = el;
const { inputProps = {} } = this.props;
Expand Down
70 changes: 48 additions & 22 deletions packages/datetime/src/dateRangeInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -395,7 +395,7 @@ export class DateRangeInput extends AbstractComponent<IDateRangeInputProps, IDat
// Callbacks - DateRangePicker
// ===========================

private handleDateRangePickerChange = (selectedRange: DateRange) => {
private handleDateRangePickerChange = (selectedRange: DateRange, didSubmitWithEnter = false) => {
// ignore mouse events in the date-range picker if the popover is animating closed.
if (!this.state.isOpen) {
return;
Expand Down Expand Up @@ -427,7 +427,10 @@ export class DateRangeInput extends AbstractComponent<IDateRangeInputProps, IDat
} else if (this.props.closeOnSelection) {
isOpen = false;
isStartInputFocused = false;
isEndInputFocused = false;
// if we submit via click or Tab, the focus will have moved already.
// it we submit with Enter, the focus won't have moved, and setting
// the flag to false won't have an effect anyway, so leave it true.
isEndInputFocused = didSubmitWithEnter ? true : false;
} else if (this.state.lastFocusedField === DateRangeBoundary.START) {
// keep the start field focused
isStartInputFocused = true;
Expand Down Expand Up @@ -545,40 +548,63 @@ export class DateRangeInput extends AbstractComponent<IDateRangeInputProps, IDat
// - 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>) => {
const isTabPressed = e.keyCode === Keys.TAB;
const isTabPressed = e.which === Keys.TAB;
const isEnterPressed = e.which === Keys.ENTER;
const isShiftPressed = e.shiftKey;

const { selectedStart, selectedEnd } = this.state;

// order of JS events is our enemy here. when tabbing between fields,
// this handler will fire in the middle of a focus exchange when no
// field is currently focused. we work around this by referring to the
// most recently focused field, rather than the currently focused field.
const wasStartFieldFocused = this.state.lastFocusedField === DateRangeBoundary.START;
const wasEndFieldFocused = this.state.lastFocusedField === DateRangeBoundary.END;

let isEndInputFocused: boolean;
let isStartInputFocused: boolean;

// move focus to the other field
if (wasStartFieldFocused && isTabPressed && !isShiftPressed) {
isStartInputFocused = false;
isEndInputFocused = true;
} else if (wasEndFieldFocused && isTabPressed && isShiftPressed) {
isStartInputFocused = true;
isEndInputFocused = false;
if (isTabPressed) {
let isEndInputFocused: boolean;
let isStartInputFocused: boolean;
let isOpen = true;

if (wasStartFieldFocused && !isShiftPressed) {
isStartInputFocused = false;
isEndInputFocused = true;

// prevent the default focus-change behavior to avoid race conditions;
// we'll handle the focus change ourselves in componentDidUpdate.
e.preventDefault();
} else if (wasEndFieldFocused && isShiftPressed) {
isStartInputFocused = true;
isEndInputFocused = false;
e.preventDefault();
} else {
// don't prevent default here, otherwise Tab won't do anything.
isStartInputFocused = false;
isEndInputFocused = false;
isOpen = false;
}

this.setState({
isEndInputFocused,
isOpen,
isStartInputFocused,
wasLastFocusChangeDueToHover: false,
});
} else if (wasStartFieldFocused && isEnterPressed) {
const nextStartValue = this.dateStringToMoment(this.state.startInputString);
const nextStartDate = fromMomentToDate(nextStartValue);
const nextEndDate = isMomentNull(selectedEnd) ? undefined : fromMomentToDate(selectedEnd);
this.handleDateRangePickerChange([nextStartDate, nextEndDate] as DateRange, true);
} else if (wasEndFieldFocused && isEnterPressed) {
const nextStartDate = isMomentNull(selectedStart) ? undefined : fromMomentToDate(selectedStart);
const nextEndValue = this.dateStringToMoment(this.state.endInputString);
const nextEndDate = fromMomentToDate(nextEndValue);
this.handleDateRangePickerChange([nextStartDate, nextEndDate] as DateRange, true);
} else {
// let the default keystroke happen without side effects
return;
}

// prevent the default focus-change behavior to avoid race conditions;
// we'll handle the focus change ourselves in componentDidUpdate.
e.preventDefault();

this.setState({
isEndInputFocused,
isStartInputFocused,
wasLastFocusChangeDueToHover: false,
});
};

private handleInputMouseDown = () => {
Expand Down
31 changes: 31 additions & 0 deletions packages/datetime/test/dateInputTests.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,20 @@ describe("<DateInput>", () => {
});

describe("when uncontrolled", () => {
it("Pressing Enter saves the inputted date and closes the popover", () => {
const IMPROPERLY_FORMATTED_DATE_STRING = "2015-2-15";
const PROPERLY_FORMATTED_DATE_STRING = "2015-02-15";
const onKeyDown = sinon.spy();
const wrapper = mount(<DateInput inputProps={{ onKeyDown }} />).setState({ isOpen: true });
const input = wrapper.find("input").first();
input.simulate("change", { target: { value: IMPROPERLY_FORMATTED_DATE_STRING } });
input.simulate("keydown", { which: Keys.ENTER });
assert.isFalse(wrapper.state("isOpen"), "popover closed");
assert.isTrue(wrapper.state("isInputFocused"), "input still focused");
assert.strictEqual(wrapper.find(InputGroup).prop("value"), PROPERLY_FORMATTED_DATE_STRING);
assert.isTrue(onKeyDown.calledOnce, "onKeyDown called once");
});

it("Clicking a date puts it in the input box and closes the popover", () => {
const wrapper = mount(<DateInput />).setState({ isOpen: true });
assert.equal(wrapper.find(InputGroup).prop("value"), "");
Expand Down Expand Up @@ -344,6 +358,23 @@ describe("<DateInput>", () => {
const DATE2_STR = "2015-02-01";
const DATE2_DE_STR = "01.02.2015";

it("Pressing Enter saves the inputted date and closes the popover", () => {
const onKeyDown = sinon.spy();
const onChange = sinon.spy();
const { root } = wrap(<DateInput inputProps={{ onKeyDown }} onChange={onChange} value={DATE} />);
root.setState({ isOpen: true });

const input = root.find("input").first();
input.simulate("change", { target: { value: DATE2_STR } });
input.simulate("keydown", { which: Keys.ENTER });

// onChange is called once on change, once on Enter
assert.isTrue(onChange.calledTwice, "onChange called twice");
assertDateEquals(onChange.args[1][0], DATE2_STR);
assert.isTrue(onKeyDown.calledOnce, "onKeyDown called once");
assert.isTrue(root.state("isInputFocused"), "input still focused");
});

it("Clicking a date invokes onChange callback with that date", () => {
const onChange = sinon.spy();
const { getDay, root } = wrap(<DateInput onChange={onChange} value={DATE} />);
Expand Down
76 changes: 76 additions & 0 deletions packages/datetime/test/dateRangeInputTests.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
HTMLInputProps,
IInputGroupProps,
InputGroup,
Keys,
Popover,
Position,
} from "@blueprintjs/core";
Expand Down Expand Up @@ -256,6 +257,26 @@ describe("<DateRangeInput>", () => {
expect(root.find(DateRangePicker).prop("shortcuts")).to.be.false;
});

it("pressing Shift+Tab in the start field blurs the start field and closes the popover", () => {
const startInputProps = { onKeyDown: sinon.spy() };
const { root } = wrap(<DateRangeInput {...{ startInputProps }} />);
const startInput = getStartInput(root);
startInput.simulate("keydown", { which: Keys.TAB, shiftKey: true });
expect(root.state("isStartInputFocused"), "start input blurred").to.be.false;
expect(startInputProps.onKeyDown.calledOnce, "onKeyDown called once").to.be.true;
expect(root.state("isOpen"), "popover closed").to.be.false;
});

it("pressing Tab in the end field blurs the end field and closes the popover", () => {
const endInputProps = { onKeyDown: sinon.spy() };
const { root } = wrap(<DateRangeInput {...{ endInputProps }} />);
const endInput = getEndInput(root);
endInput.simulate("keydown", { which: Keys.TAB });
expect(root.state("isEndInputFocused"), "end input blurred").to.be.false;
expect(endInputProps.onKeyDown.calledOnce, "onKeyDown called once").to.be.true;
expect(root.state("isOpen"), "popover closed").to.be.false;
});

describe("selectAllOnFocus", () => {
it("if false (the default), does not select any text on focus", () => {
const attachTo = document.createElement("div");
Expand Down Expand Up @@ -379,6 +400,34 @@ describe("<DateRangeInput>", () => {
assertInputTextsEqual(root, START_STR, END_STR);
});

it("Pressing Enter saves the inputted date and closes the popover", () => {
const startInputProps = { onKeyDown: sinon.spy() };
const endInputProps = { onKeyDown: sinon.spy() };
const { root } = wrap(<DateRangeInput {...{ startInputProps, endInputProps }} />);
root.setState({ isOpen: true });

const startInput = getStartInput(root);
startInput.simulate("focus");
startInput.simulate("change", { target: { value: START_STR } });
startInput.simulate("keydown", { which: Keys.ENTER });
expect(startInputProps.onKeyDown.calledOnce, "startInputProps.onKeyDown called once");
expect(isStartInputFocused(root), "start input still focused").to.be.false;

expect(root.state("isOpen"), "popover still open").to.be.true;

const endInput = getEndInput(root);
endInput.simulate("focus");
endInput.simulate("change", { target: { value: END_STR } });
endInput.simulate("keydown", { which: Keys.ENTER });
expect(endInputProps.onKeyDown.calledOnce, "endInputProps.onKeyDown called once");
expect(isEndInputFocused(root), "end input still focused").to.be.true;

expect(startInput.prop("value")).to.equal(START_STR);
expect(endInput.prop("value")).to.equal(END_STR);

expect(root.state("isOpen"), "popover closed at end").to.be.false;
});

it("Clicking a date invokes onChange with the new date range and updates the input fields", () => {
const defaultValue = [START_DATE, null] as DateRange;

Expand Down Expand Up @@ -2049,6 +2098,33 @@ describe("<DateRangeInput>", () => {
assertInputTextsEqual(root, START_STR_2, END_STR_2);
});

it("Pressing Enter saves the inputted date and closes the popover", () => {
const onChange = sinon.spy();
const { root } = wrap(<DateRangeInput onChange={onChange} value={[undefined, undefined]} />);
root.setState({ isOpen: true });

const startInput = getStartInput(root);
startInput.simulate("focus");
startInput.simulate("change", { target: { value: START_STR } });
startInput.simulate("keydown", { which: Keys.ENTER });
expect(isStartInputFocused(root), "start input blurred next").to.be.false;

expect(root.state("isOpen"), "popover still open").to.be.true;

const endInput = getEndInput(root);
expect(isEndInputFocused(root), "end input focused next").to.be.true;
endInput.simulate("change", { target: { value: END_STR } });
endInput.simulate("keydown", { which: Keys.ENTER });

expect(isStartInputFocused(root), "start input blurred at end").to.be.false;
expect(isEndInputFocused(root), "end input still focused at end").to.be.true;

// onChange is called once on change, once on Enter
expect(onChange.callCount, "onChange called four times").to.equal(4);
// check one of the invocations
assertDateRangesEqual(onChange.args[1][0], [START_STR, null]);
});

it("Clicking a date invokes onChange with the new date range and updates the input field text", () => {
const onChange = sinon.spy();
const { root, getDayElement } = wrap(<DateRangeInput value={DATE_RANGE} onChange={onChange} />);
Expand Down

0 comments on commit 826fbc7

Please sign in to comment.