]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-19165 New DatePicker component
authorJeremy Davis <jeremy.davis@sonarsource.com>
Thu, 4 May 2023 09:48:56 +0000 (11:48 +0200)
committersonartech <sonartech@sonarsource.com>
Fri, 12 May 2023 20:02:41 +0000 (20:02 +0000)
17 files changed:
server/sonar-web/design-system/package.json
server/sonar-web/design-system/src/components/DatePicker.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/DateRangePicker.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/DiscreetSelect.tsx
server/sonar-web/design-system/src/components/DropdownToggler.tsx
server/sonar-web/design-system/src/components/FocusOutHandler.tsx
server/sonar-web/design-system/src/components/InputField.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/InputSelect.tsx
server/sonar-web/design-system/src/components/__tests__/DatePicker-test.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/__tests__/DateRangePicker-test.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/__tests__/InputField-test.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/icons/CalendarIcon.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/icons/ChevronLeftIcon.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/icons/index.ts
server/sonar-web/design-system/src/components/index.ts
server/sonar-web/src/main/js/components/controls/DateInput.css
server/sonar-web/yarn.lock

index 4e07b7d40ce4efa1c47aabddc0baf4d03ed126bf..0e92ce18eea17fcef3b29e4401aadd7d8e81d0e4 100644 (file)
     "d3-array": "3.2.3",
     "d3-scale": "4.0.2",
     "d3-shape": "3.2.0",
+    "date-fns": "2.29.3",
     "lodash": "4.17.21",
     "react": "17.0.2",
+    "react-day-picker": "8.6.0",
     "react-dom": "17.0.2",
     "react-helmet-async": "1.3.0",
     "react-intl": "6.2.5",
diff --git a/server/sonar-web/design-system/src/components/DatePicker.tsx b/server/sonar-web/design-system/src/components/DatePicker.tsx
new file mode 100644 (file)
index 0000000..601eae0
--- /dev/null
@@ -0,0 +1,388 @@
+/*
+ * 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;
+}
+
+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,
+      size,
+    } = 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={true}
+              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={PopupZLevel.Global}
+            >
+              <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={true}
+                  ref={inputRef}
+                  size={size}
+                  title={this.props.valueFormatter(selectedDay)}
+                  type="text"
+                  value={this.props.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={() => previousMonth && goToMonth(previousMonth)}
+          size="small"
+        />
+        <InputSelect
+          isClearable={false}
+          onChange={(value) => {
+            if (value) {
+              goToMonth(value.value);
+            }
+          }}
+          options={months}
+          size="full"
+          value={months.find((m) => isSameMonth(m.value, displayMonth))}
+        />
+        <InputSelect
+          className="sw-ml-1"
+          isClearable={false}
+          onChange={(value) => {
+            if (value) {
+              goToMonth(value.value);
+            }
+          }}
+          options={years}
+          size="full"
+          value={years.find((y) => isSameYear(y.value, displayMonth))}
+        />
+        <InteractiveIcon
+          Icon={ChevronRightIcon}
+          aria-label={ariaNextMonthLabel}
+          className="sw-ml-2"
+          onClick={() => nextMonth && goToMonth(nextMonth)}
+          size="small"
+        />
+      </nav>
+    );
+  };
+}
diff --git a/server/sonar-web/design-system/src/components/DateRangePicker.tsx b/server/sonar-web/design-system/src/components/DateRangePicker.tsx
new file mode 100644 (file)
index 0000000..132deaf
--- /dev/null
@@ -0,0 +1,129 @@
+/*
+ * 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 { 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;
+}
+
+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,
+    } = 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}
+        />
+        <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}
+        />
+      </div>
+    );
+  }
+}
index 7d68321d6663e115047769053c800a64a4d216db..dc3fb0c0b4ba5605ab54a160e4f323e28cfa7a32 100644 (file)
@@ -23,16 +23,16 @@ import { themeBorder, themeColor, themeContrast } from '../helpers/theme';
 import { InputSizeKeys } from '../types/theme';
 import { InputSelect, LabelValueSelectOption } from './InputSelect';
 
-interface Props {
+interface Props<V> {
   className?: string;
   customValue?: JSX.Element;
-  options: LabelValueSelectOption[];
-  setValue: ({ value }: LabelValueSelectOption) => void;
+  options: LabelValueSelectOption<V>[];
+  setValue: ({ value }: LabelValueSelectOption<V>) => void;
   size?: InputSizeKeys;
-  value: string;
+  value: V;
 }
 
-export function DiscreetSelect({
+export function DiscreetSelect<V>({
   className,
   customValue,
   options,
@@ -40,7 +40,7 @@ export function DiscreetSelect({
   setValue,
   value,
   ...props
-}: Props) {
+}: Props<V>) {
   return (
     <StyledSelect
       className={className}
index 14ed580b209edb9914c31982cfc136d9a8ad8938..e81ac54bbadbb53c8824ee356ebb208324f66f03 100644 (file)
@@ -18,7 +18,7 @@
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import EscKeydownHandler from './EscKeydownHandler';
-import FocusOutHandler from './FocusOutHandler';
+import { FocusOutHandler } from './FocusOutHandler';
 import OutsideClickHandler from './OutsideClickHandler';
 import { Popup } from './popups';
 
index dbd700e633aa86557abded1f92e7058b3d1e3741..205477fa3863f9a69bfec8104534300342770e53 100644 (file)
@@ -24,7 +24,7 @@ interface Props extends React.BaseHTMLAttributes<HTMLDivElement> {
   onFocusOut: () => void;
 }
 
-export default class FocusOutHandler extends React.PureComponent<React.PropsWithChildren<Props>> {
+export class FocusOutHandler extends React.PureComponent<React.PropsWithChildren<Props>> {
   ref?: HTMLDivElement;
 
   componentDidMount() {
diff --git a/server/sonar-web/design-system/src/components/InputField.tsx b/server/sonar-web/design-system/src/components/InputField.tsx
new file mode 100644 (file)
index 0000000..1dab905
--- /dev/null
@@ -0,0 +1,142 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import { css } from '@emotion/react';
+import styled from '@emotion/styled';
+import { forwardRef } from 'react';
+import tw from 'twin.macro';
+import { INPUT_SIZES } from '../helpers/constants';
+import { themeBorder, themeColor, themeContrast } from '../helpers/theme';
+import { InputSizeKeys, ThemedProps } from '../types/theme';
+
+interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size'> {
+  as?: React.ElementType;
+  className?: string;
+  isInvalid?: boolean;
+  isValid?: boolean;
+  size?: InputSizeKeys;
+}
+
+interface InputTextAreaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
+  className?: string;
+  isInvalid?: boolean;
+  isValid?: boolean;
+  size?: InputSizeKeys;
+}
+
+export const InputField = forwardRef<HTMLInputElement, InputProps>(
+  ({ size = 'medium', style, ...props }, ref) => {
+    return (
+      <StyledInput ref={ref} style={{ ...style, '--inputSize': INPUT_SIZES[size] }} {...props} />
+    );
+  }
+);
+InputField.displayName = 'InputField';
+
+export const InputTextArea = forwardRef<HTMLTextAreaElement, InputTextAreaProps>(
+  ({ size = 'medium', style, ...props }, ref) => {
+    return (
+      <StyledTextArea ref={ref} style={{ ...style, '--inputSize': INPUT_SIZES[size] }} {...props} />
+    );
+  }
+);
+InputTextArea.displayName = 'InputTextArea';
+
+const defaultStyle = (props: ThemedProps) => css`
+  --border: ${themeBorder('default', 'inputBorder')(props)};
+  --focusBorder: ${themeBorder('default', 'inputFocus')(props)};
+  --focusOutline: ${themeBorder('focus', 'inputFocus')(props)};
+`;
+
+const dangerStyle = (props: ThemedProps) => css`
+  --border: ${themeBorder('default', 'inputDanger')(props)};
+  --focusBorder: ${themeBorder('default', 'inputDangerFocus')(props)};
+  --focusOutline: ${themeBorder('focus', 'inputDangerFocus')(props)};
+`;
+
+const successStyle = (props: ThemedProps) => css`
+  --border: ${themeBorder('default', 'inputSuccess')(props)};
+  --focusBorder: ${themeBorder('default', 'inputSuccessFocus')(props)};
+  --focusOutline: ${themeBorder('focus', 'inputSuccessFocus')(props)};
+`;
+
+const getInputVariant = (props: ThemedProps & { isInvalid?: boolean; isValid?: boolean }) => {
+  const { isValid, isInvalid } = props;
+  if (isInvalid) {
+    return dangerStyle;
+  } else if (isValid) {
+    return successStyle;
+  }
+  return defaultStyle;
+};
+
+const baseStyle = (props: ThemedProps) => css`
+  color: ${themeContrast('inputBackground')(props)};
+  background: ${themeColor('inputBackground')(props)};
+  border: var(--border);
+  width: var(--inputSize);
+  transition: border-color 0.2s ease;
+
+  ${tw`sw-body-sm`}
+  ${tw`sw-box-border`}
+  ${tw`sw-rounded-2`}
+  ${tw`sw-px-3 sw-py-2`}
+
+  &::placeholder {
+    color: ${themeColor('inputPlaceholder')(props)};
+  }
+
+  &:hover {
+    border: var(--focusBorder);
+  }
+
+  &:active,
+  &:focus,
+  &:focus-within,
+  &:focus-visible {
+    border: var(--focusBorder);
+    outline: var(--focusOutline);
+  }
+
+  &:disabled,
+  &:disabled:hover {
+    color: ${themeContrast('inputDisabled')(props)};
+    background-color: ${themeColor('inputDisabled')(props)};
+    border: ${themeBorder('default', 'inputDisabledBorder')(props)};
+    outline: none;
+
+    ${tw`sw-cursor-not-allowed`};
+    &::placeholder {
+      color: ${themeContrast('inputDisabled')(props)};
+    }
+  }
+`;
+
+const StyledInput = styled.input`
+  input[type='text']& {
+    ${getInputVariant}
+    ${baseStyle}
+    ${tw`sw-h-control`}
+  }
+`;
+
+const StyledTextArea = styled.textarea`
+  ${getInputVariant};
+  ${baseStyle};
+`;
index 2563d3c9c15d3b2eeaecd2918d0bcc43e6c7aac9..3693ec13018cc7bf235fd45d7b2001a0e89df958 100644 (file)
@@ -33,7 +33,7 @@ import { themeBorder, themeColor, themeContrast } from '../helpers/theme';
 import { InputSizeKeys } from '../types/theme';
 import { ChevronDownIcon } from './icons';
 
-export interface LabelValueSelectOption<V = string> {
+export interface LabelValueSelectOption<V> {
   Icon?: ReactNode;
   label: string;
   value: V;
@@ -44,14 +44,16 @@ interface StyleExtensionProps {
 }
 
 type SelectProps<
-  Option = LabelValueSelectOption,
-  IsMulti extends boolean = boolean,
+  V,
+  Option extends LabelValueSelectOption<V>,
+  IsMulti extends boolean = false,
   Group extends GroupBase<Option> = GroupBase<Option>
 > = NamedProps<Option, IsMulti, Group> & StyleExtensionProps;
 
 function IconOption<
-  Option extends LabelValueSelectOption,
-  IsMulti extends boolean = boolean,
+  V,
+  Option extends LabelValueSelectOption<V>,
+  IsMulti extends boolean = false,
   Group extends GroupBase<Option> = GroupBase<Option>
 >(props: OptionProps<Option, IsMulti, Group>) {
   const {
@@ -69,8 +71,9 @@ function IconOption<
 }
 
 function SingleValue<
-  Option extends LabelValueSelectOption,
-  IsMulti extends boolean = boolean,
+  V,
+  Option extends LabelValueSelectOption<V>,
+  IsMulti extends boolean = false,
   Group extends GroupBase<Option> = GroupBase<Option>
 >(props: OptionProps<Option, IsMulti, Group>) {
   const {
@@ -88,8 +91,9 @@ function SingleValue<
 }
 
 function IndicatorsContainer<
-  Option extends LabelValueSelectOption,
-  IsMulti extends boolean = boolean,
+  V,
+  Option extends LabelValueSelectOption<V>,
+  IsMulti extends boolean = false,
   Group extends GroupBase<Option> = GroupBase<Option>
 >(props: OptionProps<Option, IsMulti, Group>) {
   return (
@@ -102,10 +106,11 @@ function IndicatorsContainer<
 }
 
 export function InputSelect<
-  Option extends LabelValueSelectOption = LabelValueSelectOption,
-  IsMulti extends boolean = boolean,
+  V,
+  Option extends LabelValueSelectOption<V>,
+  IsMulti extends boolean = false,
   Group extends GroupBase<Option> = GroupBase<Option>
->({ size = 'medium', ...props }: SelectProps<Option, IsMulti, Group>) {
+>({ size = 'medium', ...props }: SelectProps<V, Option, IsMulti, Group>) {
   return (
     <ReactSelect<Option, IsMulti, Group>
       {...omit(props, 'className', 'large')}
@@ -114,10 +119,11 @@ export function InputSelect<
       classNames={{
         container: () => 'sw-relative sw-inline-block sw-align-middle',
         placeholder: () => 'sw-truncate sw-leading-4',
-        menu: () => 'sw-overflow-y-auto sw-py-2 sw-max-h-[12.25rem]',
+        menu: () => 'sw-w-auto',
+        menuList: () => 'sw-overflow-y-auto sw-py-2 sw-max-h-[12.25rem]',
         control: ({ isDisabled }) =>
           classNames(
-            'sw-absolut sw-box-border sw-rounded-2 sw-mt-1 sw-overflow-hidden sw-z-dropdown-menu',
+            'sw-absolut sw-box-border sw-rounded-2 sw-overflow-hidden sw-z-dropdown-menu',
             isDisabled && 'sw-pointer-events-none sw-cursor-not-allowed'
           ),
         option: ({ isDisabled }) =>
@@ -134,14 +140,15 @@ export function InputSelect<
         IndicatorSeparator: null,
       }}
       isSearchable={props.isSearchable ?? false}
-      styles={selectStyle<Option, IsMulti, Group>({ size })}
+      styles={selectStyle({ size })}
     />
   );
 }
 
 export function selectStyle<
-  Option = LabelValueSelectOption,
-  IsMulti extends boolean = boolean,
+  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();
diff --git a/server/sonar-web/design-system/src/components/__tests__/DatePicker-test.tsx b/server/sonar-web/design-system/src/components/__tests__/DatePicker-test.tsx
new file mode 100644 (file)
index 0000000..8edfa14
--- /dev/null
@@ -0,0 +1,117 @@
+/*
+ * 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('highlights the appropriate days', async () => {
+  const user = userEvent.setup();
+
+  const value = parseISO('2022-06-14');
+  renderDatePicker({ highlightFrom: parseISO('2022-06-12'), showClearButton: true, value });
+
+  await user.click(screen.getByRole('textbox'));
+
+  expect(screen.getByText('11')).not.toHaveClass('rdp-highlighted');
+  expect(screen.getByText('12')).toHaveClass('rdp-highlighted');
+  expect(screen.getByText('13')).toHaveClass('rdp-highlighted');
+  expect(screen.getByText('14')).toHaveClass('rdp-highlighted');
+  expect(screen.getByText('15')).not.toHaveClass('rdp-highlighted');
+});
+
+function renderDatePicker(overrides: Partial<DatePicker['props']> = {}) {
+  const defaultFormatter = (date?: Date) => (date ? date.toISOString() : '');
+
+  render(
+    <DatePicker
+      ariaNextMonthLabel="next"
+      ariaPreviousMonthLabel="previous"
+      clearButtonLabel="clear"
+      onChange={jest.fn()}
+      placeholder="placeholder"
+      valueFormatter={defaultFormatter}
+      {...overrides}
+    />
+  );
+}
diff --git a/server/sonar-web/design-system/src/components/__tests__/DateRangePicker-test.tsx b/server/sonar-web/design-system/src/components/__tests__/DateRangePicker-test.tsx
new file mode 100644 (file)
index 0000000..355dd21
--- /dev/null
@@ -0,0 +1,92 @@
+/*
+ * 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';
+
+beforeAll(() => {
+  jest.useFakeTimers().setSystemTime(parseISO('2022-06-12'));
+});
+
+afterAll(() => {
+  jest.useRealTimers();
+});
+
+it('behaves correctly', async () => {
+  // Remove delay to play nice with fake timers
+  const user = userEvent.setup({ delay: null });
+
+  const onChange = jest.fn((_: { from?: Date; to?: Date }) => undefined);
+  renderDateRangePicker({ onChange });
+
+  await user.click(screen.getByRole('textbox', { name: 'from' }));
+
+  const fromDateNav = screen.getByRole('navigation');
+  expect(fromDateNav).toBeInTheDocument();
+
+  await user.click(within(fromDateNav).getByRole('button', { name: 'previous' }));
+  await user.click(screen.getByText('7'));
+
+  expect(screen.queryByRole('navigation')).not.toBeInTheDocument();
+
+  expect(onChange).toHaveBeenCalled();
+  const { from } = onChange.mock.calls[0][0]; // first argument
+  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+  expect(formatISO(from!, { representation: 'date' })).toBe('2022-05-07');
+
+  onChange.mockClear();
+
+  jest.runAllTimers();
+
+  const toDateNav = await screen.findByRole('navigation');
+  const previousButton = within(toDateNav).getByRole('button', { name: 'previous' });
+  const nextButton = within(toDateNav).getByRole('button', { name: 'next' });
+  await user.click(previousButton);
+  await user.click(nextButton);
+  await user.click(previousButton);
+  await user.click(screen.getByText('12'));
+
+  expect(screen.queryByRole('navigation')).not.toBeInTheDocument();
+
+  expect(onChange).toHaveBeenCalled();
+  const { to } = onChange.mock.calls[0][0]; // first argument
+  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+  expect(formatISO(to!, { representation: 'date' })).toBe('2022-05-12');
+});
+
+function renderDateRangePicker(overrides: Partial<DateRangePicker['props']> = {}) {
+  const defaultFormatter = (date?: Date) =>
+    date ? formatISO(date, { representation: 'date' }) : '';
+
+  render(
+    <DateRangePicker
+      ariaNextMonthLabel="next"
+      ariaPreviousMonthLabel="previous"
+      clearButtonLabel="clear"
+      fromLabel="from"
+      onChange={jest.fn()}
+      toLabel="to"
+      valueFormatter={defaultFormatter}
+      {...overrides}
+    />
+  );
+}
diff --git a/server/sonar-web/design-system/src/components/__tests__/InputField-test.tsx b/server/sonar-web/design-system/src/components/__tests__/InputField-test.tsx
new file mode 100644 (file)
index 0000000..47fcac1
--- /dev/null
@@ -0,0 +1,36 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import { render, screen } from '@testing-library/react';
+import { InputField } from '../InputField';
+
+describe('Input Field', () => {
+  it.each([
+    ['default', false, false, 'defaultStyle'],
+    ['invalid', true, false, 'dangerStyle'],
+    ['valid', false, true, 'successStyle'],
+  ])('should handle status %s', (_, isInvalid, isValid, expectedStyle) => {
+    render(<InputField isInvalid={isInvalid} isValid={isValid} />);
+
+    // Emotion classes contain pseudo-random parts, we're interesting in the fixed part
+    // so we can't just check a specific class
+    // eslint-disable-next-line jest-dom/prefer-to-have-class
+    expect(screen.getByRole('textbox').className).toContain(expectedStyle);
+  });
+});
diff --git a/server/sonar-web/design-system/src/components/icons/CalendarIcon.tsx b/server/sonar-web/design-system/src/components/icons/CalendarIcon.tsx
new file mode 100644 (file)
index 0000000..dbdd8ca
--- /dev/null
@@ -0,0 +1,23 @@
+/*
+ * 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 { CalendarIcon as Octicon } from '@primer/octicons-react';
+import { OcticonHoc } from './Icon';
+
+export const CalendarIcon = OcticonHoc(Octicon);
diff --git a/server/sonar-web/design-system/src/components/icons/ChevronLeftIcon.tsx b/server/sonar-web/design-system/src/components/icons/ChevronLeftIcon.tsx
new file mode 100644 (file)
index 0000000..289afea
--- /dev/null
@@ -0,0 +1,37 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+
+import { useTheme } from '@emotion/react';
+import { themeColor } from '../../helpers/theme';
+import { CustomIcon, IconProps } from './Icon';
+
+export function ChevronLeftIcon({ fill = 'currentColor', ...iconProps }: IconProps) {
+  const theme = useTheme();
+  return (
+    <CustomIcon {...iconProps}>
+      <path
+        clipRule="evenodd"
+        d="M10.16801 12.7236c-.19527.1953-.51185.1953-.70711 0l-4.185-4.18499c-.1953-.19526-.1953-.51184 0-.7071l4.185-4.18503c.19526-.19527.51184-.19527.70711 0 .19526.19526.19526.51184 0 .7071L6.33653 8.18506l3.83148 3.83144c.19526.1953.19526.5119 0 .7071Z"
+        fill={themeColor(fill)({ theme })}
+        fillRule="evenodd"
+      />
+    </CustomIcon>
+  );
+}
index 3b23d108df4717889a1ac7949d04f050341ec224..c81fbbf5fbb6f24f7c5425ce9e3ef86d548bc213 100644 (file)
@@ -20,7 +20,9 @@
 
 export { BranchIcon } from './BranchIcon';
 export { BugIcon } from './BugIcon';
+export { CalendarIcon } from './CalendarIcon';
 export { ChevronDownIcon } from './ChevronDownIcon';
+export { ChevronLeftIcon } from './ChevronLeftIcon';
 export { ChevronRightIcon } from './ChevronRightIcon';
 export { ClockIcon } from './ClockIcon';
 export { CodeSmellIcon } from './CodeSmellIcon';
index 1859acdfabadf904a4a3c6ffb9424723bb960328..5f4a162be75eb467377016602508ecf0e96ae08d 100644 (file)
@@ -24,6 +24,8 @@ export { Badge } from './Badge';
 export { BarChart } from './BarChart';
 export * from './Card';
 export * from './CoverageIndicator';
+export * from './DatePicker';
+export * from './DateRangePicker';
 export { DeferredSpinner } from './DeferredSpinner';
 export * from './DiscreetSelect';
 export { Dropdown } from './Dropdown';
@@ -37,6 +39,7 @@ export { FlagMessage } from './FlagMessage';
 export * from './GenericAvatar';
 export * from './HighlightedSection';
 export { HotspotRating } from './HotspotRating';
+export * from './InputField';
 export { InputSearch } from './InputSearch';
 export * from './InputSelect';
 export * from './InteractiveIcon';
index a35e988f6b236934c84d6a0e21f6c58f8ad8369f..3c610b8625c8def1d042406a609a8d871d2303a5 100644 (file)
 }
 
 .rdp-day_selected {
-  background-color: var(--blue) !important;
+  background-color: var(--blue);
+}
+
+.rdp-day_selected:hover {
+  background-color: var(--blue);
 }
 
 .date-input-control {
index fc4247f452ca4010f01abc16d16a36e7941c3226..a34d7f09b13add40b3e155588bcdf5694c48c5b9 100644 (file)
@@ -6153,8 +6153,10 @@ __metadata:
     d3-array: 3.2.3
     d3-scale: 4.0.2
     d3-shape: 3.2.0
+    date-fns: 2.29.3
     lodash: 4.17.21
     react: 17.0.2
+    react-day-picker: 8.6.0
     react-dom: 17.0.2
     react-helmet-async: 1.3.0
     react-intl: 6.2.5