]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-17450 Close toggler when focus is outside of overlay
authorMathieu Suen <mathieu.suen@sonarsource.com>
Wed, 12 Oct 2022 08:46:50 +0000 (10:46 +0200)
committersonartech <sonartech@sonarsource.com>
Thu, 13 Oct 2022 20:03:19 +0000 (20:03 +0000)
server/sonar-web/src/main/js/components/controls/FocusOutHandler.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/components/controls/Toggler.tsx
server/sonar-web/src/main/js/components/controls/__tests__/EscKeydownHandler-test.tsx [deleted file]
server/sonar-web/src/main/js/components/controls/__tests__/OutsideClickHandler-test.tsx [deleted file]
server/sonar-web/src/main/js/components/controls/__tests__/Toggler-test.tsx
server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/EscKeydownHandler-test.tsx.snap [deleted file]
server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/OutsideClickHandler-test.tsx.snap [deleted file]
server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/Toggler-test.tsx.snap [deleted file]

diff --git a/server/sonar-web/src/main/js/components/controls/FocusOutHandler.tsx b/server/sonar-web/src/main/js/components/controls/FocusOutHandler.tsx
new file mode 100644 (file)
index 0000000..8e42862
--- /dev/null
@@ -0,0 +1,48 @@
+/*
+ * 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>;
+  }
+}
index 4e0236237a2d2340ac4c15d3e2f7a0d0255210d4..e824dde58417437ce587e25c6beda1d8db56c3aa 100644 (file)
@@ -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<Props> {
       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) {
diff --git a/server/sonar-web/src/main/js/components/controls/__tests__/EscKeydownHandler-test.tsx b/server/sonar-web/src/main/js/components/controls/__tests__/EscKeydownHandler-test.tsx
deleted file mode 100644 (file)
index c132782..0000000
+++ /dev/null
@@ -1,53 +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 { 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>
-  );
-}
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 (file)
index d89a951..0000000
+++ /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(
-    <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>
-  );
-}
index 9a648ec9bc3b321ee369f3e9a67ccd862fcecf56..4286593997bfb2f27dcc5fdcf7287919f3a738fc 100644 (file)
  * 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} />);
+  };
 }
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 (file)
index 239d6bf..0000000
+++ /dev/null
@@ -1,7 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`should render correctly 1`] = `
-<span>
-  Hi there
-</span>
-`;
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 (file)
index a4f533d..0000000
+++ /dev/null
@@ -1,7 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`should render correctly 1`] = `
-<div
-  id="children"
-/>
-`;
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 (file)
index dfe5d96..0000000
+++ /dev/null
@@ -1,58 +0,0 @@
-// 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>
-`;