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

- Fixed an issue where pressing Shift + Tab on the last tabbable element inside `EuiInputPopover` popover would close the popover unexpectedly
1 change: 1 addition & 0 deletions packages/eui/src/components/form/range/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ export interface _SharedRangeInputProps {
| 'isOpen'
| 'closePopover'
| 'disableFocusTrap'
| 'ownFocus'
| 'popoverScreenReaderText'
| 'fullWidth'
>;
Expand Down
34 changes: 16 additions & 18 deletions packages/eui/src/components/popover/input_popover.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,22 @@ describe('EuiPopover', () => {

cy.get('[data-popover-panel]').should('not.exist');
});

it('does not close the popover when users Shift+Tab from the last item in the popover', () => {
cy.mount(
<StatefulInputPopover>
<button data-test-subj="one">one</button>
<button data-test-subj="two">two</button>
</StatefulInputPopover>
);

cy.focused().invoke('attr', 'data-test-subj').should('eq', 'one');
cy.realPress('Tab');
cy.focused().invoke('attr', 'data-test-subj').should('eq', 'two');
cy.realPress(['Shift', 'Tab']);
cy.focused().invoke('attr', 'data-test-subj').should('eq', 'one');
cy.get('[data-popover-panel]').should('exist');
});
});

describe('with focus trap disabled', () => {
Expand All @@ -192,24 +208,6 @@ describe('EuiPopover', () => {
cy.get('[data-popover-panel]').should('exist');
});

// Not sure how much sense this behavior makes, but this logic was
// apparently added to EuiInputPopover with EuiDualRange in mind
it('automatically closes the popover when users tab from anywhere in the popover', () => {
cy.mount(
<>
<StatefulInputPopover disableFocusTrap={true}>
<button data-test-subj="one">one</button>
<button data-test-subj="two">two</button>
</StatefulInputPopover>
</>
);

cy.get('[data-test-subj="one"]').click();
cy.realPress('Tab');

cy.get('[data-popover-panel]').should('not.exist');
});

it('behaves with normal popover focus trap/tab behavior if `ownFocus` is set to true', () => {
cy.mount(
<>
Expand Down
57 changes: 55 additions & 2 deletions packages/eui/src/components/popover/input_popover.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@
* Side Public License, v 1.
*/

import React, { useState } from 'react';
import React, { MutableRefObject, useRef, useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import { css } from '@emotion/react';

import {
disableStorybookControls,
Expand All @@ -16,6 +17,7 @@ import {
} from '../../../.storybook/utils';
import { LOKI_SELECTORS } from '../../../.storybook/loki';
import { EuiFieldText } from '../form';
import { EuiButton } from '../button';
import { EuiInputPopover, EuiInputPopoverProps } from './input_popover';

const meta: Meta<EuiInputPopoverProps> = {
Expand Down Expand Up @@ -91,8 +93,16 @@ const StatefulInputPopover = ({
isOpen: _isOpen,
...rest
}: EuiInputPopoverProps) => {
const panelRef: MutableRefObject<HTMLElement | null> = useRef(null);

const [isOpen, setOpen] = useState(_isOpen);

const setPanelRef = (node: HTMLElement | null) => {
setTimeout(() => {
panelRef.current = node;
});
};

const handleOnClose = () => {
setOpen(false);
closePopover?.();
Expand All @@ -101,7 +111,15 @@ const StatefulInputPopover = ({
const connectedInput = React.isValidElement(input)
? React.cloneElement(input, {
...input.props,
onFocus: () => setOpen(true),
onFocus: (e: React.FocusEvent) => {
if (
(e.relatedTarget != null &&
!panelRef.current?.contains(e.relatedTarget)) ||
(e.relatedTarget == null && !panelRef.current)
) {
setOpen(true);
}
},
})
: input;

Expand All @@ -110,6 +128,7 @@ const StatefulInputPopover = ({
isOpen={isOpen}
closePopover={handleOnClose}
input={connectedInput}
panelRef={setPanelRef}
{...rest}
>
{children}
Expand All @@ -132,3 +151,37 @@ export const AnchorPosition: Story = {
</div>
),
};

export const InteractiveChildren: Story = {
parameters: {
controls: { include: ['ownFocus', 'disableFocusTrap'] },
loki: {
skip: true,
},
},
args: {
input: (
<EuiFieldText
placeholder="Focus me to toggle an input popover"
aria-label="Popover attached to input element"
/>
),
},
render: (args) => (
<>
<StatefulInputPopover {...args}>
<div
css={({ euiTheme }) => css`
display: flex;
flex-direction: column;
gap: ${euiTheme.size.s};
`}
>
<EuiButton>First button</EuiButton>
<EuiButton>Second button</EuiButton>
<EuiButton>Third button</EuiButton>
</div>
</StatefulInputPopover>
</>
),
};
38 changes: 26 additions & 12 deletions packages/eui/src/components/popover/input_popover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -135,31 +135,45 @@ export const EuiInputPopover: FunctionComponent<EuiInputPopoverProps> = ({

const panelPropsOnKeyDown = props.panelProps?.onKeyDown;

const handleTabNavigation = useCallback(
(e: KeyboardEvent<HTMLDivElement>) => {
const tabbableItems = tabbable(e.currentTarget).filter(
(el) => !el.hasAttribute('data-focus-guard')
);
if (!tabbableItems.length) return;

const tabbingFromFirstItemInPopover =
document.activeElement === tabbableItems[0];
const tabbingFromLastItemInPopover =
document.activeElement === tabbableItems[tabbableItems.length - 1];

if (
(tabbingFromFirstItemInPopover && e.shiftKey) ||
(tabbingFromLastItemInPopover && !e.shiftKey)
) {
closePopover();
}
},
[closePopover]
);

const onKeyDown = useCallback(
(event: KeyboardEvent<HTMLDivElement>) => {
panelPropsOnKeyDown?.(event);

if (event.key === keys.TAB) {
if (disableFocusTrap) {
if (!ownFocus) {
closePopover();
handleTabNavigation(event);
}
} else {
const tabbableItems = tabbable(event.currentTarget).filter(
(el) => !el.hasAttribute('data-focus-guard')
);
if (!tabbableItems.length) return;

const tabbingFromLastItemInPopover =
document.activeElement === tabbableItems[tabbableItems.length - 1];

if (tabbingFromLastItemInPopover) {
closePopover();
if (!ownFocus) {
handleTabNavigation(event);
}
}
}
},
[disableFocusTrap, ownFocus, closePopover, panelPropsOnKeyDown]
[disableFocusTrap, ownFocus, panelPropsOnKeyDown, handleTabNavigation]
);

/**
Expand Down