From fdb78927f43c324750c185f105ade4d151fa9193 Mon Sep 17 00:00:00 2001 From: Mathieu Suen Date: Wed, 12 Oct 2022 10:46:50 +0200 Subject: [PATCH] SONAR-17450 Close toggler when focus is outside of overlay --- ...wnHandler-test.tsx => FocusOutHandler.tsx} | 49 +++-- .../main/js/components/controls/Toggler.tsx | 18 +- .../__tests__/OutsideClickHandler-test.tsx | 81 -------- .../controls/__tests__/Toggler-test.tsx | 187 ++++++++++++++++-- .../EscKeydownHandler-test.tsx.snap | 7 - .../OutsideClickHandler-test.tsx.snap | 7 - .../__snapshots__/Toggler-test.tsx.snap | 58 ------ 7 files changed, 206 insertions(+), 201 deletions(-) rename server/sonar-web/src/main/js/components/controls/{__tests__/EscKeydownHandler-test.tsx => FocusOutHandler.tsx} (50%) delete mode 100644 server/sonar-web/src/main/js/components/controls/__tests__/OutsideClickHandler-test.tsx delete mode 100644 server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/EscKeydownHandler-test.tsx.snap delete mode 100644 server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/OutsideClickHandler-test.tsx.snap delete mode 100644 server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/Toggler-test.tsx.snap diff --git a/server/sonar-web/src/main/js/components/controls/__tests__/EscKeydownHandler-test.tsx b/server/sonar-web/src/main/js/components/controls/FocusOutHandler.tsx similarity index 50% rename from server/sonar-web/src/main/js/components/controls/__tests__/EscKeydownHandler-test.tsx rename to server/sonar-web/src/main/js/components/controls/FocusOutHandler.tsx index c132782d0d4..8e428629328 100644 --- a/server/sonar-web/src/main/js/components/controls/__tests__/EscKeydownHandler-test.tsx +++ b/server/sonar-web/src/main/js/components/controls/FocusOutHandler.tsx @@ -17,37 +17,32 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { shallow } from 'enzyme'; import * as React from 'react'; -import { KeyboardKeys } from '../../../helpers/keycodes'; -import { keydown } from '../../../helpers/testUtils'; -import EscKeydownHandler from '../EscKeydownHandler'; -beforeAll(() => { - jest.useFakeTimers(); -}); +interface Props { + onFocusOut: () => void; +} + +export default class FocusOutHandler extends React.PureComponent> { + ref = React.createRef(); -afterAll(() => { - jest.runOnlyPendingTimers(); - jest.useRealTimers(); -}); + componentDidMount() { + setTimeout(() => { + document.addEventListener('focusin', this.handleFocusOut); + }, 0); + } -it('should render correctly', () => { - expect(shallowRender()).toMatchSnapshot(); -}); + componentWillUnmount() { + document.removeEventListener('focusin', this.handleFocusOut); + } -it('should correctly trigger the keydown handler when hitting Esc', () => { - const onKeydown = jest.fn(); - shallowRender({ onKeydown }); - jest.runAllTimers(); - keydown({ key: KeyboardKeys.Escape }); - expect(onKeydown).toBeCalled(); -}); + handleFocusOut = () => { + if (this.ref.current && this.ref.current.querySelector(':focus') === null) { + this.props.onFocusOut(); + } + }; -function shallowRender(props: Partial = {}) { - return shallow( - - Hi there - - ); + render() { + return
{this.props.children}
; + } } diff --git a/server/sonar-web/src/main/js/components/controls/Toggler.tsx b/server/sonar-web/src/main/js/components/controls/Toggler.tsx index 4e0236237a2..e824dde5841 100644 --- a/server/sonar-web/src/main/js/components/controls/Toggler.tsx +++ b/server/sonar-web/src/main/js/components/controls/Toggler.tsx @@ -20,6 +20,7 @@ import * as React from 'react'; import DocumentClickHandler from './DocumentClickHandler'; import EscKeydownHandler from './EscKeydownHandler'; +import FocusOutHandler from './FocusOutHandler'; import OutsideClickHandler from './OutsideClickHandler'; interface Props { @@ -27,6 +28,7 @@ interface Props { closeOnClick?: boolean; closeOnClickOutside?: boolean; closeOnEscape?: boolean; + closeOnFocusOut?: boolean; onRequestClose: () => void; open: boolean; overlay: React.ReactNode; @@ -38,15 +40,23 @@ export default class Toggler extends React.Component { closeOnClick = false, closeOnClickOutside = true, closeOnEscape = true, + closeOnFocusOut = true, onRequestClose, overlay } = this.props; - let renderedOverlay; + let renderedOverlay = overlay; + + if (closeOnFocusOut) { + renderedOverlay = ( + {renderedOverlay} + ); + } + if (closeOnEscape) { - renderedOverlay = {overlay}; - } else { - renderedOverlay = overlay; + renderedOverlay = ( + {renderedOverlay} + ); } if (closeOnClick) { diff --git a/server/sonar-web/src/main/js/components/controls/__tests__/OutsideClickHandler-test.tsx b/server/sonar-web/src/main/js/components/controls/__tests__/OutsideClickHandler-test.tsx deleted file mode 100644 index d89a951576b..00000000000 --- a/server/sonar-web/src/main/js/components/controls/__tests__/OutsideClickHandler-test.tsx +++ /dev/null @@ -1,81 +0,0 @@ -/* - * SonarQube - * Copyright (C) 2009-2022 SonarSource SA - * mailto:info AT sonarsource DOT com - * - * This program is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; either - * version 3 of the License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with this program; if not, write to the Free Software Foundation, - * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - */ -import { mount, shallow } from 'enzyme'; -import * as React from 'react'; -import OutsideClickHandler from '../OutsideClickHandler'; - -beforeAll(() => { - jest.useFakeTimers(); -}); - -afterAll(() => { - jest.runOnlyPendingTimers(); - jest.useRealTimers(); -}); - -it('should render correctly', () => { - expect(shallowRender()).toMatchSnapshot(); -}); - -it('should register for click event', () => { - const addEventListener = jest.spyOn(window, 'addEventListener'); - const removeEventListener = jest.spyOn(window, 'removeEventListener'); - - const wrapper = shallowRender(); - - jest.runAllTimers(); - - expect(addEventListener).toHaveBeenCalledWith('click', expect.anything()); - - wrapper.instance().componentWillUnmount(); - - expect(removeEventListener).toHaveBeenCalledWith('click', expect.anything()); -}); - -it('should call event handler on click on window', () => { - const onClickOutside = jest.fn(); - - const map: { [key: string]: EventListener } = {}; - window.addEventListener = jest.fn((event, callback) => { - map[event] = callback as EventListener; - }); - jest.spyOn(window.document, 'contains').mockReturnValue(true); - - mount( -
- -
- -
- ); - - jest.runAllTimers(); - - map['click'](new Event('click')); - expect(onClickOutside).toHaveBeenCalled(); -}); - -function shallowRender(props: Partial = {}) { - return shallow( - -
- - ); -} diff --git a/server/sonar-web/src/main/js/components/controls/__tests__/Toggler-test.tsx b/server/sonar-web/src/main/js/components/controls/__tests__/Toggler-test.tsx index 9a648ec9bc3..4286593997b 100644 --- a/server/sonar-web/src/main/js/components/controls/__tests__/Toggler-test.tsx +++ b/server/sonar-web/src/main/js/components/controls/__tests__/Toggler-test.tsx @@ -17,32 +17,185 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { shallow } from 'enzyme'; +import { act, render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { UserEvent } from '@testing-library/user-event/dist/types/setup'; import * as React from 'react'; +import { byRole } from 'testing-library-selector'; import Toggler from '../Toggler'; -it('should render only children', () => { - expect(shallowRender({ open: false })).toMatchSnapshot(); +beforeAll(() => { + jest.useFakeTimers(); }); -it('should render children and overlay', () => { - expect(shallowRender()).toMatchSnapshot(); +afterAll(() => { + jest.useRealTimers(); }); +const ui = { + toggleButton: byRole('button', { name: 'toggle' }), + outButton: byRole('button', { name: 'out' }), + overlayButton: byRole('button', { name: 'overlay' }) +}; -it('should render when closeOnClick=true', () => { - expect(shallowRender({ closeOnClick: true })).toMatchSnapshot(); +async function openToggler(user: UserEvent) { + await user.click(ui.toggleButton.get()); + act(() => { + jest.runAllTimers(); + }); + expect(ui.overlayButton.get()).toBeInTheDocument(); +} + +function focusOut() { + act(() => { + ui.overlayButton.get().focus(); + ui.outButton.get().focus(); + }); +} + +it('should handle escape correclty', async () => { + const user = userEvent.setup({ delay: null }); + const rerender = renderToggler({ + closeOnEscape: true, + closeOnClick: false, + closeOnClickOutside: false, + closeOnFocusOut: false + }); + + await openToggler(user); + + await user.keyboard('{Escape}'); + expect(ui.overlayButton.query()).not.toBeInTheDocument(); + + rerender({ closeOnEscape: false }); + await openToggler(user); + + await user.keyboard('{Escape}'); + expect(ui.overlayButton.get()).toBeInTheDocument(); +}); + +it('should handle focus correctly', async () => { + const user = userEvent.setup({ delay: null }); + const rerender = renderToggler({ + closeOnEscape: false, + closeOnClick: false, + closeOnClickOutside: false, + closeOnFocusOut: true + }); + + await openToggler(user); + + focusOut(); + expect(ui.overlayButton.query()).not.toBeInTheDocument(); + + rerender({ closeOnFocusOut: false }); + await openToggler(user); + + focusOut(); + expect(ui.overlayButton.get()).toBeInTheDocument(); +}); + +it('should handle click correctly', async () => { + const user = userEvent.setup({ delay: null }); + const rerender = renderToggler({ + closeOnEscape: false, + closeOnClick: true, + closeOnClickOutside: false, + closeOnFocusOut: false + }); + + await openToggler(user); + + await user.click(ui.outButton.get()); + expect(ui.overlayButton.query()).not.toBeInTheDocument(); + + await openToggler(user); + + await user.click(ui.overlayButton.get()); + expect(ui.overlayButton.query()).not.toBeInTheDocument(); + + rerender({ closeOnClick: false }); + await openToggler(user); + + await user.click(ui.outButton.get()); + expect(ui.overlayButton.get()).toBeInTheDocument(); }); -it('should not render click wrappers', () => { - expect( - shallowRender({ closeOnClick: false, closeOnClickOutside: false, closeOnEscape: false }) - ).toMatchSnapshot(); +it('should handle click outside correctly', async () => { + const user = userEvent.setup({ delay: null }); + const rerender = renderToggler({ + closeOnEscape: false, + closeOnClick: false, + closeOnClickOutside: true, + closeOnFocusOut: false + }); + + await openToggler(user); + + await user.click(ui.overlayButton.get()); + expect(await ui.overlayButton.find()).toBeInTheDocument(); + + await user.click(ui.outButton.get()); + expect(ui.overlayButton.query()).not.toBeInTheDocument(); + + rerender({ closeOnClickOutside: false }); + await openToggler(user); + + await user.click(ui.outButton.get()); + expect(ui.overlayButton.get()).toBeInTheDocument(); }); -function shallowRender(props?: Partial) { - return shallow( - } {...props}> -
- - ); +it('should open/close correctly when default props is applied', async () => { + const user = userEvent.setup({ delay: null }); + renderToggler(); + + await openToggler(user); + + // Should not close when on overlay + await user.click(ui.overlayButton.get()); + expect(await ui.overlayButton.find()).toBeInTheDocument(); + + // Focus out should close + act(() => { + ui.overlayButton.get().focus(); + ui.outButton.get().focus(); + }); + expect(ui.overlayButton.query()).not.toBeInTheDocument(); + + await openToggler(user); + + // Escape should close + await user.keyboard('{Escape}'); + expect(ui.overlayButton.query()).not.toBeInTheDocument(); + + await openToggler(user); + + // Click should close (focus out is trigger first) + await user.click(ui.outButton.get()); + expect(ui.overlayButton.query()).not.toBeInTheDocument(); +}); + +function renderToggler(override?: Partial) { + function App(props: Partial) { + const [open, setOpen] = React.useState(false); + + return ( + <> + setOpen(false)} + open={open} + overlay={} + {...props}> + + + + + ); + } + + const { rerender } = render(); + return function(reoverride: Partial) { + return rerender(); + }; } diff --git a/server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/EscKeydownHandler-test.tsx.snap b/server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/EscKeydownHandler-test.tsx.snap deleted file mode 100644 index 239d6bfe358..00000000000 --- a/server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/EscKeydownHandler-test.tsx.snap +++ /dev/null @@ -1,7 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`should render correctly 1`] = ` - - Hi there - -`; diff --git a/server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/OutsideClickHandler-test.tsx.snap b/server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/OutsideClickHandler-test.tsx.snap deleted file mode 100644 index a4f533d8e3a..00000000000 --- a/server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/OutsideClickHandler-test.tsx.snap +++ /dev/null @@ -1,7 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`should render correctly 1`] = ` -
-`; diff --git a/server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/Toggler-test.tsx.snap b/server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/Toggler-test.tsx.snap deleted file mode 100644 index dfe5d96a394..00000000000 --- a/server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/Toggler-test.tsx.snap +++ /dev/null @@ -1,58 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`should not render click wrappers 1`] = ` - -
-
- -`; - -exports[`should render children and overlay 1`] = ` - -
- - -
- - - -`; - -exports[`should render only children 1`] = ` - -
- -`; - -exports[`should render when closeOnClick=true 1`] = ` - -
- - -
- - - -`; -- 2.39.5