index.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440
  1. import React from 'react';
  2. import cls from 'classnames';
  3. import PropTypes from 'prop-types';
  4. import {
  5. noop,
  6. isString,
  7. isArray,
  8. isNull,
  9. isUndefined,
  10. isFunction
  11. } from 'lodash-es';
  12. import { cssClasses, strings } from '@douyinfe/semi-foundation/tagInput/constants';
  13. import '@douyinfe/semi-foundation/tagInput/tagInput.scss';
  14. import TagInputFoundation, { TagInputAdapter } from '@douyinfe/semi-foundation/tagInput/foundation';
  15. import { ArrayElement } from '../_base/base';
  16. import BaseComponent from '../_base/baseComponent';
  17. import Tag from '../tag';
  18. import Input from '../input';
  19. import Popover, { PopoverProps } from '../popover';
  20. import Paragraph from '../typography/paragraph';
  21. import { IconClear } from '@douyinfe/semi-icons';
  22. export type Size = ArrayElement<typeof strings.SIZE_SET>;
  23. export type RestTagsPopoverProps = PopoverProps;
  24. type ValidateStatus = "default" | "error" | "warning";
  25. export interface TagInputProps {
  26. className?: string;
  27. defaultValue?: string[];
  28. disabled?: boolean;
  29. inputValue?: string;
  30. maxLength?: number;
  31. max?: number;
  32. maxTagCount?: number;
  33. showRestTagsPopover?: boolean;
  34. restTagsPopoverProps?: RestTagsPopoverProps;
  35. showContentTooltip?: boolean;
  36. allowDuplicates?: boolean;
  37. addOnBlur?: boolean;
  38. onAdd?: (addedValue: string[]) => void;
  39. onBlur?: (e: React.MouseEvent<HTMLInputElement>) => void;
  40. onChange?: (value: string[]) => void;
  41. onExceed?: ((value: string[]) => void);
  42. onFocus?: (e: React.MouseEvent<HTMLInputElement>) => void;
  43. onInputChange?: (value: string, e: React.MouseEvent<HTMLInputElement>) => void;
  44. onInputExceed?: ((value: string) => void);
  45. onKeyDown?: (e: React.MouseEvent<HTMLInputElement>) => void;
  46. onRemove?: (removedValue: string, idx: number) => void;
  47. placeholder?: string;
  48. prefix?: React.ReactNode;
  49. renderTagItem?: (value: string, index: number) => React.ReactNode;
  50. separator?: string | string[] | null;
  51. showClear?: boolean;
  52. size?: Size;
  53. style?: React.CSSProperties;
  54. suffix?: React.ReactNode;
  55. validateStatus?: ValidateStatus;
  56. value?: string[];
  57. autoFocus?: boolean;
  58. }
  59. export interface TagInputState {
  60. tagsArray?: string[];
  61. inputValue?: string;
  62. focusing?: boolean;
  63. hovering?: boolean;
  64. }
  65. const prefixCls = cssClasses.PREFIX;
  66. class TagInput extends BaseComponent<TagInputProps, TagInputState> {
  67. static propTypes = {
  68. children: PropTypes.node,
  69. style: PropTypes.object,
  70. className: PropTypes.string,
  71. disabled: PropTypes.bool,
  72. allowDuplicates: PropTypes.bool,
  73. max: PropTypes.number,
  74. maxTagCount: PropTypes.number,
  75. maxLength: PropTypes.number,
  76. showRestTagsPopover: PropTypes.bool,
  77. restTagsPopoverProps: PropTypes.object,
  78. showContentTooltip: PropTypes.bool,
  79. defaultValue: PropTypes.array,
  80. value: PropTypes.array,
  81. inputValue: PropTypes.string,
  82. placeholder: PropTypes.string,
  83. separator: PropTypes.oneOfType([PropTypes.string, PropTypes.array]),
  84. showClear: PropTypes.bool,
  85. addOnBlur: PropTypes.bool,
  86. autoFocus: PropTypes.bool,
  87. renderTagItem: PropTypes.func,
  88. onBlur: PropTypes.func,
  89. onFocus: PropTypes.func,
  90. onChange: PropTypes.func,
  91. onInputChange: PropTypes.func,
  92. onExceed: PropTypes.func,
  93. onInputExceed: PropTypes.func,
  94. onAdd: PropTypes.func,
  95. onRemove: PropTypes.func,
  96. onKeyDown: PropTypes.func,
  97. size: PropTypes.oneOf(strings.SIZE_SET),
  98. validateStatus: PropTypes.oneOf(strings.STATUS),
  99. prefix: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
  100. suffix: PropTypes.oneOfType([PropTypes.string, PropTypes.node])
  101. };
  102. static defaultProps = {
  103. showClear: false,
  104. addOnBlur: false,
  105. allowDuplicates: true,
  106. showRestTagsPopover: true,
  107. autoFocus: false,
  108. showContentTooltip: true,
  109. separator: ',',
  110. size: 'default' as const,
  111. validateStatus: 'default' as const,
  112. onBlur: noop,
  113. onFocus: noop,
  114. onChange: noop,
  115. onInputChange: noop,
  116. onExceed: noop,
  117. onInputExceed: noop,
  118. onAdd: noop,
  119. onRemove: noop,
  120. onKeyDown: noop,
  121. };
  122. inputRef: React.RefObject<HTMLInputElement>;
  123. constructor(props: TagInputProps) {
  124. super(props);
  125. this.foundation = new TagInputFoundation(this.adapter);
  126. this.state = {
  127. tagsArray: props.defaultValue || [],
  128. inputValue: '',
  129. focusing: false,
  130. hovering: false
  131. };
  132. this.inputRef = React.createRef();
  133. }
  134. static getDerivedStateFromProps(nextProps: TagInputProps, prevState: TagInputState) {
  135. const {
  136. value,
  137. inputValue,
  138. } = nextProps;
  139. return {
  140. tagsArray: isArray(value) ? value : prevState.tagsArray,
  141. inputValue: isString(inputValue) ? inputValue : prevState.inputValue
  142. };
  143. }
  144. get adapter(): TagInputAdapter {
  145. return {
  146. ...super.adapter,
  147. setInputValue: (inputValue: string) => {
  148. this.setState({ inputValue });
  149. },
  150. setTagsArray: (tagsArray: string[]) => {
  151. this.setState({ tagsArray });
  152. },
  153. setFocusing: (focusing: boolean) => {
  154. this.setState({ focusing });
  155. },
  156. setHovering: (hovering: boolean) => {
  157. this.setState({ hovering });
  158. },
  159. notifyBlur: (e: React.MouseEvent<HTMLInputElement>) => {
  160. this.props.onBlur(e);
  161. },
  162. notifyFocus: (e: React.MouseEvent<HTMLInputElement>) => {
  163. this.props.onFocus(e);
  164. },
  165. notifyInputChange: (v: string, e: React.MouseEvent<HTMLInputElement>) => {
  166. this.props.onInputChange(v, e);
  167. },
  168. notifyTagChange: (v: string[]) => {
  169. this.props.onChange(v);
  170. },
  171. notifyTagAdd: (v: string[]) => {
  172. this.props.onAdd(v);
  173. },
  174. notifyTagRemove: (v: string, idx: number) => {
  175. this.props.onRemove(v, idx);
  176. },
  177. notifyKeyDown: e => {
  178. this.props.onKeyDown(e);
  179. },
  180. };
  181. }
  182. componentDidMount() {
  183. const { disabled, autoFocus } = this.props;
  184. if (!disabled && autoFocus) {
  185. this.inputRef.current.focus();
  186. }
  187. }
  188. handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
  189. this.foundation.handleInputChange(e);
  190. };
  191. handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
  192. this.foundation.handleKeyDown(e);
  193. };
  194. handleInputFocus = (e: React.MouseEvent<HTMLInputElement>) => {
  195. this.foundation.handleInputFocus(e);
  196. };
  197. handleInputBlur = (e: React.MouseEvent<HTMLInputElement>) => {
  198. this.foundation.handleInputBlur(e);
  199. };
  200. handleClearBtn = (e: React.MouseEvent<HTMLDivElement>) => {
  201. this.foundation.handleClearBtn(e);
  202. };
  203. handleTagClose = (idx: number) => {
  204. this.foundation.handleTagClose(idx);
  205. };
  206. handleInputMouseLeave = (e: React.MouseEvent<HTMLDivElement>) => {
  207. this.foundation.handleInputMouseLeave();
  208. };
  209. handleInputMouseEnter = (e: React.MouseEvent<HTMLDivElement>) => {
  210. this.foundation.handleInputMouseEnter();
  211. };
  212. renderClearBtn() {
  213. const { hovering, tagsArray, inputValue } = this.state;
  214. const { showClear, disabled } = this.props;
  215. const clearCls = cls(`${prefixCls}-clearBtn`, {
  216. [`${prefixCls}-clearBtn-invisible`]: !hovering || (inputValue === '' && tagsArray.length === 0) || disabled,
  217. });
  218. if (showClear) {
  219. return (
  220. <div className={clearCls} onClick={e => this.handleClearBtn(e)}>
  221. <IconClear />
  222. </div>
  223. );
  224. }
  225. return null;
  226. }
  227. renderPrefix() {
  228. const { prefix } = this.props;
  229. if (isNull(prefix) || isUndefined(prefix)) {
  230. return null;
  231. }
  232. const prefixWrapperCls = cls(`${prefixCls}-prefix`, {
  233. [`${prefixCls}-prefix-text`]: prefix && isString(prefix),
  234. // eslint-disable-next-line max-len
  235. [`${prefixCls}-prefix-icon`]: React.isValidElement(prefix) && !(prefix && isString(prefix)),
  236. });
  237. return <div className={prefixWrapperCls}>{prefix}</div>;
  238. }
  239. renderSuffix() {
  240. const { suffix } = this.props;
  241. if (isNull(suffix) || isUndefined(suffix)) {
  242. return null;
  243. }
  244. const suffixWrapperCls = cls(`${prefixCls}-suffix`, {
  245. [`${prefixCls}-suffix-text`]: suffix && isString(suffix),
  246. // eslint-disable-next-line max-len
  247. [`${prefixCls}-suffix-icon`]: React.isValidElement(suffix) && !(suffix && isString(suffix)),
  248. });
  249. return <div className={suffixWrapperCls}>{suffix}</div>;
  250. }
  251. renderTags() {
  252. const {
  253. size,
  254. disabled,
  255. renderTagItem,
  256. maxTagCount,
  257. showContentTooltip,
  258. showRestTagsPopover,
  259. restTagsPopoverProps = {},
  260. } = this.props;
  261. const { tagsArray } = this.state;
  262. const tagCls = cls(`${prefixCls}-wrapper-tag`, {
  263. [`${prefixCls}-wrapper-tag-size-${size}`]: size,
  264. });
  265. const typoCls = cls(`${prefixCls}-wrapper-typo`, {
  266. [`${prefixCls}-wrapper-typo-disabled`]: disabled
  267. });
  268. const spanNotWithPopoverCls = cls(`${prefixCls}-wrapper-n`, {
  269. [`${prefixCls}-wrapper-n-disabled`]: disabled
  270. });
  271. const restTags: Array<React.ReactNode> = [];
  272. const tags: Array<React.ReactNode> = [];
  273. tagsArray.forEach((value, index) => {
  274. let item = null;
  275. if (isFunction(renderTagItem)) {
  276. item = renderTagItem(value, index);
  277. } else {
  278. item = (
  279. <Tag
  280. className={tagCls}
  281. color="white"
  282. size={size === 'small' ? 'small' : 'large'}
  283. type="light"
  284. onClose={() => {
  285. !disabled && this.handleTagClose(index);
  286. }}
  287. closable={!disabled}
  288. key={`${index}${value}`}
  289. visible
  290. >
  291. <Paragraph
  292. className={typoCls}
  293. ellipsis={{ showTooltip: showContentTooltip, rows: 1 }}
  294. >
  295. {value}
  296. </Paragraph>
  297. </Tag>
  298. );
  299. }
  300. if (maxTagCount && index >= maxTagCount) {
  301. restTags.push(item);
  302. } else {
  303. tags.push(item);
  304. }
  305. });
  306. return (
  307. <>
  308. {tags}
  309. {
  310. restTags.length > 0 &&
  311. (
  312. showRestTagsPopover && !disabled ?
  313. (
  314. <Popover
  315. content={restTags}
  316. showArrow
  317. trigger="hover"
  318. position="top"
  319. autoAdjustOverflow
  320. {...restTagsPopoverProps}
  321. >
  322. <span className={cls(`${prefixCls}-wrapper-n`)}>
  323. +{tagsArray.length - maxTagCount}
  324. </span>
  325. </Popover>
  326. ) :
  327. (
  328. <span className={spanNotWithPopoverCls}>
  329. {`+${tagsArray.length - maxTagCount}`}
  330. </span>
  331. )
  332. )
  333. }
  334. </>
  335. );
  336. }
  337. blur() {
  338. this.inputRef.current.blur();
  339. }
  340. focus() {
  341. this.inputRef.current.focus();
  342. }
  343. render() {
  344. const {
  345. size,
  346. style,
  347. className,
  348. disabled,
  349. placeholder,
  350. validateStatus,
  351. } = this.props;
  352. const {
  353. focusing,
  354. hovering,
  355. tagsArray,
  356. inputValue
  357. } = this.state;
  358. const tagInputCls = cls(prefixCls, className, {
  359. [`${prefixCls}-focus`]: focusing,
  360. [`${prefixCls}-disabled`]: disabled,
  361. [`${prefixCls}-hover`]: hovering && !disabled,
  362. [`${prefixCls}-error`]: validateStatus === 'error',
  363. [`${prefixCls}-warning`]: validateStatus === 'warning'
  364. });
  365. const inputCls = cls(`${prefixCls}-wrapper-input`);
  366. const wrapperCls = cls(`${prefixCls}-wrapper`);
  367. return (
  368. <div
  369. style={style}
  370. className={tagInputCls}
  371. onMouseEnter={e => {
  372. this.handleInputMouseEnter(e);
  373. }}
  374. onMouseLeave={e => {
  375. this.handleInputMouseLeave(e);
  376. }}
  377. >
  378. {this.renderPrefix()}
  379. <div className={wrapperCls}>
  380. {this.renderTags()}
  381. <Input
  382. ref={this.inputRef as any}
  383. className={inputCls}
  384. disabled={disabled}
  385. value={inputValue}
  386. size={size}
  387. placeholder={tagsArray.length === 0 ? placeholder : ''}
  388. onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
  389. this.handleKeyDown(e);
  390. }}
  391. onChange={(v: string, e: React.ChangeEvent<HTMLInputElement>) => {
  392. this.handleInputChange(e);
  393. }}
  394. onBlur={(e: React.FocusEvent<HTMLInputElement>) => {
  395. this.handleInputBlur(e as any);
  396. }}
  397. onFocus={(e: React.FocusEvent<HTMLInputElement>) => {
  398. this.handleInputFocus(e as any);
  399. }}
  400. />
  401. </div>
  402. {this.renderClearBtn()}
  403. {this.renderSuffix()}
  404. </div>
  405. );
  406. }
  407. }
  408. export default TagInput;
  409. export { ValidateStatus };