/* * 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; loading?: boolean; maxLength?: number; minLength?: number; onBlur?: React.FocusEventHandler; onChange: (value: string) => void; onFocus?: React.FocusEventHandler; onKeyDown?: React.KeyboardEventHandler; onMouseDown?: React.MouseEventHandler; 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) { const input = useRef(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) => { const eventValue = event.currentTarget.value; setValue(eventValue); changeValue(eventValue); }; const handleInputKeyDown = (event: React.KeyboardEvent) => { 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 ( {children ?? ( )} {value && ( )} {tooShort && tooShortText && isDefined(minLength) && ( {tooShortText} )} ); } 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`} `;