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';
);
return this.state.open ? (
- <OutsideClickHandler onClickOutside={this.handleClickOutside}>{search}</OutsideClickHandler>
+ <FocusOutHandler onFocusOut={this.handleClickOutside}>
+ <OutsideClickHandler onClickOutside={this.handleClickOutside}>{search}</OutsideClickHandler>
+ </FocusOutHandler>
) : (
search
);
*/
import classNames from 'classnames';
import * as React from 'react';
-import { KeyboardKeys } from '../../helpers/keycodes';
import SelectListItem from './SelectListItem';
interface Props {
interface State {
active: string;
- selected: string;
}
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;
}
return React.cloneElement(child, {
active: this.state.active,
- selected: this.state.selected,
- onHover: this.handleHover,
onSelect: this.handleSelect,
});
};
this.props.items.map((item) => (
<SelectListItem
active={this.state.active}
- selected={this.state.selected}
item={item}
key={item}
- onHover={this.handleHover}
onSelect={this.handleSelect}
/>
))}
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);
};
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
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>
*/
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', () => {
));
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) {
active="seconditem"
item="item"
key=".$item"
- onHover={[Function]}
onSelect={[Function]}
- selected="seconditem"
>
<i
className="myicon"
active="seconditem"
item="seconditem"
key=".$seconditem"
- onHover={[Function]}
onSelect={[Function]}
- selected="seconditem"
>
<i
className="myicon"
active="seconditem"
item="third"
key=".$third"
- onHover={[Function]}
onSelect={[Function]}
- selected="seconditem"
>
<i
className="myicon"
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>
`;
<ButtonPlain
aria-selected={false}
className=""
+ onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
+ onMouseLeave={[Function]}
onMouseOver={[Function]}
preventDefault={true}
>
<ButtonPlain
aria-selected={false}
className=""
+ onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
+ onMouseLeave={[Function]}
onMouseOver={[Function]}
preventDefault={true}
>
<ButtonPlain
aria-selected={false}
className=""
+ onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
+ onMouseLeave={[Function]}
onMouseOver={[Function]}
preventDefault={true}
>
<ButtonPlain
aria-selected={true}
className="active"
+ onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
+ onMouseLeave={[Function]}
onMouseOver={[Function]}
preventDefault={true}
>
import EscKeydownHandler from './EscKeydownHandler';
import FocusOutHandler from './FocusOutHandler';
import OutsideClickHandler from './OutsideClickHandler';
+import UpDownKeyboardHanlder from './UpDownKeyboardHandler';
interface Props {
children?: React.ReactNode;
closeOnClickOutside?: boolean;
closeOnEscape?: boolean;
closeOnFocusOut?: boolean;
+ navigateWithKeyboard?: boolean;
onRequestClose: () => void;
open: boolean;
overlay: React.ReactNode;
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>
--- /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';
+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;
+ }
+}
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) {
});
}
+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({
<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">
}
const { rerender } = render(<App {...override} />);
- return function (reoverride: Partial<Toggler['props']>) {
+ return function (reoverride?: Partial<Toggler['props']>) {
return rerender(<App {...override} {...reoverride} />);
};
}
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 {