-
Notifications
You must be signed in to change notification settings - Fork 842
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
[EuiPopover][EuiContextMenu] Attempt to manually restore focus to the toggling button on popover close if focus becomes stranded #5760
Changes from 7 commits
b60e810
8eb8105
ac56282
a429a4c
ae21121
254dc79
75342b4
c01d2f4
39e49e4
148ddb2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -9,6 +9,7 @@ | |
import React, { ReactNode } from 'react'; | ||
import { render, mount } from 'enzyme'; | ||
import { requiredProps } from '../../test/required_props'; | ||
import { EuiFocusTrap } from '../'; | ||
|
||
import { | ||
EuiPopover, | ||
|
@@ -439,6 +440,88 @@ describe('EuiPopover', () => { | |
jest.advanceTimersByTime(10); | ||
}); | ||
}); | ||
|
||
describe('onEscapeKey', () => { | ||
const closePopover = jest.fn(); | ||
const closingTransitionTime = 250; // TODO: DRY out var when converting to CSS-in-JS | ||
|
||
const mockEvent = { | ||
preventDefault: () => {}, | ||
stopPropagation: () => {}, | ||
} as Event; | ||
|
||
beforeAll(() => jest.useFakeTimers()); | ||
beforeEach(() => { | ||
jest.clearAllMocks(); | ||
(document.activeElement as HTMLElement)?.blur(); // Reset focus between tests | ||
}); | ||
afterAll(() => jest.useRealTimers()); | ||
|
||
it('closes the popover and refocuses the toggle button', () => { | ||
const toggleButtonEl = React.createRef<HTMLButtonElement>(); | ||
const toggleButton = <button ref={toggleButtonEl} />; | ||
|
||
const component = mount( | ||
<EuiPopover | ||
isOpen={true} | ||
button={toggleButton} | ||
closePopover={closePopover} | ||
{...requiredProps} | ||
/> | ||
); | ||
component.find(EuiFocusTrap).invoke('onEscapeKey')!(mockEvent); | ||
component.setProps({ isOpen: false }); | ||
jest.advanceTimersByTime(closingTransitionTime); | ||
|
||
expect(closePopover).toHaveBeenCalled(); | ||
expect(document.activeElement).toEqual(toggleButtonEl.current); | ||
}); | ||
|
||
it('refocuses the first nested toggle button on focus trap deactivation', () => { | ||
const toggleButtonEl = React.createRef<HTMLButtonElement>(); | ||
const toggleDiv = ( | ||
<div> | ||
<button ref={toggleButtonEl} tabIndex={-1} /> | ||
Comment on lines
+494
to
+498
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Just want to note that this |
||
<button tabIndex={-1} /> | ||
</div> | ||
); | ||
|
||
const component = mount( | ||
<EuiPopover | ||
isOpen={true} | ||
button={toggleDiv} | ||
closePopover={closePopover} | ||
{...requiredProps} | ||
/> | ||
); | ||
component.find(EuiFocusTrap).invoke('onEscapeKey')!(mockEvent); | ||
component.setProps({ isOpen: false }); | ||
jest.advanceTimersByTime(closingTransitionTime); | ||
|
||
expect(closePopover).toHaveBeenCalled(); | ||
expect(document.activeElement).toEqual(toggleButtonEl.current); | ||
}); | ||
|
||
it('does not refocus if the toggle button is not focusable', () => { | ||
const toggleDivEl = React.createRef<HTMLDivElement>(); | ||
const toggleDiv = <div ref={toggleDivEl} />; | ||
|
||
const component = mount( | ||
<EuiPopover | ||
button={toggleDiv} | ||
isOpen={true} | ||
closePopover={closePopover} | ||
{...requiredProps} | ||
/> | ||
); | ||
component.find(EuiFocusTrap).invoke('onEscapeKey')!(mockEvent); | ||
component.setProps({ isOpen: false }); | ||
jest.advanceTimersByTime(closingTransitionTime); | ||
|
||
expect(closePopover).toHaveBeenCalled(); | ||
expect(document.activeElement).not.toEqual(toggleDivEl.current); | ||
}); | ||
}); | ||
}); | ||
|
||
describe('getPopoverPositionFromAnchorPosition', () => { | ||
|
Original file line number | Diff line number | Diff line change | ||||||||
---|---|---|---|---|---|---|---|---|---|---|
|
@@ -16,7 +16,7 @@ import React, { | |||||||||
RefCallback, | ||||||||||
} from 'react'; | ||||||||||
import classNames from 'classnames'; | ||||||||||
import tabbable from 'tabbable'; | ||||||||||
import { tabbable, focusable } from 'tabbable'; | ||||||||||
|
||||||||||
import { CommonProps, NoArgCallback } from '../common'; | ||||||||||
import { FocusTarget, EuiFocusTrap, EuiFocusTrapProps } from '../focus_trap'; | ||||||||||
|
@@ -281,6 +281,7 @@ function getElementFromInitialFocus( | |||||||||
} | ||||||||||
|
||||||||||
const returnFocusConfig = { preventScroll: true }; | ||||||||||
const closingTransitionTime = 250; // TODO: DRY out var when converting to CSS-in-JS | ||||||||||
|
||||||||||
export type Props = CommonProps & | ||||||||||
HTMLAttributes<HTMLDivElement> & | ||||||||||
|
@@ -383,9 +384,26 @@ export class EuiPopover extends Component<Props, State> { | |||||||||
event.preventDefault(); | ||||||||||
event.stopPropagation(); | ||||||||||
this.closePopover(); | ||||||||||
this.handleStrandedFocus(); | ||||||||||
} | ||||||||||
}; | ||||||||||
|
||||||||||
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. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @thompsongl I ended up doing a complete 180 (🤠) and decided to leave in
Thoughts? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This all makes sense to me 👍 Another idea: Could we use the function version of There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I can definitely give that a shot! I'm definitely curious to see what it sends as the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sadly, it looks like from my logging that 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ah yep that makes sense. Thanks for investigating! There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @thompsongl So it looks like if we comment out lines 268-271 of 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 WDYT? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Let's move forward with this PR. |
||||||||||
if (document.activeElement === document.body) { | ||||||||||
if (!this.button) return; | ||||||||||
|
||||||||||
const focusableItems = focusable(this.button); | ||||||||||
if (!focusableItems.length) return; | ||||||||||
|
||||||||||
const toggleButton = focusableItems[0]; | ||||||||||
toggleButton.focus(returnFocusConfig); | ||||||||||
} | ||||||||||
}, closingTransitionTime); | ||||||||||
}; | ||||||||||
|
||||||||||
onKeyDown = (event: KeyboardEvent) => { | ||||||||||
if (event.key === cascadingMenuKeys.ESCAPE) { | ||||||||||
this.onEscapeKey((event as unknown) as Event); | ||||||||||
|
@@ -541,7 +559,7 @@ export class EuiPopover extends Component<Props, State> { | |||||||||
this.setState({ | ||||||||||
isClosing: false, | ||||||||||
}); | ||||||||||
}, 250); | ||||||||||
}, closingTransitionTime); | ||||||||||
} | ||||||||||
} | ||||||||||
|
||||||||||
|
@@ -776,7 +794,6 @@ export class EuiPopover extends Component<Props, State> { | |||||||||
{...focusTrapProps} | ||||||||||
returnFocus={returnFocus} // Ignore temporary state of indecisive focus | ||||||||||
initialFocus={initialFocus} | ||||||||||
onDeactivation={onTrapDeactivation} | ||||||||||
cee-chen marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||
onClickOutside={this.onClickOutside} | ||||||||||
onEscapeKey={this.onEscapeKey} | ||||||||||
disabled={ | ||||||||||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
**Bug fixes** | ||
|
||
- Fixed focus not being restored to popover toggles for certain complex popover children like `EuiContextMenu` |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.