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

[release/1.x] Close dateinput popover #2093

Merged
merged 10 commits into from
Feb 15, 2018
72 changes: 69 additions & 3 deletions packages/datetime/src/dateInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,8 @@ export class DateInput extends AbstractComponent<IDateInputProps, IDateInputStat
public static displayName = "Blueprint.DateInput";

private inputRef: HTMLElement = null;
private contentRef: HTMLElement = null;
private lastElementInPopover: HTMLElement = null;

public constructor(props?: IDateInputProps, context?: any) {
super(props, context);
Expand All @@ -198,15 +200,35 @@ export class DateInput extends AbstractComponent<IDateInputProps, IDateInputStat
};
}

public componentWillUnmount() {
super.componentWillUnmount();
this.unregisterPopoverBlurHandler();
}

public render() {
const { value, valueString } = this.state;
const dateString = this.state.isInputFocused ? valueString : this.getDateString(value);
const date = this.state.isInputFocused ? this.createMoment(valueString) : value;
const dateValue = this.isMomentValidAndInRange(value) ? fromMomentToDate(value) : null;
const dayPickerProps = {
...this.props.dayPickerProps,
// dom elements for the updated month is not available when
// onMonthChange is called. setTimeout is necessary to wait
// for the updated month to be rendered
onMonthChange: (month: Date) => {
Utils.safeInvoke(this.props.dayPickerProps.onMonthChange, month);
this.setTimeout(this.registerPopoverBlurHandler);
},
};

const popoverContent =
this.props.timePrecision === undefined ? (
<DatePicker {...this.props} onChange={this.handleDateChange} value={dateValue} />
<DatePicker
{...this.props}
dayPickerProps={dayPickerProps}
onChange={this.handleDateChange}
value={dateValue}
/>
) : (
<DateTimePicker
canClearSelection={this.props.canClearSelection}
Expand All @@ -216,6 +238,7 @@ export class DateInput extends AbstractComponent<IDateInputProps, IDateInputStat
timePickerProps={{ ...this.props.timePickerProps, precision: this.props.timePrecision }}
/>
);
const wrappedPopoverContent = <div ref={this.setContentRef}>{popoverContent}</div>;
Copy link
Contributor

Choose a reason for hiding this comment

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

another wrapper element 😢
guess that's how it is.

Copy link
Contributor Author

@mud mud Feb 6, 2018

Choose a reason for hiding this comment

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

  • acknowledge sadness 😿

Copy link
Contributor

Choose a reason for hiding this comment

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

😂 feeling better already

// assign default empty object here to prevent mutation
const { inputProps = {}, popoverProps = {}, format } = this.props;
// exclude ref (comes from HTMLInputProps typings, not InputGroup)
Expand All @@ -239,7 +262,7 @@ export class DateInput extends AbstractComponent<IDateInputProps, IDateInputStat
{...popoverProps}
autoFocus={false}
className={popoverClassName}
content={popoverContent}
content={wrappedPopoverContent}
enforceFocus={false}
onClose={this.handleClosePopover}
popoverClassName={classNames("pt-dateinput-popover", popoverProps.popoverClassName)}
Expand Down Expand Up @@ -307,7 +330,7 @@ export class DateInput extends AbstractComponent<IDateInputProps, IDateInputStat
return isMomentInRange(value, this.props.minDate, this.props.maxDate);
}

private handleClosePopover = (e: React.SyntheticEvent<HTMLElement>) => {
private handleClosePopover = (e?: React.SyntheticEvent<HTMLElement>) => {
const { popoverProps = {} } = this.props;
Utils.safeInvoke(popoverProps.onClose, e);
this.setState({ isOpen: false });
Expand Down Expand Up @@ -434,6 +457,7 @@ export class DateInput extends AbstractComponent<IDateInputProps, IDateInputStat
this.setState({ isInputFocused: false });
}
}
this.registerPopoverBlurHandler();
this.safeInvokeInputProp("onBlur", e);
};

Expand All @@ -447,16 +471,58 @@ export class DateInput extends AbstractComponent<IDateInputProps, IDateInputStat
// the page. tabbing forward should *not* close the popover, because
// focus will be moving into the popover itself.
this.setState({ isOpen: false });
} else if (e.which === Keys.ESCAPE) {
Copy link
Contributor

Choose a reason for hiding this comment

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

popovers have a feature to closeOnEscapeKey... can we leverage that instead?

try adding an onInteraction handler to the Popover.

Copy link
Contributor Author

@mud mud Feb 6, 2018

Choose a reason for hiding this comment

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

  • use closeOnEscapeKey and try onInteraction on the popover

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Looks like without additional work, pressing ESC while focus is within the popover, the popover will close.

However, when the focus is on the input we still need to handle ESC and set the popover to close.

this.setState({ isOpen: false });
this.inputRef.blur();
}
this.safeInvokeInputProp("onKeyDown", e);
};

// focus DOM event listener (not React event)
private handlePopoverBlur = (e: FocusEvent) => {
const relatedTarget = e.relatedTarget as HTMLElement;
if (relatedTarget == null || !this.contentRef.contains(relatedTarget)) {
this.handleClosePopover();
}
};

private registerPopoverBlurHandler = () => {
if (this.contentRef != null) {
// Popover contents are well structured, but the selector will need
// to be updated if more focusable components are added in the future
const tabbableElements = this.contentRef.querySelectorAll("input, [tabindex]:not([tabindex='-1'])");
Copy link
Contributor

Choose a reason for hiding this comment

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

note to self / @giladgray: we'll need to update this for dom4 v2

Copy link
Contributor

Choose a reason for hiding this comment

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

this is already updated. queryAll was removed.

const numOfElements = tabbableElements.length;
if (numOfElements > 0) {
// Keep track of the last focusable element in popover and add
// a blur handler, so that when:
// * user tabs to the next element, popover closes
// * focus moves to element within popover, popover stays open
const lastElement = tabbableElements[numOfElements - 1] as HTMLElement;
if (this.lastElementInPopover !== lastElement) {
this.unregisterPopoverBlurHandler();
this.lastElementInPopover = lastElement;
this.lastElementInPopover.addEventListener("blur", this.handlePopoverBlur);
}
}
}
};

private unregisterPopoverBlurHandler = () => {
if (this.lastElementInPopover != null) {
this.lastElementInPopover.removeEventListener("blur", this.handlePopoverBlur);
}
};

private setInputRef = (el: HTMLElement) => {
this.inputRef = el;
const { inputProps = {} } = this.props;
Utils.safeInvoke(inputProps.inputRef, el);
};

private setContentRef = (el: HTMLElement) => {
this.contentRef = el;
};

/** safe wrapper around invoking input props event handler (prop defaults to undefined) */
private safeInvokeInputProp(name: keyof HTMLInputProps, e: React.SyntheticEvent<HTMLElement>) {
const { inputProps = {} } = this.props;
Expand Down
74 changes: 74 additions & 0 deletions packages/datetime/test/dateInputTests.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,73 @@ describe("<DateInput>", () => {
assert.isFalse(wrapper.find(Popover).prop("isOpen"));
});

it("Popover closes when ESC key pressed", () => {
Copy link
Contributor

Choose a reason for hiding this comment

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

Popover provides this feature, and has corresponding tests. can we leverage it instead of reimplementing/testing here?
https://github.com/palantir/blueprint/blob/develop/packages/core/test/popover/popoverTests.tsx#L565

Copy link
Contributor Author

@mud mud Feb 6, 2018

Choose a reason for hiding this comment

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

  • use popover's close implementation, remove this test

Copy link
Contributor Author

Choose a reason for hiding this comment

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

As mentioned up top, we still need the functionality for hitting ESC while the input is in focus. So I still think we need this.

const wrapper = mount(<DateInput openOnFocus={true} />);
const input = wrapper.find("input");
input.simulate("focus");
const popover = wrapper.find(Popover);
assert.isTrue(popover.prop("isOpen"));
input.simulate("keydown", { which: Keys.ESCAPE });
assert.isFalse(popover.prop("isOpen"));
});

it("Popover closes when last tabbable component is blurred", () => {
const defaultValue = new Date(2018, Months.FEBRUARY, 6, 15, 0, 0, 0);
const wrapper = mount(<DateInput defaultValue={defaultValue} />);
wrapper.setState({ isOpen: true });
wrapper
.find("input")
.simulate("focus")
.simulate("blur");
const popover = wrapper.find(Popover);
// We need to use classname selector since enzyme doesn't
// support tabindex selector
const tabbables = popover.find(".DayPicker-Day--outside");
const lastTabbable = tabbables.last().getDOMNode() as HTMLElement;
lastTabbable.dispatchEvent(createFocusEvent("blur"));
assert.isFalse(popover.prop("isOpen"));
});

it("Popover should not close if focus moves to previous date", () => {
const defaultValue = new Date(2018, Months.FEBRUARY, 6, 15, 0, 0, 0);
const wrapper = mount(<DateInput defaultValue={defaultValue} />);
wrapper.setState({ isOpen: true });
wrapper
.find("input")
.simulate("focus")
.simulate("blur");
const tabbables = wrapper.find(".DayPicker-Day--outside");
const lastTabbable = tabbables.last().getDOMNode() as HTMLElement;
const relatedTarget = tabbables.at(tabbables.length - 1).getDOMNode();
const event = createFocusEvent("blur", relatedTarget);
lastTabbable.dispatchEvent(event);
assert.isTrue(wrapper.find(Popover).prop("isOpen"));
});

it("Popover should not close if focus moves to month select", () => {
const defaultValue = new Date(2018, Months.FEBRUARY, 6, 15, 0, 0, 0);
const wrapper = mount(<DateInput defaultValue={defaultValue} />);
wrapper.setState({ isOpen: true });
wrapper
.find("input")
.simulate("focus")
.simulate("blur");
wrapper.find(".pt-datepicker-month-select").simulate("change", { value: Months.FEBRUARY.toString() });
assert.isTrue(wrapper.find(Popover).prop("isOpen"));
});

it("Popover should not close if focus moves to year select", () => {
const defaultValue = new Date(2018, Months.FEBRUARY, 6, 15, 0, 0, 0);
const wrapper = mount(<DateInput defaultValue={defaultValue} />);
wrapper.setState({ isOpen: true });
wrapper
.find("input")
.simulate("focus")
.simulate("blur");
wrapper.find(".pt-datepicker-year-select").simulate("change", { value: "2016" });
assert.isTrue(wrapper.find(Popover).prop("isOpen"));
});

it("setting timePrecision renders a TimePicker", () => {
const wrapper = mount(<DateInput timePrecision={TimePickerPrecision.SECOND} />).setState({ isOpen: true });
// assert TimePicker appears
Expand Down Expand Up @@ -465,4 +532,11 @@ describe("<DateInput>", () => {
root: wrapper,
};
}

// PhantomJS fails when creating FocusEvent, so creating an Event, and attaching releatedTarget works
function createFocusEvent(eventType: string, relatedTarget: EventTarget = null) {
const event = new Event(eventType) as any;
event.relatedTarget = relatedTarget;
return event;
}
});