--- /dev/null
+/*
+ * 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 * as React from 'react';
+
+interface Props {
+ onFocusOut: () => void;
+}
+
+export default class FocusOutHandler extends React.PureComponent<React.PropsWithChildren<Props>> {
+ ref = React.createRef<HTMLDivElement>();
+
+ componentDidMount() {
+ setTimeout(() => {
+ document.addEventListener('focusin', this.handleFocusOut);
+ }, 0);
+ }
+
+ componentWillUnmount() {
+ document.removeEventListener('focusin', this.handleFocusOut);
+ }
+
+ handleFocusOut = () => {
+ if (this.ref.current && this.ref.current.querySelector(':focus') === null) {
+ this.props.onFocusOut();
+ }
+ };
+
+ render() {
+ return <div ref={this.ref}>{this.props.children}</div>;
+ }
+}
import * as React from 'react';
import DocumentClickHandler from './DocumentClickHandler';
import EscKeydownHandler from './EscKeydownHandler';
+import FocusOutHandler from './FocusOutHandler';
import OutsideClickHandler from './OutsideClickHandler';
interface Props {
closeOnClick?: boolean;
closeOnClickOutside?: boolean;
closeOnEscape?: boolean;
+ closeOnFocusOut?: boolean;
onRequestClose: () => void;
open: boolean;
overlay: React.ReactNode;
closeOnClick = false,
closeOnClickOutside = true,
closeOnEscape = true,
+ closeOnFocusOut = true,
onRequestClose,
overlay
} = this.props;
- let renderedOverlay;
+ let renderedOverlay = overlay;
+
+ if (closeOnFocusOut) {
+ renderedOverlay = (
+ <FocusOutHandler onFocusOut={onRequestClose}>{renderedOverlay}</FocusOutHandler>
+ );
+ }
+
if (closeOnEscape) {
- renderedOverlay = <EscKeydownHandler onKeydown={onRequestClose}>{overlay}</EscKeydownHandler>;
- } else {
- renderedOverlay = overlay;
+ renderedOverlay = (
+ <EscKeydownHandler onKeydown={onRequestClose}>{renderedOverlay}</EscKeydownHandler>
+ );
}
if (closeOnClick) {
+++ /dev/null
-/*
- * 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 { 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();
-});
-
-afterAll(() => {
- jest.runOnlyPendingTimers();
- jest.useRealTimers();
-});
-
-it('should render correctly', () => {
- expect(shallowRender()).toMatchSnapshot();
-});
-
-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();
-});
-
-function shallowRender(props: Partial<EscKeydownHandler['props']> = {}) {
- return shallow<EscKeydownHandler>(
- <EscKeydownHandler onKeydown={jest.fn()} {...props}>
- <span>Hi there</span>
- </EscKeydownHandler>
- );
-}
+++ /dev/null
-/*
- * 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(
- <div id="outside-element">
- <OutsideClickHandler onClickOutside={onClickOutside}>
- <div id="children" />
- </OutsideClickHandler>
- </div>
- );
-
- jest.runAllTimers();
-
- map['click'](new Event('click'));
- expect(onClickOutside).toHaveBeenCalled();
-});
-
-function shallowRender(props: Partial<OutsideClickHandler['props']> = {}) {
- return shallow<OutsideClickHandler>(
- <OutsideClickHandler onClickOutside={jest.fn()} {...props}>
- <div id="children" />
- </OutsideClickHandler>
- );
-}
* 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<Toggler['props']>) {
- return shallow(
- <Toggler onRequestClose={jest.fn()} open={true} overlay={<div id="overlay" />} {...props}>
- <div id="toggle" />
- </Toggler>
- );
+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<Toggler['props']>) {
+ function App(props: Partial<Toggler['props']>) {
+ const [open, setOpen] = React.useState(false);
+
+ return (
+ <>
+ <Toggler
+ onRequestClose={() => setOpen(false)}
+ open={open}
+ overlay={<button type="button">overlay</button>}
+ {...props}>
+ <button onClick={() => setOpen(true)} type="button">
+ toggle
+ </button>
+ </Toggler>
+ <button type="button">out</button>
+ </>
+ );
+ }
+
+ const { rerender } = render(<App {...override} />);
+ return function(reoverride: Partial<Toggler['props']>) {
+ return rerender(<App {...override} {...reoverride} />);
+ };
}
+++ /dev/null
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`should render correctly 1`] = `
-<span>
- Hi there
-</span>
-`;
+++ /dev/null
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`should render correctly 1`] = `
-<div
- id="children"
-/>
-`;
+++ /dev/null
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`should not render click wrappers 1`] = `
-<Fragment>
- <div
- id="toggle"
- />
- <div
- id="overlay"
- />
-</Fragment>
-`;
-
-exports[`should render children and overlay 1`] = `
-<Fragment>
- <div
- id="toggle"
- />
- <OutsideClickHandler
- onClickOutside={[MockFunction]}
- >
- <EscKeydownHandler
- onKeydown={[MockFunction]}
- >
- <div
- id="overlay"
- />
- </EscKeydownHandler>
- </OutsideClickHandler>
-</Fragment>
-`;
-
-exports[`should render only children 1`] = `
-<Fragment>
- <div
- id="toggle"
- />
-</Fragment>
-`;
-
-exports[`should render when closeOnClick=true 1`] = `
-<Fragment>
- <div
- id="toggle"
- />
- <DocumentClickHandler
- onClick={[MockFunction]}
- >
- <EscKeydownHandler
- onKeydown={[MockFunction]}
- >
- <div
- id="overlay"
- />
- </EscKeydownHandler>
- </DocumentClickHandler>
-</Fragment>
-`;