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.

SearchSelectDropdown.tsx 6.3KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228
  1. /*
  2. * SonarQube
  3. * Copyright (C) 2009-2024 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 React from 'react';
  22. import {
  23. ActionMeta,
  24. GroupBase,
  25. InputActionMeta,
  26. OnChangeValue,
  27. OptionsOrGroups,
  28. } from 'react-select';
  29. import { AsyncProps } from 'react-select/async';
  30. import Select from 'react-select/dist/declarations/src/Select';
  31. import tw from 'twin.macro';
  32. import { PopupPlacement, PopupZLevel, themeBorder } from '../../helpers';
  33. import { InputSizeKeys } from '../../types/theme';
  34. import { DropdownToggler } from '../DropdownToggler';
  35. import { SearchHighlighterContext } from '../SearchHighlighter';
  36. import { IconOption, LabelValueSelectOption, SelectProps } from './InputSelect';
  37. import { SearchSelect } from './SearchSelect';
  38. import { SearchSelectDropdownControl } from './SearchSelectDropdownControl';
  39. declare module 'react-select/dist/declarations/src/Select' {
  40. export interface Props<Option, IsMulti extends boolean, Group extends GroupBase<Option>> {
  41. minLength?: number;
  42. }
  43. }
  44. export interface SearchSelectDropdownProps<
  45. V,
  46. Option extends LabelValueSelectOption<V>,
  47. IsMulti extends boolean = false,
  48. Group extends GroupBase<Option> = GroupBase<Option>,
  49. > extends SelectProps<V, Option, IsMulti, Group>,
  50. AsyncProps<Option, IsMulti, Group> {
  51. className?: string;
  52. controlAriaLabel?: string;
  53. controlLabel?: React.ReactNode | string;
  54. controlPlaceholder?: string;
  55. controlSize?: InputSizeKeys;
  56. isDiscreet?: boolean;
  57. zLevel?: PopupZLevel;
  58. }
  59. export function SearchSelectDropdown<
  60. V,
  61. Option extends LabelValueSelectOption<V>,
  62. IsMulti extends boolean = false,
  63. Group extends GroupBase<Option> = GroupBase<Option>,
  64. >(props: SearchSelectDropdownProps<V, Option, IsMulti, Group>) {
  65. const {
  66. className,
  67. isDiscreet,
  68. value,
  69. loadOptions,
  70. controlLabel,
  71. controlPlaceholder,
  72. controlSize,
  73. isDisabled,
  74. minLength,
  75. controlAriaLabel,
  76. menuIsOpen,
  77. onChange,
  78. onInputChange,
  79. isClearable,
  80. zLevel = PopupZLevel.Global,
  81. placeholder = '',
  82. ...rest
  83. } = props;
  84. const [open, setOpen] = React.useState(false);
  85. const [inputValue, setInputValue] = React.useState('');
  86. React.useEffect(() => {
  87. if (menuIsOpen) {
  88. setOpen(true);
  89. }
  90. }, [menuIsOpen]);
  91. const ref = React.useRef<Select<Option, IsMulti, Group>>(null);
  92. const computedControlLabel = controlLabel ?? (value as Option | undefined)?.label ?? null;
  93. const toggleDropdown = React.useCallback(
  94. (value?: boolean) => {
  95. setOpen(value ?? !open);
  96. },
  97. [open],
  98. );
  99. const handleChange = React.useCallback(
  100. (newValue: OnChangeValue<Option, IsMulti>, actionMeta: ActionMeta<Option>) => {
  101. toggleDropdown(false);
  102. onChange?.(newValue, actionMeta);
  103. },
  104. [toggleDropdown, onChange],
  105. );
  106. const handleLoadOptions = React.useCallback(
  107. (query: string, callback: (options: OptionsOrGroups<Option, Group>) => void) => {
  108. return query.length >= (minLength ?? 0) ? loadOptions?.(query, callback) : undefined;
  109. },
  110. [minLength, loadOptions],
  111. );
  112. const handleInputChange = React.useCallback(
  113. (newValue: string, actionMeta: InputActionMeta) => {
  114. if (actionMeta.action === 'menu-close') {
  115. setInputValue(actionMeta.prevInputValue);
  116. return actionMeta.prevInputValue;
  117. }
  118. setInputValue(newValue);
  119. onInputChange?.(newValue, actionMeta);
  120. return newValue;
  121. },
  122. [onInputChange],
  123. );
  124. const handleClear = () => {
  125. onChange?.(null as OnChangeValue<Option, IsMulti>, {
  126. action: 'clear',
  127. removedValues: [],
  128. });
  129. };
  130. React.useEffect(() => {
  131. if (open) {
  132. ref.current?.inputRef?.select();
  133. } else {
  134. setInputValue('');
  135. }
  136. }, [open]);
  137. return (
  138. <DropdownToggler
  139. allowResizing
  140. className="sw-overflow-visible sw-border-none"
  141. onRequestClose={() => {
  142. toggleDropdown(false);
  143. }}
  144. open={open}
  145. overlay={
  146. <SearchHighlighterContext.Provider value={inputValue}>
  147. <StyledSearchSelectWrapper>
  148. <SearchSelect
  149. cacheOptions
  150. {...rest}
  151. components={{
  152. SingleValue: () => null,
  153. Option: IconOption,
  154. ...rest.components,
  155. }}
  156. loadOptions={handleLoadOptions}
  157. menuIsOpen
  158. minLength={minLength}
  159. onChange={handleChange}
  160. onInputChange={handleInputChange}
  161. placeholder={placeholder}
  162. selectRef={ref}
  163. size="large"
  164. />
  165. </StyledSearchSelectWrapper>
  166. </SearchHighlighterContext.Provider>
  167. }
  168. placement={PopupPlacement.BottomLeft}
  169. zLevel={zLevel}
  170. >
  171. <SearchSelectDropdownControl
  172. ariaLabel={controlAriaLabel}
  173. className={className}
  174. disabled={isDisabled}
  175. isClearable={isClearable && Boolean(value)}
  176. isDiscreet={isDiscreet}
  177. label={computedControlLabel}
  178. onClear={handleClear}
  179. onClick={() => {
  180. toggleDropdown(true);
  181. }}
  182. placeholder={controlPlaceholder}
  183. size={controlSize}
  184. />
  185. </DropdownToggler>
  186. );
  187. }
  188. const StyledSearchSelectWrapper = styled.div`
  189. ${tw`sw-w-full`};
  190. ${tw`sw-rounded-2`};
  191. .react-select {
  192. border: ${themeBorder('default', 'inputDisabledBorder')};
  193. ${tw`sw-rounded-2`};
  194. }
  195. .react-select__menu {
  196. ${tw`sw-m-0`};
  197. ${tw`sw-relative`};
  198. ${tw`sw-shadow-none`};
  199. ${tw`sw-rounded-2`};
  200. }
  201. .react-select__menu-notice--loading {
  202. ${tw`sw-hidden`}
  203. }
  204. .react-select__input-container {
  205. &::after {
  206. content: '' !important;
  207. }
  208. }
  209. `;