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

[DateInput/DateRangeInput] Commit typed value on 'Enter' key press #1825

Merged
merged 9 commits into from
Dec 8, 2017
Merged
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 @@ -251,6 +252,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 @@ -306,7 +308,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 @@ -319,10 +321,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 @@ -393,7 +402,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 @@ -423,6 +432,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 @@ -392,7 +392,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 @@ -424,7 +424,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 @@ -542,40 +545,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