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

[core] fix(Overlay): use focus traps to fix enforceFocus #4894

Merged
merged 8 commits into from
Sep 14, 2021

Conversation

michael-yx-wu
Copy link
Contributor

@michael-yx-wu michael-yx-wu commented Sep 9, 2021

Fixes #3445

Checklist

  • Includes tests
  • Update documentation (n/a)

Changes proposed in this pull request:

When enforceFocus is enabled, surround Overlay content with dummy div
elements with focus event listeners that keep focus inside the Overlay.

Reviewers should focus on:

I want to flag that I've removed the tabindex attribute from the backdrop to prevent it from being keyboard focusable.

This change also fixes an issue where multiple open overlays will compete with each for focus. As an example, go to https://blueprintjs.com/docs/#core/components/overlay and open the overlay and then hit shift+s to open the omnibar search (also powered by Overlay). The search input will not get autofocused. Repeat the same workflow on https://46971-71939872-gh.circle-artifacts.com/0/packages/docs-app/dist/index.html#core/components/overlay and note that the omnibar search does get focus correctly.

Screenshot

Before

When using a portal, the portal is the last element on the page so tabbing will eventually cause focus to leave the document and none of the existing event handlers run before that happens. Note that shift+tabbing is not closing the overlay, the gif is just looping.

2021-09-09 19 59 13

After

Tab/shift+tab will never allow focus to leave the overlay if enforceFocus.

2021-09-09 19 58 14

When enforceFocus is enabled, surround Overlay content with dummy div
elements with focus event listeners that keep focus inside the Overlay.
@blueprint-bot

This comment has been minimized.

@blueprint-bot

This comment has been minimized.

Comment on lines -484 to -496
private handleDocumentFocus = (e: FocusEvent) => {
// get the actually target even if we are in an open mode Shadow DOM
const eventTarget = e.composed ? e.composedPath()[0] : e.target;
if (
this.props.enforceFocus &&
this.containerElement != null &&
eventTarget instanceof Node &&
!this.containerElement.contains(eventTarget as HTMLElement)
) {
// prevent default focus behavior (sometimes auto-scrolls the page)
e.preventDefault();
e.stopImmediatePropagation();
this.bringFocusInsideOverlay();
Copy link
Contributor Author

@michael-yx-wu michael-yx-wu Sep 9, 2021

Choose a reason for hiding this comment

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

when enforceFocus=true, focus will start on the overlay and will only leave if another overlay with autoFocus or enforceFocus is opened. because we take care to focus the last opened overlay when closing the current overlay and we call bringFocusInsideOverlay in the mousedown event handler, we don't need this focus event handler anymore

@blueprint-bot

This comment has been minimized.

@@ -415,7 +501,7 @@ export class Overlay extends AbstractPureComponent2<OverlayProps, IOverlayState>
if (openStack.length > 0) {
const lastOpenedOverlay = Overlay.getLastOpened();
if (lastOpenedOverlay.props.enforceFocus) {
document.addEventListener("focus", lastOpenedOverlay.handleDocumentFocus, /* useCapture */ true);
lastOpenedOverlay.bringFocusInsideOverlay();
Copy link
Contributor Author

Choose a reason for hiding this comment

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

this handler was a roundabout way to bring focus back to the last opened overlay. we can just call bringFocusInsideOverlay directly.

Comment on lines +63 to +65
after(() => {
document.documentElement.removeChild(testsContainerElement);
});
Copy link
Contributor Author

Choose a reason for hiding this comment

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

not really important, but seems good to clean up?

@blueprint-bot
Copy link

Fix tests by unmounting properly

Previews: documentation | landing | table

@michael-yx-wu michael-yx-wu marked this pull request as ready for review September 9, 2021 23:54
@@ -319,11 +335,11 @@ export class Overlay extends AbstractPureComponent2<OverlayProps, IOverlayState>
if (isFocusOutsideModal) {
// element marked autofocus has higher priority than the other clowns
const autofocusElement = this.containerElement.querySelector("[autofocus]") as HTMLElement;
Copy link
Contributor Author

@michael-yx-wu michael-yx-wu Sep 10, 2021

Choose a reason for hiding this comment

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

I noticed that InputGroup's autoFocus prop is passed to the input element as is. React doesn't actually render the autofocus attribute to the DOM (see facebook/react#11851 (comment)), so this querySelector is unlikely to work unless the consumer explicitly sets the DOM autofocus (no caps) attribute instead. Not sure if we want to do anything about this though, since React will warn if you set the DOM attribute directly

Copy link
Contributor

Choose a reason for hiding this comment

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

we should do something about this in a FLUP. not a high priority though

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Filed #4908

Copy link
Contributor

@adidahiya adidahiya left a comment

Choose a reason for hiding this comment

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

overall looks pretty good!

packages/core/src/components/overlay/overlay.tsx Outdated Show resolved Hide resolved
This allows bringFocusInsideOverlay to work even when there are no
keyboard-focusable elements.
@michael-yx-wu
Copy link
Contributor Author

michael-yx-wu commented Sep 14, 2021

I found a bug where this doesn't properly enforce focus if focus is coming back to the document from another part of the browser (e.g. the URL or bookmarks bar). I will restore the focus event handler logic, but I will listen instead for focusin instead of focus since the latter isn't supposed to bubble

update: as suggested, I'll address this in a flup

@blueprint-bot

This comment has been minimized.

@blueprint-bot
Copy link

Fix selector

Previews: documentation | landing | table

@adidahiya adidahiya changed the title Use focus traps to fix Overlay enforceFocus [core] fix(Overlay): use focus traps to fix enforceFocus behavior Sep 14, 2021
Copy link
Contributor

@adidahiya adidahiya left a comment

Choose a reason for hiding this comment

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

nice 👍

@adidahiya adidahiya changed the title [core] fix(Overlay): use focus traps to fix enforceFocus behavior [core] fix(Overlay): use focus traps to fix enforceFocus Sep 14, 2021
@ro-savage
Copy link

@michael-yx-wu - The appears to be a bug or unintentional behaviour with this change on dialogs with scrolls.

See: #4920

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Dialog and Popover enforceFocus has no affect
4 participants