* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import EscKeydownHandler from './EscKeydownHandler';
+import FocusOutHandler from './FocusOutHandler';
import OutsideClickHandler from './OutsideClickHandler';
import { Popup } from './popups';
overlay={
open ? (
<OutsideClickHandler onClickOutside={onRequestClose}>
- <EscKeydownHandler onKeydown={onRequestClose}>{overlay}</EscKeydownHandler>
+ <FocusOutHandler onFocusOut={onRequestClose}>
+ <EscKeydownHandler onKeydown={onRequestClose}>{overlay}</EscKeydownHandler>
+ </FocusOutHandler>
</OutsideClickHandler>
) : undefined
}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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 extends React.BaseHTMLAttributes<HTMLDivElement> {
+ innerRef?: (instance: HTMLDivElement) => void;
+ onFocusOut: () => void;
+}
+
+export default class FocusOutHandler extends React.PureComponent<React.PropsWithChildren<Props>> {
+ ref?: HTMLDivElement;
+
+ componentDidMount() {
+ setTimeout(() => {
+ document.addEventListener('focusin', this.handleFocusOut);
+ }, 0);
+ }
+
+ componentWillUnmount() {
+ document.removeEventListener('focusin', this.handleFocusOut);
+ }
+
+ nodeRef = (node: HTMLDivElement) => {
+ const { innerRef } = this.props;
+ this.ref = node;
+ innerRef?.(node);
+ };
+
+ handleFocusOut = () => {
+ if (this.ref?.querySelector(':focus') === null) {
+ this.props.onFocusOut();
+ }
+ };
+
+ render() {
+ const { onFocusOut, innerRef, children, ...props } = this.props;
+ return (
+ <div ref={this.nodeRef} {...props}>
+ {children}
+ </div>
+ );
+ }
+}
if (autoFocus && input.current) {
input.current.focus();
}
- });
+ }, [autoFocus]);
const changeValue = (newValue: string) => {
if (newValue.length === 0 || !minLength || minLength <= newValue.length) {
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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 classNames from 'classnames';
+import { difference } from 'lodash';
+import { PureComponent } from 'react';
+import { Key } from '../helpers/keyboard';
+import { ItemDivider, ItemHeader } from './DropdownMenu';
+import { InputSearch } from './InputSearch';
+import { MultiSelectOption } from './MultiSelectOption';
+
+interface Props {
+ allowNewElements?: boolean;
+ allowSelection?: boolean;
+ clearIconAriaLabel: string;
+ createElementLabel: string;
+ elements: string[];
+ footerNode?: React.ReactNode;
+ headerNode?: React.ReactNode;
+ listSize: number;
+ noResultsLabel: string;
+ onSearch: (query: string) => Promise<void>;
+ onSelect: (item: string) => void;
+ onUnselect: (item: string) => void;
+ placeholder: string;
+ searchInputAriaLabel: string;
+ selectedElements: string[];
+ validateSearchInput?: (value: string) => string;
+}
+
+interface State {
+ activeIdx: number;
+ loading: boolean;
+ query: string;
+ selectedElements: string[];
+ unselectedElements: string[];
+}
+
+interface DefaultProps {
+ filterSelected: (query: string, selectedElements: string[]) => string[];
+ renderLabel: (element: string) => React.ReactNode;
+ validateSearchInput: (value: string) => string;
+}
+
+type PropsWithDefault = Props & DefaultProps;
+
+export class MultiSelect extends PureComponent<Props, State> {
+ container?: HTMLDivElement | null;
+ searchInput?: HTMLInputElement | null;
+ mounted = false;
+
+ static defaultProps: DefaultProps = {
+ filterSelected: (query: string, selectedElements: string[]) =>
+ selectedElements.filter((elem) => elem.includes(query)),
+ renderLabel: (element: string) => element,
+ validateSearchInput: (value: string) => value,
+ };
+
+ constructor(props: Props) {
+ super(props);
+ this.state = {
+ activeIdx: 0,
+ loading: true,
+ query: '',
+ selectedElements: [],
+ unselectedElements: [],
+ };
+ }
+
+ componentDidMount() {
+ this.mounted = true;
+ this.onSearchQuery('');
+ this.updateSelectedElements(this.props as PropsWithDefault);
+ this.updateUnselectedElements();
+ if (this.container) {
+ this.container.addEventListener('keydown', this.handleKeyboard, true);
+ }
+ }
+
+ componentDidUpdate(prevProps: Props) {
+ if (this.searchInput) {
+ this.searchInput.focus();
+ }
+
+ if (
+ prevProps.elements !== this.props.elements ||
+ prevProps.selectedElements !== this.props.selectedElements
+ ) {
+ this.updateSelectedElements(this.props as PropsWithDefault);
+ this.updateUnselectedElements();
+
+ const totalElements = this.getAllElements(this.props, this.state).length;
+
+ if (this.state.activeIdx >= totalElements) {
+ this.setState({ activeIdx: totalElements - 1 });
+ }
+ }
+ }
+
+ componentWillUnmount() {
+ this.mounted = false;
+ if (this.container) {
+ this.container.removeEventListener('keydown', this.handleKeyboard);
+ }
+ }
+
+ handleSelectChange = (selected: boolean, item: string) => {
+ if (selected) {
+ this.onSelectItem(item);
+ } else {
+ this.onUnselectItem(item);
+ }
+ };
+
+ handleSearchChange = (value: string) => {
+ this.onSearchQuery((this.props as PropsWithDefault).validateSearchInput(value));
+ };
+
+ handleElementHover = (element: string) => {
+ this.setState((prevState, props) => {
+ return { activeIdx: this.getAllElements(props, prevState).indexOf(element) };
+ });
+ };
+
+ handleKeyboard = (evt: KeyboardEvent) => {
+ switch (evt.key) {
+ case Key.ArrowDown:
+ evt.stopPropagation();
+ evt.preventDefault();
+ this.setState(this.selectNextElement);
+ break;
+ case Key.ArrowUp:
+ evt.stopPropagation();
+ evt.preventDefault();
+ this.setState(this.selectPreviousElement);
+ break;
+ case Key.ArrowLeft:
+ case Key.ArrowRight:
+ evt.stopPropagation();
+ break;
+ case Key.Enter: {
+ const allElements = this.getAllElements(this.props, this.state);
+ if (this.state.activeIdx >= 0 && this.state.activeIdx < allElements.length) {
+ this.toggleSelect(allElements[this.state.activeIdx]);
+ }
+ break;
+ }
+ }
+ };
+
+ onSearchQuery = (query: string) => {
+ this.setState({ activeIdx: 0, loading: true, query });
+ this.props.onSearch(query).then(this.stopLoading, this.stopLoading);
+ };
+
+ onSelectItem = (item: string) => {
+ if (this.isNewElement(item, this.props)) {
+ this.onSearchQuery('');
+ }
+ this.props.onSelect(item);
+ };
+
+ onUnselectItem = (item: string) => this.props.onUnselect(item);
+
+ isNewElement = (elem: string, { selectedElements, elements }: Props) =>
+ elem.length > 0 && selectedElements.indexOf(elem) === -1 && elements.indexOf(elem) === -1;
+
+ updateSelectedElements = (props: PropsWithDefault) => {
+ this.setState((state: State) => {
+ if (state.query) {
+ return {
+ selectedElements: props.filterSelected(state.query, props.selectedElements),
+ };
+ }
+ return { selectedElements: [...props.selectedElements] };
+ });
+ };
+
+ updateUnselectedElements = () => {
+ const { listSize } = this.props;
+ this.setState((state: State) => {
+ if (listSize === 0) {
+ return { unselectedElements: difference(this.props.elements, this.props.selectedElements) };
+ } else if (listSize < state.selectedElements.length) {
+ return { unselectedElements: [] };
+ }
+ return {
+ unselectedElements: difference(this.props.elements, this.props.selectedElements).slice(
+ 0,
+ listSize - state.selectedElements.length
+ ),
+ };
+ });
+ };
+
+ getAllElements = (props: Props, state: State) => {
+ const { allowNewElements = true } = props;
+ if (allowNewElements && this.isNewElement(state.query, props)) {
+ return [...state.selectedElements, ...state.unselectedElements, state.query];
+ }
+ return [...state.selectedElements, ...state.unselectedElements];
+ };
+
+ selectNextElement = (state: State, props: Props) => {
+ const { activeIdx } = state;
+ const allElements = this.getAllElements(props, state);
+ if (activeIdx < 0 || activeIdx >= allElements.length - 1) {
+ return { activeIdx: 0 };
+ }
+ return { activeIdx: activeIdx + 1 };
+ };
+
+ selectPreviousElement = (state: State, props: Props) => {
+ const { activeIdx } = state;
+ const allElements = this.getAllElements(props, state);
+ if (activeIdx <= 0) {
+ const lastIdx = allElements.length - 1;
+ return { activeIdx: lastIdx };
+ }
+ return { activeIdx: activeIdx - 1 };
+ };
+
+ stopLoading = () => {
+ if (this.mounted) {
+ this.setState({ loading: false });
+ }
+ };
+
+ toggleSelect = (item: string) => {
+ if (!this.props.selectedElements.includes(item)) {
+ this.onSelectItem(item);
+ this.setState(this.selectNextElement);
+ } else {
+ this.onUnselectItem(item);
+ }
+ };
+
+ render() {
+ const {
+ allowSelection = true,
+ allowNewElements = true,
+ createElementLabel,
+ headerNode = '',
+ footerNode = '',
+ clearIconAriaLabel,
+ noResultsLabel,
+ searchInputAriaLabel,
+ } = this.props;
+ const { query, activeIdx, selectedElements, unselectedElements } = this.state;
+ const activeElement = this.getAllElements(this.props, this.state)[activeIdx];
+ const showNewElement = allowNewElements && this.isNewElement(query, this.props);
+ const isFixedHeight = this.props.listSize === 0;
+ const hasFooter = Boolean(footerNode);
+
+ return (
+ <div ref={(div) => (this.container = div)}>
+ <div className="sw-px-3">
+ <InputSearch
+ autoFocus={true}
+ className="sw-mt-1"
+ clearIconAriaLabel={clearIconAriaLabel}
+ loading={this.state.loading}
+ onChange={this.handleSearchChange}
+ placeholder={this.props.placeholder}
+ searchInputAriaLabel={searchInputAriaLabel}
+ size="full"
+ value={query}
+ />
+ </div>
+ <ItemHeader>{headerNode}</ItemHeader>
+ <ul
+ className={classNames('sw-mt-2', {
+ 'sw-max-h-abs-200 sw-overflow-y-auto': isFixedHeight,
+ })}
+ >
+ {selectedElements.length > 0 &&
+ selectedElements.map((element) => (
+ <MultiSelectOption
+ active={activeElement === element}
+ createElementLabel={createElementLabel}
+ element={element}
+ key={element}
+ onHover={this.handleElementHover}
+ onSelectChange={this.handleSelectChange}
+ selected={true}
+ />
+ ))}
+ {unselectedElements.length > 0 &&
+ unselectedElements.map((element) => (
+ <MultiSelectOption
+ active={activeElement === element}
+ createElementLabel={createElementLabel}
+ disabled={!allowSelection}
+ element={element}
+ key={element}
+ onHover={this.handleElementHover}
+ onSelectChange={this.handleSelectChange}
+ />
+ ))}
+ {showNewElement && (
+ <MultiSelectOption
+ active={activeElement === query}
+ createElementLabel={createElementLabel}
+ custom={true}
+ element={query}
+ key={query}
+ onHover={this.handleElementHover}
+ onSelectChange={this.handleSelectChange}
+ />
+ )}
+ {!showNewElement && selectedElements.length < 1 && unselectedElements.length < 1 && (
+ <li className="sw-ml-2">{noResultsLabel}</li>
+ )}
+ </ul>
+ {hasFooter && <ItemDivider className="sw-mt-2" />}
+ <div className="sw-px-3">{footerNode}</div>
+ </div>
+ );
+ }
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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 classNames from 'classnames';
+import { ItemCheckbox } from './DropdownMenu';
+
+export interface MultiSelectOptionProps {
+ active?: boolean;
+ createElementLabel: string;
+ custom?: boolean;
+ disabled?: boolean;
+ element: string;
+ onHover: (element: string) => void;
+ onSelectChange: (selected: boolean, element: string) => void;
+ selected?: boolean;
+}
+
+export function MultiSelectOption(props: MultiSelectOptionProps) {
+ const { active, createElementLabel, custom, disabled, element, onSelectChange, selected } = props;
+ const onHover = () => props.onHover(element);
+
+ return (
+ <ItemCheckbox
+ checked={Boolean(selected)}
+ className={classNames('sw-flex sw-py-2 sw-px-4', { active })}
+ disabled={disabled}
+ id={element}
+ onCheck={onSelectChange}
+ onFocus={onHover}
+ onPointerEnter={onHover}
+ >
+ {custom ? (
+ <span
+ aria-label={`${createElementLabel}: ${element}`}
+ className="sw-ml-3"
+ title={createElementLabel}
+ >
+ <span aria-hidden={true} className="sw-mr-1">
+ +
+ </span>
+ {element}
+ </span>
+ ) : (
+ <span className="sw-ml-3">{element}</span>
+ )}
+ </ItemCheckbox>
+ );
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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 styled from '@emotion/styled';
+import classNames from 'classnames';
+import * as React from 'react';
+import tw from 'twin.macro';
+import { PopupPlacement, PopupZLevel } from '../helpers';
+import { themeColor, themeContrast } from '../helpers/theme';
+import { Dropdown } from './Dropdown';
+import { LightLabel } from './Text';
+import { WrapperButton } from './buttons';
+
+interface Props {
+ allowUpdate?: boolean;
+ ariaTagsListLabel: string;
+ className?: string;
+ emptyText: string;
+ menuId?: string;
+ overlay?: React.ReactNode;
+ popupPlacement?: PopupPlacement;
+ tags: string[];
+ tagsToDisplay?: number;
+}
+
+export function Tags({
+ allowUpdate = false,
+ ariaTagsListLabel,
+ className,
+ emptyText,
+ menuId = '',
+ overlay,
+ popupPlacement,
+ tags,
+ tagsToDisplay = 3,
+}: Props) {
+ const displayedTags = tags.slice(0, tagsToDisplay);
+ const extraTags = tags.slice(tagsToDisplay);
+
+ const displayedTagsContent = () => (
+ <span className="sw-inline-flex sw-items-center sw-gap-1" title={tags.join(', ')}>
+ {/* Display first 3 (tagsToDisplay) tags */}
+ {displayedTags.map((tag) => (
+ <TagLabel key={tag}>{tag}</TagLabel>
+ ))}
+
+ {/* Show ellipsis if there are more tags */}
+ {extraTags.length > 0 ? <TagLabel>...</TagLabel> : null}
+
+ {/* Handle no tags with its own styling */}
+ {tags.length === 0 && <LightLabel>{emptyText}</LightLabel>}
+ </span>
+ );
+
+ return (
+ <span
+ aria-label={`${ariaTagsListLabel}: ${tags.join(', ')}`}
+ className={classNames('sw-cursor-default sw-flex sw-items-center', className)}
+ >
+ {allowUpdate ? (
+ <Dropdown
+ allowResizing={true}
+ closeOnClick={false}
+ id={menuId}
+ overlay={overlay}
+ placement={popupPlacement}
+ zLevel={PopupZLevel.Global}
+ >
+ {({ a11yAttrs, onToggleClick }) => (
+ <WrapperButton
+ className="sw-flex sw-items-center sw-gap-1 sw-p-1 sw-h-auto sw-rounded-0"
+ onClick={onToggleClick}
+ {...a11yAttrs}
+ >
+ {displayedTagsContent()}
+ <TagLabel className="sw-cursor-pointer">+</TagLabel>
+ </WrapperButton>
+ )}
+ </Dropdown>
+ ) : (
+ <span>{displayedTagsContent()}</span>
+ )}
+ </span>
+ );
+}
+
+const TagLabel = styled.span`
+ color: ${themeContrast('tag')};
+ background: ${themeColor('tag')};
+
+ ${tw`sw-body-sm`}
+ ${tw`sw-box-border`}
+ ${tw`sw-truncate`}
+ ${tw`sw-rounded-1/2`}
+ ${tw`sw-px-1 sw-py-1/2`}
+ ${tw`sw-max-w-32`}
+`;
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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 { MultiSelect } from './MultiSelect';
+
+interface Props {
+ clearIconAriaLabel: string;
+ createElementLabel: string;
+ headerLabel: string;
+ listSize: number;
+ noResultsLabel: string;
+ onSearch: (query: string) => Promise<void>;
+ onSelect: (item: string) => void;
+ onUnselect: (item: string) => void;
+ searchInputAriaLabel: string;
+ selectedTags: string[];
+ tags: string[];
+}
+
+export function TagsSelector(props: Props) {
+ const {
+ clearIconAriaLabel,
+ createElementLabel,
+ headerLabel,
+ listSize,
+ noResultsLabel,
+ searchInputAriaLabel,
+ selectedTags,
+ tags,
+ } = props;
+
+ return (
+ <MultiSelect
+ clearIconAriaLabel={clearIconAriaLabel}
+ createElementLabel={createElementLabel}
+ elements={tags}
+ headerNode={<div className="sw-mt-4 sw-font-semibold">{headerLabel}</div>}
+ listSize={listSize}
+ noResultsLabel={noResultsLabel}
+ onSearch={props.onSearch}
+ onSelect={props.onSelect}
+ onUnselect={props.onUnselect}
+ placeholder={searchInputAriaLabel}
+ searchInputAriaLabel={searchInputAriaLabel}
+ selectedElements={selectedTags}
+ validateSearchInput={validateTag}
+ />
+ );
+}
+
+export function validateTag(value: string) {
+ // Allow only a-z, 0-9, '+', '-', '#', '.'
+ return value.toLowerCase().replace(/[^-a-z0-9+#.]/gi, '');
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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 { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { MultiSelect } from '../MultiSelect';
+
+const elements = ['foo', 'bar', 'baz'];
+
+beforeAll(() => {
+ jest.useFakeTimers();
+});
+
+afterAll(() => {
+ jest.useRealTimers();
+});
+
+it('should allow selecting and deselecting a new option', async () => {
+ const user = userEvent.setup({ delay: null });
+ const onSelect = jest.fn();
+ const onUnselect = jest.fn();
+ renderMultiselect({ elements, onSelect, onUnselect, allowNewElements: true });
+
+ await user.keyboard('new option');
+ jest.runAllTimers(); // skip the debounce
+
+ expect(screen.getByText('new option')).toBeInTheDocument();
+
+ await user.click(screen.getByText('new option'));
+
+ expect(onSelect).toHaveBeenCalledWith('new option');
+
+ renderMultiselect({
+ elements,
+ onUnselect,
+ allowNewElements: true,
+ selectedElements: ['new option'],
+ });
+
+ await user.click(screen.getByText('new option'));
+ expect(onUnselect).toHaveBeenCalledWith('new option');
+});
+
+it('should ignore the left and right arrow keys', async () => {
+ const user = userEvent.setup({ delay: null });
+ const onSelect = jest.fn();
+ renderMultiselect({ elements, onSelect });
+
+ /* eslint-disable testing-library/no-node-access */
+ await user.keyboard('{arrowdown}');
+ expect(screen.getAllByRole('checkbox')[1].parentElement).toHaveClass('active');
+
+ await user.keyboard('{arrowleft}');
+ expect(screen.getAllByRole('checkbox')[1].parentElement).toHaveClass('active');
+
+ await user.keyboard('{arrowright}');
+ expect(screen.getAllByRole('checkbox')[1].parentElement).toHaveClass('active');
+
+ await user.keyboard('{arrowdown}');
+ expect(screen.getAllByRole('checkbox')[1].parentElement).not.toHaveClass('active');
+ expect(screen.getAllByRole('checkbox')[2].parentElement).toHaveClass('active');
+
+ await user.keyboard('{arrowup}');
+ await user.keyboard('{arrowup}');
+ expect(screen.getAllByRole('checkbox')[0].parentElement).toHaveClass('active');
+ await user.keyboard('{arrowup}');
+ expect(screen.getAllByRole('checkbox')[2].parentElement).toHaveClass('active');
+
+ expect(screen.getAllByRole('checkbox')[2]).not.toBeChecked();
+ await user.keyboard('{enter}');
+ expect(onSelect).toHaveBeenCalledWith('baz');
+});
+
+it('should show no results', () => {
+ renderMultiselect();
+ expect(screen.getByText('no results')).toBeInTheDocument();
+});
+
+function renderMultiselect(props: Partial<MultiSelect['props']> = {}) {
+ return render(
+ <MultiSelect
+ clearIconAriaLabel="clear"
+ createElementLabel="create thing"
+ elements={[]}
+ filterSelected={jest.fn()}
+ listSize={10}
+ noResultsLabel="no results"
+ onSearch={jest.fn(() => Promise.resolve())}
+ onSelect={jest.fn()}
+ onUnselect={jest.fn()}
+ placeholder=""
+ searchInputAriaLabel="search"
+ selectedElements={[]}
+ {...props}
+ />
+ );
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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.
+ */
+
+/* eslint-disable import/no-extraneous-dependencies */
+
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { useState } from 'react';
+import { FCProps } from '../../types/misc';
+import { Tags } from '../Tags';
+import { TagsSelector } from '../TagsSelector';
+
+it('should display "no tags"', () => {
+ renderTags({ tags: [] });
+
+ expect(screen.getByText('no tags')).toBeInTheDocument();
+});
+
+it('should display tags', () => {
+ const tags = ['tag1', 'tag2'];
+ renderTags({ tags });
+
+ expect(screen.getByText('tag1')).toBeInTheDocument();
+ expect(screen.getByText('tag2')).toBeInTheDocument();
+});
+
+it('should handle more tags than the max to display', () => {
+ const tags = ['tag1', 'tag2', 'tag3'];
+ renderTags({ tags, tagsToDisplay: 2 });
+
+ expect(screen.getByText('tag1')).toBeInTheDocument();
+ expect(screen.getByText('tag2')).toBeInTheDocument();
+ expect(screen.queryByText('tag3')).not.toBeInTheDocument();
+ expect(screen.getByText('...')).toBeInTheDocument();
+});
+
+it('should allow editing tags', async () => {
+ const user = userEvent.setup();
+ renderTags({ allowUpdate: true });
+
+ const plusButton = screen.getByText('+');
+ expect(plusButton).toBeInTheDocument();
+ await user.click(plusButton);
+
+ expect(await screen.findByLabelText('search')).toBeInTheDocument();
+ await user.click(screen.getByText('tag3'));
+ await user.keyboard('{Escape}');
+
+ expect(screen.getByText('tag1')).toBeInTheDocument();
+ expect(screen.getByText('tag3')).toBeInTheDocument();
+
+ await user.click(plusButton);
+ await user.click(screen.getByRole('checkbox', { name: 'tag1' }));
+ await user.keyboard('{Escape}');
+
+ expect(screen.queryByText('tag1')).not.toBeInTheDocument();
+ expect(screen.getByText('tag3')).toBeInTheDocument();
+});
+
+function renderTags(overrides: Partial<FCProps<typeof Tags>> = {}) {
+ render(<Wrapper {...overrides} />);
+}
+
+function Wrapper(overrides: Partial<FCProps<typeof Tags>> = {}) {
+ const [selectedTags, setSelectedTags] = useState<string[]>(overrides.tags ?? ['tag1']);
+
+ const overlay = (
+ <TagsSelector
+ clearIconAriaLabel="clear"
+ createElementLabel="create new tag"
+ headerLabel="edit tags"
+ listSize={4}
+ noResultsLabel="no results"
+ onSearch={jest.fn().mockResolvedValue(undefined)}
+ onSelect={(tag) => {
+ setSelectedTags([...selectedTags, tag]);
+ }}
+ onUnselect={(tag) => {
+ const i = selectedTags.indexOf(tag);
+ if (i > -1) {
+ setSelectedTags([...selectedTags.slice(0, i), ...selectedTags.slice(i + 1)]);
+ }
+ }}
+ searchInputAriaLabel="search"
+ selectedTags={selectedTags}
+ tags={['tag1', 'tag2', 'tag3']}
+ />
+ );
+
+ return (
+ <Tags
+ ariaTagsListLabel="list"
+ emptyText="no tags"
+ overlay={overlay}
+ tags={selectedTags}
+ {...overrides}
+ />
+ );
+}
--border: ${themeBorder('default', 'dangerButtonSecondaryBorder')};
`;
+export const WrapperButton: React.FC<ButtonProps> = styled(Button)`
+ --background: none;
+ --backgroundHover: none;
+ --color: none;
+ --focus: ${themeColor('button', OPACITY_20_PERCENT)};
+ --border: none;
+`;
+
interface ThirdPartyProps extends Omit<ButtonProps, 'Icon'> {
iconPath: string;
name: string;
export * from './Separator';
export * from './SizeIndicator';
export * from './SonarQubeLogo';
+export * from './Tags';
+export * from './TagsSelector';
export * from './Text';
export { ToggleButton } from './ToggleButton';
export { TopBar } from './TopBar';