aboutsummaryrefslogtreecommitdiffstats
path: root/server/sonar-web/design-system/src/components/input/InputSearch.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'server/sonar-web/design-system/src/components/input/InputSearch.tsx')
-rw-r--r--server/sonar-web/design-system/src/components/input/InputSearch.tsx261
1 files changed, 261 insertions, 0 deletions
diff --git a/server/sonar-web/design-system/src/components/input/InputSearch.tsx b/server/sonar-web/design-system/src/components/input/InputSearch.tsx
new file mode 100644
index 00000000000..beb1ef1ca18
--- /dev/null
+++ b/server/sonar-web/design-system/src/components/input/InputSearch.tsx
@@ -0,0 +1,261 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+
+import styled from '@emotion/styled';
+import classNames from 'classnames';
+import { debounce } from 'lodash';
+import React, { PropsWithChildren, useEffect, useMemo, useRef, useState } from 'react';
+import tw, { theme } from 'twin.macro';
+import { DEBOUNCE_DELAY, INPUT_SIZES } from '../../helpers/constants';
+import { Key } from '../../helpers/keyboard';
+import { themeBorder, themeColor, themeContrast } from '../../helpers/theme';
+import { isDefined } from '../../helpers/types';
+import { InputSizeKeys } from '../../types/theme';
+import { DeferredSpinner, Spinner } from '../DeferredSpinner';
+import { InteractiveIcon } from '../InteractiveIcon';
+import { CloseIcon } from '../icons/CloseIcon';
+import { SearchIcon } from '../icons/SearchIcon';
+
+interface Props {
+ autoFocus?: boolean;
+ className?: string;
+ clearIconAriaLabel: string;
+ id?: string;
+ innerRef?: React.RefCallback<HTMLInputElement>;
+ loading?: boolean;
+ maxLength?: number;
+ minLength?: number;
+ onBlur?: React.FocusEventHandler<HTMLInputElement>;
+ onChange: (value: string) => void;
+ onFocus?: React.FocusEventHandler<HTMLInputElement>;
+ onKeyDown?: React.KeyboardEventHandler<HTMLInputElement>;
+ onMouseDown?: React.MouseEventHandler<HTMLInputElement>;
+ placeholder?: string;
+ searchInputAriaLabel?: string;
+ size?: InputSizeKeys;
+ tooShortText?: string;
+ value?: string;
+}
+
+const DEFAULT_MAX_LENGTH = 100;
+
+export function InputSearch({
+ autoFocus,
+ id,
+ className,
+ innerRef,
+ onBlur,
+ onChange,
+ onFocus,
+ onKeyDown,
+ onMouseDown,
+ placeholder,
+ loading,
+ minLength,
+ maxLength = DEFAULT_MAX_LENGTH,
+ size = 'medium',
+ value: parentValue,
+ tooShortText,
+ searchInputAriaLabel,
+ clearIconAriaLabel,
+ children,
+}: PropsWithChildren<Props>) {
+ const input = useRef<null | HTMLElement>(null);
+ const [value, setValue] = useState(parentValue ?? '');
+ const debouncedOnChange = useMemo(() => debounce(onChange, DEBOUNCE_DELAY), [onChange]);
+
+ const tooShort = isDefined(minLength) && value.length > 0 && value.length < minLength;
+ const inputClassName = classNames('js-input-search', {
+ touched: value.length > 0 && (!minLength || minLength > value.length),
+ 'sw-pr-10': value.length > 0,
+ });
+
+ useEffect(() => {
+ if (parentValue !== undefined) {
+ setValue(parentValue);
+ }
+ }, [parentValue]);
+
+ useEffect(() => {
+ if (autoFocus && input.current) {
+ input.current.focus();
+ }
+ }, [autoFocus]);
+
+ const changeValue = (newValue: string) => {
+ if (newValue.length === 0 || !minLength || minLength <= newValue.length) {
+ debouncedOnChange(newValue);
+ }
+ };
+
+ const handleInputChange = (event: React.SyntheticEvent<HTMLInputElement>) => {
+ const eventValue = event.currentTarget.value;
+ setValue(eventValue);
+ changeValue(eventValue);
+ };
+
+ const handleInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
+ if (event.key === Key.Escape) {
+ event.preventDefault();
+ handleClearClick();
+ }
+ onKeyDown?.(event);
+ };
+
+ const handleClearClick = () => {
+ onChange('');
+ if (parentValue === undefined || parentValue === '') {
+ setValue('');
+ }
+ input.current?.focus();
+ };
+ const ref = (node: HTMLInputElement | null) => {
+ input.current = node;
+ innerRef?.(node);
+ };
+
+ return (
+ <InputSearchWrapper
+ className={className}
+ id={id}
+ onMouseDown={onMouseDown}
+ style={{ '--inputSize': INPUT_SIZES[size] }}
+ title={tooShort && tooShortText && isDefined(minLength) ? tooShortText : ''}
+ >
+ <StyledInputWrapper className="sw-flex sw-items-center">
+ {children ?? (
+ <input
+ aria-label={searchInputAriaLabel}
+ autoComplete="off"
+ className={inputClassName}
+ maxLength={maxLength}
+ onBlur={onBlur}
+ onChange={handleInputChange}
+ onFocus={onFocus}
+ onKeyDown={handleInputKeyDown}
+ placeholder={placeholder}
+ ref={ref}
+ role="searchbox"
+ type="search"
+ value={value}
+ />
+ )}
+ <DeferredSpinner className="sw-z-normal" loading={loading ?? false}>
+ <StyledSearchIcon />
+ </DeferredSpinner>
+ {value && (
+ <StyledInteractiveIcon
+ Icon={CloseIcon}
+ aria-label={clearIconAriaLabel}
+ className="it__search-box-clear"
+ onClick={handleClearClick}
+ size="small"
+ />
+ )}
+
+ {tooShort && tooShortText && isDefined(minLength) && (
+ <StyledNote className="sw-ml-1" role="note">
+ {tooShortText}
+ </StyledNote>
+ )}
+ </StyledInputWrapper>
+ </InputSearchWrapper>
+ );
+}
+
+InputSearch.displayName = 'InputSearch'; // so that tests don't see the obfuscated production name
+
+export const InputSearchWrapper = styled.div`
+ width: var(--inputSize);
+
+ ${tw`sw-relative sw-inline-block`}
+ ${tw`sw-whitespace-nowrap`}
+ ${tw`sw-align-middle`}
+ ${tw`sw-h-control`}
+
+ ${Spinner} {
+ top: calc((2.25rem - ${theme('spacing.4')}) / 2);
+ ${tw`sw-left-3`};
+ ${tw`sw-absolute`};
+ }
+`;
+
+export const StyledInputWrapper = styled.div`
+ input {
+ background: ${themeColor('inputBackground')};
+ color: ${themeContrast('inputBackground')};
+ border: ${themeBorder('default', 'inputBorder')};
+
+ ${tw`sw-rounded-2`}
+ ${tw`sw-box-border`}
+ ${tw`sw-pl-10`}
+ ${tw`sw-body-sm`}
+ ${tw`sw-w-full sw-h-control`}
+
+ &::placeholder {
+ color: ${themeColor('inputPlaceholder')};
+
+ ${tw`sw-truncate`}
+ }
+
+ &:hover {
+ border: ${themeBorder('default', 'inputFocus')};
+ }
+
+ &:focus,
+ &:active {
+ border: ${themeBorder('default', 'inputFocus')};
+ outline: ${themeBorder('focus', 'inputFocus')};
+ }
+
+ &::-webkit-search-decoration,
+ &::-webkit-search-cancel-button,
+ &::-webkit-search-results-button,
+ &::-webkit-search-results-decoration {
+ ${tw`sw-hidden sw-appearance-none`}
+ }
+ }
+`;
+
+const StyledSearchIcon = styled(SearchIcon)`
+ color: ${themeColor('inputBorder')};
+ top: calc((${theme('height.control')} - ${theme('spacing.4')}) / 2);
+
+ ${tw`sw-left-3`}
+ ${tw`sw-absolute`}
+ ${tw`sw-z-normal`}
+`;
+
+export const StyledInteractiveIcon = styled(InteractiveIcon)`
+ ${tw`sw-absolute`}
+ ${tw`sw-right-2`}
+`;
+
+const StyledNote = styled.span`
+ color: ${themeColor('inputPlaceholder')};
+ top: calc(1px + ${theme('inset.2')});
+
+ ${tw`sw-absolute`}
+ ${tw`sw-left-12 sw-right-10`}
+ ${tw`sw-body-sm`}
+ ${tw`sw-text-right`}
+ ${tw`sw-truncate`}
+ ${tw`sw-pointer-events-none`}
+`;