index.tsx 15 KB

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