Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions packages/eui/changelogs/upcoming/8716.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
- Added support for `focusTrapProps.returnFocus` on `EuiFlyout`

72 changes: 69 additions & 3 deletions packages/eui/src/components/flyout/flyout.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
/// <reference types="cypress-real-events" />
/// <reference types="../../../cypress/support" />

import React, { useState } from 'react';
import React, { useRef, useState } from 'react';

import { EuiGlobalToastList } from '../toast';
import {
Expand All @@ -22,6 +22,7 @@ import { EuiCollapsibleNavBeta } from '../collapsible_nav_beta';
import { EuiFlyout } from './flyout';
import { EuiCollapsibleNav, EuiCollapsibleNavGroup } from '../collapsible_nav';
import { EuiIcon } from '../icon';
import { EuiButton } from '../button';

const childrenDefault = (
<>
Expand All @@ -32,11 +33,23 @@ const childrenDefault = (
</>
);

const Flyout = ({ children = childrenDefault, ...rest }) => {
const [isOpen, setIsOpen] = useState(true);
const Flyout = ({
children = childrenDefault,
hasTrigger = false,
...rest
}) => {
const [isOpen, setIsOpen] = useState(hasTrigger ? false : true);

return (
<>
{hasTrigger && (
<EuiButton
onClick={() => setIsOpen(!isOpen)}
data-test-subj="flyoutSpecTrigger"
>
Trigger
</EuiButton>
)}
{isOpen ? (
<EuiFlyout
data-test-subj="flyoutSpec"
Expand Down Expand Up @@ -81,6 +94,59 @@ describe('EuiFlyout', () => {
});
});

describe('Return focus behavior', () => {
it('returns focus to the trigger on close', () => {
cy.mount(<Flyout hasTrigger />);

cy.get('[data-test-subj="flyoutSpecTrigger"]').click();
cy.get('[data-test-subj="flyoutSpec"]').should('be.focused');
cy.get('[data-test-subj="euiFlyoutCloseButton"]').click();
cy.get('[data-test-subj="flyoutSpecTrigger"]').should('be.focused');
});

it('does not return focus to the trigger when `focusTrapProps.returnFocus` is `false`', () => {
cy.mount(<Flyout hasTrigger focusTrapProps={{ returnFocus: false }} />);

cy.get('[data-test-subj="flyoutSpecTrigger"]').click();
cy.get('[data-test-subj="flyoutSpec"]').should('be.focused');
cy.get('[data-test-subj="euiFlyoutCloseButton"]').click();
cy.get('[data-test-subj="flyoutSpecTrigger"]').should('not.be.focused');
});

it('returns focus to a custom element', () => {
const FlyoutWrapper = () => {
const customTriggerRef = useRef<HTMLButtonElement>(null);

return (
<>
<EuiButton
buttonRef={customTriggerRef}
data-test-subj="flyoutSpecCustomTrigger"
>
Custom trigger
</EuiButton>
<Flyout
hasTrigger
focusTrapProps={{
returnFocus: () => {
customTriggerRef.current?.focus();
return false;
},
}}
/>
</>
);
};
cy.mount(<FlyoutWrapper />);

cy.get('[data-test-subj="flyoutSpecTrigger"]').click();
cy.get('[data-test-subj="flyoutSpec"]').should('be.focused');
cy.get('[data-test-subj="euiFlyoutCloseButton"]').click();
cy.get('[data-test-subj="flyoutSpecTrigger"]').should('not.be.focused');
cy.get('[data-test-subj="flyoutSpecCustomTrigger"]').should('be.focused');
});
});

describe('Close behavior: standard', () => {
it('closes the flyout when the close button is clicked', () => {
cy.mount(<Flyout />);
Expand Down
78 changes: 71 additions & 7 deletions packages/eui/src/components/flyout/flyout.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@
* Side Public License, v 1.
*/

import React, { useState } from 'react';
import React, { useRef, useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import { action } from '@storybook/addon-actions';

import { EuiButton, EuiCallOut, EuiText, EuiTitle } from '../index';
import { EuiButton, EuiCallOut, EuiSpacer, EuiText, EuiTitle } from '../index';

import {
EuiFlyout,
Expand Down Expand Up @@ -54,18 +54,29 @@ type Story = StoryObj<EuiFlyoutProps>;

const onClose = action('onClose');

const StatefulFlyout = (props: Partial<EuiFlyoutProps>) => {
const [isOpen, setIsOpen] = useState(true);
const StatefulFlyout = (
props: Partial<
EuiFlyoutProps & { isOpen: boolean; onToggle: (open: boolean) => void }
>
) => {
const { isOpen, onToggle } = props;
const [_isOpen, setIsOpen] = useState(isOpen ?? true);

const handleToggle = (open: boolean) => {
setIsOpen(open);
onToggle?.(open);
};

return (
<>
<EuiButton size="s" onClick={() => setIsOpen(!isOpen)}>
<EuiButton size="s" onClick={() => handleToggle(!_isOpen)}>
Toggle flyout
</EuiButton>
{isOpen && (
{_isOpen && (
<EuiFlyout
{...props}
onClose={(...args) => {
setIsOpen(false);
handleToggle(false);
onClose(...args);
}}
/>
Expand Down Expand Up @@ -137,6 +148,59 @@ export const PushFlyouts: Story = {
},
};

export const ManualReturnFocus: Story = {
parameters: {
controls: {
include: ['focusTrapProps'],
},
},
args: {
children: (
<>
<EuiFlyoutHeader hasBorder>
<EuiTitle size="m">
<h2>Flyout header</h2>
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody>Flyout body</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiButton fill>Flyout footer</EuiButton>
</EuiFlyoutFooter>
</>
),
},
render: function Render({ ...args }) {
const manualTriggerRef = useRef<HTMLButtonElement>(null);

return (
<>
<EuiButton size="s" buttonRef={manualTriggerRef}>
Manual trigger
</EuiButton>
<EuiSpacer size="s" />
<StatefulFlyout
{...args}
focusTrapProps={{
...args.focusTrapProps,
returnFocus: (returnTo: Element) => {
if (manualTriggerRef.current) {
manualTriggerRef.current?.focus();
return false;
}

if (returnTo && returnTo !== document.body) {
return true;
}

return false;
},
}}
/>
</>
);
},
};

export const HighContrast: Story = {
tags: ['vrt-only'],
globals: { highContrastMode: true },
Expand Down
6 changes: 5 additions & 1 deletion packages/eui/src/components/flyout/flyout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -145,8 +145,12 @@ interface _EuiFlyoutProps {
* Object of props passed to EuiFocusTrap.
* `shards` specifies an array of elements that will be considered part of the flyout, preventing the flyout from being closed when clicked.
* `closeOnMouseup` will delay the close callback, allowing time for external toggle buttons to handle close behavior.
* `returnFocus` defines the return focus behavior and provides the possibility to check the available target element or opt out of the behavior in favor of manually returning focus
*/
focusTrapProps?: Pick<EuiFocusTrapProps, 'closeOnMouseup' | 'shards'>;
focusTrapProps?: Pick<
EuiFocusTrapProps,
'closeOnMouseup' | 'shards' | 'returnFocus'
>;
/**
* By default, EuiFlyout will consider any fixed `EuiHeader`s that sit alongside or above the EuiFlyout
* as part of the flyout's focus trap. This prevents focus fighting with interactive elements
Expand Down