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/9103.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
**Accessibility**

- Fixed an issue where portalled components like `EuiPopover` were not included in `EuiFlyout`'s focus trap through `includeSelectorInFocusTrap`, making them inaccessible to keyboard users
121 changes: 117 additions & 4 deletions packages/eui/src/components/flyout/flyout.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,27 @@
/// <reference types="../../../cypress/support" />

import React, { useRef, useState } from 'react';
import { css } from '@emotion/react';

import { EuiButton } from '../button';
import { EuiCollapsibleNav, EuiCollapsibleNavGroup } from '../collapsible_nav';
import { EuiCollapsibleNavBeta } from '../collapsible_nav_beta';
import { EuiFlexGroup } from '../flex';
import { EuiFlyout } from './flyout';
import { EuiFlyoutBody } from './flyout_body';
import { EuiFlyoutHeader } from './flyout_header';
import { EuiGlobalToastList } from '../toast';
import {
EuiHeader,
EuiHeaderSection,
EuiHeaderSectionItemButton,
} from '../header';
import { EuiCollapsibleNavBeta } from '../collapsible_nav_beta';
import { EuiFlyout } from './flyout';
import { EuiCollapsibleNav, EuiCollapsibleNavGroup } from '../collapsible_nav';
import { EuiIcon } from '../icon';
import { EuiButton } from '../button';
import { EuiPanel } from '../panel';
import { EuiPopover } from '../popover';
import { EuiText } from '../text';
import { EuiTitle } from '../title';
import { useEuiTheme } from '../../services';

const childrenDefault = (
<>
Expand Down Expand Up @@ -251,6 +260,7 @@ describe('EuiFlyout', () => {
}: {
children?: React.ReactNode;
collapsibleNavVariant?: 'beta' | 'default';
includeFixedHeadersInFocusTrap?: boolean;
}) => {
const [isOpen, setIsOpen] = useState(false);
const [navIsOpen, setNavIsOpen] = useState(false);
Expand Down Expand Up @@ -453,4 +463,107 @@ describe('EuiFlyout', () => {
cy.get(':root').cssVar(euiPushFlyoutOffsetInlineEnd).should('not.exist');
});
});

describe('Focus trap shards', () => {
const INCLUDE_IN_FLYOUT_TRAP_FOCUS_DATA_ATTRIBUTE =
'data-include-in-flyout-trap-focus';
const INCLUDE_IN_FLYOUT_TRAP_FOCUS = {
prop: {
[INCLUDE_IN_FLYOUT_TRAP_FOCUS_DATA_ATTRIBUTE]: 'true',
},
attribute: INCLUDE_IN_FLYOUT_TRAP_FOCUS_DATA_ATTRIBUTE,
selector: `[${INCLUDE_IN_FLYOUT_TRAP_FOCUS_DATA_ATTRIBUTE}="true"]`,
};

const FlyoutWithPopoverShard = () => {
const { euiTheme } = useEuiTheme();

const [isFlyoutOpen, setIsFlyoutOpen] = useState(false);
const [isPopoverOpen, setIsPopoverOpen] = useState(false);

return (
<>
<EuiPanel
borderRadius="none"
css={css`
position: fixed;
block-size: 100%;
z-index: ${Number(euiTheme.levels.flyout) + 20};
`}
{...INCLUDE_IN_FLYOUT_TRAP_FOCUS.prop}
>
<EuiFlexGroup direction="column" gutterSize="m" wrap>
<EuiButton onClick={() => setIsFlyoutOpen(true)}>
Toggle flyout
</EuiButton>
<EuiPopover
button={
<EuiButton onClick={() => setIsPopoverOpen(true)}>
Toggle popover
</EuiButton>
}
isOpen={isPopoverOpen}
panelProps={{
...INCLUDE_IN_FLYOUT_TRAP_FOCUS.prop,
'data-test-subj': 'popover-panel',
}}
>
<EuiButton
data-test-subj="popover-confirm-action"
onClick={() => setIsPopoverOpen(false)}
>
Confirm action
</EuiButton>
<EuiButton
color="danger"
data-test-subj="popover-destructive-action"
onClick={() => setIsPopoverOpen(false)}
>
Destructive action
</EuiButton>
</EuiPopover>
</EuiFlexGroup>
</EuiPanel>
{isFlyoutOpen && (
<EuiFlyout
data-test-subj="flyoutSpec"
onClose={() => setIsFlyoutOpen(false)}
includeSelectorInFocusTrap={[
INCLUDE_IN_FLYOUT_TRAP_FOCUS.selector,
]}
>
<EuiFlyoutHeader hasBorder>
<EuiTitle size="m">
<h2>Popover composition</h2>
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<EuiText>
<p>These popovers are portalled outside the flyout DOM.</p>
</EuiText>
</EuiFlyoutBody>
</EuiFlyout>
)}
</>
);
};

it('includes popover panels in the focus trap when opened', () => {
cy.mount(<FlyoutWithPopoverShard />);

// 1. Open the flyout.
cy.realPress('Tab');
cy.realPress('Enter');
cy.get('[data-test-subj="flyoutSpec"]').should('be.focused');

// 2. Open the popover.
cy.repeatRealPress('Tab', 4);
cy.realPress('Enter');
cy.get('[data-test-subj="popover-panel"]').should('exist');

// 3. Tab from the popover trigger into the popover content.
cy.realPress('Tab');
cy.get('[data-test-subj="popover-confirm-action"]').should('be.focused');
});
});
});
56 changes: 38 additions & 18 deletions packages/eui/src/components/flyout/flyout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
useEuiMemoizedStyles,
useGeneratedHtmlId,
useEuiThemeCSSVariables,
focusTrapPubSub,
} from '../../services';
import { logicalStyle } from '../../global_styling';

Expand Down Expand Up @@ -400,26 +401,45 @@ export const EuiFlyout = forwardRef(
return selectors;
}, [includeSelectorInFocusTrap, includeFixedHeadersInFocusTrap]);

useEffect(() => {
if (focusTrapSelectors.length > 0) {
const shardsEls = focusTrapSelectors.flatMap((selector) =>
Array.from(document.querySelectorAll<HTMLElement>(selector))
);
/**
* Finds the shards to include in the focus trap by querying by `focusTrapSelectors`.
*
* @param shouldAutoFocus Whether to auto-focus the flyout wrapper when the focus trap is activated.
* This is necessary because when a flyout is toggled from within a shard, the focus trap's `autoFocus`
* feature doesn't work. This logic manually focuses the flyout as a workaround.
*/
const findShards = useCallback(
(shouldAutoFocus: boolean = false) => {
if (focusTrapSelectors.length > 0) {
const shardsEls = focusTrapSelectors.flatMap((selector) =>
Array.from(document.querySelectorAll<HTMLElement>(selector))
);

setFocusTrapShards(Array.from(shardsEls));

if (shouldAutoFocus) {
shardsEls.forEach((shard) => {
if (shard.contains(flyoutToggle.current)) {
resizeRef?.focus();
}
});
}
} else {
// Clear existing shards if necessary, e.g. switching to `false`
setFocusTrapShards((shards) => (shards.length ? [] : shards));
}
},
[focusTrapSelectors, resizeRef]
);

setFocusTrapShards(Array.from(shardsEls));
useEffect(() => {
// Auto-focus should only happen on initial flyout mount (or when the dependencies change)
// because it snaps focus to the flyout wrapper, which steals it from subsequently focused elements.
findShards(true);

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

const focusTrapProps: EuiFlyoutProps['focusTrapProps'] = useMemo(
() => ({
Expand Down
4 changes: 4 additions & 0 deletions packages/eui/src/components/popover/popover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
getWaitDuration,
performOnFrame,
htmlIdGenerator,
focusTrapPubSub,
} from '../../services';
import { setMultipleRefs } from '../../services/hooks/useCombinedRefs';

Expand Down Expand Up @@ -448,6 +449,7 @@ export class EuiPopover extends Component<Props, State> {
this.repositionTimeout = window.setTimeout(() => {
this.setState({ isOpenStable: true }, () => {
this.positionPopoverFixed();
focusTrapPubSub.publish();
});
}, durationMatch + delayMatch);
};
Expand Down Expand Up @@ -492,6 +494,7 @@ export class EuiPopover extends Component<Props, State> {
this.setState({
isClosing: false,
});
focusTrapPubSub.publish();
}, closingTransitionTime);
}
}
Expand All @@ -502,6 +505,7 @@ export class EuiPopover extends Component<Props, State> {
clearTimeout(this.strandedFocusTimeout);
clearTimeout(this.closingTransitionTimeout);
cancelAnimationFrame(this.closingTransitionAnimationFrame!);
focusTrapPubSub.publish();
}

onMutation = (records: MutationRecord[]) => {
Expand Down
56 changes: 56 additions & 0 deletions packages/eui/src/services/focus_trap/focus_trap_pub_sub.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/*
* 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.
*/

import { focusTrapPubSub } from './focus_trap_pub_sub';

describe('focusTrapPubSub', () => {
let unsubscribeAll: Array<() => void> = [];

afterEach(() => {
unsubscribeAll.forEach((unsubscribe) => unsubscribe());
unsubscribeAll = [];
});

it('subscribes a listener and calls it on publish', () => {
const listener = jest.fn();

unsubscribeAll.push(focusTrapPubSub.subscribe(listener));
focusTrapPubSub.publish();

expect(listener).toHaveBeenCalledTimes(1);
});

it('does not call the listener after it has been unsubscribed', () => {
const listener = jest.fn();
const unsubscribe = focusTrapPubSub.subscribe(listener);

unsubscribe();
focusTrapPubSub.publish();

expect(listener).not.toHaveBeenCalled();
});

it('can handle multiple subscribers and unsubscribes them independently', () => {
const listener1 = jest.fn();
const listener2 = jest.fn();

const unsubscribe1 = focusTrapPubSub.subscribe(listener1);

unsubscribeAll.push(focusTrapPubSub.subscribe(listener2));
focusTrapPubSub.publish();

expect(listener1).toHaveBeenCalledTimes(1);
expect(listener2).toHaveBeenCalledTimes(1);

unsubscribe1();
focusTrapPubSub.publish();

expect(listener1).toHaveBeenCalledTimes(1);
expect(listener2).toHaveBeenCalledTimes(2);
});
});
73 changes: 73 additions & 0 deletions packages/eui/src/services/focus_trap/focus_trap_pub_sub.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/*
* 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.
*/

type Listener = () => void;

const listeners: Set<Listener> = new Set();

/**
* Subscribes a listener function to be called whenever focus trap updates are published.
*
* @param listener The function to be called on updates.
* @returns A function that, when called, will unsubscribe the listener. Please remember
* to call this function for proper cleanup.
* @example
* ```tsx
* useEffect(() => {
* const unsubscribe = focusTrapPubSub.subscribe(() => {
* console.log('focus trap updated');
* });
*
* return () => unsubscribe();
* }, []);
* ```
*/
const subscribe = (listener: Listener) => {
listeners.add(listener);

return () => unsubscribe(listener);
};

/**
* Unsubscribes a listener from the focus trap PubSub service.
*
* @param listener The function to unsubscribe.
*/
const unsubscribe = (listener: Listener) => {
listeners.delete(listener);
};

/**
* Publishes an event to all subscribed listeners, signaling that
* components managing focus traps should re-evaluate their tracked elements.
*/
const publish = () => {
listeners.forEach((listener) => listener());
};

/**
* A lightweight, global PubSub service for loose coupling of components
* that need to interact with the same focus trap.
*
* This allows a component (like `EuiPopover`) to be rendered in a React Portal
* and still be included in the focus trap of another component (like `EuiFlyout`)
* without either component needing a direct reference to the other.
*
* How it works:
*
* 1. A container component (e.g., `EuiFlyout`) `subscribe`s to this service on mount.
* 2. An ephemeral component (e.g., `EuiPopover`) calls `publish` when its state
* changes in a way that affects the DOM (e.g., opening, closing, unmounting).
* 3. The container component's subscribed callback fires, causing it to re-query
* the DOM for any elements it should include in its focus trap.
*/
export const focusTrapPubSub = {
subscribe,
unsubscribe,
publish,
};
9 changes: 9 additions & 0 deletions packages/eui/src/services/focus_trap/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/*
* 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.
*/

export { focusTrapPubSub } from './focus_trap_pub_sub';
Loading