]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-19296 Add SearchSelectDropdown component to ui lib
authorstanislavh <stanislav.honcharov@sonarsource.com>
Fri, 12 May 2023 14:55:58 +0000 (16:55 +0200)
committersonartech <sonartech@sonarsource.com>
Tue, 16 May 2023 20:02:49 +0000 (20:02 +0000)
13 files changed:
server/sonar-web/design-system/package.json
server/sonar-web/design-system/src/components/DeferredSpinner.tsx
server/sonar-web/design-system/src/components/InputSearch.tsx
server/sonar-web/design-system/src/components/InputSelect.tsx
server/sonar-web/design-system/src/components/SearchHighlighter.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/SearchSelect.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/SearchSelectDropdown.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/SearchSelectDropdownControl.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/__tests__/SearchSelectDropdown-test.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/index.ts
server/sonar-web/design-system/src/helpers/keyboard.ts
server/sonar-web/package.json
server/sonar-web/yarn.lock

index 0e92ce18eea17fcef3b29e4401aadd7d8e81d0e4..bedf128e5e12a0a6e2b2b48a2531f7c9f8601e8b 100644 (file)
@@ -25,6 +25,7 @@
     "@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",
@@ -59,6 +60,7 @@
     "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",
index 3f3a06e577aa26d08cec7ce581679543e18acbab..711214f2a2e0ff189494bb2364fabddb41fe491d 100644 (file)
 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;
@@ -105,7 +104,7 @@ const spinAnimation = keyframes`
   }
 `;
 
-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;
@@ -118,12 +117,6 @@ const Spinner = styled.div`
   ${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' };
index 0bf61f9cae1f31ce5817f35d67aeec13798fb706..3c0233ef392b19f5a6110752ce33635e83fb797a 100644 (file)
 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';
@@ -47,8 +47,8 @@ interface Props {
   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;
@@ -75,7 +75,8 @@ export function InputSearch({
   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]);
@@ -139,22 +140,24 @@ export function InputSearch({
       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 && (
@@ -184,6 +187,12 @@ export const InputSearchWrapper = styled.div`
   ${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`
@@ -229,6 +238,7 @@ const StyledSearchIcon = styled(SearchIcon)`
 
   ${tw`sw-left-3`}
   ${tw`sw-absolute`}
+  ${tw`sw-z-normal`}
 `;
 
 export const StyledInteractiveIcon = styled(InteractiveIcon)`
index 3693ec13018cc7bf235fd45d7b2001a0e89df958..59bb6d728d9f749939c5b765ba7584dfff3c8594 100644 (file)
@@ -31,6 +31,7 @@ import ReactSelect, {
 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> {
@@ -43,14 +44,14 @@ interface StyleExtensionProps {
   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,
@@ -64,7 +65,7 @@ function IconOption<
     <components.Option {...props}>
       <div className="sw-flex sw-items-center sw-gap-1">
         {Icon}
-        {label}
+        <SearchHighlighter>{label}</SearchHighlighter>
       </div>
     </components.Option>
   );
diff --git a/server/sonar-web/design-system/src/components/SearchHighlighter.tsx b/server/sonar-web/design-system/src/components/SearchHighlighter.tsx
new file mode 100644 (file)
index 0000000..b911715
--- /dev/null
@@ -0,0 +1,58 @@
+/*
+ * 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')};
+  }
+`;
diff --git a/server/sonar-web/design-system/src/components/SearchSelect.tsx b/server/sonar-web/design-system/src/components/SearchSelect.tsx
new file mode 100644 (file)
index 0000000..c17c22b
--- /dev/null
@@ -0,0 +1,126 @@
+/*
+ * 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>
+  );
+}
diff --git a/server/sonar-web/design-system/src/components/SearchSelectDropdown.tsx b/server/sonar-web/design-system/src/components/SearchSelectDropdown.tsx
new file mode 100644 (file)
index 0000000..67987e4
--- /dev/null
@@ -0,0 +1,179 @@
+/*
+ * 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;
+    }
+  }
+`;
diff --git a/server/sonar-web/design-system/src/components/SearchSelectDropdownControl.tsx b/server/sonar-web/design-system/src/components/SearchSelectDropdownControl.tsx
new file mode 100644 (file)
index 0000000..fcb802f
--- /dev/null
@@ -0,0 +1,126 @@
+/*
+ * 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')};
+  }
+`;
diff --git a/server/sonar-web/design-system/src/components/__tests__/SearchSelectDropdown-test.tsx b/server/sonar-web/design-system/src/components/__tests__/SearchSelectDropdown-test.tsx
new file mode 100644 (file)
index 0000000..2ff4967
--- /dev/null
@@ -0,0 +1,97 @@
+/*
+ * 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}
+    />
+  );
+}
index 5f4a162be75eb467377016602508ecf0e96ae08d..298efab3dd0abf4b03109a372d6d06b0dbfe3b5c 100644 (file)
@@ -53,6 +53,8 @@ export * from './MetricsRatingBadge';
 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';
index 37b26e3cb2e238ecf3a8d424d38087b3c1ccd193..5a96c128628645d86c7be6633ba8a79d4d9a900b 100644 (file)
@@ -52,3 +52,9 @@ export function isInput(event: KeyboardEvent): boolean {
   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;
+}
index 3b90aede7e86a4f8f2380f4f95abd68866501bad..c9dd15014ffb3e761eb2056cb66151b255997d21 100644 (file)
@@ -31,6 +31,7 @@
     "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",
@@ -64,6 +65,7 @@
     "@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",
index a34d7f09b13add40b3e155588bcdf5694c48c5b9..96fdeac3e6166c33d0011a49084d4df92e468694 100644 (file)
@@ -4068,6 +4068,15 @@ __metadata:
   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"
@@ -4519,6 +4528,7 @@ __metadata:
     "@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
@@ -4574,6 +4584,7 @@ __metadata:
     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
@@ -6126,6 +6137,7 @@ __metadata:
     "@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
@@ -6159,6 +6171,7 @@ __metadata:
     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
@@ -7818,6 +7831,13 @@ __metadata:
   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"
@@ -9652,6 +9672,13 @@ __metadata:
   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"
@@ -10644,25 +10671,25 @@ __metadata:
   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
 
@@ -10800,6 +10827,19 @@ __metadata:
   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"