You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

InputSearch.tsx 6.8KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243
  1. /*
  2. * SonarQube
  3. * Copyright (C) 2009-2023 SonarSource SA
  4. * mailto:info AT sonarsource DOT com
  5. *
  6. * This program is free software; you can redistribute it and/or
  7. * modify it under the terms of the GNU Lesser General Public
  8. * License as published by the Free Software Foundation; either
  9. * version 3 of the License, or (at your option) any later version.
  10. *
  11. * This program is distributed in the hope that it will be useful,
  12. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  13. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
  14. * Lesser General Public License for more details.
  15. *
  16. * You should have received a copy of the GNU Lesser General Public License
  17. * along with this program; if not, write to the Free Software Foundation,
  18. * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
  19. */
  20. import styled from '@emotion/styled';
  21. import classNames from 'classnames';
  22. import { debounce } from 'lodash';
  23. import React, { useEffect, useMemo, useRef, useState } from 'react';
  24. import tw, { theme } from 'twin.macro';
  25. import { DEBOUNCE_DELAY, INPUT_SIZES } from '../helpers/constants';
  26. import { Key } from '../helpers/keyboard';
  27. import { themeBorder, themeColor, themeContrast } from '../helpers/theme';
  28. import { isDefined } from '../helpers/types';
  29. import { InputSizeKeys } from '../types/theme';
  30. import DeferredSpinner from './DeferredSpinner';
  31. import CloseIcon from './icons/CloseIcon';
  32. import SearchIcon from './icons/SearchIcon';
  33. import { InteractiveIcon } from './InteractiveIcon';
  34. interface Props {
  35. autoFocus?: boolean;
  36. className?: string;
  37. clearIconAriaLabel: string;
  38. id?: string;
  39. innerRef?: React.RefCallback<HTMLInputElement>;
  40. loading?: boolean;
  41. maxLength?: number;
  42. minLength?: number;
  43. onBlur?: React.FocusEventHandler<HTMLInputElement>;
  44. onChange: (value: string) => void;
  45. onFocus?: React.FocusEventHandler<HTMLInputElement>;
  46. onKeyDown?: React.KeyboardEventHandler<HTMLInputElement>;
  47. onMouseDown?: React.MouseEventHandler<HTMLInputElement>;
  48. placeholder: string;
  49. searchInputAriaLabel: string;
  50. size?: InputSizeKeys;
  51. tooShortText: string;
  52. value?: string;
  53. }
  54. const DEFAULT_MAX_LENGTH = 100;
  55. export default function InputSearch({
  56. autoFocus,
  57. id,
  58. className,
  59. innerRef,
  60. onBlur,
  61. onChange,
  62. onFocus,
  63. onKeyDown,
  64. onMouseDown,
  65. placeholder,
  66. loading,
  67. minLength,
  68. maxLength = DEFAULT_MAX_LENGTH,
  69. size = 'medium',
  70. value: parentValue,
  71. tooShortText,
  72. searchInputAriaLabel,
  73. clearIconAriaLabel,
  74. }: Props) {
  75. const input = useRef<null | HTMLElement>(null);
  76. const [value, setValue] = useState(parentValue ?? '');
  77. const debouncedOnChange = useMemo(() => debounce(onChange, DEBOUNCE_DELAY), [onChange]);
  78. const tooShort = isDefined(minLength) && value.length > 0 && value.length < minLength;
  79. const inputClassName = classNames('js-input-search', {
  80. touched: value.length > 0 && (!minLength || minLength > value.length),
  81. 'sw-pr-10': value.length > 0,
  82. });
  83. useEffect(() => {
  84. if (parentValue !== undefined) {
  85. setValue(parentValue);
  86. }
  87. }, [parentValue]);
  88. const changeValue = (newValue: string) => {
  89. if (newValue.length === 0 || !minLength || minLength <= newValue.length) {
  90. debouncedOnChange(newValue);
  91. }
  92. };
  93. const handleInputChange = (event: React.SyntheticEvent<HTMLInputElement>) => {
  94. const eventValue = event.currentTarget.value;
  95. setValue(eventValue);
  96. changeValue(eventValue);
  97. };
  98. const handleInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
  99. if (event.key === Key.Escape) {
  100. event.preventDefault();
  101. handleClearClick();
  102. }
  103. onKeyDown?.(event);
  104. };
  105. const handleClearClick = () => {
  106. onChange('');
  107. if (parentValue === undefined || parentValue === '') {
  108. setValue('');
  109. }
  110. input.current?.focus();
  111. };
  112. const ref = (node: HTMLInputElement | null) => {
  113. input.current = node;
  114. innerRef?.(node);
  115. };
  116. return (
  117. <InputSearchWrapper
  118. className={className}
  119. id={id}
  120. onMouseDown={onMouseDown}
  121. style={{ '--inputSize': INPUT_SIZES[size] }}
  122. title={tooShort && isDefined(minLength) ? tooShortText : ''}
  123. >
  124. <StyledInputWrapper className="sw-flex sw-items-center">
  125. <input
  126. aria-label={searchInputAriaLabel}
  127. autoComplete="off"
  128. autoFocus={autoFocus}
  129. className={inputClassName}
  130. maxLength={maxLength}
  131. onBlur={onBlur}
  132. onChange={handleInputChange}
  133. onFocus={onFocus}
  134. onKeyDown={handleInputKeyDown}
  135. placeholder={placeholder}
  136. ref={ref}
  137. role="searchbox"
  138. type="search"
  139. value={value}
  140. />
  141. <DeferredSpinner loading={loading !== undefined ? loading : false}>
  142. <StyledSearchIcon />
  143. </DeferredSpinner>
  144. {value && (
  145. <StyledInteractiveIcon
  146. Icon={CloseIcon}
  147. aria-label={clearIconAriaLabel}
  148. className="js-input-search-clear"
  149. onClick={handleClearClick}
  150. size="small"
  151. />
  152. )}
  153. {tooShort && isDefined(minLength) && (
  154. <StyledNote className="sw-ml-1" role="note">
  155. {tooShortText}
  156. </StyledNote>
  157. )}
  158. </StyledInputWrapper>
  159. </InputSearchWrapper>
  160. );
  161. }
  162. export const InputSearchWrapper = styled.div`
  163. width: var(--inputSize);
  164. ${tw`sw-relative sw-inline-block`}
  165. ${tw`sw-whitespace-nowrap`}
  166. ${tw`sw-align-middle`}
  167. ${tw`sw-h-control`}
  168. `;
  169. export const StyledInputWrapper = styled.div`
  170. input {
  171. background: ${themeColor('inputBackground')};
  172. color: ${themeContrast('inputBackground')};
  173. border: ${themeBorder('default', 'inputBorder')};
  174. ${tw`sw-rounded-2`}
  175. ${tw`sw-box-border`}
  176. ${tw`sw-pl-10`}
  177. ${tw`sw-body-sm`}
  178. ${tw`sw-w-full sw-h-control`}
  179. &::placeholder {
  180. color: ${themeColor('inputPlaceholder')};
  181. ${tw`sw-truncate`}
  182. }
  183. &:hover {
  184. border: ${themeBorder('default', 'inputFocus')};
  185. }
  186. &:focus,
  187. &:active {
  188. border: ${themeBorder('default', 'inputFocus')};
  189. outline: ${themeBorder('focus', 'inputFocus')};
  190. }
  191. &::-webkit-search-decoration,
  192. &::-webkit-search-cancel-button,
  193. &::-webkit-search-results-button,
  194. &::-webkit-search-results-decoration {
  195. ${tw`sw-hidden sw-appearance-none`}
  196. }
  197. }
  198. `;
  199. const StyledSearchIcon = styled(SearchIcon)`
  200. color: ${themeColor('inputBorder')};
  201. top: calc((${theme('height.control')} - ${theme('spacing.4')}) / 2);
  202. ${tw`sw-left-3`}
  203. ${tw`sw-absolute`}
  204. `;
  205. export const StyledInteractiveIcon = styled(InteractiveIcon)`
  206. ${tw`sw-absolute`}
  207. ${tw`sw-right-2`}
  208. `;
  209. const StyledNote = styled.span`
  210. color: ${themeColor('inputPlaceholder')};
  211. top: calc(1px + ${theme('inset.2')});
  212. ${tw`sw-absolute`}
  213. ${tw`sw-left-12 sw-right-10`}
  214. ${tw`sw-body-sm`}
  215. ${tw`sw-text-right`}
  216. ${tw`sw-truncate`}
  217. ${tw`sw-pointer-events-none`}
  218. `;