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
3 changes: 3 additions & 0 deletions packages/eui/changelogs/upcoming/8849.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
- Added `includeSelectorInFocusTrap` prop for `EuiFlyout`
- Added component defaults for `EuiFlyout` that include `includeSelectorInFocusTrap` and `includeFixedHeadersInFocusTrap`

Original file line number Diff line number Diff line change
Expand Up @@ -1125,7 +1125,7 @@ exports[`EuiFlyout renders extra screen reader instructions when fixed EuiHeader
>
You are in a modal dialog. Press Escape or tap/click outside the dialog on the shadowed overlay to close.

You can still continue tabbing through the page headers in addition to the dialog.
You can still continue tabbing through other global page landmarks.
</p>
<button
aria-label="Close this dialog"
Expand Down
72 changes: 67 additions & 5 deletions packages/eui/src/components/flyout/flyout.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { shouldRenderCustomStyles } from '../../test/internal';

import { EuiHeader } from '../header';
import { EuiFlyout, SIZES, PADDING_SIZES, SIDES } from './flyout';
import { EuiProvider } from '../provider';

jest.mock('../overlay_mask', () => ({
EuiOverlayMask: ({ headerZindexLocation, maskRef, ...props }: any) => (
Expand Down Expand Up @@ -51,10 +52,7 @@ describe('EuiFlyout', () => {

expect(baseElement).toMatchSnapshot();
expect(
queryByText(
'You can still continue tabbing through the page headers in addition to the dialog.',
{ exact: false }
)
queryByText('You can still continue tabbing through', { exact: false })
).toBeTruthy();

// Should not shard or render instructions when `includeFixedHeadersInFocusTrap={false}
Expand All @@ -65,12 +63,30 @@ describe('EuiFlyout', () => {
</>
);
expect(
queryByText('You can still continue tabbing through the page headers', {
queryByText('You can still continue tabbing through', {
exact: false,
})
).toBeFalsy();
});

it('renders extra screen reader instructions when specified selector exists on the page', () => {
const { queryByText } = render(
<>
<div data-custom-sidebar />
<EuiFlyout
{...requiredProps}
onClose={() => {}}
includeSelectorInFocusTrap={'[data-custom-sidebar]'}
includeFixedHeadersInFocusTrap={false}
/>
</>
);

expect(
queryByText('You can still continue tabbing through', { exact: false })
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I am not sure what the best way is to test the focusTrap props or functionality, so I will stick to this workaround that was already here.

).toBeTruthy();
});

it('allows setting custom aria-describedby attributes', () => {
const { getByTestSubject } = render(
<>
Expand Down Expand Up @@ -308,4 +324,50 @@ describe('EuiFlyout', () => {
});
});
});

describe('component defaults', () => {
test('includeSelectorInFocusTrap', () => {
const { queryByText } = render(
<EuiProvider
componentDefaults={{
EuiFlyout: {
includeSelectorInFocusTrap: ['[data-custom-sidebar]'],
},
}}
>
<div data-custom-sidebar />
<EuiFlyout {...requiredProps} onClose={() => {}} />
</EuiProvider>,
{
wrapper: undefined,
}
);

expect(
queryByText('You can still continue tabbing through', { exact: false })
).toBeTruthy();
});

test('includeFixedHeadersInFocusTrap', () => {
const { queryByText } = render(
<EuiProvider
componentDefaults={{
EuiFlyout: {
includeFixedHeadersInFocusTrap: false,
},
}}
>
<EuiHeader position="fixed" />
<EuiFlyout {...requiredProps} onClose={() => {}} />
</EuiProvider>,
{
wrapper: undefined,
}
);

expect(
queryByText('You can still continue tabbing through', { exact: false })
).not.toBeTruthy();
});
});
});
74 changes: 50 additions & 24 deletions packages/eui/src/components/flyout/flyout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import { EuiFlyoutCloseButton } from './_flyout_close_button';
import { euiFlyoutStyles } from './flyout.styles';
import { EuiFlyoutChild } from './flyout_child';
import { EuiFlyoutChildProvider } from './flyout_child_manager';
import { usePropsWithComponentDefaults } from '../provider/component_defaults';

export const TYPES = ['push', 'overlay'] as const;
type _EuiFlyoutType = (typeof TYPES)[number];
Expand Down Expand Up @@ -162,6 +163,11 @@ interface _EuiFlyoutProps {
* Set this to `false` if you need to disable this behavior for a specific reason.
*/
includeFixedHeadersInFocusTrap?: boolean;

/**
* Specify additional css selectors to include in the focus trap.
*/
includeSelectorInFocusTrap?: string[] | string;
}

const defaultElement = 'div';
Expand All @@ -179,7 +185,13 @@ export type EuiFlyoutProps<T extends ElementType = typeof defaultElement> =

export const EuiFlyout = forwardRef(
<T extends ElementType = typeof defaultElement>(
{
props: EuiFlyoutProps<T>,
ref:
| ((instance: ComponentPropsWithRef<T> | null) => void)
| MutableRefObject<ComponentPropsWithRef<T> | null>
| null
) => {
const {
className,
children,
as,
Expand All @@ -200,14 +212,11 @@ export const EuiFlyout = forwardRef(
pushAnimation = false,
focusTrapProps: _focusTrapProps,
includeFixedHeadersInFocusTrap = true,
includeSelectorInFocusTrap,
'aria-describedby': _ariaDescribedBy,
...rest
}: EuiFlyoutProps<T>,
ref:
| ((instance: ComponentPropsWithRef<T> | null) => void)
| MutableRefObject<ComponentPropsWithRef<T> | null>
| null
) => {
} = usePropsWithComponentDefaults('EuiFlyout', props);

const Element = as || defaultElement;
const maskRef = useRef<HTMLDivElement>(null);

Expand Down Expand Up @@ -359,34 +368,51 @@ export const EuiFlyout = forwardRef(
* (both mousedown and mouseup) the overlay mask.
*/
const flyoutToggle = useRef<Element | null>(document.activeElement);
const [fixedHeaders, setFixedHeaders] = useState<HTMLDivElement[]>([]);
const [focusTrapShards, setFocusTrapShards] = useState<HTMLElement[]>([]);

const focusTrapSelectors = useMemo(() => {
let selectors: string[] = [];

if (includeSelectorInFocusTrap) {
selectors = Array.isArray(includeSelectorInFocusTrap)
? includeSelectorInFocusTrap
: [includeSelectorInFocusTrap];
}

useEffect(() => {
if (includeFixedHeadersInFocusTrap) {
const fixedHeaderEls = document.querySelectorAll<HTMLDivElement>(
'.euiHeader[data-fixed-header]'
selectors.push('.euiHeader[data-fixed-header]');
}

return selectors;
}, [includeSelectorInFocusTrap, includeFixedHeadersInFocusTrap]);

useEffect(() => {
if (focusTrapSelectors.length > 0) {
const shardsEls = focusTrapSelectors.flatMap((selector) =>
Array.from(document.querySelectorAll<HTMLElement>(selector))
);
setFixedHeaders(Array.from(fixedHeaderEls));

// Flyouts that are toggled from fixed headers do not have working
setFocusTrapShards(Array.from(shardsEls));

// Flyouts that are toggled from shards do not have working
// focus trap autoFocus, so we need to focus the flyout wrapper ourselves
fixedHeaderEls.forEach((header) => {
if (header.contains(flyoutToggle.current)) {
shardsEls.forEach((shard) => {
if (shard.contains(flyoutToggle.current)) {
resizeRef?.focus();
}
});
} else {
// Clear existing headers if necessary, e.g. switching to `false`
setFixedHeaders((headers) => (headers.length ? [] : headers));
// Clear existing shards if necessary, e.g. switching to `false`
setFocusTrapShards((shards) => (shards.length ? [] : shards));
}
}, [includeFixedHeadersInFocusTrap, resizeRef]);
}, [focusTrapSelectors, resizeRef]);

const focusTrapProps: EuiFlyoutProps['focusTrapProps'] = useMemo(
() => ({
..._focusTrapProps,
shards: [...fixedHeaders, ...(_focusTrapProps?.shards || [])],
shards: [...focusTrapShards, ...(_focusTrapProps?.shards || [])],
}),
[_focusTrapProps, fixedHeaders]
[_focusTrapProps, focusTrapShards]
);

/*
Expand All @@ -411,16 +437,16 @@ export const EuiFlyout = forwardRef(
default="You are in a non-modal dialog. To close the dialog, press Escape."
/>
)}{' '}
{fixedHeaders.length > 0 && (
{focusTrapShards.length > 0 && (
<EuiI18n
token="euiFlyout.screenReaderFixedHeaders"
default="You can still continue tabbing through the page headers in addition to the dialog."
token="euiFlyout.screenReaderFocusTrapShards"
default="You can still continue tabbing through other global page landmarks."
/>
)}
</p>
</EuiScreenReaderOnly>
),
[hasOverlayMask, descriptionId, fixedHeaders.length]
[hasOverlayMask, descriptionId, focusTrapShards.length]
);

/*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import React, {
import type { EuiPortalProps } from '../../portal';
import type { EuiFocusTrapProps } from '../../focus_trap';
import type { EuiTablePaginationProps, EuiTableProps } from '../../table';
import type { EuiFlyoutProps } from '../../flyout';

export type EuiComponentDefaults = {
/**
Expand All @@ -43,6 +44,15 @@ export type EuiComponentDefaults = {
* Defaults will be inherited by all `EuiBasicTable`s and `EuiInMemoryTable`s.
*/
EuiTable?: Pick<EuiTableProps, 'responsiveBreakpoint'>;

/**
* Provide a global configuration for `EuiFlyout`s.
* Defaults will be inherited by all `EuiFlyout`s.
*/
EuiFlyout?: Pick<
EuiFlyoutProps,
'includeSelectorInFocusTrap' | 'includeFixedHeadersInFocusTrap'
>;
};

// Declaring as a static const for reference integrity/reducing rerenders
Expand Down