index.tsx 17 KB

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