]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-17495 Add up/down navigation on toggler
authorMathieu Suen <mathieu.suen@sonarsource.com>
Tue, 25 Oct 2022 08:29:26 +0000 (10:29 +0200)
committersonartech <sonartech@sonarsource.com>
Mon, 7 Nov 2022 20:02:53 +0000 (20:02 +0000)
server/sonar-web/src/main/js/app/components/search/Search.tsx
server/sonar-web/src/main/js/components/common/SelectList.tsx
server/sonar-web/src/main/js/components/common/SelectListItem.tsx
server/sonar-web/src/main/js/components/common/__tests__/SelectList-test.tsx
server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/SelectList-test.tsx.snap
server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/SelectListItem-test.tsx.snap
server/sonar-web/src/main/js/components/controls/Toggler.tsx
server/sonar-web/src/main/js/components/controls/UpDownKeyboardHandler.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/components/controls/__tests__/Toggler-test.tsx
server/sonar-web/src/main/js/components/controls/buttons.tsx

index 0099a3a09a17575069e6a17916bfc4fdf496e93c..00d47e99ead1e3a2fb00e2c624b1b32bb854130e 100644 (file)
@@ -22,6 +22,7 @@ import * as React from 'react';
 import { FormattedMessage } from 'react-intl';
 import { getSuggestions } from '../../../api/components';
 import { DropdownOverlay } from '../../../components/controls/Dropdown';
+import FocusOutHandler from '../../../components/controls/FocusOutHandler';
 import OutsideClickHandler from '../../../components/controls/OutsideClickHandler';
 import SearchBox from '../../../components/controls/SearchBox';
 import { Router, withRouter } from '../../../components/hoc/withRouter';
@@ -400,7 +401,9 @@ export class Search extends React.PureComponent<Props, State> {
     );
 
     return this.state.open ? (
-      <OutsideClickHandler onClickOutside={this.handleClickOutside}>{search}</OutsideClickHandler>
+      <FocusOutHandler onFocusOut={this.handleClickOutside}>
+        <OutsideClickHandler onClickOutside={this.handleClickOutside}>{search}</OutsideClickHandler>
+      </FocusOutHandler>
     ) : (
       search
     );
index 70f14cac0c3abac09c27790854f72013426fe806..7dbc81a85d2986ce45d9f810fc6d83f9e2195ca3 100644 (file)
@@ -19,7 +19,6 @@
  */
 import classNames from 'classnames';
 import * as React from 'react';
-import { KeyboardKeys } from '../../helpers/keycodes';
 import SelectListItem from './SelectListItem';
 
 interface Props {
@@ -31,7 +30,6 @@ interface Props {
 
 interface State {
   active: string;
-  selected: string;
 }
 
 export default class SelectList extends React.PureComponent<Props, State> {
@@ -39,69 +37,22 @@ export default class SelectList extends React.PureComponent<Props, State> {
     super(props);
     this.state = {
       active: props.currentItem,
-      selected: props.currentItem,
     };
   }
 
-  componentDidMount() {
-    document.addEventListener('keydown', this.handleKeyDown, { capture: true });
-  }
-
   componentDidUpdate(prevProps: Props) {
     if (
       prevProps.currentItem !== this.props.currentItem &&
       !this.props.items.includes(this.state.active)
     ) {
-      this.setState({ active: this.props.currentItem, selected: this.props.currentItem });
+      this.setState({ active: this.props.currentItem });
     }
   }
 
-  componentWillUnmount() {
-    document.removeEventListener('keydown', this.handleKeyDown, { capture: true });
-  }
-
-  handleKeyDown = (event: KeyboardEvent) => {
-    if (event.key === KeyboardKeys.DownArrow) {
-      event.preventDefault();
-      event.stopImmediatePropagation();
-      this.setState(this.selectNextElement);
-    } else if (event.key === KeyboardKeys.UpArrow) {
-      event.preventDefault();
-      event.stopImmediatePropagation();
-      this.setState(this.selectPreviousElement);
-    } else if (event.key === KeyboardKeys.Enter) {
-      event.preventDefault();
-      event.stopImmediatePropagation();
-      if (this.state.selected != null) {
-        this.handleSelect(this.state.selected);
-      }
-    }
-  };
-
   handleSelect = (item: string) => {
     this.props.onSelect(item);
   };
 
-  handleHover = (selected: string) => {
-    this.setState({ selected });
-  };
-
-  selectNextElement = (state: State, props: Props) => {
-    const idx = props.items.indexOf(state.selected);
-    if (idx < 0) {
-      return { selected: props.items[0] };
-    }
-    return { selected: props.items[(idx + 1) % props.items.length] };
-  };
-
-  selectPreviousElement = (state: State, props: Props) => {
-    const idx = props.items.indexOf(state.selected);
-    if (idx <= 0) {
-      return { selected: props.items[props.items.length - 1] };
-    }
-    return { selected: props.items[idx - 1] };
-  };
-
   renderChild = (child: any) => {
     if (child == null) {
       return null;
@@ -112,8 +63,6 @@ export default class SelectList extends React.PureComponent<Props, State> {
     }
     return React.cloneElement(child, {
       active: this.state.active,
-      selected: this.state.selected,
-      onHover: this.handleHover,
       onSelect: this.handleSelect,
     });
   };
@@ -128,10 +77,8 @@ export default class SelectList extends React.PureComponent<Props, State> {
           this.props.items.map((item) => (
             <SelectListItem
               active={this.state.active}
-              selected={this.state.selected}
               item={item}
               key={item}
-              onHover={this.handleHover}
               onSelect={this.handleSelect}
             />
           ))}
index afe33d2b5af811eee83a6235c393f3ec53133df1..03025a06ee8ad2b651930d96fea0182e299e05a6 100644 (file)
@@ -26,13 +26,22 @@ interface Props {
   active?: string;
   className?: string;
   item: string;
-  onHover?: (item: string) => void;
   onSelect?: (item: string) => void;
-  selected?: string;
   title?: React.ReactNode;
 }
 
-export default class SelectListItem extends React.PureComponent<Props> {
+interface State {
+  selected: boolean;
+}
+
+export default class SelectListItem extends React.PureComponent<Props, State> {
+  constructor(props: Props) {
+    super(props);
+    this.state = {
+      selected: false,
+    };
+  }
+
   handleSelect = () => {
     if (this.props.onSelect) {
       this.props.onSelect(this.props.item);
@@ -40,13 +49,16 @@ export default class SelectListItem extends React.PureComponent<Props> {
   };
 
   handleHover = () => {
-    if (this.props.onHover) {
-      this.props.onHover(this.props.item);
-    }
+    this.setState({ selected: true });
+  };
+
+  handleBlur = () => {
+    this.setState({ selected: false });
   };
 
   renderLink() {
     const children = this.props.children || this.props.item;
+    const { selected } = this.state;
     return (
       <li>
         <ButtonPlain
@@ -55,13 +67,15 @@ export default class SelectListItem extends React.PureComponent<Props> {
           className={classNames(
             {
               active: this.props.active === this.props.item,
-              hover: this.props.selected === this.props.item,
+              hover: selected,
             },
             this.props.className
           )}
           onClick={this.handleSelect}
           onFocus={this.handleHover}
+          onBlur={this.handleBlur}
           onMouseOver={this.handleHover}
+          onMouseLeave={this.handleBlur}
         >
           {children}
         </ButtonPlain>
index 873b33bc4daa3c0fdc6cf1f9236a126f8341c057..39809a74aef4507faaa25b37bc9b02df0dc3280e 100644 (file)
  */
 import { shallow } from 'enzyme';
 import * as React from 'react';
-import { KeyboardKeys } from '../../../helpers/keycodes';
-import { keydown } from '../../../helpers/testUtils';
 import SelectList from '../SelectList';
 import SelectListItem from '../SelectListItem';
 
 it('should render correctly without children', () => {
   const wrapper = shallowRender();
   expect(wrapper).toMatchSnapshot();
-  wrapper.instance().componentWillUnmount!();
 });
 
 it('should render correctly with children', () => {
@@ -40,31 +37,6 @@ it('should render correctly with children', () => {
   ));
   const wrapper = shallowRender({ items }, children);
   expect(wrapper).toMatchSnapshot();
-  wrapper.instance().componentWillUnmount!();
-});
-
-it('should correclty handle user actions', () => {
-  const onSelect = jest.fn();
-  const items = ['item', 'seconditem', 'third'];
-  const children = items.map((item) => (
-    <SelectListItem item={item} key={item}>
-      <i className="myicon" />
-      item
-    </SelectListItem>
-  ));
-  const list = shallowRender({ items, onSelect }, children);
-  expect(list.state().selected).toBe('seconditem');
-  keydown({ key: KeyboardKeys.DownArrow });
-  expect(list.state().selected).toBe('third');
-  keydown({ key: KeyboardKeys.DownArrow });
-  expect(list.state().selected).toBe('item');
-  keydown({ key: KeyboardKeys.UpArrow });
-  expect(list.state().selected).toBe('third');
-  keydown({ key: KeyboardKeys.UpArrow });
-  expect(list.state().selected).toBe('seconditem');
-  keydown({ key: KeyboardKeys.Enter });
-  expect(onSelect).toHaveBeenCalledWith('seconditem');
-  list.instance().componentWillUnmount!();
 });
 
 function shallowRender(props: Partial<SelectList['props']> = {}, children?: React.ReactNode) {
index 03783efa411d160e960b65f91dc1b645d7033774..7066fe02d4fe593ee71f63433ab769ac045a4f00 100644 (file)
@@ -8,9 +8,7 @@ exports[`should render correctly with children 1`] = `
     active="seconditem"
     item="item"
     key=".$item"
-    onHover={[Function]}
     onSelect={[Function]}
-    selected="seconditem"
   >
     <i
       className="myicon"
@@ -21,9 +19,7 @@ exports[`should render correctly with children 1`] = `
     active="seconditem"
     item="seconditem"
     key=".$seconditem"
-    onHover={[Function]}
     onSelect={[Function]}
-    selected="seconditem"
   >
     <i
       className="myicon"
@@ -34,9 +30,7 @@ exports[`should render correctly with children 1`] = `
     active="seconditem"
     item="third"
     key=".$third"
-    onHover={[Function]}
     onSelect={[Function]}
-    selected="seconditem"
   >
     <i
       className="myicon"
@@ -54,25 +48,19 @@ exports[`should render correctly without children 1`] = `
     active="seconditem"
     item="item"
     key="item"
-    onHover={[Function]}
     onSelect={[Function]}
-    selected="seconditem"
   />
   <SelectListItem
     active="seconditem"
     item="seconditem"
     key="seconditem"
-    onHover={[Function]}
     onSelect={[Function]}
-    selected="seconditem"
   />
   <SelectListItem
     active="seconditem"
     item="third"
     key="third"
-    onHover={[Function]}
     onSelect={[Function]}
-    selected="seconditem"
   />
 </ul>
 `;
index 131abbb83f82c717d125b08dbe7d13af1e2a2681..bd319c9b038f7b31c2b392b3ef81d1ecf99e556d 100644 (file)
@@ -9,8 +9,10 @@ exports[`should render correctly with a tooltip 1`] = `
     <ButtonPlain
       aria-selected={false}
       className=""
+      onBlur={[Function]}
       onClick={[Function]}
       onFocus={[Function]}
+      onMouseLeave={[Function]}
       onMouseOver={[Function]}
       preventDefault={true}
     >
@@ -28,8 +30,10 @@ exports[`should render correctly with children 1`] = `
     <ButtonPlain
       aria-selected={false}
       className=""
+      onBlur={[Function]}
       onClick={[Function]}
       onFocus={[Function]}
+      onMouseLeave={[Function]}
       onMouseOver={[Function]}
       preventDefault={true}
     >
@@ -52,8 +56,10 @@ exports[`should render correctly without children 1`] = `
     <ButtonPlain
       aria-selected={false}
       className=""
+      onBlur={[Function]}
       onClick={[Function]}
       onFocus={[Function]}
+      onMouseLeave={[Function]}
       onMouseOver={[Function]}
       preventDefault={true}
     >
@@ -71,8 +77,10 @@ exports[`should render with the active class 1`] = `
     <ButtonPlain
       aria-selected={true}
       className="active"
+      onBlur={[Function]}
       onClick={[Function]}
       onFocus={[Function]}
+      onMouseLeave={[Function]}
       onMouseOver={[Function]}
       preventDefault={true}
     >
index 3413386cd29ce164b7e9be0c9e485d2173a0883b..4616c5c0297aa6d18dfc0620581c781f5d6412b6 100644 (file)
@@ -22,6 +22,7 @@ import DocumentClickHandler from './DocumentClickHandler';
 import EscKeydownHandler from './EscKeydownHandler';
 import FocusOutHandler from './FocusOutHandler';
 import OutsideClickHandler from './OutsideClickHandler';
+import UpDownKeyboardHanlder from './UpDownKeyboardHandler';
 
 interface Props {
   children?: React.ReactNode;
@@ -29,6 +30,7 @@ interface Props {
   closeOnClickOutside?: boolean;
   closeOnEscape?: boolean;
   closeOnFocusOut?: boolean;
+  navigateWithKeyboard?: boolean;
   onRequestClose: () => void;
   open: boolean;
   overlay: React.ReactNode;
@@ -41,12 +43,17 @@ export default class Toggler extends React.Component<Props> {
       closeOnClickOutside = true,
       closeOnEscape = true,
       closeOnFocusOut = true,
+      navigateWithKeyboard = true,
       onRequestClose,
       overlay,
     } = this.props;
 
     let renderedOverlay = overlay;
 
+    if (navigateWithKeyboard) {
+      renderedOverlay = <UpDownKeyboardHanlder>{renderedOverlay}</UpDownKeyboardHanlder>;
+    }
+
     if (closeOnFocusOut) {
       renderedOverlay = (
         <FocusOutHandler onFocusOut={onRequestClose}>{renderedOverlay}</FocusOutHandler>
diff --git a/server/sonar-web/src/main/js/components/controls/UpDownKeyboardHandler.tsx b/server/sonar-web/src/main/js/components/controls/UpDownKeyboardHandler.tsx
new file mode 100644 (file)
index 0000000..faf70fc
--- /dev/null
@@ -0,0 +1,119 @@
+/*
+ * 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';
+import { isShortcut } from '../../helpers/keyboardEventHelpers';
+import { KeyboardKeys } from '../../helpers/keycodes';
+
+interface Props {
+  containerClass?: string;
+}
+
+interface State {
+  focusIndex?: number;
+}
+
+export default class UpDownKeyboardHanlder extends React.PureComponent<
+  React.PropsWithChildren<Props>,
+  State
+> {
+  constructor(props: React.PropsWithChildren<Props>) {
+    super(props);
+    this.state = {};
+  }
+
+  componentDidMount() {
+    document.addEventListener('keydown', this.handleKeyboard, true);
+  }
+
+  componentWillUnmount() {
+    document.removeEventListener('keydown', this.handleKeyboard, true);
+  }
+
+  handleKeyboard = (event: KeyboardEvent) => {
+    if (isShortcut(event)) {
+      return true;
+    }
+    switch (event.key) {
+      case KeyboardKeys.DownArrow:
+        event.stopPropagation();
+        event.preventDefault();
+        this.selectNextFocusElement();
+        return false;
+      case KeyboardKeys.UpArrow:
+        event.stopPropagation();
+        event.preventDefault();
+        this.selectPreviousFocusElement();
+        return false;
+    }
+    return true;
+  };
+
+  getFocusableElement() {
+    const { containerClass = 'popup' } = this.props;
+    const focussableElements = `.${containerClass} a,.${containerClass} button,.${containerClass} input[type=text]`;
+    return document.querySelectorAll<HTMLElement>(focussableElements);
+  }
+
+  selectNextFocusElement() {
+    const { focusIndex = -1 } = this.state;
+    const focusableElement = this.getFocusableElement();
+
+    for (const [index, focusable] of focusableElement.entries()) {
+      if (focusable === document.activeElement) {
+        focusableElement[(index + 1) % focusableElement.length].focus();
+        this.setState({ focusIndex: (index + 1) % focusableElement.length });
+        return;
+      }
+    }
+
+    if (focusableElement[(focusIndex + 1) % focusableElement.length]) {
+      focusableElement[(focusIndex + 1) % focusableElement.length].focus();
+      this.setState({ focusIndex: (focusIndex + 1) % focusableElement.length });
+    }
+  }
+
+  selectPreviousFocusElement() {
+    const { focusIndex = 0 } = this.state;
+    const focusableElement = this.getFocusableElement();
+
+    for (const [index, focusable] of focusableElement.entries()) {
+      if (focusable === document.activeElement) {
+        focusableElement[(index - 1 + focusableElement.length) % focusableElement.length].focus();
+        this.setState({
+          focusIndex: (index - 1 + focusableElement.length) % focusableElement.length,
+        });
+        return;
+      }
+    }
+
+    if (focusableElement[(focusIndex - 1 + focusableElement.length) % focusableElement.length]) {
+      focusableElement[
+        (focusIndex - 1 + focusableElement.length) % focusableElement.length
+      ].focus();
+      this.setState({
+        focusIndex: (focusIndex - 1 + focusableElement.length) % focusableElement.length,
+      });
+    }
+  }
+
+  render() {
+    return this.props.children;
+  }
+}
index 9b5bb5331536f39877ad265d9812aea68f82afd4..3b476d48c72c045e1d0debe4957433b24861672a 100644 (file)
@@ -35,6 +35,7 @@ const ui = {
   toggleButton: byRole('button', { name: 'toggle' }),
   outButton: byRole('button', { name: 'out' }),
   overlayButton: byRole('button', { name: 'overlay' }),
+  nextOverlayButton: byRole('button', { name: 'next overlay' }),
 };
 
 async function openToggler(user: UserEvent) {
@@ -52,6 +53,33 @@ function focusOut() {
   });
 }
 
+it('should handle key up/down', async () => {
+  const user = userEvent.setup({ delay: null });
+  const rerender = renderToggler();
+
+  await openToggler(user);
+  await user.keyboard('{ArrowUp}');
+  expect(ui.nextOverlayButton.get()).toHaveFocus();
+
+  await user.keyboard('{ArrowDown}');
+  expect(ui.overlayButton.get()).toHaveFocus();
+
+  await user.keyboard('{ArrowDown}');
+  expect(ui.nextOverlayButton.get()).toHaveFocus();
+
+  await user.keyboard('{ArrowUp}');
+  expect(ui.overlayButton.get()).toHaveFocus();
+
+  // No focus change when using shortcut
+  await user.keyboard('{Control>}{ArrowUp}{/Control}');
+  expect(ui.overlayButton.get()).toHaveFocus();
+
+  rerender();
+  await openToggler(user);
+  await user.keyboard('{ArrowDown}');
+  expect(ui.overlayButton.get()).toHaveFocus();
+});
+
 it('should handle escape correclty', async () => {
   const user = userEvent.setup({ delay: null });
   const rerender = renderToggler({
@@ -183,7 +211,12 @@ function renderToggler(override?: Partial<Toggler['props']>) {
         <Toggler
           onRequestClose={() => setOpen(false)}
           open={open}
-          overlay={<button type="button">overlay</button>}
+          overlay={
+            <div className="popup">
+              <button type="button">overlay</button>
+              <button type="button">next overlay</button>
+            </div>
+          }
           {...props}
         >
           <button onClick={() => setOpen(true)} type="button">
@@ -196,7 +229,7 @@ function renderToggler(override?: Partial<Toggler['props']>) {
   }
 
   const { rerender } = render(<App {...override} />);
-  return function (reoverride: Partial<Toggler['props']>) {
+  return function (reoverride?: Partial<Toggler['props']>) {
     return rerender(<App {...override} {...reoverride} />);
   };
 }
index 92036d82178396f3b506ee12fd57feee7167194b..15a560da9ff796d1dd8a567a082aae74d90c46b5 100644 (file)
@@ -30,7 +30,16 @@ import Tooltip, { TooltipProps } from './Tooltip';
 
 type AllowedButtonAttributes = Pick<
   React.ButtonHTMLAttributes<HTMLButtonElement>,
-  'aria-label' | 'className' | 'disabled' | 'id' | 'style' | 'title' | 'onFocus' | 'onMouseOver'
+  | 'aria-label'
+  | 'className'
+  | 'disabled'
+  | 'id'
+  | 'style'
+  | 'title'
+  | 'onFocus'
+  | 'onBlur'
+  | 'onMouseOver'
+  | 'onMouseLeave'
 >;
 
 interface ButtonProps extends AllowedButtonAttributes {