[EuiPopover][EuiContextMenu] Attempt to manually restore focus to the toggling button on popover close if focus becomes stranded#5760
Conversation
+ update import/fix types
…gle button on trap deactivation
| "remark-parse": "^8.0.3", | ||
| "remark-rehype": "^8.0.0", | ||
| "tabbable": "^3.0.0", | ||
| "tabbable": "^5.2.1", |
There was a problem hiding this comment.
I upgraded tabbable because I wanted their new focusable API, and also generally just to get us on the latest. This has some amount of risk but I've briefly QA'd potentially affected components (EuiContextMenu, EuiDataGrid, EuiComboBox, EuiSuperSelect) and have not found any UX issues.
src/components/popover/popover.tsx
Outdated
| const focusableItems = focusable(this.button); | ||
| if (focusableItems.length) { | ||
| const toggleButton = focusableItems[0]; | ||
| requestAnimationFrame(() => toggleButton.focus(returnFocusConfig)); |
There was a problem hiding this comment.
A couple notes here:
-
Q: Why
focusableand nottabbable?
A: I actually ran into this on EuiDataGrid while testing/attempting to fix some of its focus shenanigans (separate from this PR). If a consumer setstabIndex={-1}on a button (or in my case, a grid cell), it is still focusable but not tabbable - and it is still better for keyboard users to attempt to return to this element rather than being stranded at the top of the document - thus the need for specifyingfocusableovertabbable. -
Q: Why the
requestAnimationFramedelay?
A: This one comes from react-focus-lock's docs:[...] if you are using the
disabledprop to control FocusLock, you will need a zero-timeout to correctly restore focus.
| it('refocuses the first nested toggle button on focus trap deactivation', () => { | ||
| const toggleButtonEl = React.createRef<HTMLButtonElement>(); | ||
| const toggleDiv = ( | ||
| <div> | ||
| <button ref={toggleButtonEl} tabIndex={-1} /> |
There was a problem hiding this comment.
Just want to note that this button DOM setup isn't common, and is most likely to come from EuiWrappingPopover (which adds a div wrapper as a button prop) - hence the need for using focusable() in the first place over simply attempting to focus directly onto the button prop
|
Preview documentation changes for this PR: https://eui.elastic.co/pr_5760/ |
|
This is definitely edge-case-y, but maybe you can explain or have ideas. It looks like when a form element has focus prior to I'm not terribly worried because this will be a huge improvement regardless Screen.Recording.2022-04-05.at.2.59.42.PM.mov |
|
Ah nice catch, thanks Greg! Looking into this now, also confused as to why it would be happening 🤔 |
|
It looks like it's Any preference? With this new code on place, passing |
|
Actually, I also just realized I should be putting this code in the |
+ improve unit tests to use .invoke()
That sounds right
Let's try that. It'd be cool if the function version of |
- Instead of always running this logic, wait the popover to finish closing and check for stranded document.activeElement first before manually restoring focus
i've come to update you again because a code change softly creeping left its diffs while I was sleeping
| handleStrandedFocus = () => { | ||
| setTimeout(() => { | ||
| // If `returnFocus` failed and focus was stranded on the body, | ||
| // attempt to manually restore focus to the toggle button |
There was a problem hiding this comment.
@thompsongl I ended up doing a complete 180 (🤠) and decided to leave in returnFocus as-is and instead narrowed down the scope of this bugfix further. This was for a few reasons:
-
Removing
returnFocusended up stranding cell focus completely for EuiDataGrid, since thebuttonanchor it passes is the child of the interactive cell and so it never returns a focusable element. In theory [EuiDataGrid] When a cell expansion popover is closed, ensure focus is always returned to the originating cell for keyboard users #5761 addresses this but as a separate issue - but I got worried at that point there were other cases wherereturnFocuswas working better than this code and decided to leave it in. -
focusable()iteration is slightly less performant than the simpler ref tracking react-focus-lock uses, so it makes sense to prefer it as a fallback and not as a default -
Also, it turns out focus doesn't get 'fully' stranded back to the document body until the close popover animation finishes running, so I had to abstract out that duration (250ms) and use that as the setTimeout for this check, which handles the issue of this logic fighting
returnFocus.
Thoughts?
There was a problem hiding this comment.
This all makes sense to me 👍
Another idea: Could we use the function version of returnFocus, which would get the to-be-focused element as its argument, and then return false if it's document.body and run your stranded focus function? If the arg to returnFocus is valid return the returnFocus object; then no need to run all focusable stuff.
There was a problem hiding this comment.
I can definitely give that a shot! I'm definitely curious to see what it sends as the returnFocusTo arg
There was a problem hiding this comment.
Sadly, it looks like from my logging that EuiContextMenu is the receiving the popover content as the returnFocusTo (see first 3 log lines) and not the toggling button (see last 2 logs, which are coming from a standard EuiPopover).
That does explain why the focus is getting stranded; it's attempting to return to a DOM node that's getting deleted essentially. My best guess is that because EuiContextMenu does so much focus hijacking that it's focusing on the panel too early and causing document.activeElement to be incorrect on activation.
There was a problem hiding this comment.
Ah yep that makes sense. Thanks for investigating!
There was a problem hiding this comment.
@thompsongl So it looks like if we comment out lines 268-271 of context_menu_panel.tsx:
eui/src/components/context_menu/context_menu_panel.tsx
Lines 268 to 271 in b60e810
Then none of the code in this PR is specifically needed. The above lines specifically are what's causing the early focus hijacking. I don't see a huge downside to removing the above lines as EuiPopover/EuiFocusTrap handles focusing the popover wrapper in any case if no children are focusable.
However, I still feel this PR is overall a more robust solution: it's 100% possible for any consumer to .focus() something randomly upon opening a popover and cause react-focus-lock's document.activeElement to be incorrect. This PR handles cases that we don't control and with fairly limited scope, and IMO is a net benefit to not just EuiContextMenu.
WDYT?
There was a problem hiding this comment.
Let's move forward with this PR. EuiContextMenu is not necessarily always in an EuiPopover so we can't rely entirely on the focus mechanism(s) it provides.
|
Preview documentation changes for this PR: https://eui.elastic.co/pr_5760/ |
+ improve listener cleanup unit tests
|
Preview documentation changes for this PR: https://eui.elastic.co/pr_5760/ |
thompsongl
left a comment
There was a problem hiding this comment.
There's some on-close logic in componentDidUpdate. We don't need to account for stranded focus in that scenario, right?
|
Preview documentation changes for this PR: https://eui.elastic.co/pr_5760/ |
|
jenkins test this |
|
Preview documentation changes for this PR: https://eui.elastic.co/pr_5760/ |
thompsongl
left a comment
There was a problem hiding this comment.
LGTM! Thanks for the new tests!
- this makes it so EuiContextMenu doesn't call `.focus()` too early and hijack the `returnFocus` element that our focus trap dependency sets via `document.activeElement` - see elastic#5760 (comment)
- this makes it so EuiContextMenu doesn't call `.focus()` too early and hijack the `returnFocus` element that our focus trap dependency sets via `document.activeElement` - see elastic#5760 (comment) + Add Cypress E2E tests for popover close focus return (w/ bonus `initialFocus` regression test)
… remove need for `watchedItemProps` (#5880) * [misc] move componentDidUpdate fn - move it closer to updateFocus / componentDidMount for easier context between the other two methods * [setup] Refactor popover parent finding logic - move to separate method - create instance var - specify `initialPopover` and add `transitionType` check, as the popover doesn't re-initialize when moving between panels in the same popover, and we don't want this to re-fire unnecessarily - add E2E tests for popover behavior * [fix] Add a wait condition/state for popover focus - this makes it so EuiContextMenu doesn't call `.focus()` too early and hijack the `returnFocus` element that our focus trap dependency sets via `document.activeElement` - see #5760 (comment) + Add Cypress E2E tests for popover close focus return (w/ bonus `initialFocus` regression test) * [!!!] EuiContextMenuPanels with `children` are still broken and do not correctly return focus :( - this is because of `shouldComponentUpdate` - the `items` API updates focus less than `children`, so `children` is still updating/hijacking focus after the popover focus trap returns focus to the button * [!!!] Remove `shouldComponentUpdate` logic & `watchedItemProps` - replace `updateFocus` with `takeInitialFocus`, and do not continue to update/hijack focus once initial focus has been set - this removes the need to restrict how often `EuiContextMenuPanel` updates (which also requires a bunch of tedious `items` diffing that we will no longer need) * [!!!] Move `tabbable` iteration out of focus logic and into its own method - it shouldn't be tied to the focus call anymore since the focus call no longer occurs after update, and makes more sense as a separate call + updates to logic: - do not run `tabbable` on `children` API since it won't even use the navigation - return early - use `this.backButton` instead for back button focusing, since `children` will no longer have `menuItems` - Check for a valid focusedItem - it's possible for consumers to either pass in bad indices or for `items` to update and the index to no longer exist - Add E2E tests confirming changes & new logic work as expected * [!!!] Restore up/down key navigation behavior - rename `incrementFocusedItemIndex` to `focusMenuItem` and change args to be a bit more human readable - instead of having the previous `updateFocus` handle up/down nav, we can simply call `.focus()` from within this method, and arrow navigation works as before - note `?.focus();` - this is important to keep as users can start mashing up/down before `tabbable` is done running and there are any menu items to focus - no specific E2E tests for this, tests should simply not be failing * changelog * [PR feedback] Prefer not to hook into className for popover panel

Summary
closes #5404
This PR addresses EuiContextMenu (and other potential popovers with complex content that may inadvertantly hijack focus or confuse the popover focus trap) by always attempting to manually focus back to the passed
buttonelement (or any focusable children within the passedbutton).This PR also upgrades our
tabbabledependency to the latest so that we can obtain its newfocusableAPI.Before
After
Checklist
- [ ] Checked in both light and dark modes- [ ] Checked in mobile- [ ] Props have proper autodocs and playground toggles- [ ] Added documentation- [ ] Checked Code Sandbox works for any docs examples- [ ] Checked for breaking changes and labeled appropriately- [ ] Updated the Figma library counterpart