+++ /dev/null
-/*
- * SonarQube
- * Copyright (C) 2009-2023 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
- */
-
-import styled from '@emotion/styled';
-import 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')};
- }
-`;
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;
+++ /dev/null
-/*
- * SonarQube
- * Copyright (C) 2009-2023 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
- */
-
-import styled from '@emotion/styled';
-import classNames from 'classnames';
-import {
- 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>
- );
- };
-}
+++ /dev/null
-/*
- * SonarQube
- * Copyright (C) 2009-2023 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
- */
-import classNames from 'classnames';
-import { 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>
- );
- }
-}
+++ /dev/null
-/*
- * SonarQube
- * Copyright (C) 2009-2023 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
- */
-
-import styled from '@emotion/styled';
-import 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`};
- }
-`;
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;
+++ /dev/null
-/*
- * SonarQube
- * Copyright (C) 2009-2023 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
- */
-import styled from '@emotion/styled';
-import { 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`}
- }
-`;
+++ /dev/null
-/*
- * SonarQube
- * Copyright (C) 2009-2023 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
- */
-import { 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};
-`;
+++ /dev/null
-/*
- * SonarQube
- * Copyright (C) 2009-2023 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
- */
-import styled from '@emotion/styled';
-import classNames from 'classnames';
-import { 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')};
- }
-`;
+++ /dev/null
-/*
- * SonarQube
- * Copyright (C) 2009-2023 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
- */
-
-import styled from '@emotion/styled';
-import classNames from 'classnames';
-import { 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`}
-`;
+++ /dev/null
-/*
- * SonarQube
- * Copyright (C) 2009-2023 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
- */
-
-import { 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 }),
- }),
- };
-}
+++ /dev/null
-/*
- * SonarQube
- * Copyright (C) 2009-2023 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
- */
-import classNames from 'classnames';
-import { difference } from 'lodash';
-import { PureComponent } from 'react';
-import { Key } from '../helpers/keyboard';
-import { ItemDivider, ItemHeader } from './DropdownMenu';
-import { InputSearch } from './InputSearch';
-import { 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>
- );
- }
-}
+++ /dev/null
-/*
- * SonarQube
- * Copyright (C) 2009-2023 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
- */
-import classNames from 'classnames';
-import { 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>
- );
-}
+++ /dev/null
-/*
- * SonarQube
- * Copyright (C) 2009-2023 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
- */
-import styled from '@emotion/styled';
-import classNames from 'classnames';
-import 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;
- }
- }
-`;
+++ /dev/null
-/*
- * SonarQube
- * Copyright (C) 2009-2023 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
- */
-import classNames from 'classnames';
-import { omit } from 'lodash';
-import React, { RefObject } from 'react';
-import { GroupBase, InputProps, components } from 'react-select';
-import AsyncSelect, { AsyncProps } from 'react-select/async';
-import Select from 'react-select/dist/declarations/src/Select';
-import { INPUT_SIZES } from '../helpers';
-import { Key } from '../helpers/keyboard';
-import { translate } from '../helpers/l10n';
-import { InputSearch } from './InputSearch';
-import { LabelValueSelectOption, SelectProps, selectStyle } from './InputSelect';
-
-type SearchSelectProps<
- V,
- Option extends LabelValueSelectOption<V>,
- IsMulti extends boolean = false,
- Group extends GroupBase<Option> = GroupBase<Option>
-> = SelectProps<V, Option, IsMulti, Group> & AsyncProps<Option, IsMulti, Group>;
-
-export function SearchSelect<
- V,
- Option extends LabelValueSelectOption<V>,
- IsMulti extends boolean = false,
- Group extends GroupBase<Option> = GroupBase<Option>
->({
- size = 'full',
- selectRef,
- ...props
-}: SearchSelectProps<V, Option, IsMulti, Group> & {
- selectRef?: RefObject<Select<Option, IsMulti, Group>>;
-}) {
- const styles = selectStyle<V, Option, IsMulti, Group>({ size });
- return (
- <AsyncSelect<Option, IsMulti, Group>
- {...omit(props, 'className', 'large')}
- className={classNames('react-select', props.className)}
- classNamePrefix="react-select"
- classNames={{
- control: ({ isDisabled }) =>
- classNames(
- 'sw-border-0 sw-rounded-2 sw-outline-none sw-shadow-none',
- isDisabled && 'sw-pointer-events-none sw-cursor-not-allowed'
- ),
- indicatorsContainer: () => 'sw-hidden',
- input: () => `sw-flex sw-w-full sw-p-0 sw-m-0`,
- valueContainer: () => `sw-px-3 sw-pb-1 sw-mb-1 sw-pt-4`,
- placeholder: () => 'sw-hidden',
- ...props.classNames,
- }}
- components={{
- Input: SearchSelectInput,
- ...props.components,
- }}
- ref={selectRef}
- styles={{
- ...styles,
- menu: (base, props) => ({
- ...styles.menu?.(base, props),
- width: `calc(${INPUT_SIZES[size]} - 2px)`,
- }),
- }}
- />
- );
-}
-
-export function SearchSelectInput<
- V,
- Option extends LabelValueSelectOption<V>,
- IsMulti extends boolean = false,
- Group extends GroupBase<Option> = GroupBase<Option>
->(props: InputProps<Option, IsMulti, Group>) {
- const {
- selectProps: { clearIconLabel, placeholder, isLoading, inputValue, minLength, tooShortText },
- } = props;
-
- const onChange = (v: string, prevValue = '') => {
- props.selectProps.onInputChange(v, { action: 'input-change', prevInputValue: prevValue });
- };
-
- const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
- const target = event.target as HTMLInputElement;
-
- if (event.key === Key.Escape && target.value !== '') {
- event.stopPropagation();
- onChange('');
- }
- };
-
- return (
- <InputSearch
- clearIconAriaLabel={clearIconLabel ?? translate('clear')}
- loading={isLoading && inputValue.length >= (minLength ?? 0)}
- minLength={minLength}
- onChange={onChange}
- size="full"
- tooShortText={tooShortText}
- value={inputValue}
- >
- <components.Input
- {...props}
- onKeyDown={handleKeyDown}
- placeholder={placeholder as string}
- style={{}}
- />
- </InputSearch>
- );
-}
+++ /dev/null
-/*
- * SonarQube
- * Copyright (C) 2009-2023 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
- */
-import styled from '@emotion/styled';
-import { debounce } from 'lodash';
-import React from 'react';
-import {
- ActionMeta,
- GroupBase,
- InputActionMeta,
- OnChangeValue,
- OptionsOrGroups,
-} from 'react-select';
-import { AsyncProps } from 'react-select/async';
-import Select from 'react-select/dist/declarations/src/Select';
-import tw from 'twin.macro';
-import { DEBOUNCE_DELAY, 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;
- }
- }
-`;
+++ /dev/null
-/*
- * SonarQube
- * Copyright (C) 2009-2023 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
- */
-
-import styled from '@emotion/styled';
-import classNames from 'classnames';
-import tw from 'twin.macro';
-import { INPUT_SIZES, themeBorder, themeColor, themeContrast } from '../helpers';
-import { Key } from '../helpers/keyboard';
-import { InputSizeKeys } from '../types/theme';
-import { ChevronDownIcon } from './icons';
-
-interface SearchSelectDropdownControlProps {
- 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')};
- }
-`;
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;
* 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;
+++ /dev/null
-/*
- * SonarQube
- * Copyright (C) 2009-2023 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
- */
-
-import { screen, 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}
- />
- );
-}
+++ /dev/null
-/*
- * SonarQube
- * Copyright (C) 2009-2023 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
- */
-import { screen, 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}
- />
- );
-}
+++ /dev/null
-/*
- * SonarQube
- * Copyright (C) 2009-2023 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
- */
-import { screen } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
-import { render } from '../../helpers/testUtils';
-import { FCProps } from '../../types/misc';
-import { 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}
- />
- );
-}
+++ /dev/null
-/*
- * SonarQube
- * Copyright (C) 2009-2023 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
- */
-
-import { screen } from '@testing-library/react';
-import { 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>
- );
-}
+++ /dev/null
-/*
- * SonarQube
- * Copyright (C) 2009-2023 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
- */
-import { render, screen } from '@testing-library/react';
-import { 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);
- });
-});
+++ /dev/null
-/*
- * SonarQube
- * Copyright (C) 2009-2023 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
- */
-import { render, screen } from '@testing-library/react';
-import { 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} />);
-}
+++ /dev/null
-/*
- * SonarQube
- * Copyright (C) 2009-2023 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
- */
-import { screen, 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}
- />
- );
-}
+++ /dev/null
-/*
- * SonarQube
- * Copyright (C) 2009-2023 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
- */
-import { screen } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
-import { render } from '../../helpers/testUtils';
-import { FCProps } from '../../types/misc';
-import { 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>
- ),
- },
- ]}
- />
- );
-}
+++ /dev/null
-/*
- * SonarQube
- * Copyright (C) 2009-2023 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
- */
-import { render, screen } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
-import { 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}
- />
- );
-}
+++ /dev/null
-/*
- * SonarQube
- * Copyright (C) 2009-2023 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
- */
-import { screen } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
-import { render } from '../../helpers/testUtils';
-import { FCProps } from '../../types/misc';
-import { 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>
- );
-}
+++ /dev/null
-/*
- * SonarQube
- * Copyright (C) 2009-2023 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
- */
-import { 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}
- />
- );
-}
+++ /dev/null
-/*
- * SonarQube
- * Copyright (C) 2009-2023 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
- */
-
-import { screen } from '@testing-library/react';
-import { 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)',
- });
-});
+++ /dev/null
-/*
- * SonarQube
- * Copyright (C) 2009-2023 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
- */
-
-import { 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')};
- }
-`;
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+
+import styled from '@emotion/styled';
+import 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')};
+ }
+`;
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+
+import { 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`};
+ }
+`;
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+
+import { 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}
+`;
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+import styled from '@emotion/styled';
+import { 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')};
+ }
+`;
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+
+import styled from '@emotion/styled';
+import { 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')};
+`;
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+
+import styled from '@emotion/styled';
+import { 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')};
+ }
+`;
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+import styled from '@emotion/styled';
+import { buttonStyle } from './Button';
+import { PrimaryStyle } from './ButtonPrimary';
+
+export const DownloadButton = styled.a`
+ ${buttonStyle}
+ ${PrimaryStyle}
+ &:hover {
+ border-bottom-color: transparent;
+ }
+`;
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+
+import styled from '@emotion/styled';
+import 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')};
+`;
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+
+import styled from '@emotion/styled';
+import { 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;
+`;
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+
+import { screen } from '@testing-library/react';
+import { 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)',
+ });
+});
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+/*
+ * 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';
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';
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';
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';
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';
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+
+import styled from '@emotion/styled';
+import 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')};
+ }
+`;
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+
+import styled from '@emotion/styled';
+import classNames from 'classnames';
+import {
+ 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>
+ );
+ };
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+import classNames from 'classnames';
+import { 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>
+ );
+ }
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+
+import styled from '@emotion/styled';
+import 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`};
+ }
+`;
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+import styled from '@emotion/styled';
+import { 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`}
+ }
+`;
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+import { 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};
+`;
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+import styled from '@emotion/styled';
+import classNames from 'classnames';
+import { 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')};
+ }
+`;
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+
+import styled from '@emotion/styled';
+import classNames from 'classnames';
+import { 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`}
+`;
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+
+import { 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 }),
+ }),
+ };
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+import classNames from 'classnames';
+import { difference } from 'lodash';
+import { PureComponent } from 'react';
+import { Key } from '../../helpers/keyboard';
+import { ItemDivider, ItemHeader } from '../DropdownMenu';
+import { InputSearch } from './InputSearch';
+import { 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>
+ );
+ }
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+import classNames from 'classnames';
+import { 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>
+ );
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+import styled from '@emotion/styled';
+import classNames from 'classnames';
+import 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;
+ }
+ }
+`;
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+import classNames from 'classnames';
+import { omit } from 'lodash';
+import React, { RefObject } from 'react';
+import { GroupBase, InputProps, components } from 'react-select';
+import AsyncSelect, { AsyncProps } from 'react-select/async';
+import Select from 'react-select/dist/declarations/src/Select';
+import { INPUT_SIZES } from '../../helpers';
+import { Key } from '../../helpers/keyboard';
+import { translate } from '../../helpers/l10n';
+import { InputSearch } from './InputSearch';
+import { LabelValueSelectOption, SelectProps, selectStyle } from './InputSelect';
+
+type SearchSelectProps<
+ V,
+ Option extends LabelValueSelectOption<V>,
+ IsMulti extends boolean = false,
+ Group extends GroupBase<Option> = GroupBase<Option>
+> = SelectProps<V, Option, IsMulti, Group> & AsyncProps<Option, IsMulti, Group>;
+
+export function SearchSelect<
+ V,
+ Option extends LabelValueSelectOption<V>,
+ IsMulti extends boolean = false,
+ Group extends GroupBase<Option> = GroupBase<Option>
+>({
+ size = 'full',
+ selectRef,
+ ...props
+}: SearchSelectProps<V, Option, IsMulti, Group> & {
+ selectRef?: RefObject<Select<Option, IsMulti, Group>>;
+}) {
+ const styles = selectStyle<V, Option, IsMulti, Group>({ size });
+ return (
+ <AsyncSelect<Option, IsMulti, Group>
+ {...omit(props, 'className', 'large')}
+ className={classNames('react-select', props.className)}
+ classNamePrefix="react-select"
+ classNames={{
+ control: ({ isDisabled }) =>
+ classNames(
+ 'sw-border-0 sw-rounded-2 sw-outline-none sw-shadow-none',
+ isDisabled && 'sw-pointer-events-none sw-cursor-not-allowed'
+ ),
+ indicatorsContainer: () => 'sw-hidden',
+ input: () => `sw-flex sw-w-full sw-p-0 sw-m-0`,
+ valueContainer: () => `sw-px-3 sw-pb-1 sw-mb-1 sw-pt-4`,
+ placeholder: () => 'sw-hidden',
+ ...props.classNames,
+ }}
+ components={{
+ Input: SearchSelectInput,
+ ...props.components,
+ }}
+ ref={selectRef}
+ styles={{
+ ...styles,
+ menu: (base, props) => ({
+ ...styles.menu?.(base, props),
+ width: `calc(${INPUT_SIZES[size]} - 2px)`,
+ }),
+ }}
+ />
+ );
+}
+
+export function SearchSelectInput<
+ V,
+ Option extends LabelValueSelectOption<V>,
+ IsMulti extends boolean = false,
+ Group extends GroupBase<Option> = GroupBase<Option>
+>(props: InputProps<Option, IsMulti, Group>) {
+ const {
+ selectProps: { clearIconLabel, placeholder, isLoading, inputValue, minLength, tooShortText },
+ } = props;
+
+ const onChange = (v: string, prevValue = '') => {
+ props.selectProps.onInputChange(v, { action: 'input-change', prevInputValue: prevValue });
+ };
+
+ const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
+ const target = event.target as HTMLInputElement;
+
+ if (event.key === Key.Escape && target.value !== '') {
+ event.stopPropagation();
+ onChange('');
+ }
+ };
+
+ return (
+ <InputSearch
+ clearIconAriaLabel={clearIconLabel ?? translate('clear')}
+ loading={isLoading && inputValue.length >= (minLength ?? 0)}
+ minLength={minLength}
+ onChange={onChange}
+ size="full"
+ tooShortText={tooShortText}
+ value={inputValue}
+ >
+ <components.Input
+ {...props}
+ onKeyDown={handleKeyDown}
+ placeholder={placeholder as string}
+ style={{}}
+ />
+ </InputSearch>
+ );
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+import styled from '@emotion/styled';
+import { debounce } from 'lodash';
+import React from 'react';
+import {
+ ActionMeta,
+ GroupBase,
+ InputActionMeta,
+ OnChangeValue,
+ OptionsOrGroups,
+} from 'react-select';
+import { AsyncProps } from 'react-select/async';
+import Select from 'react-select/dist/declarations/src/Select';
+import tw from 'twin.macro';
+import { DEBOUNCE_DELAY, 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;
+ }
+ }
+`;
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+
+import styled from '@emotion/styled';
+import classNames from 'classnames';
+import tw from 'twin.macro';
+import { INPUT_SIZES, themeBorder, themeColor, themeContrast } from '../../helpers';
+import { Key } from '../../helpers/keyboard';
+import { InputSizeKeys } from '../../types/theme';
+import { ChevronDownIcon } from '../icons';
+
+interface SearchSelectDropdownControlProps {
+ 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')};
+ }
+`;
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+
+import { screen, 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}
+ />
+ );
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+import { screen, 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}
+ />
+ );
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+import { screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { render } from '../../../helpers/testUtils';
+import { FCProps } from '../../../types/misc';
+import { 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}
+ />
+ );
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+
+import { screen } from '@testing-library/react';
+import { 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>
+ );
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+import { render, screen } from '@testing-library/react';
+import { 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);
+ });
+});
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+import { render, screen } from '@testing-library/react';
+import { 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} />);
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+import { screen, 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}
+ />
+ );
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+import { screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { render } from '../../../helpers/testUtils';
+import { FCProps } from '../../../types/misc';
+import { 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>
+ ),
+ },
+ ]}
+ />
+ );
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { 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}
+ />
+ );
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+import { screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { render } from '../../../helpers/testUtils';
+import { FCProps } from '../../../types/misc';
+import { 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>
+ );
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+import { 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}
+ />
+ );
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+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';