Skip to content

Commit

Permalink
[datetime] fix(DateInput): make popover focusable via keyboard (#4925)
Browse files Browse the repository at this point in the history
  • Loading branch information
michael-yx-wu authored Sep 27, 2021
1 parent e2d6fde commit e148da2
Show file tree
Hide file tree
Showing 3 changed files with 46 additions and 72 deletions.
110 changes: 39 additions & 71 deletions packages/datetime/src/dateInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -186,10 +186,6 @@ export class DateInput extends AbstractPureComponent2<DateInputProps, IDateInput

public popoverContentElement: HTMLDivElement | null = null;

// Last element in popover that is tabbable, and the one that triggers popover closure
// when the user press TAB on it
private lastTabbableElement: HTMLElement | null = null;

private handleInputRef = refHandler<HTMLInputElement, "inputElement">(
this,
"inputElement",
Expand All @@ -198,39 +194,26 @@ export class DateInput extends AbstractPureComponent2<DateInputProps, IDateInput

private handlePopoverContentRef: IRef<HTMLDivElement> = refHandler(this, "popoverContentElement");

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

public render() {
const { value, valueString } = this.state;
const dateString = this.state.isInputFocused ? valueString : getFormattedDateString(value, this.props);
const dateValue = isDateValid(value) ? value : null;
const dayPickerProps: DayPickerProps = {
...this.props.dayPickerProps,
// If the user presses the TAB key on a DayPicker Day element and the lastTabbableElement is also a DayPicker Day
// element, the popover should be closed
onDayKeyDown: (day, modifiers, e) => {
if (
e.key === "Tab" &&
!e.shiftKey &&
this.lastTabbableElement?.classList.contains(Classes.DATEPICKER_DAY)
) {
this.setState({ isOpen: false });
}
this.props.dayPickerProps.onDayKeyDown?.(day, modifiers, e);
},
// 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) => {
this.props.dayPickerProps.onMonthChange?.(month);
this.setTimeout(this.registerPopoverBlurHandler);
},
};

// React's onFocus prop listens to the focusin browser event under the hood, so it's safe to
// provide it the focusIn event handlers instead of using a ref and manually adding the
// event listeners ourselves.
const wrappedPopoverContent = (
<div ref={this.handlePopoverContentRef}>
<div onFocus={this.handleStartFocusBoundaryFocusIn} tabIndex={0} />
<DatePicker
{...this.props}
dayPickerProps={dayPickerProps}
Expand All @@ -239,6 +222,7 @@ export class DateInput extends AbstractPureComponent2<DateInputProps, IDateInput
onShortcutChange={this.handleShortcutChange}
selectedShortcutIndex={this.state.selectedShortcutIndex}
/>
<div onFocus={this.handleEndFocusBoundaryFocusIn} tabIndex={0} />
</div>
);

Expand Down Expand Up @@ -409,7 +393,6 @@ export class DateInput extends AbstractPureComponent2<DateInputProps, IDateInput
this.setState({ isInputFocused: false });
}
}
this.registerPopoverBlurHandler();
this.safeInvokeInputProp("onBlur", e);
};

Expand All @@ -419,70 +402,55 @@ export class DateInput extends AbstractPureComponent2<DateInputProps, IDateInput
if (e.which === Keys.ENTER) {
const nextDate = this.parseDate(this.state.valueString);
this.handleDateChange(nextDate, true, true);
} else if (e.which === Keys.TAB) {
this.setState({ isOpen: false });
} else if (e.which === Keys.TAB && e.shiftKey) {
// close popover on SHIFT+TAB key press
this.handleClosePopover();
} else if (e.which === Keys.TAB && this.state.isOpen) {
this.getKeyboardFocusableElements().shift()?.focus();
// necessary to prevent focusing the second focusable element
e.preventDefault();
} else if (e.which === Keys.ESCAPE) {
this.setState({ isOpen: false });
this.inputElement?.blur();
}
this.safeInvokeInputProp("onKeyDown", e);
};

private getLastTabbableElement = () => {
// 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.popoverContentElement?.querySelectorAll("input, [tabindex]:not([tabindex='-1'])");
const numOfElements = tabbableElements?.length ?? 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 lastTabbableElement = numOfElements > 0 ? tabbableElements[numOfElements - 1] : null;

return lastTabbableElement as HTMLElement | null;
private getKeyboardFocusableElements = (): HTMLElement[] => {
const elements: HTMLElement[] = Array.from(
this.popoverContentElement?.querySelectorAll(
"button:not([disabled]),input,[tabindex]:not([tabindex='-1'])",
),
);
// Remove focus boundary div elements
elements.pop();
elements.shift();
return elements;
};

// focus DOM event listener (not React event)
private handlePopoverBlur = (e: FocusEvent) => {
let relatedTarget = e.relatedTarget as HTMLElement;
if (relatedTarget == null) {
// Support IE11 (#2924)
relatedTarget = document.activeElement as HTMLElement;
}
const eventTarget = e.target as HTMLElement;
// Beware: this.popoverContentElement is sometimes null under Chrome
if (
relatedTarget == null ||
(this.popoverContentElement != null && !this.popoverContentElement.contains(relatedTarget))
) {
// Exclude the following blur operations that makes "body" the activeElement
// and would close the Popover unexpectedly
// - On disabled change months buttons
// - DayPicker day elements, their "blur" will be managed at its own onKeyDown
const isChangeMonthEvt = eventTarget.classList.contains(Classes.DATEPICKER_NAVBUTTON);
const isChangeMonthButtonDisabled = isChangeMonthEvt && (eventTarget as HTMLButtonElement).disabled;
const isDayPickerDayEvt = eventTarget.classList.contains(Classes.DATEPICKER_DAY);
if (!isChangeMonthButtonDisabled && !isDayPickerDayEvt) {
this.handleClosePopover();
}
} else if (relatedTarget != null) {
this.unregisterPopoverBlurHandler();
this.lastTabbableElement = this.getLastTabbableElement();
this.lastTabbableElement?.addEventListener("blur", this.handlePopoverBlur);
private handleStartFocusBoundaryFocusIn = (e: React.FocusEvent<HTMLDivElement>) => {
if (this.popoverContentElement.contains(this.getRelatedTarget(e))) {
// Not closing Popover to allow user to freely switch between manually entering a date
// string in the input and selecting one via the Popover
this.inputElement?.focus();
} else {
this.getKeyboardFocusableElements().shift()?.focus();
}
};

private registerPopoverBlurHandler = () => {
if (this.popoverContentElement != null) {
this.unregisterPopoverBlurHandler();
this.lastTabbableElement = this.getLastTabbableElement();
this.lastTabbableElement?.addEventListener("blur", this.handlePopoverBlur);
private handleEndFocusBoundaryFocusIn = (e: React.FocusEvent<HTMLDivElement>) => {
if (this.popoverContentElement.contains(this.getRelatedTarget(e))) {
this.inputElement?.focus();
this.handleClosePopover();
} else {
this.getKeyboardFocusableElements().pop()?.focus();
}
};

private unregisterPopoverBlurHandler = () => {
this.lastTabbableElement?.removeEventListener("blur", this.handlePopoverBlur);
};
private getRelatedTarget(e: React.FocusEvent<HTMLDivElement>): HTMLElement {
// Support IE11 (#2924)
return (e.relatedTarget ?? document.activeElement) as HTMLElement;
}

private handleShortcutChange = (_: DatePickerShortcut, selectedShortcutIndex: number) => {
this.setState({ selectedShortcutIndex });
Expand Down
6 changes: 5 additions & 1 deletion packages/datetime/test/dateInputTests.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,11 @@ describe("<DateInput>", () => {
assert.isFalse(wrapper.find(Popover).prop("isOpen"));
});

it("Popover closes when tabbing on first day of the month", () => {
// Skipping because simulate just invokes the function passed to React's "on<EventName>" prop
// and doesn't actually simulate anything. Properly testing would require running with an actual
// browser and focusing specific elements via the DOM API. This would require changing the Karma
// config to run with Chrome instead of ChromeHeadless.
it.skip("Popover closes when tabbing on first day of the month", () => {
const defaultValue = new Date(2018, Months.FEBRUARY, 6, 15, 0, 0, 0);
const wrapper = mount(<DateInput {...DATE_FORMAT} defaultValue={defaultValue} />);
wrapper.find("input").simulate("focus").simulate("blur");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ export class PopoverDismissExample extends React.PureComponent<
return (
<Example options={false} {...this.props}>
<Popover
autoFocus={false}
enforceFocus={false}
isOpen={this.state.isOpen}
onInteraction={this.handleInteraction}
onClosed={this.reopen}
Expand Down

1 comment on commit e148da2

@blueprint-bot
Copy link

Choose a reason for hiding this comment

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

[datetime] fix(DateInput): make popover focusable via keyboard (#4925)

Previews: documentation | landing | table | modern colors demo

Please sign in to comment.