]> source.dussan.org Git - sonarqube.git/commitdiff
[NO-JIRA] Design system refactor: inputs and buttons
authorRevanshu Paliwal <revanshu.paliwal@sonarsource.com>
Tue, 27 Jun 2023 10:16:58 +0000 (12:16 +0200)
committersonartech <sonartech@sonarsource.com>
Tue, 4 Jul 2023 20:03:09 +0000 (20:03 +0000)
71 files changed:
server/sonar-web/design-system/src/components/Checkbox.tsx [deleted file]
server/sonar-web/design-system/src/components/ColorsLegend.tsx
server/sonar-web/design-system/src/components/DatePicker.tsx [deleted file]
server/sonar-web/design-system/src/components/DateRangePicker.tsx [deleted file]
server/sonar-web/design-system/src/components/DiscreetSelect.tsx [deleted file]
server/sonar-web/design-system/src/components/DropdownMenu.tsx
server/sonar-web/design-system/src/components/FormField.tsx [deleted file]
server/sonar-web/design-system/src/components/InputField.tsx [deleted file]
server/sonar-web/design-system/src/components/InputMultiSelect.tsx [deleted file]
server/sonar-web/design-system/src/components/InputSearch.tsx [deleted file]
server/sonar-web/design-system/src/components/InputSelect.tsx [deleted file]
server/sonar-web/design-system/src/components/MultiSelectMenu.tsx [deleted file]
server/sonar-web/design-system/src/components/MultiSelectMenuOption.tsx [deleted file]
server/sonar-web/design-system/src/components/RadioButton.tsx [deleted file]
server/sonar-web/design-system/src/components/SearchSelect.tsx [deleted file]
server/sonar-web/design-system/src/components/SearchSelectDropdown.tsx [deleted file]
server/sonar-web/design-system/src/components/SearchSelectDropdownControl.tsx [deleted file]
server/sonar-web/design-system/src/components/SelectionCard.tsx
server/sonar-web/design-system/src/components/TagsSelector.tsx
server/sonar-web/design-system/src/components/__tests__/DatePicker-test.tsx [deleted file]
server/sonar-web/design-system/src/components/__tests__/DateRangePicker-test.tsx [deleted file]
server/sonar-web/design-system/src/components/__tests__/DiscreetSelect-test.tsx [deleted file]
server/sonar-web/design-system/src/components/__tests__/FormField-test.tsx [deleted file]
server/sonar-web/design-system/src/components/__tests__/InputField-test.tsx [deleted file]
server/sonar-web/design-system/src/components/__tests__/InputMultiSelect-test.tsx [deleted file]
server/sonar-web/design-system/src/components/__tests__/InputSearch-test.tsx [deleted file]
server/sonar-web/design-system/src/components/__tests__/InputSelect-test.tsx [deleted file]
server/sonar-web/design-system/src/components/__tests__/MultiSelectMenu-test.tsx [deleted file]
server/sonar-web/design-system/src/components/__tests__/RadioButton-test.tsx [deleted file]
server/sonar-web/design-system/src/components/__tests__/SearchSelectDropdown-test.tsx [deleted file]
server/sonar-web/design-system/src/components/__tests__/buttons-test.tsx [deleted file]
server/sonar-web/design-system/src/components/buttons.tsx [deleted file]
server/sonar-web/design-system/src/components/buttons/BareButtons.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/buttons/Button.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/buttons/ButtonPrimary.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/buttons/ButtonSecondary.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/buttons/DangerButtonPrimary.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/buttons/DangerButtonSecondary.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/buttons/DownloadButton.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/buttons/ThirdPartyButton.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/buttons/WrapperButton.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/buttons/__tests__/buttons-test.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/buttons/index.ts [new file with mode: 0644]
server/sonar-web/design-system/src/components/index.ts
server/sonar-web/design-system/src/components/input/Checkbox.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/input/DatePicker.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/input/DateRangePicker.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/input/DiscreetSelect.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/input/FormField.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/input/InputField.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/input/InputMultiSelect.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/input/InputSearch.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/input/InputSelect.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/input/MultiSelectMenu.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/input/MultiSelectMenuOption.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/input/RadioButton.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/input/SearchSelect.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/input/SearchSelectDropdown.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/input/SearchSelectDropdownControl.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/input/__tests__/DatePicker-test.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/input/__tests__/DateRangePicker-test.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/input/__tests__/DiscreetSelect-test.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/input/__tests__/FormField-test.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/input/__tests__/InputField-test.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/input/__tests__/InputMultiSelect-test.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/input/__tests__/InputSearch-test.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/input/__tests__/InputSelect-test.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/input/__tests__/MultiSelectMenu-test.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/input/__tests__/RadioButton-test.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/input/__tests__/SearchSelectDropdown-test.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/input/index.ts [new file with mode: 0644]

diff --git a/server/sonar-web/design-system/src/components/Checkbox.tsx b/server/sonar-web/design-system/src/components/Checkbox.tsx
deleted file mode 100644 (file)
index 238a52c..0000000
+++ /dev/null
@@ -1,178 +0,0 @@
-/*
- * 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 React from 'react';
-import tw from 'twin.macro';
-import { themeBorder, themeColor, themeContrast } from '../helpers/theme';
-import { DeferredSpinner } from './DeferredSpinner';
-import { CheckIcon } from './icons/CheckIcon';
-import { CustomIcon } from './icons/Icon';
-
-interface Props {
-  checked: boolean;
-  children?: React.ReactNode;
-  className?: string;
-  disabled?: boolean;
-  id?: string;
-  label?: string;
-  loading?: boolean;
-  onCheck: (checked: boolean, id?: string) => void;
-  onClick?: (event: React.MouseEvent<HTMLInputElement>) => void;
-  onFocus?: VoidFunction;
-  right?: boolean;
-  thirdState?: boolean;
-  title?: string;
-}
-
-export function Checkbox({
-  checked,
-  disabled,
-  children,
-  className,
-  id,
-  label,
-  loading = false,
-  onCheck,
-  onFocus,
-  onClick,
-  right,
-  thirdState = false,
-  title,
-}: Props) {
-  const handleChange = () => {
-    if (!disabled) {
-      onCheck(!checked, id);
-    }
-  };
-
-  return (
-    <CheckboxContainer className={className} disabled={disabled}>
-      {right && children}
-      <AccessibleCheckbox
-        aria-label={label ?? title}
-        checked={checked}
-        disabled={disabled ?? loading}
-        id={id}
-        onChange={handleChange}
-        onClick={onClick}
-        onFocus={onFocus}
-        type="checkbox"
-      />
-      <DeferredSpinner loading={loading}>
-        <StyledCheckbox aria-hidden data-clickable="true" title={title}>
-          <CheckboxIcon checked={checked} thirdState={thirdState} />
-        </StyledCheckbox>
-      </DeferredSpinner>
-      {!right && children}
-    </CheckboxContainer>
-  );
-}
-
-interface CheckIconProps {
-  checked?: boolean;
-  thirdState?: boolean;
-}
-
-function CheckboxIcon({ checked, thirdState }: CheckIconProps) {
-  if (checked && thirdState) {
-    return (
-      <CustomIcon>
-        <rect fill="currentColor" height="2" rx="1" width="50%" x="4" y="7" />
-      </CustomIcon>
-    );
-  } else if (checked) {
-    return <CheckIcon fill="currentColor" />;
-  }
-  return null;
-}
-
-const CheckboxContainer = styled.label<{ disabled?: boolean }>`
-  color: ${themeContrast('backgroundSecondary')};
-  user-select: none;
-
-  ${tw`sw-inline-flex sw-items-center`};
-
-  &:hover {
-    ${tw`sw-cursor-pointer`}
-  }
-
-  &:disabled {
-    color: ${themeContrast('checkboxDisabled')};
-    ${tw`sw-cursor-not-allowed`}
-  }
-`;
-
-export const StyledCheckbox = styled.span`
-  border: ${themeBorder('default', 'primary')};
-  color: ${themeContrast('primary')};
-
-  ${tw`sw-w-4 sw-h-4`};
-  ${tw`sw-rounded-1/2`};
-  ${tw`sw-box-border`}
-  ${tw`sw-inline-flex sw-items-center sw-justify-center`};
-`;
-
-export const AccessibleCheckbox = styled.input`
-  // Following css makes the checkbox accessible and invisible
-  border: 0;
-  clip: rect(0 0 0 0);
-  clip-path: inset(50%);
-  height: 1px;
-  overflow: hidden;
-  padding: 0;
-  white-space: nowrap;
-  width: 1px;
-  appearance: none;
-
-  &:focus,
-  &:active {
-    &:not(:disabled) + ${StyledCheckbox} {
-      outline: ${themeBorder('focus', 'primary')};
-    }
-  }
-
-  &:checked {
-    & + ${StyledCheckbox} {
-      background: ${themeColor('primary')};
-    }
-    &:disabled + ${StyledCheckbox} {
-      background: ${themeColor('checkboxDisabledChecked')};
-    }
-  }
-
-  &:hover {
-    &:not(:disabled) + ${StyledCheckbox} {
-      background: ${themeColor('checkboxHover')};
-      border: ${themeBorder('default', 'primary')};
-    }
-
-    &:checked:not(:disabled) + ${StyledCheckbox} {
-      background: ${themeColor('checkboxCheckedHover')};
-      border: ${themeBorder('default', 'checkboxCheckedHover')};
-    }
-  }
-
-  &:disabled + ${StyledCheckbox} {
-    background: ${themeColor('checkboxDisabled')};
-    color: ${themeColor('checkboxDisabled')};
-    border: ${themeBorder('default', 'checkboxDisabledChecked')};
-  }
-`;
index c5f6e2f108210019e3a5d2acb5a631bb5814455b..5b5ed6d8555ec71b120faf84744bf032ac976a4f 100644 (file)
 import { useTheme } from '@emotion/react';
 import styled from '@emotion/styled';
 import tw from 'twin.macro';
+import { themeBorder, themeColor, themeContrast } from '../helpers';
 import { BubbleColorVal } from '../types/charts';
-import { Checkbox } from './Checkbox';
 import Tooltip from './Tooltip';
-
-import { themeBorder, themeColor, themeContrast } from '../helpers';
+import { Checkbox } from './input/Checkbox';
 
 export interface ColorFilterOption {
   ariaLabel?: string;
diff --git a/server/sonar-web/design-system/src/components/DatePicker.tsx b/server/sonar-web/design-system/src/components/DatePicker.tsx
deleted file mode 100644 (file)
index 838fbd0..0000000
+++ /dev/null
@@ -1,416 +0,0 @@
-/*
- * 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 {
-  format,
-  getYear,
-  isSameMonth,
-  isSameYear,
-  setMonth,
-  setYear,
-  startOfMonth,
-} from 'date-fns';
-import { range } from 'lodash';
-import * as React from 'react';
-import {
-  ActiveModifiers,
-  CaptionProps,
-  Matcher,
-  DayPicker as OriginalDayPicker,
-  useNavigation as useCalendarNavigation,
-  useDayPicker,
-} from 'react-day-picker';
-import tw from 'twin.macro';
-import { PopupPlacement, PopupZLevel, themeBorder, themeColor, themeContrast } from '../helpers';
-import { InputSizeKeys } from '../types/theme';
-import EscKeydownHandler from './EscKeydownHandler';
-import { FocusOutHandler } from './FocusOutHandler';
-import { InputField } from './InputField';
-import { InputSelect } from './InputSelect';
-import { InteractiveIcon } from './InteractiveIcon';
-import { OutsideClickHandler } from './OutsideClickHandler';
-import { CalendarIcon, ChevronLeftIcon, ChevronRightIcon } from './icons';
-import { CloseIcon } from './icons/CloseIcon';
-import { Popup } from './popups';
-
-// When no minDate is given, year dropdown will show year options up to PAST_MAX_YEARS in the past
-const YEARS_TO_DISPLAY = 10;
-const MONTHS_IN_A_YEAR = 12;
-
-interface Props {
-  alignRight?: boolean;
-  ariaNextMonthLabel: string;
-  ariaPreviousMonthLabel: string;
-  className?: string;
-  clearButtonLabel: string;
-  currentMonth?: Date;
-  highlightFrom?: Date;
-  highlightTo?: Date;
-  id?: string;
-  inputClassName?: string;
-  inputRef?: React.Ref<HTMLInputElement>;
-  maxDate?: Date;
-  minDate?: Date;
-  name?: string;
-  onChange: (date: Date | undefined) => void;
-  placeholder: string;
-  showClearButton?: boolean;
-  size?: InputSizeKeys;
-  value?: Date;
-  valueFormatter?: (date?: Date) => string;
-  zLevel?: PopupZLevel;
-}
-
-interface State {
-  currentMonth: Date;
-  lastHovered?: Date;
-  open: boolean;
-}
-
-function formatWeekdayName(date: Date) {
-  return format(date, 'EEE'); // Short weekday name, e.g. Wed, Thu
-}
-
-export class DatePicker extends React.PureComponent<Props, State> {
-  constructor(props: Props) {
-    super(props);
-
-    this.state = { currentMonth: props.value ?? props.currentMonth ?? new Date(), open: false };
-  }
-
-  handleResetClick = () => {
-    this.closeCalendar();
-    this.props.onChange(undefined);
-  };
-
-  openCalendar = () => {
-    this.setState({
-      currentMonth: this.props.value ?? this.props.currentMonth ?? new Date(),
-      lastHovered: undefined,
-      open: true,
-    });
-  };
-
-  closeCalendar = () => {
-    this.setState({ open: false });
-  };
-
-  handleDayClick = (day: Date, modifiers: ActiveModifiers) => {
-    if (!modifiers.disabled) {
-      this.closeCalendar();
-      this.props.onChange(day);
-    }
-  };
-
-  handleDayMouseEnter = (day: Date, modifiers: ActiveModifiers) => {
-    this.setState({ lastHovered: modifiers.disabled ? undefined : day });
-  };
-
-  render() {
-    const {
-      alignRight,
-      ariaNextMonthLabel,
-      ariaPreviousMonthLabel,
-      clearButtonLabel,
-      highlightFrom,
-      highlightTo,
-      inputRef,
-      minDate,
-      maxDate = new Date(),
-      value: selectedDay,
-      name,
-      className,
-      inputClassName,
-      id,
-      placeholder,
-      showClearButton = true,
-      valueFormatter = (date?: Date) => (date ? format(date, 'MMM d, yyyy') : ''),
-      size,
-      zLevel = PopupZLevel.Global,
-    } = this.props;
-    const { lastHovered, currentMonth, open } = this.state;
-
-    // Infer start and end dropdown year from min/max dates, if set
-    const fromYear = minDate ? minDate.getFullYear() : new Date().getFullYear() - YEARS_TO_DISPLAY;
-    const toYear = maxDate.getFullYear();
-
-    const selectedDays = selectedDay ? [selectedDay] : [];
-    let highlighted: Matcher = false;
-    const lastHoveredOrValue = lastHovered ?? selectedDay;
-
-    if (highlightFrom && lastHoveredOrValue) {
-      highlighted = { from: highlightFrom, to: lastHoveredOrValue };
-      selectedDays.push(highlightFrom);
-    }
-
-    if (highlightTo && lastHoveredOrValue) {
-      highlighted = { from: lastHoveredOrValue, to: highlightTo };
-      selectedDays.push(highlightTo);
-    }
-
-    return (
-      <FocusOutHandler onFocusOut={this.closeCalendar}>
-        <OutsideClickHandler onClickOutside={this.closeCalendar}>
-          <EscKeydownHandler onKeydown={this.closeCalendar}>
-            <Popup
-              allowResizing
-              className="sw-overflow-visible" //Necessary for the month & year selectors
-              overlay={
-                open ? (
-                  <div className={classNames('sw-p-2')}>
-                    <DayPicker
-                      captionLayout="dropdown-buttons"
-                      className="sw-body-sm"
-                      components={{
-                        Caption: getCustomCalendarNavigation({
-                          ariaNextMonthLabel,
-                          ariaPreviousMonthLabel,
-                        }),
-                      }}
-                      disabled={{ after: maxDate, before: minDate }}
-                      formatters={{
-                        formatWeekdayName,
-                      }}
-                      fromYear={fromYear}
-                      mode="default"
-                      modifiers={{ highlighted }}
-                      modifiersClassNames={{ highlighted: 'rdp-highlighted' }}
-                      month={currentMonth}
-                      onDayClick={this.handleDayClick}
-                      onDayMouseEnter={this.handleDayMouseEnter}
-                      onMonthChange={(currentMonth) => {
-                        this.setState({ currentMonth });
-                      }}
-                      selected={selectedDays}
-                      toYear={toYear}
-                      weekStartsOn={1}
-                    />
-                  </div>
-                ) : null
-              }
-              placement={alignRight ? PopupPlacement.BottomRight : PopupPlacement.BottomLeft}
-              zLevel={zLevel}
-            >
-              <span
-                className={classNames('sw-relative sw-inline-block sw-cursor-pointer', className)}
-              >
-                <StyledInputField
-                  aria-label={placeholder}
-                  className={classNames(inputClassName, {
-                    'is-filled': selectedDay !== undefined && showClearButton,
-                  })}
-                  id={id}
-                  name={name}
-                  onClick={this.openCalendar}
-                  onFocus={this.openCalendar}
-                  placeholder={placeholder}
-                  readOnly
-                  ref={inputRef}
-                  size={size}
-                  title={valueFormatter(selectedDay)}
-                  type="text"
-                  value={valueFormatter(selectedDay)}
-                />
-
-                <StyledCalendarIcon fill="datePickerIcon" />
-
-                {selectedDay !== undefined && showClearButton && (
-                  <StyledInteractiveIcon
-                    Icon={CloseIcon}
-                    aria-label={clearButtonLabel}
-                    onClick={this.handleResetClick}
-                    size="small"
-                  />
-                )}
-              </span>
-            </Popup>
-          </EscKeydownHandler>
-        </OutsideClickHandler>
-      </FocusOutHandler>
-    );
-  }
-}
-
-const StyledCalendarIcon = styled(CalendarIcon)`
-  ${tw`sw-absolute`};
-  ${tw`sw-top-[0.625rem] sw-left-2`};
-`;
-
-const StyledInteractiveIcon = styled(InteractiveIcon)`
-  ${tw`sw-absolute`};
-  ${tw`sw-top-[0.375rem] sw-right-[0.375rem]`};
-`;
-
-const StyledInputField = styled(InputField)`
-  input[type='text']& {
-    ${tw`sw-pl-8`};
-    ${tw`sw-cursor-pointer`};
-
-    &.is-filled {
-      ${tw`sw-pr-8`};
-    }
-  }
-`;
-
-const DayPicker = styled(OriginalDayPicker)`
-  --rdp-cell-size: auto;
-  /* Ensures the month/year dropdowns do not move on click, but rdp outline is not shown */
-  --rdp-outline: 2px solid transparent;
-  --rdp-outline-selected: 2px solid transparent;
-
-  margin: 0;
-
-  .rdp-head {
-    color: ${themeContrast('datePicker')};
-  }
-
-  .rdp-day {
-    height: 28px;
-    width: 33px;
-    border-radius: 0;
-    color: ${themeContrast('datePickerDefault')};
-  }
-
-  /* Default modifiers */
-
-  .rdp-day_disabled {
-    cursor: not-allowed;
-    background: ${themeColor('datePickerDisabled')};
-    color: ${themeContrast('datePickerDisabled')};
-  }
-
-  .rdp-day:hover:not(.rdp-day_outside):not(.rdp-day_disabled):not(.rdp-day_selected) {
-    background: ${themeColor('datePickerHover')};
-    color: ${themeContrast('datePickerHover')};
-  }
-
-  .rdp-day:focus-visible {
-    outline: ${themeBorder('focus', 'inputFocus')};
-    background: inherit;
-    z-index: 1;
-  }
-
-  .rdp-day.rdp-highlighted:not(.rdp-day_selected) {
-    background: ${themeColor('datePickerRange')};
-    color: ${themeContrast('datePickerRange')};
-  }
-
-  .rdp-day_selected,
-  .rdp-day_selected:focus-visible {
-    background: ${themeColor('datePickerSelected')};
-    color: ${themeContrast('datePickerSelected')};
-  }
-`;
-
-function getCustomCalendarNavigation({
-  ariaNextMonthLabel,
-  ariaPreviousMonthLabel,
-}: {
-  ariaNextMonthLabel: string;
-  ariaPreviousMonthLabel: string;
-}) {
-  return function CalendarNavigation(props: CaptionProps) {
-    const { displayMonth } = props;
-    const { fromYear, toYear } = useDayPicker();
-    const { goToMonth, nextMonth, previousMonth } = useCalendarNavigation();
-
-    const baseDate = startOfMonth(displayMonth); // reference date
-
-    const months = range(MONTHS_IN_A_YEAR).map((month) => {
-      const monthValue = setMonth(baseDate, month);
-
-      return {
-        label: format(monthValue, 'MMM'),
-        value: monthValue,
-      };
-    });
-
-    const startYear = fromYear ?? getYear(Date.now()) - YEARS_TO_DISPLAY;
-
-    const years = range(startYear, toYear ? toYear + 1 : undefined).map((year) => {
-      const yearValue = setYear(baseDate, year);
-
-      return {
-        label: String(year),
-        value: yearValue,
-      };
-    });
-
-    return (
-      <nav className="sw-flex sw-items-center sw-justify-between sw-py-1">
-        <InteractiveIcon
-          Icon={ChevronLeftIcon}
-          aria-label={ariaPreviousMonthLabel}
-          className="sw-mr-2"
-          onClick={() => {
-            if (previousMonth) {
-              goToMonth(previousMonth);
-            }
-          }}
-          size="small"
-        />
-
-        <span data-testid="month-select">
-          <InputSelect
-            isClearable={false}
-            onChange={(value) => {
-              if (value) {
-                goToMonth(value.value);
-              }
-            }}
-            options={months}
-            size="full"
-            value={months.find((m) => isSameMonth(m.value, displayMonth))}
-          />
-        </span>
-
-        <span data-testid="year-select">
-          <InputSelect
-            className="sw-ml-1"
-            data-testid="year-select"
-            isClearable={false}
-            onChange={(value) => {
-              if (value) {
-                goToMonth(value.value);
-              }
-            }}
-            options={years}
-            size="full"
-            value={years.find((y) => isSameYear(y.value, displayMonth))}
-          />
-        </span>
-
-        <InteractiveIcon
-          Icon={ChevronRightIcon}
-          aria-label={ariaNextMonthLabel}
-          className="sw-ml-2"
-          onClick={() => {
-            if (nextMonth) {
-              goToMonth(nextMonth);
-            }
-          }}
-          size="small"
-        />
-      </nav>
-    );
-  };
-}
diff --git a/server/sonar-web/design-system/src/components/DateRangePicker.tsx b/server/sonar-web/design-system/src/components/DateRangePicker.tsx
deleted file mode 100644 (file)
index f44ca09..0000000
+++ /dev/null
@@ -1,134 +0,0 @@
-/*
- * 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 { max, min } from 'date-fns';
-import * as React from 'react';
-import { PopupZLevel } from '../helpers';
-import { DatePicker } from './DatePicker';
-import { LightLabel } from './Text';
-
-interface DateRange {
-  from?: Date;
-  to?: Date;
-}
-
-interface Props {
-  alignEndDateCalandarRight?: boolean;
-  ariaNextMonthLabel: string;
-  ariaPreviousMonthLabel: string;
-  className?: string;
-  clearButtonLabel: string;
-  fromLabel: string;
-  maxDate?: Date;
-  minDate?: Date;
-  onChange: (date: DateRange) => void;
-  separatorText?: string;
-  toLabel: string;
-  value?: DateRange;
-  valueFormatter?: (date?: Date) => string;
-  zLevel?: PopupZLevel;
-}
-
-export class DateRangePicker extends React.PureComponent<Props> {
-  toDateInput?: HTMLInputElement | null;
-
-  get from() {
-    return this.props.value?.from;
-  }
-
-  get to() {
-    return this.props.value?.to;
-  }
-
-  handleFromChange = (from: Date | undefined) => {
-    this.props.onChange({ from, to: this.to });
-
-    // use `setTimeout` to work around the immediate closing of the `toDateInput`
-    setTimeout(() => {
-      if (from && !this.to && this.toDateInput) {
-        this.toDateInput.focus();
-      }
-    }, 0);
-  };
-
-  handleToChange = (to: Date | undefined) => {
-    this.props.onChange({ from: this.from, to });
-  };
-
-  render() {
-    const {
-      alignEndDateCalandarRight,
-      ariaNextMonthLabel,
-      ariaPreviousMonthLabel,
-      clearButtonLabel,
-      fromLabel,
-      minDate,
-      maxDate,
-      separatorText,
-      toLabel,
-      valueFormatter,
-      zLevel,
-    } = this.props;
-
-    return (
-      <div className={classNames('sw-flex sw-items-center', this.props.className)}>
-        <DatePicker
-          ariaNextMonthLabel={ariaNextMonthLabel}
-          ariaPreviousMonthLabel={ariaPreviousMonthLabel}
-          clearButtonLabel={clearButtonLabel}
-          currentMonth={this.to}
-          data-test="from"
-          highlightTo={this.to}
-          id="date-from"
-          maxDate={maxDate && this.to ? min([maxDate, this.to]) : maxDate ?? this.to}
-          minDate={minDate}
-          onChange={this.handleFromChange}
-          placeholder={fromLabel}
-          size="full"
-          value={this.from}
-          valueFormatter={valueFormatter}
-          zLevel={zLevel}
-        />
-        <LightLabel className="sw-mx-2">{separatorText ?? '–'}</LightLabel>
-        <DatePicker
-          alignRight={alignEndDateCalandarRight}
-          ariaNextMonthLabel={ariaNextMonthLabel}
-          ariaPreviousMonthLabel={ariaPreviousMonthLabel}
-          clearButtonLabel={clearButtonLabel}
-          currentMonth={this.from}
-          data-test="to"
-          highlightFrom={this.from}
-          id="date-to"
-          inputRef={(element: HTMLInputElement | null) => {
-            this.toDateInput = element;
-          }}
-          maxDate={maxDate}
-          minDate={minDate && this.from ? max([minDate, this.from]) : minDate ?? this.from}
-          onChange={this.handleToChange}
-          placeholder={toLabel}
-          size="full"
-          value={this.to}
-          valueFormatter={valueFormatter}
-          zLevel={zLevel}
-        />
-      </div>
-    );
-  }
-}
diff --git a/server/sonar-web/design-system/src/components/DiscreetSelect.tsx b/server/sonar-web/design-system/src/components/DiscreetSelect.tsx
deleted file mode 100644 (file)
index 085caf1..0000000
+++ /dev/null
@@ -1,125 +0,0 @@
-/*
- * 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 tw from 'twin.macro';
-import { themeBorder, themeColor, themeContrast } from '../helpers/theme';
-import { InputSizeKeys } from '../types/theme';
-import { InputSelect, LabelValueSelectOption } from './InputSelect';
-
-interface Props<V> {
-  className?: string;
-  components?: Parameters<typeof InputSelect>[0]['components'];
-  customValue?: JSX.Element;
-  isDisabled?: boolean;
-  menuIsOpen?: boolean;
-  onMenuClose?: () => void;
-  onMenuOpen?: () => void;
-  options: Array<LabelValueSelectOption<V>>;
-  setValue: ({ value }: LabelValueSelectOption<V>) => void;
-  size?: InputSizeKeys;
-  value: V;
-}
-
-export function DiscreetSelect<V>({
-  className,
-  customValue,
-  onMenuOpen,
-  options,
-  size = 'small',
-  setValue,
-  value,
-  ...props
-}: Props<V>) {
-  return (
-    <StyledSelect
-      className={className}
-      onChange={setValue}
-      onMenuOpen={onMenuOpen}
-      options={options}
-      placeholder={customValue}
-      size={size}
-      value={options.find((item) => item.value === value)}
-      {...props}
-    />
-  );
-}
-
-const StyledSelect = styled(InputSelect)`
-  & {
-    width: inherit !important;
-  }
-
-  & .react-select__dropdown-indicator {
-    ${tw`sw-p-0 sw-py-1`};
-  }
-
-  & .react-select__value-container {
-    ${tw`sw-p-0`};
-  }
-
-  & .react-select__menu {
-    margin: 0;
-  }
-
-  & .react-select__control {
-    height: auto;
-    min-height: inherit;
-    color: ${themeContrast('discreetBackground')};
-    background: none;
-    outline: inherit;
-    box-shadow: none;
-
-    ${tw`sw-border-none`};
-    ${tw`sw-p-0`};
-    ${tw`sw-cursor-pointer`};
-    ${tw`sw-flex sw-items-center`};
-    ${tw`sw-body-sm`};
-    ${tw`sw-select-none`};
-
-    &:hover {
-      ${tw`sw-border-none`};
-      outline: none;
-      color: ${themeColor('discreetButtonHover')};
-      border-color: inherit;
-      box-shadow: none;
-
-      & .react-select__single-value,
-      & .react-select__dropdown-indicator,
-      & .react-select__placeholder {
-        color: ${themeColor('discreetButtonHover')};
-      }
-    }
-
-    &:focus {
-      ${tw`sw-rounded-1`};
-      color: ${themeColor('discreetButtonHover')};
-      background: ${themeColor('discreetBackground')};
-      outline: ${themeBorder('focus', 'discreetFocusBorder')};
-      border-color: inherit;
-      box-shadow: none;
-    }
-  }
-
-  & .react-select__control--is-focused,
-  & .react-select__control--menu-is-open {
-    ${tw`sw-border-none`};
-  }
-`;
index 74c415817038d51382b52244f01e24d430b3d3f0..d6cd692bac0385dfa93085ecd96c05b50c463bc6 100644 (file)
@@ -25,12 +25,12 @@ import tw from 'twin.macro';
 import { INPUT_SIZES } from '../helpers/constants';
 import { themeBorder, themeColor, themeContrast } from '../helpers/theme';
 import { InputSizeKeys, ThemedProps } from '../types/theme';
-import { Checkbox } from './Checkbox';
 import { BaseLink, LinkProps } from './Link';
 import NavLink from './NavLink';
-import { RadioButton } from './RadioButton';
 import Tooltip from './Tooltip';
 import { ClipboardBase } from './clipboard';
+import { Checkbox } from './input/Checkbox';
+import { RadioButton } from './input/RadioButton';
 
 interface Props extends React.HtmlHTMLAttributes<HTMLMenuElement> {
   children?: React.ReactNode;
diff --git a/server/sonar-web/design-system/src/components/FormField.tsx b/server/sonar-web/design-system/src/components/FormField.tsx
deleted file mode 100644 (file)
index a6bac58..0000000
+++ /dev/null
@@ -1,74 +0,0 @@
-/*
- * 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 { ReactNode } from 'react';
-import tw from 'twin.macro';
-import { Highlight, Note } from './Text';
-import { RequiredIcon } from './icons';
-
-interface Props {
-  ariaLabel?: string;
-  children: ReactNode;
-  className?: string;
-  description?: string | ReactNode;
-  help?: ReactNode;
-  htmlFor?: string;
-  id?: string;
-  label: string | ReactNode;
-  required?: boolean;
-  title?: string;
-}
-
-export function FormField({
-  children,
-  className,
-  description,
-  help,
-  id,
-  required,
-  label,
-  htmlFor,
-  title,
-  ariaLabel,
-}: Props) {
-  return (
-    <FieldWrapper className={className} id={id}>
-      <label aria-label={ariaLabel} className="sw-mb-2" htmlFor={htmlFor} title={title}>
-        <Highlight className="sw-flex sw-items-center sw-gap-2">
-          {label}
-          {required && <RequiredIcon className="sw--ml-1" />}
-          {help}
-        </Highlight>
-      </label>
-
-      {children}
-
-      {description && <Note className="sw-mt-2">{description}</Note>}
-    </FieldWrapper>
-  );
-}
-
-const FieldWrapper = styled.div`
-  ${tw`sw-flex sw-flex-col sw-w-full`}
-
-  &:not(:last-of-type) {
-    ${tw`sw-mb-6`}
-  }
-`;
diff --git a/server/sonar-web/design-system/src/components/InputField.tsx b/server/sonar-web/design-system/src/components/InputField.tsx
deleted file mode 100644 (file)
index 1dab905..0000000
+++ /dev/null
@@ -1,142 +0,0 @@
-/*
- * 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 { css } from '@emotion/react';
-import styled from '@emotion/styled';
-import { forwardRef } from 'react';
-import tw from 'twin.macro';
-import { INPUT_SIZES } from '../helpers/constants';
-import { themeBorder, themeColor, themeContrast } from '../helpers/theme';
-import { InputSizeKeys, ThemedProps } from '../types/theme';
-
-interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size'> {
-  as?: React.ElementType;
-  className?: string;
-  isInvalid?: boolean;
-  isValid?: boolean;
-  size?: InputSizeKeys;
-}
-
-interface InputTextAreaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
-  className?: string;
-  isInvalid?: boolean;
-  isValid?: boolean;
-  size?: InputSizeKeys;
-}
-
-export const InputField = forwardRef<HTMLInputElement, InputProps>(
-  ({ size = 'medium', style, ...props }, ref) => {
-    return (
-      <StyledInput ref={ref} style={{ ...style, '--inputSize': INPUT_SIZES[size] }} {...props} />
-    );
-  }
-);
-InputField.displayName = 'InputField';
-
-export const InputTextArea = forwardRef<HTMLTextAreaElement, InputTextAreaProps>(
-  ({ size = 'medium', style, ...props }, ref) => {
-    return (
-      <StyledTextArea ref={ref} style={{ ...style, '--inputSize': INPUT_SIZES[size] }} {...props} />
-    );
-  }
-);
-InputTextArea.displayName = 'InputTextArea';
-
-const defaultStyle = (props: ThemedProps) => css`
-  --border: ${themeBorder('default', 'inputBorder')(props)};
-  --focusBorder: ${themeBorder('default', 'inputFocus')(props)};
-  --focusOutline: ${themeBorder('focus', 'inputFocus')(props)};
-`;
-
-const dangerStyle = (props: ThemedProps) => css`
-  --border: ${themeBorder('default', 'inputDanger')(props)};
-  --focusBorder: ${themeBorder('default', 'inputDangerFocus')(props)};
-  --focusOutline: ${themeBorder('focus', 'inputDangerFocus')(props)};
-`;
-
-const successStyle = (props: ThemedProps) => css`
-  --border: ${themeBorder('default', 'inputSuccess')(props)};
-  --focusBorder: ${themeBorder('default', 'inputSuccessFocus')(props)};
-  --focusOutline: ${themeBorder('focus', 'inputSuccessFocus')(props)};
-`;
-
-const getInputVariant = (props: ThemedProps & { isInvalid?: boolean; isValid?: boolean }) => {
-  const { isValid, isInvalid } = props;
-  if (isInvalid) {
-    return dangerStyle;
-  } else if (isValid) {
-    return successStyle;
-  }
-  return defaultStyle;
-};
-
-const baseStyle = (props: ThemedProps) => css`
-  color: ${themeContrast('inputBackground')(props)};
-  background: ${themeColor('inputBackground')(props)};
-  border: var(--border);
-  width: var(--inputSize);
-  transition: border-color 0.2s ease;
-
-  ${tw`sw-body-sm`}
-  ${tw`sw-box-border`}
-  ${tw`sw-rounded-2`}
-  ${tw`sw-px-3 sw-py-2`}
-
-  &::placeholder {
-    color: ${themeColor('inputPlaceholder')(props)};
-  }
-
-  &:hover {
-    border: var(--focusBorder);
-  }
-
-  &:active,
-  &:focus,
-  &:focus-within,
-  &:focus-visible {
-    border: var(--focusBorder);
-    outline: var(--focusOutline);
-  }
-
-  &:disabled,
-  &:disabled:hover {
-    color: ${themeContrast('inputDisabled')(props)};
-    background-color: ${themeColor('inputDisabled')(props)};
-    border: ${themeBorder('default', 'inputDisabledBorder')(props)};
-    outline: none;
-
-    ${tw`sw-cursor-not-allowed`};
-    &::placeholder {
-      color: ${themeContrast('inputDisabled')(props)};
-    }
-  }
-`;
-
-const StyledInput = styled.input`
-  input[type='text']& {
-    ${getInputVariant}
-    ${baseStyle}
-    ${tw`sw-h-control`}
-  }
-`;
-
-const StyledTextArea = styled.textarea`
-  ${getInputVariant};
-  ${baseStyle};
-`;
diff --git a/server/sonar-web/design-system/src/components/InputMultiSelect.tsx b/server/sonar-web/design-system/src/components/InputMultiSelect.tsx
deleted file mode 100644 (file)
index 6a7eca7..0000000
+++ /dev/null
@@ -1,70 +0,0 @@
-/*
- * 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 { themeBorder } from '../helpers';
-import { Badge } from './Badge';
-import { LightLabel } from './Text';
-import { ButtonProps, WrapperButton } from './buttons';
-import { ChevronDownIcon } from './icons';
-
-interface Props extends Pick<ButtonProps, 'onClick'> {
-  className?: string;
-  count?: number;
-  id?: string;
-  placeholder: string;
-  selectedLabel: string;
-}
-
-export function InputMultiSelect(props: Props) {
-  const { className, count, id, placeholder, selectedLabel } = props;
-
-  return (
-    <StyledWrapper
-      className={classNames('sw-flex sw-justify-between sw-px-2 sw-body-sm', className)}
-      id={id}
-      onClick={props.onClick}
-      role="combobox"
-    >
-      {count ? selectedLabel : <LightLabel>{placeholder}</LightLabel>}
-
-      <div>
-        {count !== undefined && count > 0 && <Badge variant="counter">{count}</Badge>}
-        <ChevronDownIcon className="sw-ml-2" />
-      </div>
-    </StyledWrapper>
-  );
-}
-
-const StyledWrapper = styled(WrapperButton)`
-  border: ${themeBorder('default', 'inputBorder')};
-
-  &:hover {
-    border: ${themeBorder('default', 'inputFocus')};
-  }
-
-  &:active,
-  &:focus,
-  &:focus-within,
-  &:focus-visible {
-    border: ${themeBorder('default', 'inputFocus')};
-    outline: ${themeBorder('focus', 'inputFocus')};
-  }
-`;
diff --git a/server/sonar-web/design-system/src/components/InputSearch.tsx b/server/sonar-web/design-system/src/components/InputSearch.tsx
deleted file mode 100644 (file)
index 354fab3..0000000
+++ /dev/null
@@ -1,261 +0,0 @@
-/*
- * 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 { debounce } from 'lodash';
-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, Spinner } from './DeferredSpinner';
-import { InteractiveIcon } from './InteractiveIcon';
-import { CloseIcon } from './icons/CloseIcon';
-import { SearchIcon } from './icons/SearchIcon';
-
-interface Props {
-  autoFocus?: boolean;
-  className?: string;
-  clearIconAriaLabel: string;
-  id?: string;
-  innerRef?: React.RefCallback<HTMLInputElement>;
-  loading?: boolean;
-  maxLength?: number;
-  minLength?: number;
-  onBlur?: React.FocusEventHandler<HTMLInputElement>;
-  onChange: (value: string) => void;
-  onFocus?: React.FocusEventHandler<HTMLInputElement>;
-  onKeyDown?: React.KeyboardEventHandler<HTMLInputElement>;
-  onMouseDown?: React.MouseEventHandler<HTMLInputElement>;
-  placeholder?: string;
-  searchInputAriaLabel?: string;
-  size?: InputSizeKeys;
-  tooShortText?: string;
-  value?: string;
-}
-
-const DEFAULT_MAX_LENGTH = 100;
-
-export function InputSearch({
-  autoFocus,
-  id,
-  className,
-  innerRef,
-  onBlur,
-  onChange,
-  onFocus,
-  onKeyDown,
-  onMouseDown,
-  placeholder,
-  loading,
-  minLength,
-  maxLength = DEFAULT_MAX_LENGTH,
-  size = 'medium',
-  value: parentValue,
-  tooShortText,
-  searchInputAriaLabel,
-  clearIconAriaLabel,
-  children,
-}: PropsWithChildren<Props>) {
-  const input = useRef<null | HTMLElement>(null);
-  const [value, setValue] = useState(parentValue ?? '');
-  const debouncedOnChange = useMemo(() => debounce(onChange, DEBOUNCE_DELAY), [onChange]);
-
-  const tooShort = isDefined(minLength) && value.length > 0 && value.length < minLength;
-  const inputClassName = classNames('js-input-search', {
-    touched: value.length > 0 && (!minLength || minLength > value.length),
-    'sw-pr-10': value.length > 0,
-  });
-
-  useEffect(() => {
-    if (parentValue !== undefined) {
-      setValue(parentValue);
-    }
-  }, [parentValue]);
-
-  useEffect(() => {
-    if (autoFocus && input.current) {
-      input.current.focus();
-    }
-  }, [autoFocus]);
-
-  const changeValue = (newValue: string) => {
-    if (newValue.length === 0 || !minLength || minLength <= newValue.length) {
-      debouncedOnChange(newValue);
-    }
-  };
-
-  const handleInputChange = (event: React.SyntheticEvent<HTMLInputElement>) => {
-    const eventValue = event.currentTarget.value;
-    setValue(eventValue);
-    changeValue(eventValue);
-  };
-
-  const handleInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
-    if (event.key === Key.Escape) {
-      event.preventDefault();
-      handleClearClick();
-    }
-    onKeyDown?.(event);
-  };
-
-  const handleClearClick = () => {
-    onChange('');
-    if (parentValue === undefined || parentValue === '') {
-      setValue('');
-    }
-    input.current?.focus();
-  };
-  const ref = (node: HTMLInputElement | null) => {
-    input.current = node;
-    innerRef?.(node);
-  };
-
-  return (
-    <InputSearchWrapper
-      className={className}
-      id={id}
-      onMouseDown={onMouseDown}
-      style={{ '--inputSize': INPUT_SIZES[size] }}
-      title={tooShort && tooShortText && isDefined(minLength) ? tooShortText : ''}
-    >
-      <StyledInputWrapper className="sw-flex sw-items-center">
-        {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 && (
-          <StyledInteractiveIcon
-            Icon={CloseIcon}
-            aria-label={clearIconAriaLabel}
-            className="it__search-box-clear"
-            onClick={handleClearClick}
-            size="small"
-          />
-        )}
-
-        {tooShort && tooShortText && isDefined(minLength) && (
-          <StyledNote className="sw-ml-1" role="note">
-            {tooShortText}
-          </StyledNote>
-        )}
-      </StyledInputWrapper>
-    </InputSearchWrapper>
-  );
-}
-
-InputSearch.displayName = 'InputSearch'; // so that tests don't see the obfuscated production name
-
-export const InputSearchWrapper = styled.div`
-  width: var(--inputSize);
-
-  ${tw`sw-relative sw-inline-block`}
-  ${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`
-  input {
-    background: ${themeColor('inputBackground')};
-    color: ${themeContrast('inputBackground')};
-    border: ${themeBorder('default', 'inputBorder')};
-
-    ${tw`sw-rounded-2`}
-    ${tw`sw-box-border`}
-    ${tw`sw-pl-10`}
-    ${tw`sw-body-sm`}
-    ${tw`sw-w-full sw-h-control`}
-
-    &::placeholder {
-      color: ${themeColor('inputPlaceholder')};
-
-      ${tw`sw-truncate`}
-    }
-
-    &:hover {
-      border: ${themeBorder('default', 'inputFocus')};
-    }
-
-    &:focus,
-    &:active {
-      border: ${themeBorder('default', 'inputFocus')};
-      outline: ${themeBorder('focus', 'inputFocus')};
-    }
-
-    &::-webkit-search-decoration,
-    &::-webkit-search-cancel-button,
-    &::-webkit-search-results-button,
-    &::-webkit-search-results-decoration {
-      ${tw`sw-hidden sw-appearance-none`}
-    }
-  }
-`;
-
-const StyledSearchIcon = styled(SearchIcon)`
-  color: ${themeColor('inputBorder')};
-  top: calc((${theme('height.control')} - ${theme('spacing.4')}) / 2);
-
-  ${tw`sw-left-3`}
-  ${tw`sw-absolute`}
-  ${tw`sw-z-normal`}
-`;
-
-export const StyledInteractiveIcon = styled(InteractiveIcon)`
-  ${tw`sw-absolute`}
-  ${tw`sw-right-2`}
-`;
-
-const StyledNote = styled.span`
-  color: ${themeColor('inputPlaceholder')};
-  top: calc(1px + ${theme('inset.2')});
-
-  ${tw`sw-absolute`}
-  ${tw`sw-left-12 sw-right-10`}
-  ${tw`sw-body-sm`}
-  ${tw`sw-text-right`}
-  ${tw`sw-truncate`}
-  ${tw`sw-pointer-events-none`}
-`;
diff --git a/server/sonar-web/design-system/src/components/InputSelect.tsx b/server/sonar-web/design-system/src/components/InputSelect.tsx
deleted file mode 100644 (file)
index 4edc8e5..0000000
+++ /dev/null
@@ -1,198 +0,0 @@
-/*
- * 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 { useTheme as themeInfo } from '@emotion/react';
-import classNames from 'classnames';
-import { omit } from 'lodash';
-import { ReactNode } from 'react';
-import ReactSelect, {
-  GroupBase,
-  Props as NamedProps,
-  OptionProps,
-  StylesConfig,
-  components,
-} from 'react-select';
-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> {
-  Icon?: ReactNode;
-  label: string;
-  value: V;
-}
-
-interface StyleExtensionProps {
-  size?: InputSizeKeys;
-}
-
-export type SelectProps<
-  V,
-  Option extends LabelValueSelectOption<V>,
-  IsMulti extends boolean = false,
-  Group extends GroupBase<Option> = GroupBase<Option>
-> = NamedProps<Option, IsMulti, Group> & StyleExtensionProps;
-
-export function IconOption<
-  V,
-  Option extends LabelValueSelectOption<V>,
-  IsMulti extends boolean = false,
-  Group extends GroupBase<Option> = GroupBase<Option>
->(props: OptionProps<Option, IsMulti, Group>) {
-  const {
-    data: { label, Icon },
-  } = props;
-
-  return (
-    <components.Option {...props}>
-      <div className="sw-flex sw-items-center sw-gap-1">
-        {Icon}
-        <SearchHighlighter>{label}</SearchHighlighter>
-      </div>
-    </components.Option>
-  );
-}
-
-function SingleValue<
-  V,
-  Option extends LabelValueSelectOption<V>,
-  IsMulti extends boolean = false,
-  Group extends GroupBase<Option> = GroupBase<Option>
->(props: OptionProps<Option, IsMulti, Group>) {
-  const {
-    data: { label, Icon },
-  } = props;
-
-  return (
-    <components.SingleValue {...props}>
-      <div className="sw-flex sw-items-center sw-gap-1">
-        {Icon}
-        {label}
-      </div>
-    </components.SingleValue>
-  );
-}
-
-function IndicatorsContainer<
-  V,
-  Option extends LabelValueSelectOption<V>,
-  IsMulti extends boolean = false,
-  Group extends GroupBase<Option> = GroupBase<Option>
->(props: OptionProps<Option, IsMulti, Group>) {
-  return (
-    <components.IndicatorsContainer {...props}>
-      <div className="sw-pr-2">
-        <ChevronDownIcon />
-      </div>
-    </components.IndicatorsContainer>
-  );
-}
-
-export function InputSelect<
-  V,
-  Option extends LabelValueSelectOption<V>,
-  IsMulti extends boolean = false,
-  Group extends GroupBase<Option> = GroupBase<Option>
->({ size = 'medium', className, ...props }: SelectProps<V, Option, IsMulti, Group>) {
-  return (
-    <ReactSelect<Option, IsMulti, Group>
-      {...omit(props, 'className', 'large')}
-      className={classNames('react-select', className)}
-      classNamePrefix="react-select"
-      classNames={{
-        container: () => 'sw-relative sw-inline-block sw-align-middle',
-        placeholder: () => 'sw-truncate sw-leading-4',
-        menu: () => 'sw-z-dropdown-menu sw-ml-1/2 sw-mt-2',
-        menuList: () => 'sw-overflow-y-auto sw-py-2 sw-max-h-[12.25rem]',
-        control: ({ isDisabled }) =>
-          classNames(
-            'sw-box-border sw-rounded-2 sw-overflow-hidden',
-            isDisabled && 'sw-pointer-events-none sw-cursor-not-allowed'
-          ),
-        option: ({ isDisabled }) =>
-          classNames(
-            'it__select-option sw-py-2 sw-px-3 sw-cursor-pointer',
-            isDisabled && 'sw-pointer-events-none sw-cursor-not-allowed'
-          ),
-        ...props.classNames,
-      }}
-      components={{
-        Option: IconOption,
-        SingleValue,
-        IndicatorsContainer,
-        IndicatorSeparator: null,
-        ...props.components,
-      }}
-      isSearchable={props.isSearchable ?? false}
-      onMenuOpen={props.onMenuOpen}
-      styles={selectStyle({ size })}
-    />
-  );
-}
-
-export function selectStyle<
-  V,
-  Option extends LabelValueSelectOption<V>,
-  IsMulti extends boolean = false,
-  Group extends GroupBase<Option> = GroupBase<Option>
->({ size }: { size: InputSizeKeys }): StylesConfig<Option, IsMulti, Group> {
-  const theme = themeInfo();
-
-  return {
-    control: (base, { isFocused, menuIsOpen, isDisabled }) => ({
-      ...base,
-      color: themeContrast('inputBackground')({ theme }),
-      cursor: 'pointer',
-      background: themeColor('inputBackground')({ theme }),
-      transition: 'border 0.2s ease, outline 0.2s ease',
-      outline: isFocused && !menuIsOpen ? themeBorder('focus', 'inputFocus')({ theme }) : 'none',
-      ...(isDisabled && {
-        color: themeContrast('inputDisabled')({ theme }),
-        background: themeColor('inputDisabled')({ theme }),
-        border: themeBorder('default', 'inputDisabledBorder')({ theme }),
-        outline: 'none',
-      }),
-      ...(isFocused && {
-        border: themeBorder('default', 'inputBorder')({ theme }),
-      }),
-    }),
-    menu: (base) => ({
-      ...base,
-      width: INPUT_SIZES[size],
-    }),
-    option: (base, { isFocused, isSelected }) => ({
-      ...base,
-      ...((isSelected || isFocused) && {
-        background: themeColor('selectOptionSelected')({ theme }),
-        color: themeContrast('primaryLight')({ theme }),
-      }),
-    }),
-    singleValue: (base) => ({
-      ...base,
-      color: themeContrast('primaryLight')({ theme }),
-    }),
-    placeholder: (base) => ({
-      ...base,
-      color: themeContrast('inputPlaceholder')({ theme }),
-    }),
-  };
-}
diff --git a/server/sonar-web/design-system/src/components/MultiSelectMenu.tsx b/server/sonar-web/design-system/src/components/MultiSelectMenu.tsx
deleted file mode 100644 (file)
index ecfaa45..0000000
+++ /dev/null
@@ -1,343 +0,0 @@
-/*
- * 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 { MultiSelectMenuOption } from './MultiSelectMenuOption';
-
-interface Props {
-  allowNewElements?: boolean;
-  allowSelection?: boolean;
-  clearIconAriaLabel: string;
-  createElementLabel: string;
-  elements: string[];
-  footerNode?: React.ReactNode;
-  headerNode?: React.ReactNode;
-  inputId?: string;
-  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 MultiSelectMenu 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 = '',
-      inputId,
-      clearIconAriaLabel,
-      noResultsLabel,
-      searchInputAriaLabel,
-    } = this.props;
-    const { renderLabel } = this.props as PropsWithDefault;
-
-    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
-            className="sw-mt-1"
-            clearIconAriaLabel={clearIconAriaLabel}
-            id={inputId}
-            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) => (
-              <MultiSelectMenuOption
-                active={activeElement === element}
-                createElementLabel={createElementLabel}
-                element={element}
-                key={element}
-                onHover={this.handleElementHover}
-                onSelectChange={this.handleSelectChange}
-                renderLabel={renderLabel}
-                selected
-              />
-            ))}
-          {unselectedElements.length > 0 &&
-            unselectedElements.map((element) => (
-              <MultiSelectMenuOption
-                active={activeElement === element}
-                createElementLabel={createElementLabel}
-                disabled={!allowSelection}
-                element={element}
-                key={element}
-                onHover={this.handleElementHover}
-                onSelectChange={this.handleSelectChange}
-                renderLabel={renderLabel}
-              />
-            ))}
-          {showNewElement && (
-            <MultiSelectMenuOption
-              active={activeElement === query}
-              createElementLabel={createElementLabel}
-              custom
-              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>
-    );
-  }
-}
diff --git a/server/sonar-web/design-system/src/components/MultiSelectMenuOption.tsx b/server/sonar-web/design-system/src/components/MultiSelectMenuOption.tsx
deleted file mode 100644 (file)
index e68dd7c..0000000
+++ /dev/null
@@ -1,80 +0,0 @@
-/*
- * 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 { identity } from 'lodash';
-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;
-  renderLabel?: (element: string) => React.ReactNode;
-  selected?: boolean;
-}
-
-export function MultiSelectMenuOption(props: MultiSelectOptionProps) {
-  const {
-    active,
-    createElementLabel,
-    custom,
-    disabled,
-    element,
-    onSelectChange,
-    selected,
-    renderLabel = identity,
-  } = props;
-
-  const onHover = () => {
-    props.onHover(element);
-  };
-
-  const label = renderLabel(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 className="sw-mr-1">
-            +
-          </span>
-          {element}
-        </span>
-      ) : (
-        <span className="sw-ml-3">{label}</span>
-      )}
-    </ItemCheckbox>
-  );
-}
diff --git a/server/sonar-web/design-system/src/components/RadioButton.tsx b/server/sonar-web/design-system/src/components/RadioButton.tsx
deleted file mode 100644 (file)
index 069f6d2..0000000
+++ /dev/null
@@ -1,139 +0,0 @@
-/*
- * 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 React from 'react';
-import tw from 'twin.macro';
-import { themeBorder, themeColor } from '../helpers/theme';
-
-type AllowedRadioButtonAttributes = Pick<
-  React.InputHTMLAttributes<HTMLInputElement>,
-  'aria-label' | 'autoFocus' | 'id' | 'name' | 'style' | 'title' | 'type'
->;
-
-interface PropsBase extends AllowedRadioButtonAttributes {
-  checked: boolean;
-  children?: React.ReactNode;
-  className?: string;
-  disabled?: boolean;
-}
-
-type Props =
-  | ({ onCheck: (value: string) => void; value: string } & PropsBase)
-  | ({ onCheck: () => void; value: never } & PropsBase);
-
-export function RadioButton({
-  checked,
-  children,
-  className,
-  disabled,
-  onCheck,
-  value,
-  ...htmlProps
-}: Props) {
-  const handleChange = () => {
-    if (!disabled) {
-      onCheck(value);
-    }
-  };
-
-  return (
-    <label
-      className={classNames(
-        'sw-flex sw-items-center',
-        {
-          'sw-cursor-pointer': !disabled,
-          'sw-cursor-not-allowed': disabled,
-        },
-        className
-      )}
-    >
-      <RadioButtonStyled
-        aria-disabled={disabled}
-        checked={checked}
-        disabled={disabled}
-        onChange={handleChange}
-        type="radio"
-        value={value}
-        {...htmlProps}
-      />
-      {children}
-    </label>
-  );
-}
-
-export const RadioButtonStyled = styled.input`
-  appearance: none; //disables native style
-  border: ${themeBorder('default', 'radioBorder')};
-
-  ${tw`sw-cursor-pointer`}
-
-  ${tw`sw-w-4 sw-min-w-4 sw-h-4 sw-min-h-4`}
-  ${tw`sw-p-1 sw-mr-2`}
-  ${tw`sw-inline-block`}
-  ${tw`sw-box-border`}
-  ${tw`sw-rounded-pill`}
-
-  &:hover {
-    background: ${themeColor('radioHover')};
-  }
-
-  &:focus,
-  &:focus-visible {
-    background: ${themeColor('radioHover')};
-    border: ${themeBorder('default', 'radioFocusBorder')};
-    outline: ${themeBorder('focus', 'radioFocusOutline')};
-  }
-
-  &.is-checked,
-  &:focus:checked,
-  &:focus-visible:checked,
-  &:hover:checked,
-  &:checked {
-    // Color cannot be used with multiple backgrounds, only image is allowed
-    background-image: linear-gradient(to right, ${themeColor('radio')}, ${themeColor('radio')}),
-      linear-gradient(to right, ${themeColor('radioChecked')}, ${themeColor('radioChecked')});
-    background-clip: content-box, padding-box;
-    border: ${themeBorder('default', 'radioBorder')};
-  }
-
-  &.is-disabled,
-  &:disabled {
-    background: ${themeColor('radioDisabledBackground')};
-    border: ${themeBorder('default', 'radioDisabledBorder')};
-    background-clip: unset;
-
-    &.is-checked,
-    &:checked {
-      background-image: linear-gradient(
-          to right,
-          ${themeColor('radioDisabled')},
-          ${themeColor('radioDisabled')}
-        ),
-        linear-gradient(
-          to right,
-          ${themeColor('radioDisabledBackground')},
-          ${themeColor('radioDisabledBackground')}
-        ) !important;
-      background-clip: content-box, padding-box !important;
-      border: ${themeBorder('default', 'radioDisabledBorder')} !important;
-    }
-  }
-`;
diff --git a/server/sonar-web/design-system/src/components/SearchSelect.tsx b/server/sonar-web/design-system/src/components/SearchSelect.tsx
deleted file mode 100644 (file)
index c17c22b..0000000
+++ /dev/null
@@ -1,126 +0,0 @@
-/*
- * 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
deleted file mode 100644 (file)
index f44bff6..0000000
+++ /dev/null
@@ -1,215 +0,0 @@
-/*
- * 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, PopupPlacement, PopupZLevel, themeBorder } from '../helpers';
-import { InputSizeKeys } from '../types/theme';
-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> {
-  className?: string;
-  controlAriaLabel?: string;
-  controlLabel?: React.ReactNode | string;
-  controlSize?: InputSizeKeys;
-  isDiscreet?: boolean;
-  zLevel?: PopupZLevel;
-}
-
-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 {
-    className,
-    isDiscreet,
-    value,
-    loadOptions,
-    controlLabel,
-    controlSize,
-    isDisabled,
-    minLength,
-    controlAriaLabel,
-    menuIsOpen,
-    onChange,
-    onInputChange,
-    zLevel = PopupZLevel.Global,
-    ...rest
-  } = props;
-  const [open, setOpen] = React.useState(false);
-  const [inputValue, setInputValue] = React.useState('');
-
-  React.useEffect(() => {
-    if (menuIsOpen) {
-      setOpen(true);
-    }
-  }, [menuIsOpen]);
-
-  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);
-      onChange?.(newValue, actionMeta);
-    },
-    [toggleDropdown, 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) => {
-      if (actionMeta.action === 'menu-close') {
-        setInputValue(actionMeta.prevInputValue);
-        return actionMeta.prevInputValue;
-      }
-
-      setInputValue(newValue);
-      onInputChange?.(newValue, actionMeta);
-      return newValue;
-    },
-    [onInputChange]
-  );
-
-  React.useEffect(() => {
-    if (open) {
-      ref.current?.inputRef?.select();
-    } else {
-      setInputValue('');
-    }
-  }, [open]);
-
-  return (
-    <DropdownToggler
-      allowResizing
-      className="sw-overflow-visible sw-border-none"
-      onRequestClose={() => {
-        toggleDropdown(false);
-      }}
-      open={open}
-      overlay={
-        <SearchHighlighterContext.Provider value={inputValue}>
-          <StyledSearchSelectWrapper>
-            <SearchSelect
-              cacheOptions
-              {...rest}
-              components={{
-                SingleValue: () => null,
-                Option: IconOption,
-                ...rest.components,
-              }}
-              inputValue={inputValue}
-              loadOptions={debouncedLoadOptions.current}
-              menuIsOpen
-              minLength={minLength}
-              onChange={handleChange}
-              onInputChange={handleInputChange}
-              selectRef={ref}
-            />
-          </StyledSearchSelectWrapper>
-        </SearchHighlighterContext.Provider>
-      }
-      placement={PopupPlacement.BottomLeft}
-      zLevel={zLevel}
-    >
-      <SearchSelectDropdownControl
-        ariaLabel={controlAriaLabel}
-        className={className}
-        disabled={isDisabled}
-        isDiscreet={isDiscreet}
-        label={controlLabel}
-        onClick={() => {
-          toggleDropdown(true);
-        }}
-        size={controlSize}
-      />
-    </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
deleted file mode 100644 (file)
index 8387379..0000000
+++ /dev/null
@@ -1,130 +0,0 @@
-/*
- * 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 {
-  ariaLabel?: string;
-  className?: string;
-  disabled?: boolean;
-  isDiscreet?: boolean;
-  label?: React.ReactNode | string;
-  onClick: VoidFunction;
-  size?: InputSizeKeys;
-}
-
-export function SearchSelectDropdownControl(props: SearchSelectDropdownControlProps) {
-  const { className, disabled, label, isDiscreet, onClick, size = 'full', ariaLabel = '' } = props;
-  return (
-    <StyledControl
-      aria-label={ariaLabel}
-      className={classNames(className, { 'is-discreet': isDiscreet })}
-      onClick={() => {
-        if (!disabled) {
-          onClick();
-        }
-      }}
-      onKeyDown={(event) => {
-        if (event.key === Key.Enter || event.key === Key.ArrowDown) {
-          onClick();
-        }
-      }}
-      role="combobox"
-      style={{ '--inputSize': isDiscreet ? 'auto' : INPUT_SIZES[size] }}
-      tabIndex={disabled ? -1 : 0}
-    >
-      <InputValue
-        className={classNames('it__js-search-input-value sw-flex sw-justify-between', {
-          'is-disabled': disabled,
-          'is-placeholder': !label,
-        })}
-      >
-        <span className="sw-truncate">{label}</span>
-        <ChevronDownIcon className="sw-ml-1" />
-      </InputValue>
-    </StyledControl>
-  );
-}
-
-const StyledControl = styled.div`
-  color: ${themeContrast('inputBackground')};
-  background: ${themeColor('inputBackground')};
-  border: ${themeBorder('default', 'inputBorder')};
-  width: var(--inputSize);
-
-  ${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-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: 100%;
-  color: ${themeContrast('inputBackground')};
-
-  ${tw`sw-truncate`};
-
-  &.is-placeholder {
-    color: ${themeColor('inputPlaceholder')};
-  }
-
-  &.is-disabled {
-    color: ${themeContrast('inputDisabled')};
-  }
-`;
index 31df423e578e52680baaaa000fe8ea83a13edb6b..41c5541a3eac925eb188748ca598669aa56ed1c2 100644 (file)
@@ -22,9 +22,9 @@ import classNames from 'classnames';
 import tw from 'twin.macro';
 import { translate } from '../helpers/l10n';
 import { themeBorder, themeColor, themeContrast, themeShadow } from '../helpers/theme';
-import { RadioButtonStyled } from './RadioButton';
 import { LightLabel } from './Text';
 import { RecommendedIcon } from './icons/RecommendedIcon';
+import { RadioButtonStyled } from './input/RadioButton';
 
 export interface SelectionCardProps {
   children?: React.ReactNode;
index 9a7480751385c22efae7e74f940e7372444bb941..4e1f7b47f70077b50c50e87107b5e3524d3aa9d5 100644 (file)
@@ -17,7 +17,7 @@
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
-import { MultiSelectMenu } from './MultiSelectMenu';
+import { MultiSelectMenu } from './input/MultiSelectMenu';
 
 interface Props {
   allowNewElements?: boolean;
diff --git a/server/sonar-web/design-system/src/components/__tests__/DatePicker-test.tsx b/server/sonar-web/design-system/src/components/__tests__/DatePicker-test.tsx
deleted file mode 100644 (file)
index 01aa68b..0000000
+++ /dev/null
@@ -1,145 +0,0 @@
-/*
- * 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, within } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
-import { getMonth, getYear, parseISO } from 'date-fns';
-import { render } from '../../helpers/testUtils';
-import { DatePicker } from '../DatePicker';
-
-it('behaves correctly', async () => {
-  const user = userEvent.setup();
-
-  const onChange = jest.fn((_: Date) => undefined);
-  const currentMonth = parseISO('2022-06-13');
-  renderDatePicker({ currentMonth, onChange });
-
-  /*
-   * Open the DatePicker, navigate to the previous month and choose an arbitrary day (7)
-   * Then check that onChange was correctly called with a date in the previous month
-   */
-  await user.click(screen.getByRole('textbox'));
-
-  const nav = screen.getByRole('navigation');
-  expect(nav).toBeInTheDocument();
-
-  await user.click(within(nav).getByRole('button', { name: 'previous' }));
-  await user.click(screen.getByText('7'));
-
-  expect(onChange).toHaveBeenCalled();
-  const newDate = onChange.mock.calls[0][0]; // first argument of the first and only call
-  expect(getMonth(newDate)).toBe(getMonth(currentMonth) - 1);
-
-  onChange.mockClear();
-
-  /*
-   * Open the DatePicker, navigate to the next month twice and choose an arbitrary day (12)
-   * Then check that onChange was correctly called with a date in the following month
-   */
-  await user.click(screen.getByRole('textbox'));
-  const nextButton = screen.getByRole('button', { name: 'next' });
-  await user.click(nextButton);
-  await user.click(nextButton);
-  await user.click(screen.getByText('12'));
-
-  expect(onChange).toHaveBeenCalled();
-  const newDate2 = onChange.mock.calls[0][0]; // first argument
-  expect(getMonth(newDate2)).toBe(getMonth(currentMonth) + 1);
-
-  onChange.mockClear();
-
-  /*
-   * Open the DatePicker, select the month, select the year and choose an arbitrary day (10)
-   * Then check that onChange was correctly called with a date in the selected month & year
-   */
-  await user.click(screen.getByRole('textbox'));
-  // Select month
-  await user.click(screen.getByText('Jun'));
-  await user.click(screen.getByText('Feb'));
-
-  // Select year
-  await user.click(screen.getByText('2022'));
-  await user.click(screen.getByText('2019'));
-
-  await user.click(screen.getByText('10'));
-
-  const newDate3 = onChange.mock.calls[0][0]; // first argument
-
-  expect(getMonth(newDate3)).toBe(1);
-  expect(getYear(newDate3)).toBe(2019);
-});
-
-it('should clear the value', async () => {
-  const user = userEvent.setup();
-
-  const onChange = jest.fn((_: Date) => undefined);
-
-  const currentDate = parseISO('2022-06-13');
-
-  renderDatePicker({
-    currentMonth: currentDate,
-    onChange,
-    showClearButton: true,
-    value: currentDate,
-    // eslint-disable-next-line jest/no-conditional-in-test
-    valueFormatter: (date?: Date) => (date ? 'formatted date' : 'no date'),
-  });
-
-  await user.click(screen.getByRole('textbox'));
-
-  await user.click(screen.getByLabelText('clear'));
-
-  expect(onChange).toHaveBeenCalledWith(undefined);
-});
-
-it.each([
-  [{ highlightFrom: parseISO('2022-06-12'), value: parseISO('2022-06-14') }],
-  [{ alignRight: true, highlightTo: parseISO('2022-06-14'), value: parseISO('2022-06-12') }],
-])('highlights the appropriate days', async (props) => {
-  const user = userEvent.setup();
-
-  const hightlightClass = 'rdp-highlighted';
-
-  renderDatePicker(props);
-
-  await user.click(screen.getByRole('textbox'));
-
-  expect(screen.getByText('11')).not.toHaveClass(hightlightClass);
-  expect(screen.getByText('12')).toHaveClass(hightlightClass);
-  expect(screen.getByText('13')).toHaveClass(hightlightClass);
-  expect(screen.getByText('14')).toHaveClass(hightlightClass);
-  expect(screen.getByText('15')).not.toHaveClass(hightlightClass);
-});
-
-function renderDatePicker(overrides: Partial<DatePicker['props']> = {}) {
-  const defaultFormatter = (date?: Date) => (date ? date.toISOString() : '');
-
-  render(
-    <DatePicker
-      ariaNextMonthLabel="next"
-      ariaPreviousMonthLabel="previous"
-      clearButtonLabel="clear"
-      onChange={jest.fn()}
-      placeholder="placeholder"
-      valueFormatter={defaultFormatter}
-      {...overrides}
-    />
-  );
-}
diff --git a/server/sonar-web/design-system/src/components/__tests__/DateRangePicker-test.tsx b/server/sonar-web/design-system/src/components/__tests__/DateRangePicker-test.tsx
deleted file mode 100644 (file)
index 6710f23..0000000
+++ /dev/null
@@ -1,93 +0,0 @@
-/*
- * 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, within } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
-import { formatISO, parseISO } from 'date-fns';
-import { render } from '../../helpers/testUtils';
-import { DateRangePicker } from '../DateRangePicker';
-
-beforeEach(() => {
-  jest.useFakeTimers().setSystemTime(parseISO('2022-06-12'));
-});
-
-afterEach(() => {
-  jest.runOnlyPendingTimers();
-  jest.useRealTimers();
-});
-
-it('behaves correctly', async () => {
-  // Remove delay to play nice with fake timers
-  const user = userEvent.setup({ delay: null });
-
-  const onChange = jest.fn((_: { from?: Date; to?: Date }) => undefined);
-  renderDateRangePicker({ onChange });
-
-  await user.click(screen.getByRole('textbox', { name: 'from' }));
-
-  const fromDateNav = screen.getByRole('navigation');
-  expect(fromDateNav).toBeInTheDocument();
-
-  await user.click(within(fromDateNav).getByRole('button', { name: 'previous' }));
-  await user.click(screen.getByText('7'));
-
-  expect(screen.queryByRole('navigation')).not.toBeInTheDocument();
-
-  expect(onChange).toHaveBeenCalled();
-  const { from } = onChange.mock.calls[0][0]; // first argument
-  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-  expect(formatISO(from!, { representation: 'date' })).toBe('2022-05-07');
-
-  onChange.mockClear();
-
-  jest.runAllTimers();
-
-  const toDateNav = await screen.findByRole('navigation');
-  const previousButton = within(toDateNav).getByRole('button', { name: 'previous' });
-  const nextButton = within(toDateNav).getByRole('button', { name: 'next' });
-  await user.click(previousButton);
-  await user.click(nextButton);
-  await user.click(previousButton);
-  await user.click(screen.getByText('12'));
-
-  expect(screen.queryByRole('navigation')).not.toBeInTheDocument();
-
-  expect(onChange).toHaveBeenCalled();
-  const { to } = onChange.mock.calls[0][0]; // first argument
-  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-  expect(formatISO(to!, { representation: 'date' })).toBe('2022-05-12');
-});
-
-function renderDateRangePicker(overrides: Partial<DateRangePicker['props']> = {}) {
-  const defaultFormatter = (date?: Date) =>
-    date ? formatISO(date, { representation: 'date' }) : '';
-
-  render(
-    <DateRangePicker
-      ariaNextMonthLabel="next"
-      ariaPreviousMonthLabel="previous"
-      clearButtonLabel="clear"
-      fromLabel="from"
-      onChange={jest.fn()}
-      toLabel="to"
-      valueFormatter={defaultFormatter}
-      {...overrides}
-    />
-  );
-}
diff --git a/server/sonar-web/design-system/src/components/__tests__/DiscreetSelect-test.tsx b/server/sonar-web/design-system/src/components/__tests__/DiscreetSelect-test.tsx
deleted file mode 100644 (file)
index fea6a4c..0000000
+++ /dev/null
@@ -1,59 +0,0 @@
-/*
- * 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 { DiscreetSelect } from '../DiscreetSelect';
-
-it('should render discreet select and invoke CB on value click', async () => {
-  const value = 'foo';
-  const setValue = jest.fn();
-
-  const user = userEvent.setup();
-  setupWithProps({ setValue, value });
-  await user.click(screen.getByRole('combobox'));
-  expect(screen.getByText(/option foo-bar selected/)).toBeInTheDocument();
-  expect(screen.getByRole('note', { name: 'Icon' })).toBeInTheDocument();
-  await user.click(screen.getByText('bar-foo'));
-  expect(setValue).toHaveBeenCalled();
-});
-
-function setupWithProps(props: Partial<FCProps<typeof DiscreetSelect>>) {
-  return render(
-    <DiscreetSelect
-      options={[
-        { label: 'foo-bar', value: 'foo' },
-        {
-          label: 'bar-foo',
-          value: 'bar',
-          Icon: (
-            <span role="note" title="Icon">
-              Icon
-            </span>
-          ),
-        },
-      ]}
-      setValue={jest.fn()}
-      value="foo"
-      {...props}
-    />
-  );
-}
diff --git a/server/sonar-web/design-system/src/components/__tests__/FormField-test.tsx b/server/sonar-web/design-system/src/components/__tests__/FormField-test.tsx
deleted file mode 100644 (file)
index ee86ad3..0000000
+++ /dev/null
@@ -1,46 +0,0 @@
-/*
- * 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 { FCProps } from '~types/misc';
-import { render } from '../../helpers/testUtils';
-import { FormField } from '../FormField';
-
-it('should render correctly', () => {
-  renderFormField({}, <input id="input" />);
-  expect(screen.getByLabelText('Hello')).toBeInTheDocument();
-});
-
-it('should render with required and description', () => {
-  renderFormField({ description: 'some description', required: true }, <input id="input" />);
-  expect(screen.getByText('some description')).toBeInTheDocument();
-  expect(screen.getByText('*')).toBeInTheDocument();
-});
-
-function renderFormField(
-  props: Partial<FCProps<typeof FormField>> = {},
-  children: any = <div>Fake input</div>
-) {
-  return render(
-    <FormField htmlFor="input" label="Hello" {...props}>
-      {children}
-    </FormField>
-  );
-}
diff --git a/server/sonar-web/design-system/src/components/__tests__/InputField-test.tsx b/server/sonar-web/design-system/src/components/__tests__/InputField-test.tsx
deleted file mode 100644 (file)
index 47fcac1..0000000
+++ /dev/null
@@ -1,36 +0,0 @@
-/*
- * 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 { InputField } from '../InputField';
-
-describe('Input Field', () => {
-  it.each([
-    ['default', false, false, 'defaultStyle'],
-    ['invalid', true, false, 'dangerStyle'],
-    ['valid', false, true, 'successStyle'],
-  ])('should handle status %s', (_, isInvalid, isValid, expectedStyle) => {
-    render(<InputField isInvalid={isInvalid} isValid={isValid} />);
-
-    // Emotion classes contain pseudo-random parts, we're interesting in the fixed part
-    // so we can't just check a specific class
-    // eslint-disable-next-line jest-dom/prefer-to-have-class
-    expect(screen.getByRole('textbox').className).toContain(expectedStyle);
-  });
-});
diff --git a/server/sonar-web/design-system/src/components/__tests__/InputMultiSelect-test.tsx b/server/sonar-web/design-system/src/components/__tests__/InputMultiSelect-test.tsx
deleted file mode 100644 (file)
index d40de11..0000000
+++ /dev/null
@@ -1,40 +0,0 @@
-/*
- * 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 { FCProps } from '../../types/misc';
-import { InputMultiSelect } from '../InputMultiSelect';
-
-it('should render correctly', () => {
-  renderInputMultiSelect();
-  expect(screen.getByText('select')).toBeInTheDocument();
-  expect(screen.queryByText('selected')).not.toBeInTheDocument();
-  expect(screen.queryByText(/\d+/)).not.toBeInTheDocument();
-});
-
-it('should render correctly with a counter', () => {
-  renderInputMultiSelect({ count: 42 });
-  expect(screen.queryByText('select')).not.toBeInTheDocument();
-  expect(screen.getByText('selected')).toBeInTheDocument();
-  expect(screen.getByText('42')).toBeInTheDocument();
-});
-
-function renderInputMultiSelect(props: Partial<FCProps<typeof InputMultiSelect>> = {}) {
-  render(<InputMultiSelect placeholder="select" selectedLabel="selected" {...props} />);
-}
diff --git a/server/sonar-web/design-system/src/components/__tests__/InputSearch-test.tsx b/server/sonar-web/design-system/src/components/__tests__/InputSearch-test.tsx
deleted file mode 100644 (file)
index 683c2e5..0000000
+++ /dev/null
@@ -1,90 +0,0 @@
-/*
- * 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, waitFor } from '@testing-library/react';
-import { render } from '../../helpers/testUtils';
-import { FCProps } from '../../types/misc';
-import { InputSearch } from '../InputSearch';
-
-it('should warn when input is too short', async () => {
-  const { user } = setupWithProps({ value: 'f' });
-  expect(screen.getByRole('note')).toBeInTheDocument();
-  await user.type(screen.getByRole('searchbox'), 'oo');
-  expect(screen.queryByRole('note')).not.toBeInTheDocument();
-});
-
-it('should show clear button only when there is a value', async () => {
-  const { user } = setupWithProps({ value: 'f' });
-  expect(screen.getByRole('button')).toBeInTheDocument();
-  await user.clear(screen.getByRole('searchbox'));
-  expect(screen.queryByRole('button')).not.toBeInTheDocument();
-});
-
-it('should attach ref', () => {
-  const ref = jest.fn() as jest.Mock<unknown, unknown[]>;
-  setupWithProps({ innerRef: ref });
-  expect(ref).toHaveBeenCalled();
-  expect(ref.mock.calls[0][0]).toBeInstanceOf(HTMLInputElement);
-});
-
-it('should trigger reset correctly with clear button', async () => {
-  const onChange = jest.fn();
-  const { user } = setupWithProps({ onChange });
-  await user.click(screen.getByRole('button'));
-  expect(onChange).toHaveBeenCalledWith('');
-});
-
-it('should trigger change correctly', async () => {
-  const onChange = jest.fn();
-  const { user } = setupWithProps({ onChange, value: 'f' });
-  await user.type(screen.getByRole('searchbox'), 'oo');
-  await waitFor(() => {
-    expect(onChange).toHaveBeenCalledWith('foo');
-  });
-});
-
-it('should not change when value is too short', async () => {
-  const onChange = jest.fn();
-  const { user } = setupWithProps({ onChange, value: '', minLength: 3 });
-  await user.type(screen.getByRole('searchbox'), 'fo');
-  expect(onChange).not.toHaveBeenCalled();
-});
-
-it('should clear input using escape', async () => {
-  const onChange = jest.fn();
-  const { user } = setupWithProps({ onChange, value: 'foo' });
-  await user.type(screen.getByRole('searchbox'), '{Escape}');
-  expect(onChange).toHaveBeenCalledWith('');
-});
-
-function setupWithProps(props: Partial<FCProps<typeof InputSearch>> = {}) {
-  return render(
-    <InputSearch
-      clearIconAriaLabel=""
-      maxLength={150}
-      minLength={2}
-      onChange={jest.fn()}
-      placeholder="placeholder"
-      searchInputAriaLabel=""
-      tooShortText="too short"
-      value="foo"
-      {...props}
-    />
-  );
-}
diff --git a/server/sonar-web/design-system/src/components/__tests__/InputSelect-test.tsx b/server/sonar-web/design-system/src/components/__tests__/InputSelect-test.tsx
deleted file mode 100644 (file)
index 8d83594..0000000
+++ /dev/null
@@ -1,58 +0,0 @@
-/*
- * 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 { InputSelect } from '../InputSelect';
-
-it('should render select input and be able to click and change', async () => {
-  const setValue = jest.fn();
-  const user = userEvent.setup();
-  setupWithProps({ placeholder: 'placeholder-foo', onChange: setValue });
-  expect(screen.getByText('placeholder-foo')).toBeInTheDocument();
-  await user.click(screen.getByRole('combobox'));
-  expect(screen.getByText(/option foo-bar focused/)).toBeInTheDocument();
-  expect(screen.getByRole('note', { name: 'Icon' })).toBeInTheDocument();
-  await user.click(screen.getByText('bar-foo'));
-  expect(setValue).toHaveBeenCalled();
-  expect(screen.queryByText('placeholder-foo')).not.toBeInTheDocument();
-  expect(screen.getByRole('note', { name: 'Icon' })).toBeInTheDocument();
-});
-
-function setupWithProps(props: Partial<FCProps<typeof InputSelect>>) {
-  return render(
-    <InputSelect
-      {...props}
-      options={[
-        { label: 'foo-bar', value: 'foo' },
-        {
-          label: 'bar-foo',
-          value: 'bar',
-          Icon: (
-            <span role="note" title="Icon">
-              Icon
-            </span>
-          ),
-        },
-      ]}
-    />
-  );
-}
diff --git a/server/sonar-web/design-system/src/components/__tests__/MultiSelectMenu-test.tsx b/server/sonar-web/design-system/src/components/__tests__/MultiSelectMenu-test.tsx
deleted file mode 100644 (file)
index a1c7b58..0000000
+++ /dev/null
@@ -1,114 +0,0 @@
-/*
- * 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 { MultiSelectMenu } from '../MultiSelectMenu';
-
-const elements = ['foo', 'bar', 'baz'];
-
-beforeEach(() => {
-  jest.useFakeTimers();
-});
-
-afterEach(() => {
-  jest.runOnlyPendingTimers();
-  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<MultiSelectMenu['props']> = {}) {
-  return render(
-    <MultiSelectMenu
-      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}
-    />
-  );
-}
diff --git a/server/sonar-web/design-system/src/components/__tests__/RadioButton-test.tsx b/server/sonar-web/design-system/src/components/__tests__/RadioButton-test.tsx
deleted file mode 100644 (file)
index deff53b..0000000
+++ /dev/null
@@ -1,62 +0,0 @@
-/*
- * 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 { RadioButton } from '../RadioButton';
-
-const value = 'value';
-
-it('should render properly', () => {
-  setupWithProps();
-  expect(screen.getByRole('radio')).not.toBeChecked();
-});
-
-it('should render properly when checked', () => {
-  setupWithProps({ checked: true });
-  expect(screen.getByRole('radio')).toBeChecked();
-});
-
-it('should invoke callback on click', async () => {
-  const user = userEvent.setup();
-  const onCheck = jest.fn();
-  setupWithProps({ onCheck, value });
-
-  await user.click(screen.getByRole('radio'));
-  expect(onCheck).toHaveBeenCalled();
-});
-
-it('should not invoke callback on click when disabled', async () => {
-  const user = userEvent.setup();
-  const onCheck = jest.fn();
-  setupWithProps({ disabled: true, onCheck });
-
-  await user.click(screen.getByRole('radio'));
-  expect(onCheck).not.toHaveBeenCalled();
-});
-
-function setupWithProps(props?: Partial<FCProps<typeof RadioButton>>) {
-  return render(
-    <RadioButton checked={false} onCheck={jest.fn()} value="value" {...props}>
-      foo
-    </RadioButton>
-  );
-}
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
deleted file mode 100644 (file)
index 18ee6f8..0000000
+++ /dev/null
@@ -1,99 +0,0 @@
-/*
- * 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 { act, 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 act(async () => {
-    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
-      loadOptions={loadOptions}
-      placeholder="search for things"
-      {...props}
-    />
-  );
-}
diff --git a/server/sonar-web/design-system/src/components/__tests__/buttons-test.tsx b/server/sonar-web/design-system/src/components/__tests__/buttons-test.tsx
deleted file mode 100644 (file)
index f2a0886..0000000
+++ /dev/null
@@ -1,41 +0,0 @@
-/*
- * 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 { render } from '../../helpers/testUtils';
-import { CodeViewerExpander } from '../buttons';
-
-it('renders CodeViewerExpander correctly when direction is UP', () => {
-  render(<CodeViewerExpander direction="UP">Hello</CodeViewerExpander>);
-  const content = screen.getByText('Hello');
-  expect(content).toHaveStyle({
-    'border-top': 'none',
-    'border-bottom': '1px solid rgb(221,221,221)',
-  });
-});
-
-it('renders CodeViewerExpander correctly when direction is DOWN', () => {
-  render(<CodeViewerExpander direction="DOWN">Hello</CodeViewerExpander>);
-  const content = screen.getByText('Hello');
-  expect(content).toHaveStyle({
-    'border-bottom': 'none',
-    'border-top': '1px solid rgb(221,221,221)',
-  });
-});
diff --git a/server/sonar-web/design-system/src/components/buttons.tsx b/server/sonar-web/design-system/src/components/buttons.tsx
deleted file mode 100644 (file)
index e71a3ce..0000000
+++ /dev/null
@@ -1,314 +0,0 @@
-/*
- * 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 { css } from '@emotion/react';
-import styled from '@emotion/styled';
-import React from 'react';
-import tw from 'twin.macro';
-import { OPACITY_20_PERCENT } from '../helpers/constants';
-import { themeBorder, themeColor, themeContrast } from '../helpers/theme';
-import { ThemedProps } from '../types/theme';
-import { BaseLink, LinkProps } from './Link';
-
-type AllowedButtonAttributes = Pick<
-  React.ButtonHTMLAttributes<HTMLButtonElement>,
-  'aria-label' | 'autoFocus' | 'id' | 'name' | 'role' | 'style' | 'title' | 'type' | 'form'
->;
-
-export interface ButtonProps extends AllowedButtonAttributes {
-  children?: React.ReactNode;
-  className?: string;
-  disabled?: boolean;
-  download?: string;
-  icon?: React.ReactNode;
-  innerRef?: React.Ref<HTMLButtonElement>;
-  isExternal?: LinkProps['isExternal'];
-  onClick?: (event?: React.MouseEvent<HTMLButtonElement | HTMLAnchorElement>) => unknown;
-
-  preventDefault?: boolean;
-  reloadDocument?: LinkProps['reloadDocument'];
-  stopPropagation?: boolean;
-  target?: LinkProps['target'];
-  to?: LinkProps['to'];
-}
-
-class Button extends React.PureComponent<ButtonProps> {
-  handleClick = (event: React.MouseEvent<HTMLButtonElement | HTMLAnchorElement>) => {
-    const { disabled, onClick, stopPropagation = false, type } = this.props;
-    const { preventDefault = type !== 'submit' } = this.props;
-
-    if (preventDefault || disabled) {
-      event.preventDefault();
-    }
-
-    if (stopPropagation) {
-      event.stopPropagation();
-    }
-
-    if (onClick && !disabled) {
-      onClick(event);
-    }
-  };
-
-  render() {
-    const {
-      children,
-      disabled,
-      icon,
-      innerRef,
-      onClick,
-      preventDefault,
-      stopPropagation,
-      to,
-      type = 'button',
-      ...htmlProps
-    } = this.props;
-
-    const props = {
-      ...htmlProps,
-      'aria-disabled': disabled,
-      disabled,
-      type,
-    };
-
-    if (to) {
-      return (
-        <BaseButtonLink {...props} onClick={onClick} to={to}>
-          {icon}
-          {children}
-        </BaseButtonLink>
-      );
-    }
-
-    return (
-      <BaseButton {...props} onClick={this.handleClick} ref={innerRef}>
-        {icon}
-        {children}
-      </BaseButton>
-    );
-  }
-}
-
-const buttonStyle = (props: ThemedProps) => css`
-  box-sizing: border-box;
-  text-decoration: none;
-  outline: none;
-  border: var(--border);
-  color: var(--color);
-  background-color: var(--background);
-  transition: background-color 0.2s ease, outline 0.2s ease;
-
-  ${tw`sw-inline-flex sw-items-center`}
-  ${tw`sw-h-control`}
-  ${tw`sw-body-sm-highlight`}
-  ${tw`sw-py-2 sw-px-4`}
-  ${tw`sw-rounded-2`}
-  ${tw`sw-cursor-pointer`}
-
-  &:hover {
-    color: var(--color);
-    background-color: var(--backgroundHover);
-  }
-
-  &:focus,
-  &:active {
-    color: var(--color);
-    outline: ${themeBorder('focus', 'var(--focus)')(props)};
-  }
-
-  &:disabled,
-  &:disabled:hover {
-    color: ${themeContrast('buttonDisabled')(props)};
-    background-color: ${themeColor('buttonDisabled')(props)};
-    border: ${themeBorder('default', 'buttonDisabledBorder')(props)};
-
-    ${tw`sw-cursor-not-allowed`}
-  }
-
-  & > svg {
-    ${tw`sw-mr-1`}
-  }
-`;
-
-const BaseButtonLink = styled(BaseLink)`
-  ${buttonStyle}
-`;
-
-const BaseButton = styled.button`
-  ${buttonStyle}
-
-  /* Workaround for tooltips issue with onMouseLeave in disabled buttons: https://github.com/facebook/react/issues/4251 */
-  & [disabled] {
-    ${tw`sw-pointer-events-none`};
-  }
-`;
-
-const PrimaryStyle = (props: ThemedProps) => css`
-  background: ${themeColor('button')(props)};
-  backgroundhover: ${themeColor('buttonHover')(props)};
-  color: ${themeContrast('primary')(props)};
-  focus: ${themeColor('button', OPACITY_20_PERCENT)(props)};
-  border: ${themeBorder('default', 'transparent')(props)};
-`;
-
-export const ButtonPrimary: React.FC<ButtonProps> = styled(Button)`
-  ${PrimaryStyle}
-`;
-
-export const DownloadButton = styled.a`
-  ${buttonStyle}
-  ${PrimaryStyle}
-  &:hover {
-    border-bottom-color: transparent;
-  }
-`;
-
-export const ButtonSecondary: React.FC<ButtonProps> = styled(Button)`
-  --background: ${themeColor('buttonSecondary')};
-  --backgroundHover: ${themeColor('buttonSecondaryHover')};
-  --color: ${themeContrast('buttonSecondary')};
-  --focus: ${themeColor('buttonSecondaryBorder', OPACITY_20_PERCENT)};
-  --border: ${themeBorder('default', 'buttonSecondaryBorder')};
-
-  &:hover,
-  &:active,
-  &:focus {
-    border-color: ${themeColor('buttonSecondaryBorder')};
-  }
-`;
-
-export const DangerButtonPrimary: React.FC<ButtonProps> = styled(Button)`
-  --background: ${themeColor('dangerButton')};
-  --backgroundHover: ${themeColor('dangerButtonHover')};
-  --color: ${themeContrast('dangerButton')};
-  --focus: ${themeColor('dangerButtonFocus', OPACITY_20_PERCENT)};
-  --border: ${themeBorder('default', 'transparent')};
-`;
-
-export const DangerButtonSecondary: React.FC<ButtonProps> = styled(Button)`
-  --background: ${themeColor('dangerButtonSecondary')};
-  --backgroundHover: ${themeColor('dangerButtonSecondaryHover')};
-  --color: ${themeContrast('dangerButtonSecondary')};
-  --focus: ${themeColor('dangerButtonSecondaryFocus', OPACITY_20_PERCENT)};
-  --border: ${themeBorder('default', 'dangerButtonSecondaryBorder')};
-
-  &:hover,
-  &:active,
-  &:focus {
-    border-color: ${themeColor('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 function ThirdPartyButton({ children, iconPath, name, ...buttonProps }: ThirdPartyProps) {
-  const size = 16;
-  return (
-    <ThirdPartyButtonStyled {...buttonProps}>
-      <img alt={name} className="sw-mr-1" height={size} src={iconPath} width={size} />
-      {children}
-    </ThirdPartyButtonStyled>
-  );
-}
-
-const ThirdPartyButtonStyled: React.FC<ButtonProps> = styled(Button)`
-  --background: ${themeColor('thirdPartyButton')};
-  --backgroundHover: ${themeColor('thirdPartyButtonHover')};
-  --color: ${themeContrast('thirdPartyButton')};
-  --focus: ${themeColor('thirdPartyButtonBorder', OPACITY_20_PERCENT)};
-  --border: ${themeBorder('default', 'thirdPartyButtonBorder')};
-`;
-
-export const BareButton = styled.button`
-  all: unset;
-  cursor: pointer;
-
-  &:focus-visible {
-    background-color: ${themeColor('dropdownMenuHover')};
-  }
-`;
-
-interface CodeViewerExpanderProps {
-  direction: 'UP' | 'DOWN';
-}
-
-export const CodeViewerExpander = styled(BareButton)<CodeViewerExpanderProps>`
-  ${tw`sw-flex sw-items-center sw-gap-2`}
-  ${tw`sw-px-2 sw-py-1`}
-  ${tw`sw-code`}
-  ${tw`sw-w-full`}
-  ${tw`sw-box-border`}
-
-  color: ${themeContrast('codeLineEllipsis')};
-  background-color: ${themeColor('codeLineEllipsis')};
-
-  &:hover {
-    color: ${themeContrast('codeLineEllipsisHover')};
-    background-color: ${themeColor('codeLineEllipsisHover')};
-  }
-
-  border-top: ${(props) =>
-    props.direction === 'DOWN' ? themeBorder('default', 'codeLineBorder') : 'none'};
-
-  border-bottom: ${(props) =>
-    props.direction === 'UP' ? themeBorder('default', 'codeLineBorder') : 'none'};
-`;
-
-export const IssueIndicatorButton = styled(BareButton)`
-  color: ${themeColor('codeLineMeta')};
-  text-decoration: none;
-
-  ${tw`sw-whitespace-nowrap`}
-`;
-
-export const DuplicationBlock = styled(BareButton)`
-  background-color: ${themeColor('codeLineDuplication')};
-  outline: none;
-
-  ${tw`sw-block`}
-  ${tw`sw-w-1 sw-h-full`}
-  ${tw`sw-ml-1/2`}
-  ${tw`sw-cursor-pointer`}
-`;
-
-export const LineSCMStyled = styled(BareButton)`
-  outline: none;
-
-  ${tw`sw-pr-2`}
-  ${tw`sw-truncate`}
-  ${tw`sw-whitespace-nowrap`}
-  ${tw`sw-cursor-pointer`}
-  ${tw`sw-w-full sw-h-full`}
-
-&:hover {
-    color: ${themeColor('codeLineMetaHover')};
-  }
-`;
diff --git a/server/sonar-web/design-system/src/components/buttons/BareButtons.tsx b/server/sonar-web/design-system/src/components/buttons/BareButtons.tsx
new file mode 100644 (file)
index 0000000..2d3842d
--- /dev/null
@@ -0,0 +1,89 @@
+/*
+ * 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 tw from 'twin.macro';
+import { themeBorder, themeColor, themeContrast } from '../../helpers';
+
+export const BareButton = styled.button`
+  all: unset;
+  cursor: pointer;
+
+  &:focus-visible {
+    background-color: ${themeColor('dropdownMenuHover')};
+  }
+`;
+
+interface CodeViewerExpanderProps {
+  direction: 'UP' | 'DOWN';
+}
+
+export const CodeViewerExpander = styled(BareButton)<CodeViewerExpanderProps>`
+  ${tw`sw-flex sw-items-center sw-gap-2`}
+  ${tw`sw-px-2 sw-py-1`}
+  ${tw`sw-code`}
+  ${tw`sw-w-full`}
+  ${tw`sw-box-border`}
+
+  color: ${themeContrast('codeLineEllipsis')};
+  background-color: ${themeColor('codeLineEllipsis')};
+
+  &:hover {
+    color: ${themeContrast('codeLineEllipsisHover')};
+    background-color: ${themeColor('codeLineEllipsisHover')};
+  }
+
+  border-top: ${(props) =>
+    props.direction === 'DOWN' ? themeBorder('default', 'codeLineBorder') : 'none'};
+
+  border-bottom: ${(props) =>
+    props.direction === 'UP' ? themeBorder('default', 'codeLineBorder') : 'none'};
+`;
+
+export const IssueIndicatorButton = styled(BareButton)`
+  color: ${themeColor('codeLineMeta')};
+  text-decoration: none;
+
+  ${tw`sw-whitespace-nowrap`}
+`;
+
+export const DuplicationBlock = styled(BareButton)`
+  background-color: ${themeColor('codeLineDuplication')};
+  outline: none;
+
+  ${tw`sw-block`}
+  ${tw`sw-w-1 sw-h-full`}
+  ${tw`sw-ml-1/2`}
+  ${tw`sw-cursor-pointer`}
+`;
+
+export const LineSCMStyled = styled(BareButton)`
+  outline: none;
+
+  ${tw`sw-pr-2`}
+  ${tw`sw-truncate`}
+  ${tw`sw-whitespace-nowrap`}
+  ${tw`sw-cursor-pointer`}
+  ${tw`sw-w-full sw-h-full`}
+
+&:hover {
+    color: ${themeColor('codeLineMetaHover')};
+  }
+`;
diff --git a/server/sonar-web/design-system/src/components/buttons/Button.tsx b/server/sonar-web/design-system/src/components/buttons/Button.tsx
new file mode 100644 (file)
index 0000000..3a969b0
--- /dev/null
@@ -0,0 +1,163 @@
+/*
+ * 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 { css } from '@emotion/react';
+import styled from '@emotion/styled';
+import React from 'react';
+import tw from 'twin.macro';
+import { themeBorder, themeColor, themeContrast } from '../../helpers/theme';
+import { ThemedProps } from '../../types/theme';
+import { BaseLink, LinkProps } from '../Link';
+
+type AllowedButtonAttributes = Pick<
+  React.ButtonHTMLAttributes<HTMLButtonElement>,
+  'aria-label' | 'autoFocus' | 'id' | 'name' | 'role' | 'style' | 'title' | 'type' | 'form'
+>;
+
+export interface ButtonProps extends AllowedButtonAttributes {
+  children?: React.ReactNode;
+  className?: string;
+  disabled?: boolean;
+  download?: string;
+  icon?: React.ReactNode;
+  innerRef?: React.Ref<HTMLButtonElement>;
+  isExternal?: LinkProps['isExternal'];
+  onClick?: (event?: React.MouseEvent<HTMLButtonElement | HTMLAnchorElement>) => unknown;
+
+  preventDefault?: boolean;
+  reloadDocument?: LinkProps['reloadDocument'];
+  stopPropagation?: boolean;
+  target?: LinkProps['target'];
+  to?: LinkProps['to'];
+}
+
+export class Button extends React.PureComponent<ButtonProps> {
+  handleClick = (event: React.MouseEvent<HTMLButtonElement | HTMLAnchorElement>) => {
+    const { disabled, onClick, stopPropagation = false, type } = this.props;
+    const { preventDefault = type !== 'submit' } = this.props;
+
+    if (preventDefault || disabled) {
+      event.preventDefault();
+    }
+
+    if (stopPropagation) {
+      event.stopPropagation();
+    }
+
+    if (onClick && !disabled) {
+      onClick(event);
+    }
+  };
+
+  render() {
+    const {
+      children,
+      disabled,
+      icon,
+      innerRef,
+      onClick,
+      preventDefault,
+      stopPropagation,
+      to,
+      type = 'button',
+      ...htmlProps
+    } = this.props;
+
+    const props = {
+      ...htmlProps,
+      'aria-disabled': disabled,
+      disabled,
+      type,
+    };
+
+    if (to) {
+      return (
+        <BaseButtonLink {...props} onClick={onClick} to={to}>
+          {icon}
+          {children}
+        </BaseButtonLink>
+      );
+    }
+
+    return (
+      <BaseButton {...props} onClick={this.handleClick} ref={innerRef}>
+        {icon}
+        {children}
+      </BaseButton>
+    );
+  }
+}
+
+export const buttonStyle = (props: ThemedProps) => css`
+  box-sizing: border-box;
+  text-decoration: none;
+  outline: none;
+  border: var(--border);
+  color: var(--color);
+  background-color: var(--background);
+  transition: background-color 0.2s ease, outline 0.2s ease;
+
+  ${tw`sw-inline-flex sw-items-center`}
+  ${tw`sw-h-control`}
+  ${tw`sw-body-sm-highlight`}
+  ${tw`sw-py-2 sw-px-4`}
+  ${tw`sw-rounded-2`}
+  ${tw`sw-cursor-pointer`}
+
+  &:hover {
+    color: var(--color);
+    background-color: var(--backgroundHover);
+  }
+
+  &:focus,
+  &:active {
+    color: var(--color);
+    outline: ${themeBorder('focus', 'var(--focus)')(props)};
+  }
+
+  &:disabled,
+  &:disabled:hover {
+    color: ${themeContrast('buttonDisabled')(props)};
+    background-color: ${themeColor('buttonDisabled')(props)};
+    border: ${themeBorder('default', 'buttonDisabledBorder')(props)};
+
+    ${tw`sw-cursor-not-allowed`}
+  }
+
+  & > svg {
+    ${tw`sw-mr-1`}
+  }
+`;
+
+const BaseButtonLink = styled(BaseLink)`
+  ${buttonStyle}
+`;
+
+const BaseButton = styled.button`
+  ${buttonStyle}
+
+  /*
+   Workaround for tooltips issue with onMouseLeave in disabled buttons: 
+   https://github.com/facebook/react/issues/4251 
+  */
+  & [disabled] {
+    ${tw`sw-pointer-events-none`};
+  }
+`;
diff --git a/server/sonar-web/design-system/src/components/buttons/ButtonPrimary.tsx b/server/sonar-web/design-system/src/components/buttons/ButtonPrimary.tsx
new file mode 100644 (file)
index 0000000..1174565
--- /dev/null
@@ -0,0 +1,37 @@
+/*
+ * 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 { css } from '@emotion/react';
+import styled from '@emotion/styled';
+import { OPACITY_20_PERCENT, themeBorder, themeColor, themeContrast } from '../../helpers';
+import { ThemedProps } from '../../types';
+import { Button, ButtonProps } from './Button';
+
+export const PrimaryStyle = (props: ThemedProps) => css`
+  background: ${themeColor('button')(props)};
+  backgroundhover: ${themeColor('buttonHover')(props)};
+  color: ${themeContrast('primary')(props)};
+  focus: ${themeColor('button', OPACITY_20_PERCENT)(props)};
+  border: ${themeBorder('default', 'transparent')(props)};
+`;
+
+export const ButtonPrimary: React.FC<ButtonProps> = styled(Button)`
+  ${PrimaryStyle}
+`;
diff --git a/server/sonar-web/design-system/src/components/buttons/ButtonSecondary.tsx b/server/sonar-web/design-system/src/components/buttons/ButtonSecondary.tsx
new file mode 100644 (file)
index 0000000..8769d3f
--- /dev/null
@@ -0,0 +1,36 @@
+/*
+ * 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 { OPACITY_20_PERCENT, themeBorder, themeColor, themeContrast } from '../../helpers';
+import { Button, ButtonProps } from './Button';
+
+export const ButtonSecondary: React.FC<ButtonProps> = styled(Button)`
+  --background: ${themeColor('buttonSecondary')};
+  --backgroundHover: ${themeColor('buttonSecondaryHover')};
+  --color: ${themeContrast('buttonSecondary')};
+  --focus: ${themeColor('buttonSecondaryBorder', OPACITY_20_PERCENT)};
+  --border: ${themeBorder('default', 'buttonSecondaryBorder')};
+
+  &:hover,
+  &:active,
+  &:focus {
+    border-color: ${themeColor('buttonSecondaryBorder')};
+  }
+`;
diff --git a/server/sonar-web/design-system/src/components/buttons/DangerButtonPrimary.tsx b/server/sonar-web/design-system/src/components/buttons/DangerButtonPrimary.tsx
new file mode 100644 (file)
index 0000000..d954a53
--- /dev/null
@@ -0,0 +1,31 @@
+/*
+ * 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 { OPACITY_20_PERCENT, themeBorder, themeColor, themeContrast } from '../../helpers';
+import { Button, ButtonProps } from './Button';
+
+export const DangerButtonPrimary: React.FC<ButtonProps> = styled(Button)`
+  --background: ${themeColor('dangerButton')};
+  --backgroundHover: ${themeColor('dangerButtonHover')};
+  --color: ${themeContrast('dangerButton')};
+  --focus: ${themeColor('dangerButtonFocus', OPACITY_20_PERCENT)};
+  --border: ${themeBorder('default', 'transparent')};
+`;
diff --git a/server/sonar-web/design-system/src/components/buttons/DangerButtonSecondary.tsx b/server/sonar-web/design-system/src/components/buttons/DangerButtonSecondary.tsx
new file mode 100644 (file)
index 0000000..0decc17
--- /dev/null
@@ -0,0 +1,37 @@
+/*
+ * 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 { OPACITY_20_PERCENT, themeBorder, themeColor, themeContrast } from '../../helpers';
+import { Button, ButtonProps } from './Button';
+
+export const DangerButtonSecondary: React.FC<ButtonProps> = styled(Button)`
+  --background: ${themeColor('dangerButtonSecondary')};
+  --backgroundHover: ${themeColor('dangerButtonSecondaryHover')};
+  --color: ${themeContrast('dangerButtonSecondary')};
+  --focus: ${themeColor('dangerButtonSecondaryFocus', OPACITY_20_PERCENT)};
+  --border: ${themeBorder('default', 'dangerButtonSecondaryBorder')};
+
+  &:hover,
+  &:active,
+  &:focus {
+    border-color: ${themeColor('dangerButtonSecondaryBorder')};
+  }
+`;
diff --git a/server/sonar-web/design-system/src/components/buttons/DownloadButton.tsx b/server/sonar-web/design-system/src/components/buttons/DownloadButton.tsx
new file mode 100644 (file)
index 0000000..68f8c1e
--- /dev/null
@@ -0,0 +1,30 @@
+/*
+ * 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 { buttonStyle } from './Button';
+import { PrimaryStyle } from './ButtonPrimary';
+
+export const DownloadButton = styled.a`
+  ${buttonStyle}
+  ${PrimaryStyle}
+  &:hover {
+    border-bottom-color: transparent;
+  }
+`;
diff --git a/server/sonar-web/design-system/src/components/buttons/ThirdPartyButton.tsx b/server/sonar-web/design-system/src/components/buttons/ThirdPartyButton.tsx
new file mode 100644 (file)
index 0000000..86a9e0b
--- /dev/null
@@ -0,0 +1,48 @@
+/*
+ * 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 React from 'react';
+import { OPACITY_20_PERCENT } from '../../helpers/constants';
+import { themeBorder, themeColor, themeContrast } from '../../helpers/theme';
+import { Button, ButtonProps } from './Button';
+
+interface ThirdPartyProps extends Omit<ButtonProps, 'Icon'> {
+  iconPath: string;
+  name: string;
+}
+
+export function ThirdPartyButton({ children, iconPath, name, ...buttonProps }: ThirdPartyProps) {
+  const size = 16;
+  return (
+    <ThirdPartyButtonStyled {...buttonProps}>
+      <img alt={name} className="sw-mr-1" height={size} src={iconPath} width={size} />
+      {children}
+    </ThirdPartyButtonStyled>
+  );
+}
+
+const ThirdPartyButtonStyled: React.FC<ButtonProps> = styled(Button)`
+  --background: ${themeColor('thirdPartyButton')};
+  --backgroundHover: ${themeColor('thirdPartyButtonHover')};
+  --color: ${themeContrast('thirdPartyButton')};
+  --focus: ${themeColor('thirdPartyButtonBorder', OPACITY_20_PERCENT)};
+  --border: ${themeBorder('default', 'thirdPartyButtonBorder')};
+`;
diff --git a/server/sonar-web/design-system/src/components/buttons/WrapperButton.tsx b/server/sonar-web/design-system/src/components/buttons/WrapperButton.tsx
new file mode 100644 (file)
index 0000000..64d86fc
--- /dev/null
@@ -0,0 +1,31 @@
+/*
+ * 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 { OPACITY_20_PERCENT, themeColor } from '../../helpers';
+import { Button, ButtonProps } from './Button';
+
+export const WrapperButton: React.FC<ButtonProps> = styled(Button)`
+  --background: none;
+  --backgroundHover: none;
+  --color: none;
+  --focus: ${themeColor('button', OPACITY_20_PERCENT)};
+  --border: none;
+`;
diff --git a/server/sonar-web/design-system/src/components/buttons/__tests__/buttons-test.tsx b/server/sonar-web/design-system/src/components/buttons/__tests__/buttons-test.tsx
new file mode 100644 (file)
index 0000000..e8e0007
--- /dev/null
@@ -0,0 +1,41 @@
+/*
+ * 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 { render } from '../../../helpers/testUtils';
+import { CodeViewerExpander } from '../BareButtons';
+
+it('renders CodeViewerExpander correctly when direction is UP', () => {
+  render(<CodeViewerExpander direction="UP">Hello</CodeViewerExpander>);
+  const content = screen.getByText('Hello');
+  expect(content).toHaveStyle({
+    'border-top': 'none',
+    'border-bottom': '1px solid rgb(221,221,221)',
+  });
+});
+
+it('renders CodeViewerExpander correctly when direction is DOWN', () => {
+  render(<CodeViewerExpander direction="DOWN">Hello</CodeViewerExpander>);
+  const content = screen.getByText('Hello');
+  expect(content).toHaveStyle({
+    'border-bottom': 'none',
+    'border-top': '1px solid rgb(221,221,221)',
+  });
+});
diff --git a/server/sonar-web/design-system/src/components/buttons/index.ts b/server/sonar-web/design-system/src/components/buttons/index.ts
new file mode 100644 (file)
index 0000000..a813c49
--- /dev/null
@@ -0,0 +1,47 @@
+/*
+ * 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.
+ */
+/*
+ * 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.
+ */
+export * from './BareButtons';
+export * from './Button';
+export * from './ButtonPrimary';
+export * from './ButtonSecondary';
+export * from './DangerButtonPrimary';
+export * from './DangerButtonSecondary';
+export * from './DownloadButton';
+export * from './ThirdPartyButton';
+export * from './WrapperButton';
index 1a1f1db8ea6072e0a13d17bad0f5a690426c681e..d5bb51707e8ffbd669c09d3887a7bf878ad25858 100644 (file)
@@ -25,15 +25,11 @@ export { BarChart } from './BarChart';
 export { Breadcrumbs } from './Breadcrumbs';
 export * from './BubbleChart';
 export * from './Card';
-export * from './Checkbox';
 export * from './CodeSnippet';
 export * from './CodeSyntaxHighlighter';
 export * from './ColorsLegend';
 export * from './CoverageIndicator';
-export * from './DatePicker';
-export * from './DateRangePicker';
 export { DeferredSpinner } from './DeferredSpinner';
-export * from './DiscreetSelect';
 export { ActionsDropdown, Dropdown } from './Dropdown';
 export * from './DropdownMenu';
 export { DropdownToggler } from './DropdownToggler';
@@ -44,17 +40,12 @@ export * from './FacetItem';
 export { FailedQGConditionLink } from './FailedQGConditionLink';
 export { FlagMessage } from './FlagMessage';
 export * from './FlowStep';
-export * from './FormField';
 export * from './GenericAvatar';
 export * from './HighlightedSection';
 export { Histogram } from './Histogram';
 export { HotspotRating } from './HotspotRating';
 export * from './HtmlFormatter';
 export { IllustratedSelectionCard } from './IlllustredSelectionCard';
-export * from './InputField';
-export * from './InputMultiSelect';
-export { InputSearch } from './InputSearch';
-export * from './InputSelect';
 export * from './InteractiveIcon';
 export * from './IssueMessageHighlighting';
 export * from './KeyboardHint';
@@ -65,15 +56,11 @@ export * from './MainAppBar';
 export * from './MainMenu';
 export * from './MainMenuItem';
 export * from './MetricsRatingBadge';
-export * from './MultiSelectMenu';
 export * from './NavBarTabs';
 export * from './NewCodeLegend';
 export * from './OutsideClickHandler';
 export { QualityGateIndicator } from './QualityGateIndicator';
-export * from './RadioButton';
 export * from './SearchHighlighter';
-export * from './SearchSelect';
-export * from './SearchSelectDropdown';
 export * from './SelectionCard';
 export * from './Separator';
 export * from './SizeIndicator';
@@ -99,6 +86,7 @@ export * from './code-line/LineStyles';
 export * from './code-line/LineToken';
 export * from './code-line/LineWrapper';
 export * from './icons';
+export * from './input';
 export * from './layouts';
 export * from './modal/Modal';
 export * from './popups';
diff --git a/server/sonar-web/design-system/src/components/input/Checkbox.tsx b/server/sonar-web/design-system/src/components/input/Checkbox.tsx
new file mode 100644 (file)
index 0000000..707cae6
--- /dev/null
@@ -0,0 +1,178 @@
+/*
+ * 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 React from 'react';
+import tw from 'twin.macro';
+import { themeBorder, themeColor, themeContrast } from '../../helpers/theme';
+import { DeferredSpinner } from '../DeferredSpinner';
+import { CheckIcon } from '../icons/CheckIcon';
+import { CustomIcon } from '../icons/Icon';
+
+interface Props {
+  checked: boolean;
+  children?: React.ReactNode;
+  className?: string;
+  disabled?: boolean;
+  id?: string;
+  label?: string;
+  loading?: boolean;
+  onCheck: (checked: boolean, id?: string) => void;
+  onClick?: (event: React.MouseEvent<HTMLInputElement>) => void;
+  onFocus?: VoidFunction;
+  right?: boolean;
+  thirdState?: boolean;
+  title?: string;
+}
+
+export function Checkbox({
+  checked,
+  disabled,
+  children,
+  className,
+  id,
+  label,
+  loading = false,
+  onCheck,
+  onFocus,
+  onClick,
+  right,
+  thirdState = false,
+  title,
+}: Props) {
+  const handleChange = () => {
+    if (!disabled) {
+      onCheck(!checked, id);
+    }
+  };
+
+  return (
+    <CheckboxContainer className={className} disabled={disabled}>
+      {right && children}
+      <AccessibleCheckbox
+        aria-label={label ?? title}
+        checked={checked}
+        disabled={disabled ?? loading}
+        id={id}
+        onChange={handleChange}
+        onClick={onClick}
+        onFocus={onFocus}
+        type="checkbox"
+      />
+      <DeferredSpinner loading={loading}>
+        <StyledCheckbox aria-hidden data-clickable="true" title={title}>
+          <CheckboxIcon checked={checked} thirdState={thirdState} />
+        </StyledCheckbox>
+      </DeferredSpinner>
+      {!right && children}
+    </CheckboxContainer>
+  );
+}
+
+interface CheckIconProps {
+  checked?: boolean;
+  thirdState?: boolean;
+}
+
+function CheckboxIcon({ checked, thirdState }: CheckIconProps) {
+  if (checked && thirdState) {
+    return (
+      <CustomIcon>
+        <rect fill="currentColor" height="2" rx="1" width="50%" x="4" y="7" />
+      </CustomIcon>
+    );
+  } else if (checked) {
+    return <CheckIcon fill="currentColor" />;
+  }
+  return null;
+}
+
+const CheckboxContainer = styled.label<{ disabled?: boolean }>`
+  color: ${themeContrast('backgroundSecondary')};
+  user-select: none;
+
+  ${tw`sw-inline-flex sw-items-center`};
+
+  &:hover {
+    ${tw`sw-cursor-pointer`}
+  }
+
+  &:disabled {
+    color: ${themeContrast('checkboxDisabled')};
+    ${tw`sw-cursor-not-allowed`}
+  }
+`;
+
+export const StyledCheckbox = styled.span`
+  border: ${themeBorder('default', 'primary')};
+  color: ${themeContrast('primary')};
+
+  ${tw`sw-w-4 sw-h-4`};
+  ${tw`sw-rounded-1/2`};
+  ${tw`sw-box-border`}
+  ${tw`sw-inline-flex sw-items-center sw-justify-center`};
+`;
+
+export const AccessibleCheckbox = styled.input`
+  // Following css makes the checkbox accessible and invisible
+  border: 0;
+  clip: rect(0 0 0 0);
+  clip-path: inset(50%);
+  height: 1px;
+  overflow: hidden;
+  padding: 0;
+  white-space: nowrap;
+  width: 1px;
+  appearance: none;
+
+  &:focus,
+  &:active {
+    &:not(:disabled) + ${StyledCheckbox} {
+      outline: ${themeBorder('focus', 'primary')};
+    }
+  }
+
+  &:checked {
+    & + ${StyledCheckbox} {
+      background: ${themeColor('primary')};
+    }
+    &:disabled + ${StyledCheckbox} {
+      background: ${themeColor('checkboxDisabledChecked')};
+    }
+  }
+
+  &:hover {
+    &:not(:disabled) + ${StyledCheckbox} {
+      background: ${themeColor('checkboxHover')};
+      border: ${themeBorder('default', 'primary')};
+    }
+
+    &:checked:not(:disabled) + ${StyledCheckbox} {
+      background: ${themeColor('checkboxCheckedHover')};
+      border: ${themeBorder('default', 'checkboxCheckedHover')};
+    }
+  }
+
+  &:disabled + ${StyledCheckbox} {
+    background: ${themeColor('checkboxDisabled')};
+    color: ${themeColor('checkboxDisabled')};
+    border: ${themeBorder('default', 'checkboxDisabledChecked')};
+  }
+`;
diff --git a/server/sonar-web/design-system/src/components/input/DatePicker.tsx b/server/sonar-web/design-system/src/components/input/DatePicker.tsx
new file mode 100644 (file)
index 0000000..fc8f1e7
--- /dev/null
@@ -0,0 +1,416 @@
+/*
+ * 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 {
+  format,
+  getYear,
+  isSameMonth,
+  isSameYear,
+  setMonth,
+  setYear,
+  startOfMonth,
+} from 'date-fns';
+import { range } from 'lodash';
+import * as React from 'react';
+import {
+  ActiveModifiers,
+  CaptionProps,
+  Matcher,
+  DayPicker as OriginalDayPicker,
+  useNavigation as useCalendarNavigation,
+  useDayPicker,
+} from 'react-day-picker';
+import tw from 'twin.macro';
+import { PopupPlacement, PopupZLevel, themeBorder, themeColor, themeContrast } from '../../helpers';
+import { InputSizeKeys } from '../../types/theme';
+import EscKeydownHandler from '../EscKeydownHandler';
+import { FocusOutHandler } from '../FocusOutHandler';
+import { InteractiveIcon } from '../InteractiveIcon';
+import { OutsideClickHandler } from '../OutsideClickHandler';
+import { CalendarIcon, ChevronLeftIcon, ChevronRightIcon } from '../icons';
+import { CloseIcon } from '../icons/CloseIcon';
+import { Popup } from '../popups';
+import { InputField } from './InputField';
+import { InputSelect } from './InputSelect';
+
+// When no minDate is given, year dropdown will show year options up to PAST_MAX_YEARS in the past
+const YEARS_TO_DISPLAY = 10;
+const MONTHS_IN_A_YEAR = 12;
+
+interface Props {
+  alignRight?: boolean;
+  ariaNextMonthLabel: string;
+  ariaPreviousMonthLabel: string;
+  className?: string;
+  clearButtonLabel: string;
+  currentMonth?: Date;
+  highlightFrom?: Date;
+  highlightTo?: Date;
+  id?: string;
+  inputClassName?: string;
+  inputRef?: React.Ref<HTMLInputElement>;
+  maxDate?: Date;
+  minDate?: Date;
+  name?: string;
+  onChange: (date: Date | undefined) => void;
+  placeholder: string;
+  showClearButton?: boolean;
+  size?: InputSizeKeys;
+  value?: Date;
+  valueFormatter?: (date?: Date) => string;
+  zLevel?: PopupZLevel;
+}
+
+interface State {
+  currentMonth: Date;
+  lastHovered?: Date;
+  open: boolean;
+}
+
+function formatWeekdayName(date: Date) {
+  return format(date, 'EEE'); // Short weekday name, e.g. Wed, Thu
+}
+
+export class DatePicker extends React.PureComponent<Props, State> {
+  constructor(props: Props) {
+    super(props);
+
+    this.state = { currentMonth: props.value ?? props.currentMonth ?? new Date(), open: false };
+  }
+
+  handleResetClick = () => {
+    this.closeCalendar();
+    this.props.onChange(undefined);
+  };
+
+  openCalendar = () => {
+    this.setState({
+      currentMonth: this.props.value ?? this.props.currentMonth ?? new Date(),
+      lastHovered: undefined,
+      open: true,
+    });
+  };
+
+  closeCalendar = () => {
+    this.setState({ open: false });
+  };
+
+  handleDayClick = (day: Date, modifiers: ActiveModifiers) => {
+    if (!modifiers.disabled) {
+      this.closeCalendar();
+      this.props.onChange(day);
+    }
+  };
+
+  handleDayMouseEnter = (day: Date, modifiers: ActiveModifiers) => {
+    this.setState({ lastHovered: modifiers.disabled ? undefined : day });
+  };
+
+  render() {
+    const {
+      alignRight,
+      ariaNextMonthLabel,
+      ariaPreviousMonthLabel,
+      clearButtonLabel,
+      highlightFrom,
+      highlightTo,
+      inputRef,
+      minDate,
+      maxDate = new Date(),
+      value: selectedDay,
+      name,
+      className,
+      inputClassName,
+      id,
+      placeholder,
+      showClearButton = true,
+      valueFormatter = (date?: Date) => (date ? format(date, 'MMM d, yyyy') : ''),
+      size,
+      zLevel = PopupZLevel.Global,
+    } = this.props;
+    const { lastHovered, currentMonth, open } = this.state;
+
+    // Infer start and end dropdown year from min/max dates, if set
+    const fromYear = minDate ? minDate.getFullYear() : new Date().getFullYear() - YEARS_TO_DISPLAY;
+    const toYear = maxDate.getFullYear();
+
+    const selectedDays = selectedDay ? [selectedDay] : [];
+    let highlighted: Matcher = false;
+    const lastHoveredOrValue = lastHovered ?? selectedDay;
+
+    if (highlightFrom && lastHoveredOrValue) {
+      highlighted = { from: highlightFrom, to: lastHoveredOrValue };
+      selectedDays.push(highlightFrom);
+    }
+
+    if (highlightTo && lastHoveredOrValue) {
+      highlighted = { from: lastHoveredOrValue, to: highlightTo };
+      selectedDays.push(highlightTo);
+    }
+
+    return (
+      <FocusOutHandler onFocusOut={this.closeCalendar}>
+        <OutsideClickHandler onClickOutside={this.closeCalendar}>
+          <EscKeydownHandler onKeydown={this.closeCalendar}>
+            <Popup
+              allowResizing
+              className="sw-overflow-visible" //Necessary for the month & year selectors
+              overlay={
+                open ? (
+                  <div className={classNames('sw-p-2')}>
+                    <DayPicker
+                      captionLayout="dropdown-buttons"
+                      className="sw-body-sm"
+                      components={{
+                        Caption: getCustomCalendarNavigation({
+                          ariaNextMonthLabel,
+                          ariaPreviousMonthLabel,
+                        }),
+                      }}
+                      disabled={{ after: maxDate, before: minDate }}
+                      formatters={{
+                        formatWeekdayName,
+                      }}
+                      fromYear={fromYear}
+                      mode="default"
+                      modifiers={{ highlighted }}
+                      modifiersClassNames={{ highlighted: 'rdp-highlighted' }}
+                      month={currentMonth}
+                      onDayClick={this.handleDayClick}
+                      onDayMouseEnter={this.handleDayMouseEnter}
+                      onMonthChange={(currentMonth) => {
+                        this.setState({ currentMonth });
+                      }}
+                      selected={selectedDays}
+                      toYear={toYear}
+                      weekStartsOn={1}
+                    />
+                  </div>
+                ) : null
+              }
+              placement={alignRight ? PopupPlacement.BottomRight : PopupPlacement.BottomLeft}
+              zLevel={zLevel}
+            >
+              <span
+                className={classNames('sw-relative sw-inline-block sw-cursor-pointer', className)}
+              >
+                <StyledInputField
+                  aria-label={placeholder}
+                  className={classNames(inputClassName, {
+                    'is-filled': selectedDay !== undefined && showClearButton,
+                  })}
+                  id={id}
+                  name={name}
+                  onClick={this.openCalendar}
+                  onFocus={this.openCalendar}
+                  placeholder={placeholder}
+                  readOnly
+                  ref={inputRef}
+                  size={size}
+                  title={valueFormatter(selectedDay)}
+                  type="text"
+                  value={valueFormatter(selectedDay)}
+                />
+
+                <StyledCalendarIcon fill="datePickerIcon" />
+
+                {selectedDay !== undefined && showClearButton && (
+                  <StyledInteractiveIcon
+                    Icon={CloseIcon}
+                    aria-label={clearButtonLabel}
+                    onClick={this.handleResetClick}
+                    size="small"
+                  />
+                )}
+              </span>
+            </Popup>
+          </EscKeydownHandler>
+        </OutsideClickHandler>
+      </FocusOutHandler>
+    );
+  }
+}
+
+const StyledCalendarIcon = styled(CalendarIcon)`
+  ${tw`sw-absolute`};
+  ${tw`sw-top-[0.625rem] sw-left-2`};
+`;
+
+const StyledInteractiveIcon = styled(InteractiveIcon)`
+  ${tw`sw-absolute`};
+  ${tw`sw-top-[0.375rem] sw-right-[0.375rem]`};
+`;
+
+const StyledInputField = styled(InputField)`
+  input[type='text']& {
+    ${tw`sw-pl-8`};
+    ${tw`sw-cursor-pointer`};
+
+    &.is-filled {
+      ${tw`sw-pr-8`};
+    }
+  }
+`;
+
+const DayPicker = styled(OriginalDayPicker)`
+  --rdp-cell-size: auto;
+  /* Ensures the month/year dropdowns do not move on click, but rdp outline is not shown */
+  --rdp-outline: 2px solid transparent;
+  --rdp-outline-selected: 2px solid transparent;
+
+  margin: 0;
+
+  .rdp-head {
+    color: ${themeContrast('datePicker')};
+  }
+
+  .rdp-day {
+    height: 28px;
+    width: 33px;
+    border-radius: 0;
+    color: ${themeContrast('datePickerDefault')};
+  }
+
+  /* Default modifiers */
+
+  .rdp-day_disabled {
+    cursor: not-allowed;
+    background: ${themeColor('datePickerDisabled')};
+    color: ${themeContrast('datePickerDisabled')};
+  }
+
+  .rdp-day:hover:not(.rdp-day_outside):not(.rdp-day_disabled):not(.rdp-day_selected) {
+    background: ${themeColor('datePickerHover')};
+    color: ${themeContrast('datePickerHover')};
+  }
+
+  .rdp-day:focus-visible {
+    outline: ${themeBorder('focus', 'inputFocus')};
+    background: inherit;
+    z-index: 1;
+  }
+
+  .rdp-day.rdp-highlighted:not(.rdp-day_selected) {
+    background: ${themeColor('datePickerRange')};
+    color: ${themeContrast('datePickerRange')};
+  }
+
+  .rdp-day_selected,
+  .rdp-day_selected:focus-visible {
+    background: ${themeColor('datePickerSelected')};
+    color: ${themeContrast('datePickerSelected')};
+  }
+`;
+
+function getCustomCalendarNavigation({
+  ariaNextMonthLabel,
+  ariaPreviousMonthLabel,
+}: {
+  ariaNextMonthLabel: string;
+  ariaPreviousMonthLabel: string;
+}) {
+  return function CalendarNavigation(props: CaptionProps) {
+    const { displayMonth } = props;
+    const { fromYear, toYear } = useDayPicker();
+    const { goToMonth, nextMonth, previousMonth } = useCalendarNavigation();
+
+    const baseDate = startOfMonth(displayMonth); // reference date
+
+    const months = range(MONTHS_IN_A_YEAR).map((month) => {
+      const monthValue = setMonth(baseDate, month);
+
+      return {
+        label: format(monthValue, 'MMM'),
+        value: monthValue,
+      };
+    });
+
+    const startYear = fromYear ?? getYear(Date.now()) - YEARS_TO_DISPLAY;
+
+    const years = range(startYear, toYear ? toYear + 1 : undefined).map((year) => {
+      const yearValue = setYear(baseDate, year);
+
+      return {
+        label: String(year),
+        value: yearValue,
+      };
+    });
+
+    return (
+      <nav className="sw-flex sw-items-center sw-justify-between sw-py-1">
+        <InteractiveIcon
+          Icon={ChevronLeftIcon}
+          aria-label={ariaPreviousMonthLabel}
+          className="sw-mr-2"
+          onClick={() => {
+            if (previousMonth) {
+              goToMonth(previousMonth);
+            }
+          }}
+          size="small"
+        />
+
+        <span data-testid="month-select">
+          <InputSelect
+            isClearable={false}
+            onChange={(value) => {
+              if (value) {
+                goToMonth(value.value);
+              }
+            }}
+            options={months}
+            size="full"
+            value={months.find((m) => isSameMonth(m.value, displayMonth))}
+          />
+        </span>
+
+        <span data-testid="year-select">
+          <InputSelect
+            className="sw-ml-1"
+            data-testid="year-select"
+            isClearable={false}
+            onChange={(value) => {
+              if (value) {
+                goToMonth(value.value);
+              }
+            }}
+            options={years}
+            size="full"
+            value={years.find((y) => isSameYear(y.value, displayMonth))}
+          />
+        </span>
+
+        <InteractiveIcon
+          Icon={ChevronRightIcon}
+          aria-label={ariaNextMonthLabel}
+          className="sw-ml-2"
+          onClick={() => {
+            if (nextMonth) {
+              goToMonth(nextMonth);
+            }
+          }}
+          size="small"
+        />
+      </nav>
+    );
+  };
+}
diff --git a/server/sonar-web/design-system/src/components/input/DateRangePicker.tsx b/server/sonar-web/design-system/src/components/input/DateRangePicker.tsx
new file mode 100644 (file)
index 0000000..f3f65ea
--- /dev/null
@@ -0,0 +1,134 @@
+/*
+ * 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 { max, min } from 'date-fns';
+import * as React from 'react';
+import { PopupZLevel } from '../../helpers';
+import { LightLabel } from '../Text';
+import { DatePicker } from './DatePicker';
+
+interface DateRange {
+  from?: Date;
+  to?: Date;
+}
+
+interface Props {
+  alignEndDateCalandarRight?: boolean;
+  ariaNextMonthLabel: string;
+  ariaPreviousMonthLabel: string;
+  className?: string;
+  clearButtonLabel: string;
+  fromLabel: string;
+  maxDate?: Date;
+  minDate?: Date;
+  onChange: (date: DateRange) => void;
+  separatorText?: string;
+  toLabel: string;
+  value?: DateRange;
+  valueFormatter?: (date?: Date) => string;
+  zLevel?: PopupZLevel;
+}
+
+export class DateRangePicker extends React.PureComponent<Props> {
+  toDateInput?: HTMLInputElement | null;
+
+  get from() {
+    return this.props.value?.from;
+  }
+
+  get to() {
+    return this.props.value?.to;
+  }
+
+  handleFromChange = (from: Date | undefined) => {
+    this.props.onChange({ from, to: this.to });
+
+    // use `setTimeout` to work around the immediate closing of the `toDateInput`
+    setTimeout(() => {
+      if (from && !this.to && this.toDateInput) {
+        this.toDateInput.focus();
+      }
+    }, 0);
+  };
+
+  handleToChange = (to: Date | undefined) => {
+    this.props.onChange({ from: this.from, to });
+  };
+
+  render() {
+    const {
+      alignEndDateCalandarRight,
+      ariaNextMonthLabel,
+      ariaPreviousMonthLabel,
+      clearButtonLabel,
+      fromLabel,
+      minDate,
+      maxDate,
+      separatorText,
+      toLabel,
+      valueFormatter,
+      zLevel,
+    } = this.props;
+
+    return (
+      <div className={classNames('sw-flex sw-items-center', this.props.className)}>
+        <DatePicker
+          ariaNextMonthLabel={ariaNextMonthLabel}
+          ariaPreviousMonthLabel={ariaPreviousMonthLabel}
+          clearButtonLabel={clearButtonLabel}
+          currentMonth={this.to}
+          data-test="from"
+          highlightTo={this.to}
+          id="date-from"
+          maxDate={maxDate && this.to ? min([maxDate, this.to]) : maxDate ?? this.to}
+          minDate={minDate}
+          onChange={this.handleFromChange}
+          placeholder={fromLabel}
+          size="full"
+          value={this.from}
+          valueFormatter={valueFormatter}
+          zLevel={zLevel}
+        />
+        <LightLabel className="sw-mx-2">{separatorText ?? '–'}</LightLabel>
+        <DatePicker
+          alignRight={alignEndDateCalandarRight}
+          ariaNextMonthLabel={ariaNextMonthLabel}
+          ariaPreviousMonthLabel={ariaPreviousMonthLabel}
+          clearButtonLabel={clearButtonLabel}
+          currentMonth={this.from}
+          data-test="to"
+          highlightFrom={this.from}
+          id="date-to"
+          inputRef={(element: HTMLInputElement | null) => {
+            this.toDateInput = element;
+          }}
+          maxDate={maxDate}
+          minDate={minDate && this.from ? max([minDate, this.from]) : minDate ?? this.from}
+          onChange={this.handleToChange}
+          placeholder={toLabel}
+          size="full"
+          value={this.to}
+          valueFormatter={valueFormatter}
+          zLevel={zLevel}
+        />
+      </div>
+    );
+  }
+}
diff --git a/server/sonar-web/design-system/src/components/input/DiscreetSelect.tsx b/server/sonar-web/design-system/src/components/input/DiscreetSelect.tsx
new file mode 100644 (file)
index 0000000..543f182
--- /dev/null
@@ -0,0 +1,125 @@
+/*
+ * 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 tw from 'twin.macro';
+import { themeBorder, themeColor, themeContrast } from '../../helpers/theme';
+import { InputSizeKeys } from '../../types/theme';
+import { InputSelect, LabelValueSelectOption } from './InputSelect';
+
+interface Props<V> {
+  className?: string;
+  components?: Parameters<typeof InputSelect>[0]['components'];
+  customValue?: JSX.Element;
+  isDisabled?: boolean;
+  menuIsOpen?: boolean;
+  onMenuClose?: () => void;
+  onMenuOpen?: () => void;
+  options: Array<LabelValueSelectOption<V>>;
+  setValue: ({ value }: LabelValueSelectOption<V>) => void;
+  size?: InputSizeKeys;
+  value: V;
+}
+
+export function DiscreetSelect<V>({
+  className,
+  customValue,
+  onMenuOpen,
+  options,
+  size = 'small',
+  setValue,
+  value,
+  ...props
+}: Props<V>) {
+  return (
+    <StyledSelect
+      className={className}
+      onChange={setValue}
+      onMenuOpen={onMenuOpen}
+      options={options}
+      placeholder={customValue}
+      size={size}
+      value={options.find((item) => item.value === value)}
+      {...props}
+    />
+  );
+}
+
+const StyledSelect = styled(InputSelect)`
+  & {
+    width: inherit !important;
+  }
+
+  & .react-select__dropdown-indicator {
+    ${tw`sw-p-0 sw-py-1`};
+  }
+
+  & .react-select__value-container {
+    ${tw`sw-p-0`};
+  }
+
+  & .react-select__menu {
+    margin: 0;
+  }
+
+  & .react-select__control {
+    height: auto;
+    min-height: inherit;
+    color: ${themeContrast('discreetBackground')};
+    background: none;
+    outline: inherit;
+    box-shadow: none;
+
+    ${tw`sw-border-none`};
+    ${tw`sw-p-0`};
+    ${tw`sw-cursor-pointer`};
+    ${tw`sw-flex sw-items-center`};
+    ${tw`sw-body-sm`};
+    ${tw`sw-select-none`};
+
+    &:hover {
+      ${tw`sw-border-none`};
+      outline: none;
+      color: ${themeColor('discreetButtonHover')};
+      border-color: inherit;
+      box-shadow: none;
+
+      & .react-select__single-value,
+      & .react-select__dropdown-indicator,
+      & .react-select__placeholder {
+        color: ${themeColor('discreetButtonHover')};
+      }
+    }
+
+    &:focus {
+      ${tw`sw-rounded-1`};
+      color: ${themeColor('discreetButtonHover')};
+      background: ${themeColor('discreetBackground')};
+      outline: ${themeBorder('focus', 'discreetFocusBorder')};
+      border-color: inherit;
+      box-shadow: none;
+    }
+  }
+
+  & .react-select__control--is-focused,
+  & .react-select__control--menu-is-open {
+    ${tw`sw-border-none`};
+  }
+`;
diff --git a/server/sonar-web/design-system/src/components/input/FormField.tsx b/server/sonar-web/design-system/src/components/input/FormField.tsx
new file mode 100644 (file)
index 0000000..3397bc4
--- /dev/null
@@ -0,0 +1,74 @@
+/*
+ * 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 { ReactNode } from 'react';
+import tw from 'twin.macro';
+import { Highlight, Note } from '../Text';
+import { RequiredIcon } from '../icons';
+
+interface Props {
+  ariaLabel?: string;
+  children: ReactNode;
+  className?: string;
+  description?: string | ReactNode;
+  help?: ReactNode;
+  htmlFor?: string;
+  id?: string;
+  label: string | ReactNode;
+  required?: boolean;
+  title?: string;
+}
+
+export function FormField({
+  children,
+  className,
+  description,
+  help,
+  id,
+  required,
+  label,
+  htmlFor,
+  title,
+  ariaLabel,
+}: Props) {
+  return (
+    <FieldWrapper className={className} id={id}>
+      <label aria-label={ariaLabel} className="sw-mb-2" htmlFor={htmlFor} title={title}>
+        <Highlight className="sw-flex sw-items-center sw-gap-2">
+          {label}
+          {required && <RequiredIcon className="sw--ml-1" />}
+          {help}
+        </Highlight>
+      </label>
+
+      {children}
+
+      {description && <Note className="sw-mt-2">{description}</Note>}
+    </FieldWrapper>
+  );
+}
+
+const FieldWrapper = styled.div`
+  ${tw`sw-flex sw-flex-col sw-w-full`}
+
+  &:not(:last-of-type) {
+    ${tw`sw-mb-6`}
+  }
+`;
diff --git a/server/sonar-web/design-system/src/components/input/InputField.tsx b/server/sonar-web/design-system/src/components/input/InputField.tsx
new file mode 100644 (file)
index 0000000..ec3ddad
--- /dev/null
@@ -0,0 +1,142 @@
+/*
+ * 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 { css } from '@emotion/react';
+import styled from '@emotion/styled';
+import { forwardRef } from 'react';
+import tw from 'twin.macro';
+import { INPUT_SIZES } from '../../helpers/constants';
+import { themeBorder, themeColor, themeContrast } from '../../helpers/theme';
+import { InputSizeKeys, ThemedProps } from '../../types/theme';
+
+interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size'> {
+  as?: React.ElementType;
+  className?: string;
+  isInvalid?: boolean;
+  isValid?: boolean;
+  size?: InputSizeKeys;
+}
+
+interface InputTextAreaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
+  className?: string;
+  isInvalid?: boolean;
+  isValid?: boolean;
+  size?: InputSizeKeys;
+}
+
+export const InputField = forwardRef<HTMLInputElement, InputProps>(
+  ({ size = 'medium', style, ...props }, ref) => {
+    return (
+      <StyledInput ref={ref} style={{ ...style, '--inputSize': INPUT_SIZES[size] }} {...props} />
+    );
+  }
+);
+InputField.displayName = 'InputField';
+
+export const InputTextArea = forwardRef<HTMLTextAreaElement, InputTextAreaProps>(
+  ({ size = 'medium', style, ...props }, ref) => {
+    return (
+      <StyledTextArea ref={ref} style={{ ...style, '--inputSize': INPUT_SIZES[size] }} {...props} />
+    );
+  }
+);
+InputTextArea.displayName = 'InputTextArea';
+
+const defaultStyle = (props: ThemedProps) => css`
+  --border: ${themeBorder('default', 'inputBorder')(props)};
+  --focusBorder: ${themeBorder('default', 'inputFocus')(props)};
+  --focusOutline: ${themeBorder('focus', 'inputFocus')(props)};
+`;
+
+const dangerStyle = (props: ThemedProps) => css`
+  --border: ${themeBorder('default', 'inputDanger')(props)};
+  --focusBorder: ${themeBorder('default', 'inputDangerFocus')(props)};
+  --focusOutline: ${themeBorder('focus', 'inputDangerFocus')(props)};
+`;
+
+const successStyle = (props: ThemedProps) => css`
+  --border: ${themeBorder('default', 'inputSuccess')(props)};
+  --focusBorder: ${themeBorder('default', 'inputSuccessFocus')(props)};
+  --focusOutline: ${themeBorder('focus', 'inputSuccessFocus')(props)};
+`;
+
+const getInputVariant = (props: ThemedProps & { isInvalid?: boolean; isValid?: boolean }) => {
+  const { isValid, isInvalid } = props;
+  if (isInvalid) {
+    return dangerStyle;
+  } else if (isValid) {
+    return successStyle;
+  }
+  return defaultStyle;
+};
+
+const baseStyle = (props: ThemedProps) => css`
+  color: ${themeContrast('inputBackground')(props)};
+  background: ${themeColor('inputBackground')(props)};
+  border: var(--border);
+  width: var(--inputSize);
+  transition: border-color 0.2s ease;
+
+  ${tw`sw-body-sm`}
+  ${tw`sw-box-border`}
+  ${tw`sw-rounded-2`}
+  ${tw`sw-px-3 sw-py-2`}
+
+  &::placeholder {
+    color: ${themeColor('inputPlaceholder')(props)};
+  }
+
+  &:hover {
+    border: var(--focusBorder);
+  }
+
+  &:active,
+  &:focus,
+  &:focus-within,
+  &:focus-visible {
+    border: var(--focusBorder);
+    outline: var(--focusOutline);
+  }
+
+  &:disabled,
+  &:disabled:hover {
+    color: ${themeContrast('inputDisabled')(props)};
+    background-color: ${themeColor('inputDisabled')(props)};
+    border: ${themeBorder('default', 'inputDisabledBorder')(props)};
+    outline: none;
+
+    ${tw`sw-cursor-not-allowed`};
+    &::placeholder {
+      color: ${themeContrast('inputDisabled')(props)};
+    }
+  }
+`;
+
+const StyledInput = styled.input`
+  input[type='text']& {
+    ${getInputVariant}
+    ${baseStyle}
+    ${tw`sw-h-control`}
+  }
+`;
+
+const StyledTextArea = styled.textarea`
+  ${getInputVariant};
+  ${baseStyle};
+`;
diff --git a/server/sonar-web/design-system/src/components/input/InputMultiSelect.tsx b/server/sonar-web/design-system/src/components/input/InputMultiSelect.tsx
new file mode 100644 (file)
index 0000000..6020898
--- /dev/null
@@ -0,0 +1,70 @@
+/*
+ * 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 { themeBorder } from '../../helpers';
+import { Badge } from '../Badge';
+import { LightLabel } from '../Text';
+import { ButtonProps, WrapperButton } from '../buttons';
+import { ChevronDownIcon } from '../icons';
+
+interface Props extends Pick<ButtonProps, 'onClick'> {
+  className?: string;
+  count?: number;
+  id?: string;
+  placeholder: string;
+  selectedLabel: string;
+}
+
+export function InputMultiSelect(props: Props) {
+  const { className, count, id, placeholder, selectedLabel } = props;
+
+  return (
+    <StyledWrapper
+      className={classNames('sw-flex sw-justify-between sw-px-2 sw-body-sm', className)}
+      id={id}
+      onClick={props.onClick}
+      role="combobox"
+    >
+      {count ? selectedLabel : <LightLabel>{placeholder}</LightLabel>}
+
+      <div>
+        {count !== undefined && count > 0 && <Badge variant="counter">{count}</Badge>}
+        <ChevronDownIcon className="sw-ml-2" />
+      </div>
+    </StyledWrapper>
+  );
+}
+
+const StyledWrapper = styled(WrapperButton)`
+  border: ${themeBorder('default', 'inputBorder')};
+
+  &:hover {
+    border: ${themeBorder('default', 'inputFocus')};
+  }
+
+  &:active,
+  &:focus,
+  &:focus-within,
+  &:focus-visible {
+    border: ${themeBorder('default', 'inputFocus')};
+    outline: ${themeBorder('focus', 'inputFocus')};
+  }
+`;
diff --git a/server/sonar-web/design-system/src/components/input/InputSearch.tsx b/server/sonar-web/design-system/src/components/input/InputSearch.tsx
new file mode 100644 (file)
index 0000000..beb1ef1
--- /dev/null
@@ -0,0 +1,261 @@
+/*
+ * 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 { debounce } from 'lodash';
+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, Spinner } from '../DeferredSpinner';
+import { InteractiveIcon } from '../InteractiveIcon';
+import { CloseIcon } from '../icons/CloseIcon';
+import { SearchIcon } from '../icons/SearchIcon';
+
+interface Props {
+  autoFocus?: boolean;
+  className?: string;
+  clearIconAriaLabel: string;
+  id?: string;
+  innerRef?: React.RefCallback<HTMLInputElement>;
+  loading?: boolean;
+  maxLength?: number;
+  minLength?: number;
+  onBlur?: React.FocusEventHandler<HTMLInputElement>;
+  onChange: (value: string) => void;
+  onFocus?: React.FocusEventHandler<HTMLInputElement>;
+  onKeyDown?: React.KeyboardEventHandler<HTMLInputElement>;
+  onMouseDown?: React.MouseEventHandler<HTMLInputElement>;
+  placeholder?: string;
+  searchInputAriaLabel?: string;
+  size?: InputSizeKeys;
+  tooShortText?: string;
+  value?: string;
+}
+
+const DEFAULT_MAX_LENGTH = 100;
+
+export function InputSearch({
+  autoFocus,
+  id,
+  className,
+  innerRef,
+  onBlur,
+  onChange,
+  onFocus,
+  onKeyDown,
+  onMouseDown,
+  placeholder,
+  loading,
+  minLength,
+  maxLength = DEFAULT_MAX_LENGTH,
+  size = 'medium',
+  value: parentValue,
+  tooShortText,
+  searchInputAriaLabel,
+  clearIconAriaLabel,
+  children,
+}: PropsWithChildren<Props>) {
+  const input = useRef<null | HTMLElement>(null);
+  const [value, setValue] = useState(parentValue ?? '');
+  const debouncedOnChange = useMemo(() => debounce(onChange, DEBOUNCE_DELAY), [onChange]);
+
+  const tooShort = isDefined(minLength) && value.length > 0 && value.length < minLength;
+  const inputClassName = classNames('js-input-search', {
+    touched: value.length > 0 && (!minLength || minLength > value.length),
+    'sw-pr-10': value.length > 0,
+  });
+
+  useEffect(() => {
+    if (parentValue !== undefined) {
+      setValue(parentValue);
+    }
+  }, [parentValue]);
+
+  useEffect(() => {
+    if (autoFocus && input.current) {
+      input.current.focus();
+    }
+  }, [autoFocus]);
+
+  const changeValue = (newValue: string) => {
+    if (newValue.length === 0 || !minLength || minLength <= newValue.length) {
+      debouncedOnChange(newValue);
+    }
+  };
+
+  const handleInputChange = (event: React.SyntheticEvent<HTMLInputElement>) => {
+    const eventValue = event.currentTarget.value;
+    setValue(eventValue);
+    changeValue(eventValue);
+  };
+
+  const handleInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
+    if (event.key === Key.Escape) {
+      event.preventDefault();
+      handleClearClick();
+    }
+    onKeyDown?.(event);
+  };
+
+  const handleClearClick = () => {
+    onChange('');
+    if (parentValue === undefined || parentValue === '') {
+      setValue('');
+    }
+    input.current?.focus();
+  };
+  const ref = (node: HTMLInputElement | null) => {
+    input.current = node;
+    innerRef?.(node);
+  };
+
+  return (
+    <InputSearchWrapper
+      className={className}
+      id={id}
+      onMouseDown={onMouseDown}
+      style={{ '--inputSize': INPUT_SIZES[size] }}
+      title={tooShort && tooShortText && isDefined(minLength) ? tooShortText : ''}
+    >
+      <StyledInputWrapper className="sw-flex sw-items-center">
+        {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 && (
+          <StyledInteractiveIcon
+            Icon={CloseIcon}
+            aria-label={clearIconAriaLabel}
+            className="it__search-box-clear"
+            onClick={handleClearClick}
+            size="small"
+          />
+        )}
+
+        {tooShort && tooShortText && isDefined(minLength) && (
+          <StyledNote className="sw-ml-1" role="note">
+            {tooShortText}
+          </StyledNote>
+        )}
+      </StyledInputWrapper>
+    </InputSearchWrapper>
+  );
+}
+
+InputSearch.displayName = 'InputSearch'; // so that tests don't see the obfuscated production name
+
+export const InputSearchWrapper = styled.div`
+  width: var(--inputSize);
+
+  ${tw`sw-relative sw-inline-block`}
+  ${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`
+  input {
+    background: ${themeColor('inputBackground')};
+    color: ${themeContrast('inputBackground')};
+    border: ${themeBorder('default', 'inputBorder')};
+
+    ${tw`sw-rounded-2`}
+    ${tw`sw-box-border`}
+    ${tw`sw-pl-10`}
+    ${tw`sw-body-sm`}
+    ${tw`sw-w-full sw-h-control`}
+
+    &::placeholder {
+      color: ${themeColor('inputPlaceholder')};
+
+      ${tw`sw-truncate`}
+    }
+
+    &:hover {
+      border: ${themeBorder('default', 'inputFocus')};
+    }
+
+    &:focus,
+    &:active {
+      border: ${themeBorder('default', 'inputFocus')};
+      outline: ${themeBorder('focus', 'inputFocus')};
+    }
+
+    &::-webkit-search-decoration,
+    &::-webkit-search-cancel-button,
+    &::-webkit-search-results-button,
+    &::-webkit-search-results-decoration {
+      ${tw`sw-hidden sw-appearance-none`}
+    }
+  }
+`;
+
+const StyledSearchIcon = styled(SearchIcon)`
+  color: ${themeColor('inputBorder')};
+  top: calc((${theme('height.control')} - ${theme('spacing.4')}) / 2);
+
+  ${tw`sw-left-3`}
+  ${tw`sw-absolute`}
+  ${tw`sw-z-normal`}
+`;
+
+export const StyledInteractiveIcon = styled(InteractiveIcon)`
+  ${tw`sw-absolute`}
+  ${tw`sw-right-2`}
+`;
+
+const StyledNote = styled.span`
+  color: ${themeColor('inputPlaceholder')};
+  top: calc(1px + ${theme('inset.2')});
+
+  ${tw`sw-absolute`}
+  ${tw`sw-left-12 sw-right-10`}
+  ${tw`sw-body-sm`}
+  ${tw`sw-text-right`}
+  ${tw`sw-truncate`}
+  ${tw`sw-pointer-events-none`}
+`;
diff --git a/server/sonar-web/design-system/src/components/input/InputSelect.tsx b/server/sonar-web/design-system/src/components/input/InputSelect.tsx
new file mode 100644 (file)
index 0000000..586ae03
--- /dev/null
@@ -0,0 +1,198 @@
+/*
+ * 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 { useTheme as themeInfo } from '@emotion/react';
+import classNames from 'classnames';
+import { omit } from 'lodash';
+import { ReactNode } from 'react';
+import ReactSelect, {
+  GroupBase,
+  Props as NamedProps,
+  OptionProps,
+  StylesConfig,
+  components,
+} from 'react-select';
+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> {
+  Icon?: ReactNode;
+  label: string;
+  value: V;
+}
+
+interface StyleExtensionProps {
+  size?: InputSizeKeys;
+}
+
+export type SelectProps<
+  V,
+  Option extends LabelValueSelectOption<V>,
+  IsMulti extends boolean = false,
+  Group extends GroupBase<Option> = GroupBase<Option>
+> = NamedProps<Option, IsMulti, Group> & StyleExtensionProps;
+
+export function IconOption<
+  V,
+  Option extends LabelValueSelectOption<V>,
+  IsMulti extends boolean = false,
+  Group extends GroupBase<Option> = GroupBase<Option>
+>(props: OptionProps<Option, IsMulti, Group>) {
+  const {
+    data: { label, Icon },
+  } = props;
+
+  return (
+    <components.Option {...props}>
+      <div className="sw-flex sw-items-center sw-gap-1">
+        {Icon}
+        <SearchHighlighter>{label}</SearchHighlighter>
+      </div>
+    </components.Option>
+  );
+}
+
+function SingleValue<
+  V,
+  Option extends LabelValueSelectOption<V>,
+  IsMulti extends boolean = false,
+  Group extends GroupBase<Option> = GroupBase<Option>
+>(props: OptionProps<Option, IsMulti, Group>) {
+  const {
+    data: { label, Icon },
+  } = props;
+
+  return (
+    <components.SingleValue {...props}>
+      <div className="sw-flex sw-items-center sw-gap-1">
+        {Icon}
+        {label}
+      </div>
+    </components.SingleValue>
+  );
+}
+
+function IndicatorsContainer<
+  V,
+  Option extends LabelValueSelectOption<V>,
+  IsMulti extends boolean = false,
+  Group extends GroupBase<Option> = GroupBase<Option>
+>(props: OptionProps<Option, IsMulti, Group>) {
+  return (
+    <components.IndicatorsContainer {...props}>
+      <div className="sw-pr-2">
+        <ChevronDownIcon />
+      </div>
+    </components.IndicatorsContainer>
+  );
+}
+
+export function InputSelect<
+  V,
+  Option extends LabelValueSelectOption<V>,
+  IsMulti extends boolean = false,
+  Group extends GroupBase<Option> = GroupBase<Option>
+>({ size = 'medium', className, ...props }: SelectProps<V, Option, IsMulti, Group>) {
+  return (
+    <ReactSelect<Option, IsMulti, Group>
+      {...omit(props, 'className', 'large')}
+      className={classNames('react-select', className)}
+      classNamePrefix="react-select"
+      classNames={{
+        container: () => 'sw-relative sw-inline-block sw-align-middle',
+        placeholder: () => 'sw-truncate sw-leading-4',
+        menu: () => 'sw-z-dropdown-menu sw-ml-1/2 sw-mt-2',
+        menuList: () => 'sw-overflow-y-auto sw-py-2 sw-max-h-[12.25rem]',
+        control: ({ isDisabled }) =>
+          classNames(
+            'sw-box-border sw-rounded-2 sw-overflow-hidden',
+            isDisabled && 'sw-pointer-events-none sw-cursor-not-allowed'
+          ),
+        option: ({ isDisabled }) =>
+          classNames(
+            'it__select-option sw-py-2 sw-px-3 sw-cursor-pointer',
+            isDisabled && 'sw-pointer-events-none sw-cursor-not-allowed'
+          ),
+        ...props.classNames,
+      }}
+      components={{
+        Option: IconOption,
+        SingleValue,
+        IndicatorsContainer,
+        IndicatorSeparator: null,
+        ...props.components,
+      }}
+      isSearchable={props.isSearchable ?? false}
+      onMenuOpen={props.onMenuOpen}
+      styles={selectStyle({ size })}
+    />
+  );
+}
+
+export function selectStyle<
+  V,
+  Option extends LabelValueSelectOption<V>,
+  IsMulti extends boolean = false,
+  Group extends GroupBase<Option> = GroupBase<Option>
+>({ size }: { size: InputSizeKeys }): StylesConfig<Option, IsMulti, Group> {
+  const theme = themeInfo();
+
+  return {
+    control: (base, { isFocused, menuIsOpen, isDisabled }) => ({
+      ...base,
+      color: themeContrast('inputBackground')({ theme }),
+      cursor: 'pointer',
+      background: themeColor('inputBackground')({ theme }),
+      transition: 'border 0.2s ease, outline 0.2s ease',
+      outline: isFocused && !menuIsOpen ? themeBorder('focus', 'inputFocus')({ theme }) : 'none',
+      ...(isDisabled && {
+        color: themeContrast('inputDisabled')({ theme }),
+        background: themeColor('inputDisabled')({ theme }),
+        border: themeBorder('default', 'inputDisabledBorder')({ theme }),
+        outline: 'none',
+      }),
+      ...(isFocused && {
+        border: themeBorder('default', 'inputBorder')({ theme }),
+      }),
+    }),
+    menu: (base) => ({
+      ...base,
+      width: INPUT_SIZES[size],
+    }),
+    option: (base, { isFocused, isSelected }) => ({
+      ...base,
+      ...((isSelected || isFocused) && {
+        background: themeColor('selectOptionSelected')({ theme }),
+        color: themeContrast('primaryLight')({ theme }),
+      }),
+    }),
+    singleValue: (base) => ({
+      ...base,
+      color: themeContrast('primaryLight')({ theme }),
+    }),
+    placeholder: (base) => ({
+      ...base,
+      color: themeContrast('inputPlaceholder')({ theme }),
+    }),
+  };
+}
diff --git a/server/sonar-web/design-system/src/components/input/MultiSelectMenu.tsx b/server/sonar-web/design-system/src/components/input/MultiSelectMenu.tsx
new file mode 100644 (file)
index 0000000..191de5d
--- /dev/null
@@ -0,0 +1,345 @@
+/*
+ * 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 { MultiSelectMenuOption } from './MultiSelectMenuOption';
+
+interface Props {
+  allowNewElements?: boolean;
+  allowSelection?: boolean;
+  clearIconAriaLabel: string;
+  createElementLabel: string;
+  elements: string[];
+  footerNode?: React.ReactNode;
+  headerNode?: React.ReactNode;
+  inputId?: string;
+  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 MultiSelectMenu 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.includes(elem) && !elements.includes(elem);
+
+  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 = '',
+      inputId,
+      clearIconAriaLabel,
+      noResultsLabel,
+      searchInputAriaLabel,
+    } = this.props;
+    const { renderLabel } = this.props as PropsWithDefault;
+
+    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
+            className="sw-mt-1"
+            clearIconAriaLabel={clearIconAriaLabel}
+            id={inputId}
+            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) => (
+              <MultiSelectMenuOption
+                active={activeElement === element}
+                createElementLabel={createElementLabel}
+                element={element}
+                key={element}
+                onHover={this.handleElementHover}
+                onSelectChange={this.handleSelectChange}
+                renderLabel={renderLabel}
+                selected
+              />
+            ))}
+          {unselectedElements.length > 0 &&
+            unselectedElements.map((element) => (
+              <MultiSelectMenuOption
+                active={activeElement === element}
+                createElementLabel={createElementLabel}
+                disabled={!allowSelection}
+                element={element}
+                key={element}
+                onHover={this.handleElementHover}
+                onSelectChange={this.handleSelectChange}
+                renderLabel={renderLabel}
+              />
+            ))}
+          {showNewElement && (
+            <MultiSelectMenuOption
+              active={activeElement === query}
+              createElementLabel={createElementLabel}
+              custom
+              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>
+    );
+  }
+}
diff --git a/server/sonar-web/design-system/src/components/input/MultiSelectMenuOption.tsx b/server/sonar-web/design-system/src/components/input/MultiSelectMenuOption.tsx
new file mode 100644 (file)
index 0000000..f801a5d
--- /dev/null
@@ -0,0 +1,80 @@
+/*
+ * 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 { identity } from 'lodash';
+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;
+  renderLabel?: (element: string) => React.ReactNode;
+  selected?: boolean;
+}
+
+export function MultiSelectMenuOption(props: MultiSelectOptionProps) {
+  const {
+    active,
+    createElementLabel,
+    custom,
+    disabled,
+    element,
+    onSelectChange,
+    selected,
+    renderLabel = identity,
+  } = props;
+
+  const onHover = () => {
+    props.onHover(element);
+  };
+
+  const label = renderLabel(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 className="sw-mr-1">
+            +
+          </span>
+          {element}
+        </span>
+      ) : (
+        <span className="sw-ml-3">{label}</span>
+      )}
+    </ItemCheckbox>
+  );
+}
diff --git a/server/sonar-web/design-system/src/components/input/RadioButton.tsx b/server/sonar-web/design-system/src/components/input/RadioButton.tsx
new file mode 100644 (file)
index 0000000..c974846
--- /dev/null
@@ -0,0 +1,139 @@
+/*
+ * 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 React from 'react';
+import tw from 'twin.macro';
+import { themeBorder, themeColor } from '../../helpers/theme';
+
+type AllowedRadioButtonAttributes = Pick<
+  React.InputHTMLAttributes<HTMLInputElement>,
+  'aria-label' | 'autoFocus' | 'id' | 'name' | 'style' | 'title' | 'type'
+>;
+
+interface PropsBase extends AllowedRadioButtonAttributes {
+  checked: boolean;
+  children?: React.ReactNode;
+  className?: string;
+  disabled?: boolean;
+}
+
+type Props =
+  | ({ onCheck: (value: string) => void; value: string } & PropsBase)
+  | ({ onCheck: () => void; value: never } & PropsBase);
+
+export function RadioButton({
+  checked,
+  children,
+  className,
+  disabled,
+  onCheck,
+  value,
+  ...htmlProps
+}: Props) {
+  const handleChange = () => {
+    if (!disabled) {
+      onCheck(value);
+    }
+  };
+
+  return (
+    <label
+      className={classNames(
+        'sw-flex sw-items-center',
+        {
+          'sw-cursor-pointer': !disabled,
+          'sw-cursor-not-allowed': disabled,
+        },
+        className
+      )}
+    >
+      <RadioButtonStyled
+        aria-disabled={disabled}
+        checked={checked}
+        disabled={disabled}
+        onChange={handleChange}
+        type="radio"
+        value={value}
+        {...htmlProps}
+      />
+      {children}
+    </label>
+  );
+}
+
+export const RadioButtonStyled = styled.input`
+  appearance: none; //disables native style
+  border: ${themeBorder('default', 'radioBorder')};
+
+  ${tw`sw-cursor-pointer`}
+
+  ${tw`sw-w-4 sw-min-w-4 sw-h-4 sw-min-h-4`}
+  ${tw`sw-p-1 sw-mr-2`}
+  ${tw`sw-inline-block`}
+  ${tw`sw-box-border`}
+  ${tw`sw-rounded-pill`}
+
+  &:hover {
+    background: ${themeColor('radioHover')};
+  }
+
+  &:focus,
+  &:focus-visible {
+    background: ${themeColor('radioHover')};
+    border: ${themeBorder('default', 'radioFocusBorder')};
+    outline: ${themeBorder('focus', 'radioFocusOutline')};
+  }
+
+  &.is-checked,
+  &:focus:checked,
+  &:focus-visible:checked,
+  &:hover:checked,
+  &:checked {
+    // Color cannot be used with multiple backgrounds, only image is allowed
+    background-image: linear-gradient(to right, ${themeColor('radio')}, ${themeColor('radio')}),
+      linear-gradient(to right, ${themeColor('radioChecked')}, ${themeColor('radioChecked')});
+    background-clip: content-box, padding-box;
+    border: ${themeBorder('default', 'radioBorder')};
+  }
+
+  &.is-disabled,
+  &:disabled {
+    background: ${themeColor('radioDisabledBackground')};
+    border: ${themeBorder('default', 'radioDisabledBorder')};
+    background-clip: unset;
+
+    &.is-checked,
+    &:checked {
+      background-image: linear-gradient(
+          to right,
+          ${themeColor('radioDisabled')},
+          ${themeColor('radioDisabled')}
+        ),
+        linear-gradient(
+          to right,
+          ${themeColor('radioDisabledBackground')},
+          ${themeColor('radioDisabledBackground')}
+        ) !important;
+      background-clip: content-box, padding-box !important;
+      border: ${themeBorder('default', 'radioDisabledBorder')} !important;
+    }
+  }
+`;
diff --git a/server/sonar-web/design-system/src/components/input/SearchSelect.tsx b/server/sonar-web/design-system/src/components/input/SearchSelect.tsx
new file mode 100644 (file)
index 0000000..79b654a
--- /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/input/SearchSelectDropdown.tsx b/server/sonar-web/design-system/src/components/input/SearchSelectDropdown.tsx
new file mode 100644 (file)
index 0000000..2e105e2
--- /dev/null
@@ -0,0 +1,215 @@
+/*
+ * 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, PopupPlacement, PopupZLevel, themeBorder } from '../../helpers';
+import { InputSizeKeys } from '../../types/theme';
+import { DropdownToggler } from '../DropdownToggler';
+import { SearchHighlighterContext } from '../SearchHighlighter';
+import { IconOption, LabelValueSelectOption, SelectProps } from './InputSelect';
+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> {
+  className?: string;
+  controlAriaLabel?: string;
+  controlLabel?: React.ReactNode | string;
+  controlSize?: InputSizeKeys;
+  isDiscreet?: boolean;
+  zLevel?: PopupZLevel;
+}
+
+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 {
+    className,
+    isDiscreet,
+    value,
+    loadOptions,
+    controlLabel,
+    controlSize,
+    isDisabled,
+    minLength,
+    controlAriaLabel,
+    menuIsOpen,
+    onChange,
+    onInputChange,
+    zLevel = PopupZLevel.Global,
+    ...rest
+  } = props;
+  const [open, setOpen] = React.useState(false);
+  const [inputValue, setInputValue] = React.useState('');
+
+  React.useEffect(() => {
+    if (menuIsOpen) {
+      setOpen(true);
+    }
+  }, [menuIsOpen]);
+
+  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);
+      onChange?.(newValue, actionMeta);
+    },
+    [toggleDropdown, 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) => {
+      if (actionMeta.action === 'menu-close') {
+        setInputValue(actionMeta.prevInputValue);
+        return actionMeta.prevInputValue;
+      }
+
+      setInputValue(newValue);
+      onInputChange?.(newValue, actionMeta);
+      return newValue;
+    },
+    [onInputChange]
+  );
+
+  React.useEffect(() => {
+    if (open) {
+      ref.current?.inputRef?.select();
+    } else {
+      setInputValue('');
+    }
+  }, [open]);
+
+  return (
+    <DropdownToggler
+      allowResizing
+      className="sw-overflow-visible sw-border-none"
+      onRequestClose={() => {
+        toggleDropdown(false);
+      }}
+      open={open}
+      overlay={
+        <SearchHighlighterContext.Provider value={inputValue}>
+          <StyledSearchSelectWrapper>
+            <SearchSelect
+              cacheOptions
+              {...rest}
+              components={{
+                SingleValue: () => null,
+                Option: IconOption,
+                ...rest.components,
+              }}
+              inputValue={inputValue}
+              loadOptions={debouncedLoadOptions.current}
+              menuIsOpen
+              minLength={minLength}
+              onChange={handleChange}
+              onInputChange={handleInputChange}
+              selectRef={ref}
+            />
+          </StyledSearchSelectWrapper>
+        </SearchHighlighterContext.Provider>
+      }
+      placement={PopupPlacement.BottomLeft}
+      zLevel={zLevel}
+    >
+      <SearchSelectDropdownControl
+        ariaLabel={controlAriaLabel}
+        className={className}
+        disabled={isDisabled}
+        isDiscreet={isDiscreet}
+        label={controlLabel}
+        onClick={() => {
+          toggleDropdown(true);
+        }}
+        size={controlSize}
+      />
+    </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/input/SearchSelectDropdownControl.tsx b/server/sonar-web/design-system/src/components/input/SearchSelectDropdownControl.tsx
new file mode 100644 (file)
index 0000000..7090d3c
--- /dev/null
@@ -0,0 +1,130 @@
+/*
+ * 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 {
+  ariaLabel?: string;
+  className?: string;
+  disabled?: boolean;
+  isDiscreet?: boolean;
+  label?: React.ReactNode | string;
+  onClick: VoidFunction;
+  size?: InputSizeKeys;
+}
+
+export function SearchSelectDropdownControl(props: SearchSelectDropdownControlProps) {
+  const { className, disabled, label, isDiscreet, onClick, size = 'full', ariaLabel = '' } = props;
+  return (
+    <StyledControl
+      aria-label={ariaLabel}
+      className={classNames(className, { 'is-discreet': isDiscreet })}
+      onClick={() => {
+        if (!disabled) {
+          onClick();
+        }
+      }}
+      onKeyDown={(event) => {
+        if (event.key === Key.Enter || event.key === Key.ArrowDown) {
+          onClick();
+        }
+      }}
+      role="combobox"
+      style={{ '--inputSize': isDiscreet ? 'auto' : INPUT_SIZES[size] }}
+      tabIndex={disabled ? -1 : 0}
+    >
+      <InputValue
+        className={classNames('it__js-search-input-value sw-flex sw-justify-between', {
+          'is-disabled': disabled,
+          'is-placeholder': !label,
+        })}
+      >
+        <span className="sw-truncate">{label}</span>
+        <ChevronDownIcon className="sw-ml-1" />
+      </InputValue>
+    </StyledControl>
+  );
+}
+
+const StyledControl = styled.div`
+  color: ${themeContrast('inputBackground')};
+  background: ${themeColor('inputBackground')};
+  border: ${themeBorder('default', 'inputBorder')};
+  width: var(--inputSize);
+
+  ${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-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: 100%;
+  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/input/__tests__/DatePicker-test.tsx b/server/sonar-web/design-system/src/components/input/__tests__/DatePicker-test.tsx
new file mode 100644 (file)
index 0000000..81d9f16
--- /dev/null
@@ -0,0 +1,145 @@
+/*
+ * 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, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { getMonth, getYear, parseISO } from 'date-fns';
+import { render } from '../../../helpers/testUtils';
+import { DatePicker } from '../DatePicker';
+
+it('behaves correctly', async () => {
+  const user = userEvent.setup();
+
+  const onChange = jest.fn((_: Date) => undefined);
+  const currentMonth = parseISO('2022-06-13');
+  renderDatePicker({ currentMonth, onChange });
+
+  /*
+   * Open the DatePicker, navigate to the previous month and choose an arbitrary day (7)
+   * Then check that onChange was correctly called with a date in the previous month
+   */
+  await user.click(screen.getByRole('textbox'));
+
+  const nav = screen.getByRole('navigation');
+  expect(nav).toBeInTheDocument();
+
+  await user.click(within(nav).getByRole('button', { name: 'previous' }));
+  await user.click(screen.getByText('7'));
+
+  expect(onChange).toHaveBeenCalled();
+  const newDate = onChange.mock.calls[0][0]; // first argument of the first and only call
+  expect(getMonth(newDate)).toBe(getMonth(currentMonth) - 1);
+
+  onChange.mockClear();
+
+  /*
+   * Open the DatePicker, navigate to the next month twice and choose an arbitrary day (12)
+   * Then check that onChange was correctly called with a date in the following month
+   */
+  await user.click(screen.getByRole('textbox'));
+  const nextButton = screen.getByRole('button', { name: 'next' });
+  await user.click(nextButton);
+  await user.click(nextButton);
+  await user.click(screen.getByText('12'));
+
+  expect(onChange).toHaveBeenCalled();
+  const newDate2 = onChange.mock.calls[0][0]; // first argument
+  expect(getMonth(newDate2)).toBe(getMonth(currentMonth) + 1);
+
+  onChange.mockClear();
+
+  /*
+   * Open the DatePicker, select the month, select the year and choose an arbitrary day (10)
+   * Then check that onChange was correctly called with a date in the selected month & year
+   */
+  await user.click(screen.getByRole('textbox'));
+  // Select month
+  await user.click(screen.getByText('Jun'));
+  await user.click(screen.getByText('Feb'));
+
+  // Select year
+  await user.click(screen.getByText('2022'));
+  await user.click(screen.getByText('2019'));
+
+  await user.click(screen.getByText('10'));
+
+  const newDate3 = onChange.mock.calls[0][0]; // first argument
+
+  expect(getMonth(newDate3)).toBe(1);
+  expect(getYear(newDate3)).toBe(2019);
+});
+
+it('should clear the value', async () => {
+  const user = userEvent.setup();
+
+  const onChange = jest.fn((_: Date) => undefined);
+
+  const currentDate = parseISO('2022-06-13');
+
+  renderDatePicker({
+    currentMonth: currentDate,
+    onChange,
+    showClearButton: true,
+    value: currentDate,
+    // eslint-disable-next-line jest/no-conditional-in-test
+    valueFormatter: (date?: Date) => (date ? 'formatted date' : 'no date'),
+  });
+
+  await user.click(screen.getByRole('textbox'));
+
+  await user.click(screen.getByLabelText('clear'));
+
+  expect(onChange).toHaveBeenCalledWith(undefined);
+});
+
+it.each([
+  [{ highlightFrom: parseISO('2022-06-12'), value: parseISO('2022-06-14') }],
+  [{ alignRight: true, highlightTo: parseISO('2022-06-14'), value: parseISO('2022-06-12') }],
+])('highlights the appropriate days', async (props) => {
+  const user = userEvent.setup();
+
+  const hightlightClass = 'rdp-highlighted';
+
+  renderDatePicker(props);
+
+  await user.click(screen.getByRole('textbox'));
+
+  expect(screen.getByText('11')).not.toHaveClass(hightlightClass);
+  expect(screen.getByText('12')).toHaveClass(hightlightClass);
+  expect(screen.getByText('13')).toHaveClass(hightlightClass);
+  expect(screen.getByText('14')).toHaveClass(hightlightClass);
+  expect(screen.getByText('15')).not.toHaveClass(hightlightClass);
+});
+
+function renderDatePicker(overrides: Partial<DatePicker['props']> = {}) {
+  const defaultFormatter = (date?: Date) => (date ? date.toISOString() : '');
+
+  render(
+    <DatePicker
+      ariaNextMonthLabel="next"
+      ariaPreviousMonthLabel="previous"
+      clearButtonLabel="clear"
+      onChange={jest.fn()}
+      placeholder="placeholder"
+      valueFormatter={defaultFormatter}
+      {...overrides}
+    />
+  );
+}
diff --git a/server/sonar-web/design-system/src/components/input/__tests__/DateRangePicker-test.tsx b/server/sonar-web/design-system/src/components/input/__tests__/DateRangePicker-test.tsx
new file mode 100644 (file)
index 0000000..62902df
--- /dev/null
@@ -0,0 +1,93 @@
+/*
+ * 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, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { formatISO, parseISO } from 'date-fns';
+import { render } from '../../../helpers/testUtils';
+import { DateRangePicker } from '../DateRangePicker';
+
+beforeEach(() => {
+  jest.useFakeTimers().setSystemTime(parseISO('2022-06-12'));
+});
+
+afterEach(() => {
+  jest.runOnlyPendingTimers();
+  jest.useRealTimers();
+});
+
+it('behaves correctly', async () => {
+  // Remove delay to play nice with fake timers
+  const user = userEvent.setup({ delay: null });
+
+  const onChange = jest.fn((_: { from?: Date; to?: Date }) => undefined);
+  renderDateRangePicker({ onChange });
+
+  await user.click(screen.getByRole('textbox', { name: 'from' }));
+
+  const fromDateNav = screen.getByRole('navigation');
+  expect(fromDateNav).toBeInTheDocument();
+
+  await user.click(within(fromDateNav).getByRole('button', { name: 'previous' }));
+  await user.click(screen.getByText('7'));
+
+  expect(screen.queryByRole('navigation')).not.toBeInTheDocument();
+
+  expect(onChange).toHaveBeenCalled();
+  const { from } = onChange.mock.calls[0][0]; // first argument
+  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+  expect(formatISO(from!, { representation: 'date' })).toBe('2022-05-07');
+
+  onChange.mockClear();
+
+  jest.runAllTimers();
+
+  const toDateNav = await screen.findByRole('navigation');
+  const previousButton = within(toDateNav).getByRole('button', { name: 'previous' });
+  const nextButton = within(toDateNav).getByRole('button', { name: 'next' });
+  await user.click(previousButton);
+  await user.click(nextButton);
+  await user.click(previousButton);
+  await user.click(screen.getByText('12'));
+
+  expect(screen.queryByRole('navigation')).not.toBeInTheDocument();
+
+  expect(onChange).toHaveBeenCalled();
+  const { to } = onChange.mock.calls[0][0]; // first argument
+  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+  expect(formatISO(to!, { representation: 'date' })).toBe('2022-05-12');
+});
+
+function renderDateRangePicker(overrides: Partial<DateRangePicker['props']> = {}) {
+  const defaultFormatter = (date?: Date) =>
+    date ? formatISO(date, { representation: 'date' }) : '';
+
+  render(
+    <DateRangePicker
+      ariaNextMonthLabel="next"
+      ariaPreviousMonthLabel="previous"
+      clearButtonLabel="clear"
+      fromLabel="from"
+      onChange={jest.fn()}
+      toLabel="to"
+      valueFormatter={defaultFormatter}
+      {...overrides}
+    />
+  );
+}
diff --git a/server/sonar-web/design-system/src/components/input/__tests__/DiscreetSelect-test.tsx b/server/sonar-web/design-system/src/components/input/__tests__/DiscreetSelect-test.tsx
new file mode 100644 (file)
index 0000000..7db557a
--- /dev/null
@@ -0,0 +1,59 @@
+/*
+ * 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 { DiscreetSelect } from '../DiscreetSelect';
+
+it('should render discreet select and invoke CB on value click', async () => {
+  const value = 'foo';
+  const setValue = jest.fn();
+
+  const user = userEvent.setup();
+  setupWithProps({ setValue, value });
+  await user.click(screen.getByRole('combobox'));
+  expect(screen.getByText(/option foo-bar selected/)).toBeInTheDocument();
+  expect(screen.getByRole('note', { name: 'Icon' })).toBeInTheDocument();
+  await user.click(screen.getByText('bar-foo'));
+  expect(setValue).toHaveBeenCalled();
+});
+
+function setupWithProps(props: Partial<FCProps<typeof DiscreetSelect>>) {
+  return render(
+    <DiscreetSelect
+      options={[
+        { label: 'foo-bar', value: 'foo' },
+        {
+          label: 'bar-foo',
+          value: 'bar',
+          Icon: (
+            <span role="note" title="Icon">
+              Icon
+            </span>
+          ),
+        },
+      ]}
+      setValue={jest.fn()}
+      value="foo"
+      {...props}
+    />
+  );
+}
diff --git a/server/sonar-web/design-system/src/components/input/__tests__/FormField-test.tsx b/server/sonar-web/design-system/src/components/input/__tests__/FormField-test.tsx
new file mode 100644 (file)
index 0000000..a8a4fae
--- /dev/null
@@ -0,0 +1,46 @@
+/*
+ * 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 { FCProps } from '~types/misc';
+import { render } from '../../../helpers/testUtils';
+import { FormField } from '../FormField';
+
+it('should render correctly', () => {
+  renderFormField({}, <input id="input" />);
+  expect(screen.getByLabelText('Hello')).toBeInTheDocument();
+});
+
+it('should render with required and description', () => {
+  renderFormField({ description: 'some description', required: true }, <input id="input" />);
+  expect(screen.getByText('some description')).toBeInTheDocument();
+  expect(screen.getByText('*')).toBeInTheDocument();
+});
+
+function renderFormField(
+  props: Partial<FCProps<typeof FormField>> = {},
+  children: any = <div>Fake input</div>
+) {
+  return render(
+    <FormField htmlFor="input" label="Hello" {...props}>
+      {children}
+    </FormField>
+  );
+}
diff --git a/server/sonar-web/design-system/src/components/input/__tests__/InputField-test.tsx b/server/sonar-web/design-system/src/components/input/__tests__/InputField-test.tsx
new file mode 100644 (file)
index 0000000..47fcac1
--- /dev/null
@@ -0,0 +1,36 @@
+/*
+ * 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 { InputField } from '../InputField';
+
+describe('Input Field', () => {
+  it.each([
+    ['default', false, false, 'defaultStyle'],
+    ['invalid', true, false, 'dangerStyle'],
+    ['valid', false, true, 'successStyle'],
+  ])('should handle status %s', (_, isInvalid, isValid, expectedStyle) => {
+    render(<InputField isInvalid={isInvalid} isValid={isValid} />);
+
+    // Emotion classes contain pseudo-random parts, we're interesting in the fixed part
+    // so we can't just check a specific class
+    // eslint-disable-next-line jest-dom/prefer-to-have-class
+    expect(screen.getByRole('textbox').className).toContain(expectedStyle);
+  });
+});
diff --git a/server/sonar-web/design-system/src/components/input/__tests__/InputMultiSelect-test.tsx b/server/sonar-web/design-system/src/components/input/__tests__/InputMultiSelect-test.tsx
new file mode 100644 (file)
index 0000000..d362d00
--- /dev/null
@@ -0,0 +1,40 @@
+/*
+ * 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 { FCProps } from '../../../types/misc';
+import { InputMultiSelect } from '../InputMultiSelect';
+
+it('should render correctly', () => {
+  renderInputMultiSelect();
+  expect(screen.getByText('select')).toBeInTheDocument();
+  expect(screen.queryByText('selected')).not.toBeInTheDocument();
+  expect(screen.queryByText(/\d+/)).not.toBeInTheDocument();
+});
+
+it('should render correctly with a counter', () => {
+  renderInputMultiSelect({ count: 42 });
+  expect(screen.queryByText('select')).not.toBeInTheDocument();
+  expect(screen.getByText('selected')).toBeInTheDocument();
+  expect(screen.getByText('42')).toBeInTheDocument();
+});
+
+function renderInputMultiSelect(props: Partial<FCProps<typeof InputMultiSelect>> = {}) {
+  render(<InputMultiSelect placeholder="select" selectedLabel="selected" {...props} />);
+}
diff --git a/server/sonar-web/design-system/src/components/input/__tests__/InputSearch-test.tsx b/server/sonar-web/design-system/src/components/input/__tests__/InputSearch-test.tsx
new file mode 100644 (file)
index 0000000..fa5f5c7
--- /dev/null
@@ -0,0 +1,90 @@
+/*
+ * 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, waitFor } from '@testing-library/react';
+import { render } from '../../../helpers/testUtils';
+import { FCProps } from '../../../types/misc';
+import { InputSearch } from '../InputSearch';
+
+it('should warn when input is too short', async () => {
+  const { user } = setupWithProps({ value: 'f' });
+  expect(screen.getByRole('note')).toBeInTheDocument();
+  await user.type(screen.getByRole('searchbox'), 'oo');
+  expect(screen.queryByRole('note')).not.toBeInTheDocument();
+});
+
+it('should show clear button only when there is a value', async () => {
+  const { user } = setupWithProps({ value: 'f' });
+  expect(screen.getByRole('button')).toBeInTheDocument();
+  await user.clear(screen.getByRole('searchbox'));
+  expect(screen.queryByRole('button')).not.toBeInTheDocument();
+});
+
+it('should attach ref', () => {
+  const ref = jest.fn() as jest.Mock<unknown, unknown[]>;
+  setupWithProps({ innerRef: ref });
+  expect(ref).toHaveBeenCalled();
+  expect(ref.mock.calls[0][0]).toBeInstanceOf(HTMLInputElement);
+});
+
+it('should trigger reset correctly with clear button', async () => {
+  const onChange = jest.fn();
+  const { user } = setupWithProps({ onChange });
+  await user.click(screen.getByRole('button'));
+  expect(onChange).toHaveBeenCalledWith('');
+});
+
+it('should trigger change correctly', async () => {
+  const onChange = jest.fn();
+  const { user } = setupWithProps({ onChange, value: 'f' });
+  await user.type(screen.getByRole('searchbox'), 'oo');
+  await waitFor(() => {
+    expect(onChange).toHaveBeenCalledWith('foo');
+  });
+});
+
+it('should not change when value is too short', async () => {
+  const onChange = jest.fn();
+  const { user } = setupWithProps({ onChange, value: '', minLength: 3 });
+  await user.type(screen.getByRole('searchbox'), 'fo');
+  expect(onChange).not.toHaveBeenCalled();
+});
+
+it('should clear input using escape', async () => {
+  const onChange = jest.fn();
+  const { user } = setupWithProps({ onChange, value: 'foo' });
+  await user.type(screen.getByRole('searchbox'), '{Escape}');
+  expect(onChange).toHaveBeenCalledWith('');
+});
+
+function setupWithProps(props: Partial<FCProps<typeof InputSearch>> = {}) {
+  return render(
+    <InputSearch
+      clearIconAriaLabel=""
+      maxLength={150}
+      minLength={2}
+      onChange={jest.fn()}
+      placeholder="placeholder"
+      searchInputAriaLabel=""
+      tooShortText="too short"
+      value="foo"
+      {...props}
+    />
+  );
+}
diff --git a/server/sonar-web/design-system/src/components/input/__tests__/InputSelect-test.tsx b/server/sonar-web/design-system/src/components/input/__tests__/InputSelect-test.tsx
new file mode 100644 (file)
index 0000000..fefc767
--- /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 { screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { render } from '../../../helpers/testUtils';
+import { FCProps } from '../../../types/misc';
+import { InputSelect } from '../InputSelect';
+
+it('should render select input and be able to click and change', async () => {
+  const setValue = jest.fn();
+  const user = userEvent.setup();
+  setupWithProps({ placeholder: 'placeholder-foo', onChange: setValue });
+  expect(screen.getByText('placeholder-foo')).toBeInTheDocument();
+  await user.click(screen.getByRole('combobox'));
+  expect(screen.getByText(/option foo-bar focused/)).toBeInTheDocument();
+  expect(screen.getByRole('note', { name: 'Icon' })).toBeInTheDocument();
+  await user.click(screen.getByText('bar-foo'));
+  expect(setValue).toHaveBeenCalled();
+  expect(screen.queryByText('placeholder-foo')).not.toBeInTheDocument();
+  expect(screen.getByRole('note', { name: 'Icon' })).toBeInTheDocument();
+});
+
+function setupWithProps(props: Partial<FCProps<typeof InputSelect>>) {
+  return render(
+    <InputSelect
+      {...props}
+      options={[
+        { label: 'foo-bar', value: 'foo' },
+        {
+          label: 'bar-foo',
+          value: 'bar',
+          Icon: (
+            <span role="note" title="Icon">
+              Icon
+            </span>
+          ),
+        },
+      ]}
+    />
+  );
+}
diff --git a/server/sonar-web/design-system/src/components/input/__tests__/MultiSelectMenu-test.tsx b/server/sonar-web/design-system/src/components/input/__tests__/MultiSelectMenu-test.tsx
new file mode 100644 (file)
index 0000000..a1c7b58
--- /dev/null
@@ -0,0 +1,114 @@
+/*
+ * 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 { MultiSelectMenu } from '../MultiSelectMenu';
+
+const elements = ['foo', 'bar', 'baz'];
+
+beforeEach(() => {
+  jest.useFakeTimers();
+});
+
+afterEach(() => {
+  jest.runOnlyPendingTimers();
+  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<MultiSelectMenu['props']> = {}) {
+  return render(
+    <MultiSelectMenu
+      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}
+    />
+  );
+}
diff --git a/server/sonar-web/design-system/src/components/input/__tests__/RadioButton-test.tsx b/server/sonar-web/design-system/src/components/input/__tests__/RadioButton-test.tsx
new file mode 100644 (file)
index 0000000..b84f0bc
--- /dev/null
@@ -0,0 +1,62 @@
+/*
+ * 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 { RadioButton } from '../RadioButton';
+
+const value = 'value';
+
+it('should render properly', () => {
+  setupWithProps();
+  expect(screen.getByRole('radio')).not.toBeChecked();
+});
+
+it('should render properly when checked', () => {
+  setupWithProps({ checked: true });
+  expect(screen.getByRole('radio')).toBeChecked();
+});
+
+it('should invoke callback on click', async () => {
+  const user = userEvent.setup();
+  const onCheck = jest.fn();
+  setupWithProps({ onCheck, value });
+
+  await user.click(screen.getByRole('radio'));
+  expect(onCheck).toHaveBeenCalled();
+});
+
+it('should not invoke callback on click when disabled', async () => {
+  const user = userEvent.setup();
+  const onCheck = jest.fn();
+  setupWithProps({ disabled: true, onCheck });
+
+  await user.click(screen.getByRole('radio'));
+  expect(onCheck).not.toHaveBeenCalled();
+});
+
+function setupWithProps(props?: Partial<FCProps<typeof RadioButton>>) {
+  return render(
+    <RadioButton checked={false} onCheck={jest.fn()} value="value" {...props}>
+      foo
+    </RadioButton>
+  );
+}
diff --git a/server/sonar-web/design-system/src/components/input/__tests__/SearchSelectDropdown-test.tsx b/server/sonar-web/design-system/src/components/input/__tests__/SearchSelectDropdown-test.tsx
new file mode 100644 (file)
index 0000000..1c7d487
--- /dev/null
@@ -0,0 +1,99 @@
+/*
+ * 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 { act, 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 act(async () => {
+    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
+      loadOptions={loadOptions}
+      placeholder="search for things"
+      {...props}
+    />
+  );
+}
diff --git a/server/sonar-web/design-system/src/components/input/index.ts b/server/sonar-web/design-system/src/components/input/index.ts
new file mode 100644 (file)
index 0000000..ad389e9
--- /dev/null
@@ -0,0 +1,32 @@
+/*
+ * 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.
+ */
+export * from './Checkbox';
+export * from './DatePicker';
+export * from './DateRangePicker';
+export * from './DiscreetSelect';
+export * from './FormField';
+export * from './InputField';
+export * from './InputMultiSelect';
+export * from './InputSearch';
+export * from './InputSelect';
+export * from './MultiSelectMenu';
+export * from './RadioButton';
+export * from './SearchSelect';
+export * from './SearchSelectDropdown';