From 88abfdbb1135c4dd270d7d77876255598aa9a2e7 Mon Sep 17 00:00:00 2001 From: Jeremy Davis Date: Thu, 4 May 2023 11:48:56 +0200 Subject: [PATCH] SONAR-19165 New DatePicker component --- server/sonar-web/design-system/package.json | 2 + .../src/components/DatePicker.tsx | 388 ++++++++++++++++++ .../src/components/DateRangePicker.tsx | 129 ++++++ .../src/components/DiscreetSelect.tsx | 12 +- .../src/components/DropdownToggler.tsx | 2 +- .../src/components/FocusOutHandler.tsx | 2 +- .../src/components/InputField.tsx | 142 +++++++ .../src/components/InputSelect.tsx | 41 +- .../components/__tests__/DatePicker-test.tsx | 117 ++++++ .../__tests__/DateRangePicker-test.tsx | 92 +++++ .../components/__tests__/InputField-test.tsx | 36 ++ .../src/components/icons/CalendarIcon.tsx | 23 ++ .../src/components/icons/ChevronLeftIcon.tsx | 37 ++ .../src/components/icons/index.ts | 2 + .../design-system/src/components/index.ts | 3 + .../main/js/components/controls/DateInput.css | 6 +- server/sonar-web/yarn.lock | 2 + 17 files changed, 1010 insertions(+), 26 deletions(-) create mode 100644 server/sonar-web/design-system/src/components/DatePicker.tsx create mode 100644 server/sonar-web/design-system/src/components/DateRangePicker.tsx create mode 100644 server/sonar-web/design-system/src/components/InputField.tsx create mode 100644 server/sonar-web/design-system/src/components/__tests__/DatePicker-test.tsx create mode 100644 server/sonar-web/design-system/src/components/__tests__/DateRangePicker-test.tsx create mode 100644 server/sonar-web/design-system/src/components/__tests__/InputField-test.tsx create mode 100644 server/sonar-web/design-system/src/components/icons/CalendarIcon.tsx create mode 100644 server/sonar-web/design-system/src/components/icons/ChevronLeftIcon.tsx diff --git a/server/sonar-web/design-system/package.json b/server/sonar-web/design-system/package.json index 4e07b7d40ce..0e92ce18eea 100644 --- a/server/sonar-web/design-system/package.json +++ b/server/sonar-web/design-system/package.json @@ -53,8 +53,10 @@ "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 index 00000000000..601eae00971 --- /dev/null +++ b/server/sonar-web/design-system/src/components/DatePicker.tsx @@ -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; + 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 { + 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 ( + + + + + { + this.setState({ currentMonth }); + }} + selected={selectedDays} + toYear={toYear} + weekStartsOn={1} + /> + + ) : null + } + placement={alignRight ? PopupPlacement.BottomRight : PopupPlacement.BottomLeft} + zLevel={PopupZLevel.Global} + > + + + + {selectedDay !== undefined && showClearButton && ( + + )} + + + + + + ); + } +} + +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 ( + + ); + }; +} 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 index 00000000000..132deafcd04 --- /dev/null +++ b/server/sonar-web/design-system/src/components/DateRangePicker.tsx @@ -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 { + 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 ( +
+ + {separatorText ?? '–'} + { + 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} + /> +
+ ); + } +} diff --git a/server/sonar-web/design-system/src/components/DiscreetSelect.tsx b/server/sonar-web/design-system/src/components/DiscreetSelect.tsx index 7d68321d666..dc3fb0c0b4b 100644 --- a/server/sonar-web/design-system/src/components/DiscreetSelect.tsx +++ b/server/sonar-web/design-system/src/components/DiscreetSelect.tsx @@ -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 { className?: string; customValue?: JSX.Element; - options: LabelValueSelectOption[]; - setValue: ({ value }: LabelValueSelectOption) => void; + options: LabelValueSelectOption[]; + setValue: ({ value }: LabelValueSelectOption) => void; size?: InputSizeKeys; - value: string; + value: V; } -export function DiscreetSelect({ +export function DiscreetSelect({ className, customValue, options, @@ -40,7 +40,7 @@ export function DiscreetSelect({ setValue, value, ...props -}: Props) { +}: Props) { return ( { onFocusOut: () => void; } -export default class FocusOutHandler extends React.PureComponent> { +export class FocusOutHandler extends React.PureComponent> { 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 index 00000000000..1dab905fced --- /dev/null +++ b/server/sonar-web/design-system/src/components/InputField.tsx @@ -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, 'size'> { + as?: React.ElementType; + className?: string; + isInvalid?: boolean; + isValid?: boolean; + size?: InputSizeKeys; +} + +interface InputTextAreaProps extends React.TextareaHTMLAttributes { + className?: string; + isInvalid?: boolean; + isValid?: boolean; + size?: InputSizeKeys; +} + +export const InputField = forwardRef( + ({ size = 'medium', style, ...props }, ref) => { + return ( + + ); + } +); +InputField.displayName = 'InputField'; + +export const InputTextArea = forwardRef( + ({ size = 'medium', style, ...props }, ref) => { + return ( + + ); + } +); +InputTextArea.displayName = 'InputTextArea'; + +const defaultStyle = (props: ThemedProps) => css` + --border: ${themeBorder('default', 'inputBorder')(props)}; + --focusBorder: ${themeBorder('default', 'inputFocus')(props)}; + --focusOutline: ${themeBorder('focus', 'inputFocus')(props)}; +`; + +const dangerStyle = (props: ThemedProps) => css` + --border: ${themeBorder('default', 'inputDanger')(props)}; + --focusBorder: ${themeBorder('default', 'inputDangerFocus')(props)}; + --focusOutline: ${themeBorder('focus', 'inputDangerFocus')(props)}; +`; + +const successStyle = (props: ThemedProps) => css` + --border: ${themeBorder('default', 'inputSuccess')(props)}; + --focusBorder: ${themeBorder('default', 'inputSuccessFocus')(props)}; + --focusOutline: ${themeBorder('focus', 'inputSuccessFocus')(props)}; +`; + +const getInputVariant = (props: ThemedProps & { isInvalid?: boolean; isValid?: boolean }) => { + const { isValid, isInvalid } = props; + if (isInvalid) { + return dangerStyle; + } else if (isValid) { + return successStyle; + } + return defaultStyle; +}; + +const baseStyle = (props: ThemedProps) => css` + color: ${themeContrast('inputBackground')(props)}; + background: ${themeColor('inputBackground')(props)}; + border: var(--border); + width: var(--inputSize); + transition: border-color 0.2s ease; + + ${tw`sw-body-sm`} + ${tw`sw-box-border`} + ${tw`sw-rounded-2`} + ${tw`sw-px-3 sw-py-2`} + + &::placeholder { + color: ${themeColor('inputPlaceholder')(props)}; + } + + &:hover { + border: var(--focusBorder); + } + + &:active, + &:focus, + &:focus-within, + &:focus-visible { + border: var(--focusBorder); + outline: var(--focusOutline); + } + + &:disabled, + &:disabled:hover { + color: ${themeContrast('inputDisabled')(props)}; + background-color: ${themeColor('inputDisabled')(props)}; + border: ${themeBorder('default', 'inputDisabledBorder')(props)}; + outline: none; + + ${tw`sw-cursor-not-allowed`}; + &::placeholder { + color: ${themeContrast('inputDisabled')(props)}; + } + } +`; + +const StyledInput = styled.input` + input[type='text']& { + ${getInputVariant} + ${baseStyle} + ${tw`sw-h-control`} + } +`; + +const StyledTextArea = styled.textarea` + ${getInputVariant}; + ${baseStyle}; +`; diff --git a/server/sonar-web/design-system/src/components/InputSelect.tsx b/server/sonar-web/design-system/src/components/InputSelect.tsx index 2563d3c9c15..3693ec13018 100644 --- a/server/sonar-web/design-system/src/components/InputSelect.tsx +++ b/server/sonar-web/design-system/src/components/InputSelect.tsx @@ -33,7 +33,7 @@ import { themeBorder, themeColor, themeContrast } from '../helpers/theme'; import { InputSizeKeys } from '../types/theme'; import { ChevronDownIcon } from './icons'; -export interface LabelValueSelectOption { +export interface LabelValueSelectOption { Icon?: ReactNode; label: string; value: V; @@ -44,14 +44,16 @@ interface StyleExtensionProps { } type SelectProps< - Option = LabelValueSelectOption, - IsMulti extends boolean = boolean, + V, + Option extends LabelValueSelectOption, + IsMulti extends boolean = false, Group extends GroupBase