diff --git a/package.json b/package.json index a09b3949107..4ff1cea3afe 100644 --- a/package.json +++ b/package.json @@ -91,7 +91,7 @@ "remark-emoji": "^2.1.0", "remark-parse": "^8.0.3", "remark-rehype": "^8.0.0", - "tabbable": "^3.0.0", + "tabbable": "^5.2.1", "text-diff": "^1.0.1", "unified": "^9.2.0", "unist-util-visit": "^2.0.3", @@ -133,7 +133,7 @@ "@types/react-dom": "^17.0.11", "@types/react-is": "^17.0.3", "@types/react-router-dom": "^5.1.5", - "@types/tabbable": "^3.1.0", + "@types/tabbable": "^3.1.2", "@types/url-parse": "^1.4.8", "@types/uuid": "^8.3.0", "@typescript-eslint/eslint-plugin": "^5.10.2", diff --git a/src/components/context_menu/context_menu_panel.tsx b/src/components/context_menu/context_menu_panel.tsx index 3201acc8808..ce529e7b452 100644 --- a/src/components/context_menu/context_menu_panel.tsx +++ b/src/components/context_menu/context_menu_panel.tsx @@ -14,7 +14,7 @@ import React, { ReactNode, } from 'react'; import classNames from 'classnames'; -import tabbable from 'tabbable'; +import { tabbable } from 'tabbable'; import { CommonProps, NoArgCallback, keysOf } from '../common'; import { EuiIcon } from '../icon'; diff --git a/src/components/datagrid/body/data_grid_cell.tsx b/src/components/datagrid/body/data_grid_cell.tsx index 78c6ced31c3..16f659c16ac 100644 --- a/src/components/datagrid/body/data_grid_cell.tsx +++ b/src/components/datagrid/body/data_grid_cell.tsx @@ -18,7 +18,7 @@ import React, { MutableRefObject, } from 'react'; import { createPortal } from 'react-dom'; -import tabbable from 'tabbable'; +import { tabbable } from 'tabbable'; import { keys } from '../../../services'; import { EuiScreenReaderOnly } from '../../accessibility'; import { EuiFocusTrap } from '../../focus_trap'; diff --git a/src/components/datagrid/body/header/data_grid_header_cell_wrapper.tsx b/src/components/datagrid/body/header/data_grid_header_cell_wrapper.tsx index e4d1cdeeb5b..8b331754608 100644 --- a/src/components/datagrid/body/header/data_grid_header_cell_wrapper.tsx +++ b/src/components/datagrid/body/header/data_grid_header_cell_wrapper.tsx @@ -14,7 +14,7 @@ import React, { useRef, useState, } from 'react'; -import tabbable from 'tabbable'; +import { tabbable } from 'tabbable'; import { keys } from '../../../../services'; import { DataGridFocusContext } from '../../utils/focus'; import { EuiDataGridHeaderCellWrapperProps } from '../../data_grid_types'; diff --git a/src/components/datagrid/body/header/header_is_interactive.ts b/src/components/datagrid/body/header/header_is_interactive.ts index 7822f08d473..0550aad25f5 100644 --- a/src/components/datagrid/body/header/header_is_interactive.ts +++ b/src/components/datagrid/body/header/header_is_interactive.ts @@ -7,7 +7,7 @@ */ import { useCallback, useEffect, useState } from 'react'; -import tabbable from 'tabbable'; +import { tabbable } from 'tabbable'; export const useHeaderIsInteractive = (gridElement: HTMLElement | null) => { const [headerIsInteractive, setHeaderIsInteractive] = useState(false); diff --git a/src/components/datagrid/utils/focus.ts b/src/components/datagrid/utils/focus.ts index 94b15bd319a..40d992b857d 100644 --- a/src/components/datagrid/utils/focus.ts +++ b/src/components/datagrid/utils/focus.ts @@ -19,7 +19,7 @@ import { MutableRefObject, } from 'react'; import { GridOnItemsRenderedProps } from 'react-window'; -import tabbable from 'tabbable'; +import { tabbable } from 'tabbable'; import { keys } from '../../../services'; import { DataGridFocusContextShape, diff --git a/src/components/popover/input_popover.tsx b/src/components/popover/input_popover.tsx index 19d243e4080..f77e6c30681 100644 --- a/src/components/popover/input_popover.tsx +++ b/src/components/popover/input_popover.tsx @@ -14,7 +14,7 @@ import React, { useCallback, } from 'react'; import classnames from 'classnames'; -import tabbable from 'tabbable'; +import { tabbable, FocusableElement } from 'tabbable'; import { CommonProps } from '../common'; import { EuiFocusTrap } from '../focus_trap'; @@ -81,7 +81,7 @@ export const EuiInputPopover: FunctionComponent = ({ const onKeyDown = (event: React.KeyboardEvent) => { if (panelEl && event.key === cascadingMenuKeys.TAB) { - const tabbableItems = tabbable(panelEl).filter((el: HTMLElement) => { + const tabbableItems = tabbable(panelEl).filter((el: FocusableElement) => { return ( Array.from(el.attributes) .map((el) => el.name) diff --git a/src/components/popover/popover.test.tsx b/src/components/popover/popover.test.tsx index df083c0bbd2..7dbce260642 100644 --- a/src/components/popover/popover.test.tsx +++ b/src/components/popover/popover.test.tsx @@ -9,6 +9,7 @@ import React, { ReactNode } from 'react'; import { render, mount } from 'enzyme'; import { requiredProps } from '../../test/required_props'; +import { EuiFocusTrap } from '../'; import { EuiPopover, @@ -383,33 +384,36 @@ describe('EuiPopover', () => { }); describe('listener cleanup', () => { - let _raf: typeof window['requestAnimationFrame']; - let _caf: typeof window['cancelAnimationFrame']; + let rafSpy: jest.SpyInstance; + let cafSpy: jest.SpyInstance; + const activeAnimationFrames = new Map(); + let nextAnimationFrameId = 0; + beforeAll(() => { jest.useFakeTimers(); - _raf = window.requestAnimationFrame; - _caf = window.cancelAnimationFrame; - - const activeAnimationFrames = new Map(); - let nextAnimationFrameId = 0; - window.requestAnimationFrame = (fn) => { - const animationFrameId = nextAnimationFrameId++; - activeAnimationFrames.set(animationFrameId, setTimeout(fn)); - return animationFrameId; - }; - window.cancelAnimationFrame = (id: number) => { - const timeoutId = activeAnimationFrames.get(id); - if (timeoutId) { - clearTimeout(timeoutId); - activeAnimationFrames.delete(id); - } - }; + jest.spyOn(window, 'clearTimeout'); + rafSpy = jest + .spyOn(window, 'requestAnimationFrame') + .mockImplementation((fn) => { + const animationFrameId = nextAnimationFrameId++; + activeAnimationFrames.set(animationFrameId, setTimeout(fn)); + return animationFrameId; + }); + cafSpy = jest + .spyOn(window, 'cancelAnimationFrame') + .mockImplementation((id: number) => { + const timeoutId = activeAnimationFrames.get(id); + if (timeoutId) { + clearTimeout(timeoutId); + activeAnimationFrames.delete(id); + } + }); }); afterAll(() => { jest.useRealTimers(); - window.requestAnimationFrame = _raf; - window.cancelAnimationFrame = _caf; + rafSpy.mockRestore(); + cafSpy.mockRestore(); }); it('cleans up timeouts and rAFs on unmount', () => { @@ -422,10 +426,21 @@ describe('EuiPopover', () => { isOpen={false} /> ); + expect(window.clearTimeout).toHaveBeenCalledTimes(0); component.setProps({ isOpen: true }); + expect(window.clearTimeout).toHaveBeenCalledTimes(3); + expect(rafSpy).toHaveBeenCalledTimes(1); + expect(activeAnimationFrames.size).toEqual(1); + + jest.advanceTimersByTime(10); + expect(rafSpy).toHaveBeenCalledTimes(2); + expect(activeAnimationFrames.size).toEqual(2); component.unmount(); + expect(window.clearTimeout).toHaveBeenCalledTimes(10); + expect(cafSpy).toHaveBeenCalledTimes(2); + expect(activeAnimationFrames.size).toEqual(0); // EUI's jest configuration throws an error if there are any console.error calls, like // React's setState on an unmounted component warning @@ -436,7 +451,89 @@ describe('EuiPopover', () => { // execute any pending timeouts or animation frame callbacks // and validate the timeout/rAF clearing done by EuiPopover - jest.advanceTimersByTime(10); + jest.advanceTimersByTime(300); + }); + }); + + describe('onEscapeKey', () => { + const closePopover = jest.fn(); + const closingTransitionTime = 250; // TODO: DRY out var when converting to CSS-in-JS + + const mockEvent = { + preventDefault: () => {}, + stopPropagation: () => {}, + } as Event; + + beforeAll(() => jest.useFakeTimers()); + beforeEach(() => { + jest.clearAllMocks(); + (document.activeElement as HTMLElement)?.blur(); // Reset focus between tests + }); + afterAll(() => jest.useRealTimers()); + + it('closes the popover and refocuses the toggle button', () => { + const toggleButtonEl = React.createRef(); + const toggleButton =