"@testing-library/react": "12.1.5",
"@testing-library/user-event": "14.4.3",
"@types/react": "17.0.39",
+ "@types/react-highlight-words": "0.16.4",
"@typescript-eslint/parser": "5.57.0",
"@vitejs/plugin-react": "3.1.0",
"autoprefixer": "10.4.14",
"react-day-picker": "8.6.0",
"react-dom": "17.0.2",
"react-helmet-async": "1.3.0",
+ "react-highlight-words": "0.20.0",
"react-intl": "6.2.5",
"react-router-dom": "6.10.0",
"react-select": "5.7.2",
import { keyframes } from '@emotion/react';
import styled from '@emotion/styled';
import React from 'react';
-import tw, { theme } from 'twin.macro';
+import tw from 'twin.macro';
import { translate } from '../helpers/l10n';
import { themeColor } from '../helpers/theme';
-import { InputSearchWrapper } from './InputSearch';
interface Props {
children?: React.ReactNode;
}
`;
-const Spinner = styled.div`
+export const Spinner = styled.div`
border: 2px solid transparent;
background: linear-gradient(0deg, ${themeColor('primary')} 50%, transparent 50% 100%) border-box,
linear-gradient(90deg, ${themeColor('primary')} 25%, transparent 75% 100%) border-box;
${tw`sw-inline-block`};
${tw`sw-box-border`};
${tw`sw-rounded-pill`}
-
- ${InputSearchWrapper} & {
- top: calc((2.25rem - ${theme('spacing.4')}) / 2);
- ${tw`sw-left-3`};
- ${tw`sw-absolute`};
- }
`;
Spinner.defaultProps = { 'aria-label': translate('loading'), role: 'status' };
import styled from '@emotion/styled';
import classNames from 'classnames';
import { debounce } from 'lodash';
-import React, { useEffect, useMemo, useRef, useState } from 'react';
+import React, { PropsWithChildren, useEffect, useMemo, useRef, useState } from 'react';
import tw, { theme } from 'twin.macro';
import { DEBOUNCE_DELAY, INPUT_SIZES } from '../helpers/constants';
import { Key } from '../helpers/keyboard';
import { themeBorder, themeColor, themeContrast } from '../helpers/theme';
import { isDefined } from '../helpers/types';
import { InputSizeKeys } from '../types/theme';
-import { DeferredSpinner } from './DeferredSpinner';
+import { DeferredSpinner, Spinner } from './DeferredSpinner';
import { InteractiveIcon } from './InteractiveIcon';
import { CloseIcon } from './icons/CloseIcon';
import { SearchIcon } from './icons/SearchIcon';
onFocus?: React.FocusEventHandler<HTMLInputElement>;
onKeyDown?: React.KeyboardEventHandler<HTMLInputElement>;
onMouseDown?: React.MouseEventHandler<HTMLInputElement>;
- placeholder: string;
- searchInputAriaLabel: string;
+ placeholder?: string;
+ searchInputAriaLabel?: string;
size?: InputSizeKeys;
tooShortText?: string;
value?: string;
tooShortText,
searchInputAriaLabel,
clearIconAriaLabel,
-}: Props) {
+ children,
+}: PropsWithChildren<Props>) {
const input = useRef<null | HTMLElement>(null);
const [value, setValue] = useState(parentValue ?? '');
const debouncedOnChange = useMemo(() => debounce(onChange, DEBOUNCE_DELAY), [onChange]);
title={tooShort && tooShortText && isDefined(minLength) ? tooShortText : ''}
>
<StyledInputWrapper className="sw-flex sw-items-center">
- <input
- aria-label={searchInputAriaLabel}
- autoComplete="off"
- className={inputClassName}
- maxLength={maxLength}
- onBlur={onBlur}
- onChange={handleInputChange}
- onFocus={onFocus}
- onKeyDown={handleInputKeyDown}
- placeholder={placeholder}
- ref={ref}
- role="searchbox"
- type="search"
- value={value}
- />
- <DeferredSpinner loading={loading !== undefined ? loading : false}>
+ {children ?? (
+ <input
+ aria-label={searchInputAriaLabel}
+ autoComplete="off"
+ className={inputClassName}
+ maxLength={maxLength}
+ onBlur={onBlur}
+ onChange={handleInputChange}
+ onFocus={onFocus}
+ onKeyDown={handleInputKeyDown}
+ placeholder={placeholder}
+ ref={ref}
+ role="searchbox"
+ type="search"
+ value={value}
+ />
+ )}
+ <DeferredSpinner className="sw-z-normal" loading={loading ?? false}>
<StyledSearchIcon />
</DeferredSpinner>
{value && (
${tw`sw-whitespace-nowrap`}
${tw`sw-align-middle`}
${tw`sw-h-control`}
+
+ ${Spinner} {
+ top: calc((2.25rem - ${theme('spacing.4')}) / 2);
+ ${tw`sw-left-3`};
+ ${tw`sw-absolute`};
+ }
`;
export const StyledInputWrapper = styled.div`
${tw`sw-left-3`}
${tw`sw-absolute`}
+ ${tw`sw-z-normal`}
`;
export const StyledInteractiveIcon = styled(InteractiveIcon)`
import { INPUT_SIZES } from '../helpers';
import { themeBorder, themeColor, themeContrast } from '../helpers/theme';
import { InputSizeKeys } from '../types/theme';
+import { SearchHighlighter } from './SearchHighlighter';
import { ChevronDownIcon } from './icons';
export interface LabelValueSelectOption<V> {
size?: InputSizeKeys;
}
-type SelectProps<
+export type SelectProps<
V,
Option extends LabelValueSelectOption<V>,
IsMulti extends boolean = false,
Group extends GroupBase<Option> = GroupBase<Option>
> = NamedProps<Option, IsMulti, Group> & StyleExtensionProps;
-function IconOption<
+export function IconOption<
V,
Option extends LabelValueSelectOption<V>,
IsMulti extends boolean = false,
<components.Option {...props}>
<div className="sw-flex sw-items-center sw-gap-1">
{Icon}
- {label}
+ <SearchHighlighter>{label}</SearchHighlighter>
</div>
</components.Option>
);
--- /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 { deburr } from 'lodash';
+import { createContext, useContext } from 'react';
+import Highlighter from 'react-highlight-words';
+import { themeColor, themeContrast } from '../helpers/theme';
+
+export const SearchHighlighterContext = createContext<string | undefined>(undefined);
+SearchHighlighterContext.displayName = 'SearchHighlighterContext';
+
+interface Props {
+ children?: string;
+ term?: string;
+}
+
+export function SearchHighlighter({ children = '', term }: Props) {
+ const query = useContext(SearchHighlighterContext);
+
+ const searchTerm = term ?? query;
+ if (searchTerm) {
+ return (
+ <StyledHighlighter
+ autoEscape={true}
+ sanitize={deburr}
+ searchWords={[searchTerm]}
+ textToHighlight={children}
+ />
+ );
+ }
+ return <>{children}</>;
+}
+
+const StyledHighlighter = styled(Highlighter)`
+ mark {
+ color: ${themeContrast('searchHighlight')};
+ font-weight: inherit;
+ background: ${themeColor('searchHighlight')};
+ }
+`;
--- /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 { omit } from 'lodash';
+import React, { RefObject } from 'react';
+import { GroupBase, InputProps, components } from 'react-select';
+import AsyncSelect, { AsyncProps } from 'react-select/async';
+import Select from 'react-select/dist/declarations/src/Select';
+import { INPUT_SIZES } from '../helpers';
+import { Key } from '../helpers/keyboard';
+import { translate } from '../helpers/l10n';
+import { InputSearch } from './InputSearch';
+import { LabelValueSelectOption, SelectProps, selectStyle } from './InputSelect';
+
+type SearchSelectProps<
+ V,
+ Option extends LabelValueSelectOption<V>,
+ IsMulti extends boolean = false,
+ Group extends GroupBase<Option> = GroupBase<Option>
+> = SelectProps<V, Option, IsMulti, Group> & AsyncProps<Option, IsMulti, Group>;
+
+export function SearchSelect<
+ V,
+ Option extends LabelValueSelectOption<V>,
+ IsMulti extends boolean = false,
+ Group extends GroupBase<Option> = GroupBase<Option>
+>({
+ size = 'full',
+ selectRef,
+ ...props
+}: SearchSelectProps<V, Option, IsMulti, Group> & {
+ selectRef?: RefObject<Select<Option, IsMulti, Group>>;
+}) {
+ const styles = selectStyle<V, Option, IsMulti, Group>({ size });
+ return (
+ <AsyncSelect<Option, IsMulti, Group>
+ {...omit(props, 'className', 'large')}
+ className={classNames('react-select', props.className)}
+ classNamePrefix="react-select"
+ classNames={{
+ control: ({ isDisabled }) =>
+ classNames(
+ 'sw-border-0 sw-rounded-2 sw-outline-none sw-shadow-none',
+ isDisabled && 'sw-pointer-events-none sw-cursor-not-allowed'
+ ),
+ indicatorsContainer: () => 'sw-hidden',
+ input: () => `sw-flex sw-w-full sw-p-0 sw-m-0`,
+ valueContainer: () => `sw-px-3 sw-pb-1 sw-mb-1 sw-pt-4`,
+ placeholder: () => 'sw-hidden',
+ ...props.classNames,
+ }}
+ components={{
+ Input: SearchSelectInput,
+ ...props.components,
+ }}
+ ref={selectRef}
+ styles={{
+ ...styles,
+ menu: (base, props) => ({
+ ...styles.menu?.(base, props),
+ width: `calc(${INPUT_SIZES[size]} - 2px)`,
+ }),
+ }}
+ />
+ );
+}
+
+export function SearchSelectInput<
+ V,
+ Option extends LabelValueSelectOption<V>,
+ IsMulti extends boolean = false,
+ Group extends GroupBase<Option> = GroupBase<Option>
+>(props: InputProps<Option, IsMulti, Group>) {
+ const {
+ selectProps: { clearIconLabel, placeholder, isLoading, inputValue, minLength, tooShortText },
+ } = props;
+
+ const onChange = (v: string, prevValue = '') => {
+ props.selectProps.onInputChange(v, { action: 'input-change', prevInputValue: prevValue });
+ };
+
+ const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
+ const target = event.target as HTMLInputElement;
+
+ if (event.key === Key.Escape && target.value !== '') {
+ event.stopPropagation();
+ onChange('');
+ }
+ };
+
+ return (
+ <InputSearch
+ clearIconAriaLabel={clearIconLabel ?? translate('clear')}
+ loading={isLoading && inputValue.length >= (minLength ?? 0)}
+ minLength={minLength}
+ onChange={onChange}
+ size="full"
+ tooShortText={tooShortText}
+ value={inputValue}
+ >
+ <components.Input
+ {...props}
+ onKeyDown={handleKeyDown}
+ placeholder={placeholder as string}
+ style={{}}
+ />
+ </InputSearch>
+ );
+}
--- /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 { debounce } from 'lodash';
+import React from 'react';
+import {
+ ActionMeta,
+ GroupBase,
+ InputActionMeta,
+ OnChangeValue,
+ OptionsOrGroups,
+} from 'react-select';
+import { AsyncProps } from 'react-select/async';
+import Select from 'react-select/dist/declarations/src/Select';
+import tw from 'twin.macro';
+import { DEBOUNCE_DELAY, themeBorder } from '../helpers';
+import { DropdownToggler } from './DropdownToggler';
+import { IconOption, LabelValueSelectOption, SelectProps } from './InputSelect';
+import { SearchHighlighterContext } from './SearchHighlighter';
+import { SearchSelect } from './SearchSelect';
+import { SearchSelectDropdownControl } from './SearchSelectDropdownControl';
+
+declare module 'react-select/dist/declarations/src/Select' {
+ export interface Props<Option, IsMulti extends boolean, Group extends GroupBase<Option>> {
+ clearIconLabel?: string;
+ minLength?: number;
+ tooShortText?: string;
+ }
+}
+
+export interface SearchSelectDropdownProps<
+ V,
+ Option extends LabelValueSelectOption<V>,
+ IsMulti extends boolean = false,
+ Group extends GroupBase<Option> = GroupBase<Option>
+> extends SelectProps<V, Option, IsMulti, Group>,
+ AsyncProps<Option, IsMulti, Group> {
+ controlLabel?: React.ReactNode | string;
+ isDiscreet?: boolean;
+}
+
+export function SearchSelectDropdown<
+ V,
+ Option extends LabelValueSelectOption<V>,
+ IsMulti extends boolean = false,
+ Group extends GroupBase<Option> = GroupBase<Option>
+>(props: SearchSelectDropdownProps<V, Option, IsMulti, Group>) {
+ const { isDiscreet, value, loadOptions, controlLabel, isDisabled, minLength, ...rest } = props;
+ const [open, setOpen] = React.useState(false);
+ const [inputValue, setInputValue] = React.useState('');
+
+ const ref = React.useRef<Select<Option, IsMulti, Group>>(null);
+
+ const toggleDropdown = React.useCallback(
+ (value?: boolean) => {
+ setOpen(value === undefined ? !open : value);
+ },
+ [open]
+ );
+
+ const handleChange = React.useCallback(
+ (newValue: OnChangeValue<Option, IsMulti>, actionMeta: ActionMeta<Option>) => {
+ toggleDropdown(false);
+ props.onChange?.(newValue, actionMeta);
+ },
+ [toggleDropdown, props.onChange]
+ );
+
+ const handleLoadOptions = React.useCallback(
+ (query: string, callback: (options: OptionsOrGroups<Option, Group>) => void) => {
+ return query.length >= (minLength ?? 0) ? loadOptions?.(query, callback) : undefined;
+ },
+ [minLength, loadOptions]
+ );
+ const debouncedLoadOptions = React.useRef(debounce(handleLoadOptions, DEBOUNCE_DELAY));
+
+ const handleInputChange = React.useCallback(
+ (newValue: string, actionMeta: InputActionMeta) => {
+ const value = actionMeta.action === 'menu-close' ? actionMeta.prevInputValue : newValue;
+ setInputValue(value);
+ props.onInputChange?.(value, actionMeta);
+ },
+ [props.onInputChange]
+ );
+
+ React.useEffect(() => {
+ if (open) {
+ ref.current?.inputRef?.select();
+ } else {
+ setInputValue('');
+ }
+ }, [open]);
+
+ return (
+ <DropdownToggler
+ allowResizing={true}
+ className="sw-overflow-visible sw-border-none"
+ onRequestClose={() => {
+ toggleDropdown(false);
+ }}
+ open={open}
+ overlay={
+ <SearchHighlighterContext.Provider value={inputValue}>
+ <StyledSearchSelectWrapper>
+ <SearchSelect
+ cacheOptions={true}
+ {...rest}
+ components={{
+ SingleValue: () => null,
+ Option: IconOption,
+ ...rest.components,
+ }}
+ inputValue={inputValue}
+ loadOptions={debouncedLoadOptions.current}
+ menuIsOpen={true}
+ minLength={minLength}
+ onChange={handleChange}
+ onInputChange={handleInputChange}
+ selectRef={ref}
+ />
+ </StyledSearchSelectWrapper>
+ </SearchHighlighterContext.Provider>
+ }
+ >
+ <SearchSelectDropdownControl
+ disabled={isDisabled}
+ isDiscreet={isDiscreet}
+ label={controlLabel}
+ onClick={() => {
+ toggleDropdown(true);
+ }}
+ />
+ </DropdownToggler>
+ );
+}
+
+const StyledSearchSelectWrapper = styled.div`
+ ${tw`sw-w-full`};
+ ${tw`sw-rounded-2`};
+
+ .react-select {
+ border: ${themeBorder('default', 'inputDisabledBorder')};
+ ${tw`sw-rounded-2`};
+ }
+
+ .react-select__menu {
+ ${tw`sw-m-0`};
+ ${tw`sw-relative`};
+ ${tw`sw-shadow-none`};
+ ${tw`sw-rounded-2`};
+ }
+
+ .react-select__menu-notice--loading {
+ ${tw`sw-hidden`}
+ }
+
+ .react-select__input-container {
+ &::after {
+ content: '' !important;
+ }
+ }
+`;
--- /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 tw from 'twin.macro';
+import { INPUT_SIZES, themeBorder, themeColor, themeContrast } from '../helpers';
+import { Key } from '../helpers/keyboard';
+import { InputSizeKeys } from '../types/theme';
+import { ChevronDownIcon } from './icons';
+
+interface SearchSelectDropdownControlProps {
+ disabled?: boolean;
+ isDiscreet?: boolean;
+ label?: React.ReactNode | string;
+ onClick: VoidFunction;
+ size?: InputSizeKeys;
+}
+
+export function SearchSelectDropdownControl(props: SearchSelectDropdownControlProps) {
+ const { disabled, label, isDiscreet, onClick, size = 'full' } = props;
+ return (
+ <StyledControl
+ className={classNames({ 'is-discreet': isDiscreet })}
+ onClick={() => {
+ if (!disabled) {
+ onClick();
+ }
+ }}
+ onKeyDown={(event) => {
+ if (event.key === Key.Enter || event.key === Key.ArrowDown) {
+ onClick();
+ }
+ }}
+ role="combobox"
+ tabIndex={disabled ? -1 : 0}
+ >
+ <InputValue
+ className={classNames('js-search-input-value', {
+ 'is-disabled': disabled,
+ 'is-placeholder': !label,
+ })}
+ style={{ '--inputSize': isDiscreet ? 'auto' : INPUT_SIZES[size] }}
+ >
+ {label}
+ <ChevronDownIcon />
+ </InputValue>
+ </StyledControl>
+ );
+}
+
+const StyledControl = styled.div`
+ color: ${themeContrast('inputBackground')};
+ background: ${themeColor('inputBackground')};
+ border: ${themeBorder('default', 'inputBorder')};
+
+ ${tw`sw-flex sw-justify-between sw-items-center`};
+ ${tw`sw-rounded-2`};
+ ${tw`sw-box-border`};
+ ${tw`sw-px-3 sw-py-2`};
+ ${tw`sw-body-sm`};
+ ${tw`sw-w-full sw-h-control`};
+ ${tw`sw-leading-4`};
+ ${tw`sw-cursor-pointer`};
+
+ &.is-discreet {
+ ${tw`sw-border-none`};
+ ${tw`sw-p-0`};
+ ${tw`sw-w-auto sw-h-auto`};
+
+ background: inherit;
+ }
+
+ &:hover {
+ border: ${themeBorder('default', 'inputFocus')};
+
+ &.is-discreet {
+ ${tw`sw-border-none`};
+ color: ${themeColor('discreetButtonHover')};
+ }
+ }
+
+ &:focus,
+ &:focus-visible,
+ &:focus-within {
+ border: ${themeBorder('default', 'inputFocus')};
+ outline: ${themeBorder('focus', 'inputFocus')};
+
+ &.is-discreet {
+ ${tw`sw-rounded-1 sw-border-none`};
+ outline: ${themeBorder('focus', 'discreetFocusBorder')};
+ }
+ }
+`;
+
+const InputValue = styled.span`
+ width: var(--inputSize);
+ color: ${themeContrast('inputBackground')};
+
+ ${tw`sw-truncate`};
+
+ &.is-placeholder {
+ color: ${themeColor('inputPlaceholder')};
+ }
+
+ &.is-disabled {
+ color: ${themeContrast('inputDisabled')};
+ }
+`;
--- /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 { screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { render } from '../../helpers/testUtils';
+import { FCProps } from '../../types/misc';
+import { LabelValueSelectOption } from '../InputSelect';
+import { SearchSelectDropdown } from '../SearchSelectDropdown';
+
+const defaultOptions = [
+ { label: 'label1', value: 'value1' },
+ { label: 'different', value: 'diff1' },
+];
+
+const loadOptions = (
+ query: string,
+ cb: (options: Array<LabelValueSelectOption<string>>) => void
+) => {
+ cb(defaultOptions.filter((o) => o.label.includes(query)));
+};
+
+it('should render select input and be able to search and select an option', async () => {
+ const user = userEvent.setup();
+ const onChange = jest.fn();
+ renderSearchSelectDropdown({ onChange });
+ expect(screen.getByText('not assigned')).toBeInTheDocument();
+ await user.click(screen.getByRole('combobox'));
+ expect(screen.getByText('label1')).toBeInTheDocument();
+ expect(screen.getByText('different')).toBeInTheDocument();
+ await user.type(screen.getByRole('combobox', { name: 'label' }), 'label');
+ expect(await screen.findByText('label')).toBeInTheDocument();
+ expect(screen.queryByText('different')).not.toBeInTheDocument();
+ await user.click(screen.getByText('label'));
+ expect(onChange).toHaveBeenLastCalledWith(defaultOptions[0], {
+ action: 'select-option',
+ name: undefined,
+ option: undefined,
+ });
+});
+
+it('should handle key navigation', async () => {
+ const user = userEvent.setup();
+ renderSearchSelectDropdown();
+ await user.tab();
+ await user.keyboard('{Enter}');
+ await user.type(screen.getByRole('combobox', { name: 'label' }), 'label');
+ expect(await screen.findByText('label')).toBeInTheDocument();
+ expect(screen.queryByText('different')).not.toBeInTheDocument();
+ await user.keyboard('{Escape}');
+ expect(await screen.findByText('different')).toBeInTheDocument();
+ await user.keyboard('{Escape}');
+ expect(screen.queryByText('different')).not.toBeInTheDocument();
+ await user.tab({ shift: true });
+ await user.keyboard('{ArrowDown}');
+ expect(await screen.findByText('label1')).toBeInTheDocument();
+});
+
+it('behaves correctly in disabled state', async () => {
+ const user = userEvent.setup();
+ renderSearchSelectDropdown({ isDisabled: true });
+ await user.click(screen.getByRole('combobox'));
+ expect(screen.queryByText('label1')).not.toBeInTheDocument();
+ await user.tab();
+ await user.keyboard('{Enter}');
+ expect(screen.queryByText('label1')).not.toBeInTheDocument();
+});
+
+function renderSearchSelectDropdown(props: Partial<FCProps<typeof SearchSelectDropdown>> = {}) {
+ return render(
+ <SearchSelectDropdown
+ aria-label="label"
+ controlLabel="not assigned"
+ defaultOptions={defaultOptions}
+ isDiscreet={true}
+ loadOptions={loadOptions}
+ placeholder="search for things"
+ {...props}
+ />
+ );
+}
export * from './NavBarTabs';
export * from './NewCodeLegend';
export { QualityGateIndicator } from './QualityGateIndicator';
+export * from './SearchSelect';
+export * from './SearchSelectDropdown';
export * from './SelectionCard';
export * from './Separator';
export * from './SizeIndicator';
const { tagName } = event.target as HTMLElement;
return INPUT_TAGS.includes(tagName);
}
+
+export function isTextarea(
+ event: KeyboardEvent
+): event is KeyboardEvent & { target: HTMLTextAreaElement } {
+ return event.target instanceof HTMLTextAreaElement;
+}
"react-dom": "17.0.2",
"react-draggable": "4.4.5",
"react-helmet-async": "1.3.0",
+ "react-highlight-words": "0.20.0",
"react-intl": "6.2.5",
"react-modal": "3.16.1",
"react-router-dom": "6.10.0",
"@types/react": "17.0.39",
"@types/react-dom": "17.0.11",
"@types/react-helmet": "6.1.6",
+ "@types/react-highlight-words": "0.16.4",
"@types/react-modal": "3.13.1",
"@types/react-virtualized": "9.21.21",
"@types/valid-url": "1.0.4",
languageName: node
linkType: hard
+"@types/react-highlight-words@npm:0.16.4":
+ version: 0.16.4
+ resolution: "@types/react-highlight-words@npm:0.16.4"
+ dependencies:
+ "@types/react": "*"
+ checksum: 8b7c1b2b63b8f6d58e181613ba5392755b042bf87c74e65caad7bfc4585566944e9aacda6ee3b1cad222aea6667716c8be8643b39b72ae7b4aaadde063a62d25
+ languageName: node
+ linkType: hard
+
"@types/react-modal@npm:3.13.1":
version: 3.13.1
resolution: "@types/react-modal@npm:3.13.1"
"@types/react": 17.0.39
"@types/react-dom": 17.0.11
"@types/react-helmet": 6.1.6
+ "@types/react-highlight-words": 0.16.4
"@types/react-modal": 3.13.1
"@types/react-virtualized": 9.21.21
"@types/valid-url": 1.0.4
react-dom: 17.0.2
react-draggable: 4.4.5
react-helmet-async: 1.3.0
+ react-highlight-words: 0.20.0
react-intl: 6.2.5
react-modal: 3.16.1
react-router-dom: 6.10.0
"@testing-library/react": 12.1.5
"@testing-library/user-event": 14.4.3
"@types/react": 17.0.39
+ "@types/react-highlight-words": 0.16.4
"@typescript-eslint/parser": 5.57.0
"@vitejs/plugin-react": 3.1.0
autoprefixer: 10.4.14
react-day-picker: 8.6.0
react-dom: 17.0.2
react-helmet-async: 1.3.0
+ react-highlight-words: 0.20.0
react-intl: 6.2.5
react-router-dom: 6.10.0
react-select: 5.7.2
languageName: node
linkType: hard
+"highlight-words-core@npm:^1.2.0":
+ version: 1.2.2
+ resolution: "highlight-words-core@npm:1.2.2"
+ checksum: 737758a8a572c82919552b031df300016164b7d0db6a819d24bc6c7ca2279d3cd6d03497728930d6402423c7a3fc2f42c628a9b01b025c704a0b56a635377511
+ languageName: node
+ linkType: hard
+
"history@npm:5.3.0":
version: 5.3.0
resolution: "history@npm:5.3.0"
languageName: node
linkType: hard
+"memoize-one@npm:^4.0.0":
+ version: 4.0.3
+ resolution: "memoize-one@npm:4.0.3"
+ checksum: addd18c046542f57440ba70bf8ebd48663d17626cade681f777522ef70900a87ec72c5041bed8ece4f6d40a2cb58803bae388b50a4b740d64f36bcda20c147b7
+ languageName: node
+ linkType: hard
+
"memoize-one@npm:^6.0.0":
version: 6.0.0
resolution: "memoize-one@npm:6.0.0"
languageName: node
linkType: hard
-"prop-types@npm:^15.6.0, prop-types@npm:^15.6.2, prop-types@npm:^15.7.2":
- version: 15.7.2
- resolution: "prop-types@npm:15.7.2"
+"prop-types@npm:^15.5.8, prop-types@npm:^15.7.0, prop-types@npm:^15.8.1":
+ version: 15.8.1
+ resolution: "prop-types@npm:15.8.1"
dependencies:
loose-envify: ^1.4.0
object-assign: ^4.1.1
- react-is: ^16.8.1
- checksum: 5eef82fdda64252c7e75aa5c8cc28a24bbdece0f540adb60ce67c205cf978a5bd56b83e4f269f91c6e4dcfd80b36f2a2dec24d362e278913db2086ca9c6f9430
+ react-is: ^16.13.1
+ checksum: c056d3f1c057cb7ff8344c645450e14f088a915d078dcda795041765047fa080d38e5d626560ccaac94a4e16e3aa15f3557c1a9a8d1174530955e992c675e459
languageName: node
linkType: hard
-"prop-types@npm:^15.7.0, prop-types@npm:^15.8.1":
- version: 15.8.1
- resolution: "prop-types@npm:15.8.1"
+"prop-types@npm:^15.6.0, prop-types@npm:^15.6.2, prop-types@npm:^15.7.2":
+ version: 15.7.2
+ resolution: "prop-types@npm:15.7.2"
dependencies:
loose-envify: ^1.4.0
object-assign: ^4.1.1
- react-is: ^16.13.1
- checksum: c056d3f1c057cb7ff8344c645450e14f088a915d078dcda795041765047fa080d38e5d626560ccaac94a4e16e3aa15f3557c1a9a8d1174530955e992c675e459
+ react-is: ^16.8.1
+ checksum: 5eef82fdda64252c7e75aa5c8cc28a24bbdece0f540adb60ce67c205cf978a5bd56b83e4f269f91c6e4dcfd80b36f2a2dec24d362e278913db2086ca9c6f9430
languageName: node
linkType: hard
languageName: node
linkType: hard
+"react-highlight-words@npm:0.20.0":
+ version: 0.20.0
+ resolution: "react-highlight-words@npm:0.20.0"
+ dependencies:
+ highlight-words-core: ^1.2.0
+ memoize-one: ^4.0.0
+ prop-types: ^15.5.8
+ peerDependencies:
+ react: ^0.14.0 || ^15.0.0 || ^16.0.0-0 || ^17.0.0-0 || ^18.0.0-0
+ checksum: 6794b6fe409ee81390e342ccdb951696e06354d8591b4cac050a6d64dbc77dfc7bb636fee0aabcfda841e57778aa5108fe351e7c1dc27b28abedd36aec8141e7
+ languageName: node
+ linkType: hard
+
"react-intl@npm:6.2.5":
version: 6.2.5
resolution: "react-intl@npm:6.2.5"