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

[EuiPopover] Focus on parent popover panel by default, instead of first tabbable child #5784

Merged
merged 10 commits into from
Apr 19, 2022
40 changes: 40 additions & 0 deletions src-docs/src/views/popover/initial_focus.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import React, { useState } from 'react';

import {
EuiButton,
EuiFormRow,
EuiPopover,
EuiSpacer,
EuiFieldText,
} from '../../../../src/components';

export default () => {
const [isPopoverOpen, setIsPopoverOpen] = useState(false);

const onButtonClick = () =>
setIsPopoverOpen((isPopoverOpen) => !isPopoverOpen);
const closePopover = () => setIsPopoverOpen(false);

const button = (
<EuiButton iconType="arrowDown" iconSide="right" onClick={onButtonClick}>
Show popover
</EuiButton>
);

return (
<EuiPopover
initialFocus="#name"
button={button}
isOpen={isPopoverOpen}
closePopover={closePopover}
>
<EuiFormRow label="Enter name" id="name">
<EuiFieldText compressed name="input" />
</EuiFormRow>

<EuiSpacer />

<EuiButton fill>Submit</EuiButton>
</EuiPopover>
);
};
45 changes: 45 additions & 0 deletions src-docs/src/views/popover/popover_example.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ import {
import Popover from './popover';
const popoverSource = require('!!raw-loader!./popover');

import InitialFocus from './initial_focus';
const initialFocusSource = require('!!raw-loader!./initial_focus');

import TrapFocus from './trap_focus';
const trapFocusSource = require('!!raw-loader!./trap_focus');

Expand Down Expand Up @@ -60,6 +63,14 @@ const trapFocusSnippet = `<EuiPopover
<!-- Popover content -->
</EuiPopover>`;

const initialFocusSnippet = `<EuiPopover
initialFocus=".someSelector"
button={button}
isOpen={isPopoverOpen}
closePopover={closePopover}>
<!-- Popover content -->
</EuiPopover>`;

const popoverAnchorSnippet = `<EuiPopover
button={button}
isOpen={isPopoverOpen}
Expand Down Expand Up @@ -356,6 +367,40 @@ export const PopoverExample = {
snippet: inputPopoverSnippet,
demo: <InputPopover />,
},
{
title: 'Setting an initial focus',
source: [
{
type: GuideSectionTypes.JS,
code: initialFocusSource,
},
],
text: (
<>
<p>
If you want a specific child element of the popover to immediately
gain focus when the popover is open, use the{' '}
<EuiCode language="ts">initialFocus</EuiCode> prop to pass either a
selector or DOM node.
</p>
<EuiCallOut
iconType="accessibility"
color="warning"
title={
<>
It can be jarring for keyboard and screen reader users to
immediately land on an element with no other context. To
alleviate this, ensure that your initial focus target makes
sense alone or is the primary goal of the popover.
</>
}
/>
</>
),
props: { EuiPopover },
snippet: initialFocusSnippet,
demo: <InitialFocus />,
},
{
title: 'Removing the focus trap',
source: [
Expand Down
27 changes: 11 additions & 16 deletions src-docs/src/views/popover/trap_focus.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';

import {
EuiButton,
Expand All @@ -13,15 +13,6 @@ import { useGeneratedHtmlId } from '../../../../src/services';
export default () => {
const [isPopoverOpen, setIsPopoverOpen] = useState(false);

const trapFocusFormRowId__1 = useGeneratedHtmlId({
prefix: 'trapFocusFormRow',
suffix: 'first',
});
const trapFocusFormRowId__2 = useGeneratedHtmlId({
prefix: 'trapFocusFormRow',
suffix: 'second',
});

const onButtonClick = () =>
setIsPopoverOpen((isPopoverOpen) => !isPopoverOpen);
const closePopover = () => setIsPopoverOpen(false);
Expand All @@ -32,17 +23,24 @@ export default () => {
</EuiButton>
);

// Since `hasFocus={false}` disables popover auto focus, we need to manually set it ourselves
const focusId = useGeneratedHtmlId();
useEffect(() => {
if (isPopoverOpen) {
document.getElementById(focusId).focus({ preventScroll: true });
Copy link
Contributor

Choose a reason for hiding this comment

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

👍 on the { preventScroll: true } object here. It's a good UX buffer.

}
}, [isPopoverOpen, focusId]);

return (
<EuiPopover
ownFocus={false}
button={button}
isOpen={isPopoverOpen}
closePopover={closePopover}
initialFocus={`[id=${trapFocusFormRowId__1}]`}
cee-chen marked this conversation as resolved.
Show resolved Hide resolved
>
<EuiFormRow
label="Generate a public snapshot?"
id={trapFocusFormRowId__1}
id={focusId}
hasChildLabel={false}
>
<EuiSwitch
Expand All @@ -53,10 +51,7 @@ export default () => {
/>
</EuiFormRow>

<EuiFormRow
label="Include the following in the embed"
id={trapFocusFormRowId__2}
>
<EuiFormRow label="Include the following in the embed">
<EuiSwitch
name="switch"
label="Current time range"
Expand Down
14 changes: 14 additions & 0 deletions src/components/context_menu/context_menu_panel.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

import React from 'react';

import { EuiPopover } from '../popover';
import { EuiContextMenuItem } from './context_menu_item';
import { EuiContextMenuPanel } from './context_menu_panel';

Expand Down Expand Up @@ -44,6 +45,19 @@ describe('EuiContextMenuPanel', () => {
);
cy.focused().should('not.exist');
});

describe('when inside an EuiPopover', () => {
it('reclaims focus from the parent popover panel', () => {
cy.mount(
<EuiPopover isOpen={true} button={<button />}>
<EuiContextMenuPanel items={items} />
</EuiPopover>
);
cy.wait(400); // EuiPopover's updateFocus() takes ~350ms to run
cy.focused().should('not.have.attr', 'class', 'euiPopover__panel');
cy.focused().should('have.attr', 'class', 'euiContextMenuPanel');
});
});
});

describe('Keyboard navigation of items', () => {
Expand Down
30 changes: 30 additions & 0 deletions src/components/context_menu/context_menu_panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,35 @@ export class EuiContextMenuPanel extends Component<Props, State> {
});
}

// If EuiContextMenu is used within an EuiPopover, EuiPopover's own
// `updateFocus()` method hijacks EuiContextMenuPanel's `updateFocus()`
// 350ms after the popover finishes transitioning in. This workaround
// reclaims focus from parent EuiPopovers that do not set an `initialFocus`
reclaimPopoverFocus() {
Comment on lines +267 to +271
Copy link
Member Author

Choose a reason for hiding this comment

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

Just to add more context for this commit (4958d95), which attempts to resolve #4973:

Unfortunately after testing both #5783 and #5784 together, focus still wasn't working as expected - EuiPopover was still taking focus away from EuiContextMenuPanel, and I don't think we can realistically ask every single consumer to always set <EuiPopover initialFocus={false} /> in conjunction with <EuiContextMenu />. I mean we can, but I think they'll forget to do it, and I'd rather use a workaround to ensure keyboard users aren't SOL.

I'm not a super huge fan of this DOM-heavy workaround but I think it's better than adding a hard-coded 350ms setTimeout to EuiContextMenuPanel 😬 Definitely open to other ideas/alternatives if folks can think of any!

Copy link
Member Author

Choose a reason for hiding this comment

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

Also in hindisght I realize this comment is hilarious in light of #5760 (comment). EuiPopover and EuiContextMenuPanel, name a more iconic duo... for stealing focus from one another and then requiring separate workarounds 😂

if (!this.panel) return;

const parent = this.panel.parentNode as HTMLElement;
if (!parent) return;
const hasEuiContextMenuParent = parent.classList.contains('euiContextMenu');

// It's possible to use an EuiContextMenuPanel directly in a popover without
// an EuiContextMenu, so we need to account for that when searching parent nodes
const popoverParent = hasEuiContextMenuParent
? (parent?.parentNode?.parentNode as HTMLElement)
: (parent?.parentNode as HTMLElement);
if (!popoverParent) return;

const hasPopoverParent = popoverParent.classList.contains(
'euiPopover__panel'
);
if (!hasPopoverParent) return;

// If the popover panel gains focus, switch it to the context menu panel instead
popoverParent.addEventListener('focus', () => {
this.updateFocus();
});
}

onTransitionComplete = () => {
if (this.props.onTransitionComplete) {
this.props.onTransitionComplete();
Expand All @@ -280,6 +309,7 @@ export class EuiContextMenuPanel extends Component<Props, State> {

componentDidMount() {
this.updateFocus();
this.reclaimPopoverFocus();
this._isMounted = true;
}

Expand Down
87 changes: 87 additions & 0 deletions src/components/popover/popover.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/

/// <reference types="../../../cypress/support"/>

import React, { useState } from 'react';

import { EuiButton } from '../button';
import { EuiPopover } from './popover';

const PopoverComponent = ({ children, ...rest }) => {
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const closePopover = () => setIsPopoverOpen(false);
const togglePopover = () =>
setIsPopoverOpen((isPopoverOpen) => !isPopoverOpen);

const button = (
<EuiButton onClick={togglePopover} data-test-subj="togglePopover">
Show popover
</EuiButton>
);

return (
<EuiPopover
panelProps={{ 'data-test-subj': 'popoverPanel' }}
button={button}
isOpen={isPopoverOpen}
closePopover={closePopover}
{...rest}
>
{children}
</EuiPopover>
);
};

describe('EuiPopover', () => {
describe('focus behavior', () => {
it('focuses the panel wrapper by default', () => {
cy.mount(<PopoverComponent>Test</PopoverComponent>);
cy.get('[data-test-subj="togglePopover"]').click();
cy.focused().should('have.attr', 'data-test-subj', 'popoverPanel');
});

it('does not focus anything if `ownFocus` is false', () => {
cy.mount(<PopoverComponent ownFocus={false}>Test</PopoverComponent>);
cy.get('[data-test-subj="togglePopover"]').click();
cy.focused().should('have.attr', 'data-test-subj', 'togglePopover');
});

describe('initialFocus', () => {
it('does not focus anything if `initialFocus` is false', () => {
cy.mount(
<PopoverComponent initialFocus={false}>Test</PopoverComponent>
);
cy.get('[data-test-subj="togglePopover"]').click();
cy.focused().should('have.attr', 'data-test-subj', 'togglePopover');
});

it('focuses selector strings', () => {
cy.mount(
<PopoverComponent initialFocus="#test">
<button id="test">Test</button>
</PopoverComponent>
);
cy.get('[data-test-subj="togglePopover"]').click();
cy.focused().should('have.attr', 'id', 'test');
});

it('focuses functions returning DOM Nodes', () => {
cy.mount(
<PopoverComponent
initialFocus={() => document.getElementById('test')}
>
<button id="test">Test</button>
</PopoverComponent>
);
cy.get('[data-test-subj="togglePopover"]').click();
cy.focused().should('have.attr', 'id', 'test');
});
});
});
});
11 changes: 3 additions & 8 deletions src/components/popover/popover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import React, {
RefCallback,
} from 'react';
import classNames from 'classnames';
import { tabbable, focusable } from 'tabbable';
import { focusable } from 'tabbable';

import { CommonProps, NoArgCallback } from '../common';
import { FocusTarget, EuiFocusTrap, EuiFocusTrapProps } from '../focus_trap';
Expand Down Expand Up @@ -438,16 +438,11 @@ export class EuiPopover extends Component<Props, State> {
return;
}

// Otherwise let's focus the first tabbable item and expedite input from the user.
// Otherwise focus either `initialFocus` or the panel
let focusTarget;

if (this.props.initialFocus != null) {
focusTarget = getElementFromInitialFocus(this.props.initialFocus);
} else {
const tabbableItems = tabbable(this.panel);
if (tabbableItems.length) {
focusTarget = tabbableItems[0];
}
}

// there's a race condition between the popover content becoming visible and this function call
Expand All @@ -456,7 +451,7 @@ export class EuiPopover extends Component<Props, State> {
if (focusTarget == null) {
// there isn't a focus target, one of two reasons:
// #1 is the whole panel hidden? If so, schedule another check
// #2 panel is visible but no tabbables exist, move focus to the panel
// #2 panel is visible and no `initialFocus` was set, move focus to the panel
const panelVisibility = window.getComputedStyle(this.panel).opacity;
if (panelVisibility === '0') {
// #1
Expand Down
3 changes: 3 additions & 0 deletions upcoming_changelogs/5784.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
**Breaking changes**

- `EuiPopover`s will no longer focus the first tabbable child by default - instead, the popover panel will be focused. This change should be a less better experience for both keyboard and screen reader users. Consumers who want to set an initial focus on specific popover element should use the `initialFocus` prop.
cee-chen marked this conversation as resolved.
Show resolved Hide resolved