diff --git a/packages/eui/changelogs/upcoming/8950.md b/packages/eui/changelogs/upcoming/8950.md new file mode 100644 index 00000000000..e68e639337b --- /dev/null +++ b/packages/eui/changelogs/upcoming/8950.md @@ -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 diff --git a/packages/eui/src/components/form/range/types.ts b/packages/eui/src/components/form/range/types.ts index 09034cf1aee..0b25c471384 100644 --- a/packages/eui/src/components/form/range/types.ts +++ b/packages/eui/src/components/form/range/types.ts @@ -131,6 +131,7 @@ export interface _SharedRangeInputProps { | 'isOpen' | 'closePopover' | 'disableFocusTrap' + | 'ownFocus' | 'popoverScreenReaderText' | 'fullWidth' >; diff --git a/packages/eui/src/components/popover/input_popover.spec.tsx b/packages/eui/src/components/popover/input_popover.spec.tsx index 8a3b0a18938..d02d6373cc1 100644 --- a/packages/eui/src/components/popover/input_popover.spec.tsx +++ b/packages/eui/src/components/popover/input_popover.spec.tsx @@ -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( + + + + + ); + + 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', () => { @@ -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( - <> - - - - - - ); - - 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( <> diff --git a/packages/eui/src/components/popover/input_popover.stories.tsx b/packages/eui/src/components/popover/input_popover.stories.tsx index eb7d4f8ecbf..3fcc72f38de 100644 --- a/packages/eui/src/components/popover/input_popover.stories.tsx +++ b/packages/eui/src/components/popover/input_popover.stories.tsx @@ -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, @@ -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 = { @@ -91,8 +93,16 @@ const StatefulInputPopover = ({ isOpen: _isOpen, ...rest }: EuiInputPopoverProps) => { + const panelRef: MutableRefObject = useRef(null); + const [isOpen, setOpen] = useState(_isOpen); + const setPanelRef = (node: HTMLElement | null) => { + setTimeout(() => { + panelRef.current = node; + }); + }; + const handleOnClose = () => { setOpen(false); closePopover?.(); @@ -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; @@ -110,6 +128,7 @@ const StatefulInputPopover = ({ isOpen={isOpen} closePopover={handleOnClose} input={connectedInput} + panelRef={setPanelRef} {...rest} > {children} @@ -132,3 +151,37 @@ export const AnchorPosition: Story = { ), }; + +export const InteractiveChildren: Story = { + parameters: { + controls: { include: ['ownFocus', 'disableFocusTrap'] }, + loki: { + skip: true, + }, + }, + args: { + input: ( + + ), + }, + render: (args) => ( + <> + +
css` + display: flex; + flex-direction: column; + gap: ${euiTheme.size.s}; + `} + > + First button + Second button + Third button +
+
+ + ), +}; diff --git a/packages/eui/src/components/popover/input_popover.tsx b/packages/eui/src/components/popover/input_popover.tsx index a852ec2a6a0..7eb6ddf2897 100644 --- a/packages/eui/src/components/popover/input_popover.tsx +++ b/packages/eui/src/components/popover/input_popover.tsx @@ -135,6 +135,28 @@ export const EuiInputPopover: FunctionComponent = ({ const panelPropsOnKeyDown = props.panelProps?.onKeyDown; + const handleTabNavigation = useCallback( + (e: KeyboardEvent) => { + 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) => { panelPropsOnKeyDown?.(event); @@ -142,24 +164,16 @@ export const EuiInputPopover: FunctionComponent = ({ 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] ); /**