index.tsx 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614
  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, OnSortEndProps } 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, IconHandle } from '@douyinfe/semi-icons';
  23. import { SortableContainer, SortableElement, SortableHandle } from 'react-sortable-hoc';
  24. export type Size = ArrayElement<typeof strings.SIZE_SET>;
  25. export type RestTagsPopoverProps = PopoverProps;
  26. type ValidateStatus = "default" | "error" | "warning";
  27. const SortableItem = SortableElement(props => props.item);
  28. const SortableList = SortableContainer(
  29. ({ items }) => {
  30. return (
  31. <div style={{ display: 'flex', flexFlow: 'row wrap', }}>
  32. {items.map((item, index) => (
  33. // @ts-ignore skip SortableItem type check
  34. <SortableItem key={item.key} index={index} item={item.item}></SortableItem>
  35. ))}
  36. </div>
  37. );
  38. });
  39. export interface TagInputProps {
  40. className?: string;
  41. defaultValue?: string[];
  42. disabled?: boolean;
  43. inputValue?: string;
  44. maxLength?: number;
  45. max?: number;
  46. maxTagCount?: number;
  47. showRestTagsPopover?: boolean;
  48. restTagsPopoverProps?: RestTagsPopoverProps;
  49. showContentTooltip?: boolean;
  50. allowDuplicates?: boolean;
  51. addOnBlur?: boolean;
  52. draggable?: boolean;
  53. expandRestTagsOnClick?: boolean;
  54. onAdd?: (addedValue: string[]) => void;
  55. onBlur?: (e: React.MouseEvent<HTMLInputElement>) => void;
  56. onChange?: (value: string[]) => void;
  57. onExceed?: ((value: string[]) => void);
  58. onFocus?: (e: React.MouseEvent<HTMLInputElement>) => void;
  59. onInputChange?: (value: string, e: React.MouseEvent<HTMLInputElement>) => void;
  60. onInputExceed?: ((value: string) => void);
  61. onKeyDown?: (e: React.MouseEvent<HTMLInputElement>) => void;
  62. onRemove?: (removedValue: string, idx: number) => void;
  63. placeholder?: string;
  64. insetLabel?: React.ReactNode;
  65. insetLabelId?: string;
  66. prefix?: React.ReactNode;
  67. renderTagItem?: (value: string, index: number, onClose: () => void) => React.ReactNode;
  68. separator?: string | string[] | null;
  69. showClear?: boolean;
  70. size?: Size;
  71. style?: React.CSSProperties;
  72. suffix?: React.ReactNode;
  73. validateStatus?: ValidateStatus;
  74. value?: string[] | undefined;
  75. autoFocus?: boolean;
  76. 'aria-label'?: string;
  77. preventScroll?: boolean
  78. }
  79. export interface TagInputState {
  80. tagsArray?: string[];
  81. inputValue?: string;
  82. focusing?: boolean;
  83. hovering?: boolean;
  84. active?: boolean
  85. }
  86. const prefixCls = cssClasses.PREFIX;
  87. class TagInput extends BaseComponent<TagInputProps, TagInputState> {
  88. static propTypes = {
  89. children: PropTypes.node,
  90. style: PropTypes.object,
  91. className: PropTypes.string,
  92. disabled: PropTypes.bool,
  93. allowDuplicates: PropTypes.bool,
  94. max: PropTypes.number,
  95. maxTagCount: PropTypes.number,
  96. maxLength: PropTypes.number,
  97. showRestTagsPopover: PropTypes.bool,
  98. restTagsPopoverProps: PropTypes.object,
  99. showContentTooltip: PropTypes.bool,
  100. defaultValue: PropTypes.array,
  101. value: PropTypes.array,
  102. inputValue: PropTypes.string,
  103. placeholder: PropTypes.string,
  104. separator: PropTypes.oneOfType([PropTypes.string, PropTypes.array]),
  105. showClear: PropTypes.bool,
  106. addOnBlur: PropTypes.bool,
  107. draggable: PropTypes.bool,
  108. expandRestTagsOnClick: PropTypes.bool,
  109. autoFocus: PropTypes.bool,
  110. renderTagItem: PropTypes.func,
  111. onBlur: PropTypes.func,
  112. onFocus: PropTypes.func,
  113. onChange: PropTypes.func,
  114. onInputChange: PropTypes.func,
  115. onExceed: PropTypes.func,
  116. onInputExceed: PropTypes.func,
  117. onAdd: PropTypes.func,
  118. onRemove: PropTypes.func,
  119. onKeyDown: PropTypes.func,
  120. size: PropTypes.oneOf(strings.SIZE_SET),
  121. validateStatus: PropTypes.oneOf(strings.STATUS),
  122. prefix: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
  123. suffix: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
  124. 'aria-label': PropTypes.string,
  125. preventScroll: PropTypes.bool,
  126. };
  127. static defaultProps = {
  128. showClear: false,
  129. addOnBlur: false,
  130. allowDuplicates: true,
  131. showRestTagsPopover: true,
  132. autoFocus: false,
  133. draggable: false,
  134. expandRestTagsOnClick: true,
  135. showContentTooltip: true,
  136. separator: ',',
  137. size: 'default' as const,
  138. validateStatus: 'default' as const,
  139. onBlur: noop,
  140. onFocus: noop,
  141. onChange: noop,
  142. onInputChange: noop,
  143. onExceed: noop,
  144. onInputExceed: noop,
  145. onAdd: noop,
  146. onRemove: noop,
  147. onKeyDown: noop,
  148. };
  149. inputRef: React.RefObject<HTMLInputElement>;
  150. tagInputRef: React.RefObject<HTMLDivElement>;
  151. foundation: TagInputFoundation;
  152. clickOutsideHandler: any;
  153. constructor(props: TagInputProps) {
  154. super(props);
  155. this.foundation = new TagInputFoundation(this.adapter);
  156. this.state = {
  157. tagsArray: props.defaultValue || [],
  158. inputValue: '',
  159. focusing: false,
  160. hovering: false,
  161. active: false,
  162. };
  163. this.inputRef = React.createRef();
  164. this.tagInputRef = React.createRef();
  165. this.clickOutsideHandler = null;
  166. }
  167. static getDerivedStateFromProps(nextProps: TagInputProps, prevState: TagInputState) {
  168. const { value, inputValue } = nextProps;
  169. const { tagsArray: prevTagsArray } = prevState;
  170. let tagsArray: string[];
  171. if (isArray(value)) {
  172. tagsArray = value;
  173. } else if ('value' in nextProps && !value) {
  174. tagsArray = [];
  175. } else {
  176. tagsArray = prevTagsArray;
  177. }
  178. return {
  179. tagsArray,
  180. inputValue: isString(inputValue) ? inputValue : prevState.inputValue
  181. };
  182. }
  183. get adapter(): TagInputAdapter {
  184. return {
  185. ...super.adapter,
  186. setInputValue: (inputValue: string) => {
  187. this.setState({ inputValue });
  188. },
  189. setTagsArray: (tagsArray: string[]) => {
  190. this.setState({ tagsArray });
  191. },
  192. setFocusing: (focusing: boolean) => {
  193. this.setState({ focusing });
  194. },
  195. toggleFocusing: (isFocus: boolean) => {
  196. const { preventScroll } = this.props;
  197. const input = this.inputRef && this.inputRef.current;
  198. if (isFocus) {
  199. input && input.focus({ preventScroll });
  200. } else {
  201. input && input.blur();
  202. }
  203. this.setState({ focusing: isFocus });
  204. },
  205. setHovering: (hovering: boolean) => {
  206. this.setState({ hovering });
  207. },
  208. setActive: (active: boolean) => {
  209. this.setState({ active });
  210. },
  211. getClickOutsideHandler: () => {
  212. return this.clickOutsideHandler;
  213. },
  214. notifyBlur: (e: React.MouseEvent<HTMLInputElement>) => {
  215. this.props.onBlur(e);
  216. },
  217. notifyFocus: (e: React.MouseEvent<HTMLInputElement>) => {
  218. this.props.onFocus(e);
  219. },
  220. notifyInputChange: (v: string, e: React.MouseEvent<HTMLInputElement>) => {
  221. this.props.onInputChange(v, e);
  222. },
  223. notifyTagChange: (v: string[]) => {
  224. this.props.onChange(v);
  225. },
  226. notifyTagAdd: (v: string[]) => {
  227. this.props.onAdd(v);
  228. },
  229. notifyTagRemove: (v: string, idx: number) => {
  230. this.props.onRemove(v, idx);
  231. },
  232. notifyKeyDown: e => {
  233. this.props.onKeyDown(e);
  234. },
  235. registerClickOutsideHandler: cb => {
  236. const clickOutsideHandler = (e: Event) => {
  237. const tagInputDom = this.tagInputRef && this.tagInputRef.current;
  238. const target = e.target as Element;
  239. if (tagInputDom && !tagInputDom.contains(target)) {
  240. cb(e);
  241. }
  242. };
  243. this.clickOutsideHandler = clickOutsideHandler;
  244. document.addEventListener('click', clickOutsideHandler, false);
  245. },
  246. unregisterClickOutsideHandler: () => {
  247. document.removeEventListener('click', this.clickOutsideHandler, false);
  248. this.clickOutsideHandler = null;
  249. },
  250. };
  251. }
  252. componentDidMount() {
  253. const { disabled, autoFocus, preventScroll } = this.props;
  254. if (!disabled && autoFocus) {
  255. this.inputRef.current.focus({ preventScroll });
  256. this.foundation.handleClick();
  257. }
  258. this.foundation.init();
  259. }
  260. handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
  261. this.foundation.handleInputChange(e);
  262. };
  263. handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
  264. this.foundation.handleKeyDown(e);
  265. };
  266. handleInputFocus = (e: React.MouseEvent<HTMLInputElement>) => {
  267. this.foundation.handleInputFocus(e);
  268. };
  269. handleInputBlur = (e: React.MouseEvent<HTMLInputElement>) => {
  270. this.foundation.handleInputBlur(e);
  271. };
  272. handleClearBtn = (e: React.MouseEvent<HTMLDivElement>) => {
  273. this.foundation.handleClearBtn(e);
  274. };
  275. /* istanbul ignore next */
  276. handleClearEnterPress = (e: React.KeyboardEvent<HTMLDivElement>) => {
  277. this.foundation.handleClearEnterPress(e);
  278. };
  279. handleTagClose = (idx: number) => {
  280. this.foundation.handleTagClose(idx);
  281. };
  282. handleInputMouseLeave = (e: React.MouseEvent<HTMLDivElement>) => {
  283. this.foundation.handleInputMouseLeave();
  284. };
  285. handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
  286. this.foundation.handleClick(e);
  287. };
  288. handleInputMouseEnter = (e: React.MouseEvent<HTMLDivElement>) => {
  289. this.foundation.handleInputMouseEnter();
  290. };
  291. handleClickPrefixOrSuffix = (e: React.MouseEvent<HTMLInputElement>) => {
  292. this.foundation.handleClickPrefixOrSuffix(e);
  293. };
  294. handlePreventMouseDown = (e: React.MouseEvent<HTMLInputElement>) => {
  295. this.foundation.handlePreventMouseDown(e);
  296. };
  297. renderClearBtn() {
  298. const { hovering, tagsArray, inputValue } = this.state;
  299. const { showClear, disabled } = this.props;
  300. const clearCls = cls(`${prefixCls}-clearBtn`, {
  301. [`${prefixCls}-clearBtn-invisible`]: !hovering || (inputValue === '' && tagsArray.length === 0) || disabled,
  302. });
  303. if (showClear) {
  304. return (
  305. <div
  306. role="button"
  307. tabIndex={0}
  308. aria-label="Clear TagInput value"
  309. className={clearCls}
  310. onClick={e => this.handleClearBtn(e)}
  311. onKeyPress={e => this.handleClearEnterPress(e)}
  312. >
  313. <IconClear />
  314. </div>
  315. );
  316. }
  317. return null;
  318. }
  319. renderPrefix() {
  320. const { prefix, insetLabel, insetLabelId } = this.props;
  321. const labelNode = prefix || insetLabel;
  322. if (isNull(labelNode) || isUndefined(labelNode)) {
  323. return null;
  324. }
  325. const prefixWrapperCls = cls(`${prefixCls}-prefix`, {
  326. [`${prefixCls}-inset-label`]: insetLabel,
  327. [`${prefixCls}-prefix-text`]: labelNode && isString(labelNode),
  328. // eslint-disable-next-line max-len
  329. [`${prefixCls}-prefix-icon`]: isSemiIcon(labelNode),
  330. });
  331. return (
  332. // eslint-disable-next-line jsx-a11y/no-static-element-interactions,jsx-a11y/click-events-have-key-events
  333. <div
  334. className={prefixWrapperCls}
  335. onMouseDown={this.handlePreventMouseDown}
  336. onClick={this.handleClickPrefixOrSuffix}
  337. id={insetLabelId} x-semi-prop="prefix"
  338. >
  339. {labelNode}
  340. </div>
  341. );
  342. }
  343. renderSuffix() {
  344. const { suffix } = this.props;
  345. if (isNull(suffix) || isUndefined(suffix)) {
  346. return null;
  347. }
  348. const suffixWrapperCls = cls(`${prefixCls}-suffix`, {
  349. [`${prefixCls}-suffix-text`]: suffix && isString(suffix),
  350. // eslint-disable-next-line max-len
  351. [`${prefixCls}-suffix-icon`]: isSemiIcon(suffix),
  352. });
  353. return (
  354. // eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions
  355. <div
  356. className={suffixWrapperCls}
  357. onMouseDown={this.handlePreventMouseDown}
  358. onClick={this.handleClickPrefixOrSuffix}
  359. x-semi-prop="suffix"
  360. >
  361. {suffix}
  362. </div>
  363. );
  364. }
  365. getAllTags = () => {
  366. const {
  367. size,
  368. disabled,
  369. renderTagItem,
  370. showContentTooltip,
  371. draggable,
  372. } = this.props;
  373. const { tagsArray, active } = this.state;
  374. const showIconHandler = active && draggable;
  375. const tagCls = cls(`${prefixCls}-wrapper-tag`, {
  376. [`${prefixCls}-wrapper-tag-size-${size}`]: size,
  377. [`${prefixCls}-wrapper-tag-icon`]: showIconHandler,
  378. });
  379. const typoCls = cls(`${prefixCls}-wrapper-typo`, {
  380. [`${prefixCls}-wrapper-typo-disabled`]: disabled,
  381. });
  382. const itemWrapperCls = cls({
  383. [`${prefixCls}-drag-item`]: showIconHandler,
  384. [`${prefixCls}-wrapper-tag-icon`]: showIconHandler,
  385. });
  386. const DragHandle = SortableHandle(() => <IconHandle className={`${prefixCls}-drag-handler`}></IconHandle>);
  387. return tagsArray.map((value, index) => {
  388. const elementKey = showIconHandler ? value : `${index}${value}`;
  389. const onClose = () => {
  390. !disabled && this.handleTagClose(index);
  391. };
  392. if (isFunction(renderTagItem)) {
  393. return showIconHandler? (<div className={itemWrapperCls} key={elementKey}>
  394. <DragHandle />
  395. {renderTagItem(value, index, onClose)}
  396. </div>) : renderTagItem(value, index, onClose);
  397. } else {
  398. return (
  399. <Tag
  400. className={tagCls}
  401. color="white"
  402. size={size === 'small' ? 'small' : 'large'}
  403. type="light"
  404. onClose={onClose}
  405. closable={!disabled}
  406. key={elementKey}
  407. visible
  408. aria-label={`${!disabled ? 'Closable ' : ''}Tag: ${value}`}
  409. >
  410. {showIconHandler && <DragHandle />}
  411. <Paragraph
  412. className={typoCls}
  413. ellipsis={{ showTooltip: showContentTooltip, rows: 1 }}
  414. >
  415. {value}
  416. </Paragraph>
  417. </Tag>
  418. );
  419. }
  420. });
  421. }
  422. onSortEnd = (callbackProps: OnSortEndProps) => {
  423. this.foundation.handleSortEnd(callbackProps);
  424. }
  425. renderTags() {
  426. const {
  427. disabled,
  428. maxTagCount,
  429. showRestTagsPopover,
  430. restTagsPopoverProps = {},
  431. draggable,
  432. expandRestTagsOnClick,
  433. } = this.props;
  434. const { tagsArray, active } = this.state;
  435. const restTagsCls = cls(`${prefixCls}-wrapper-n`, {
  436. [`${prefixCls}-wrapper-n-disabled`]: disabled,
  437. });
  438. const allTags = this.getAllTags();
  439. let restTags: Array<React.ReactNode> = [];
  440. let tags: Array<React.ReactNode> = [...allTags];
  441. if (( !active || !expandRestTagsOnClick) && maxTagCount && maxTagCount < allTags.length){
  442. tags = allTags.slice(0, maxTagCount);
  443. restTags = allTags.slice(maxTagCount);
  444. }
  445. const restTagsContent = (
  446. <span className={restTagsCls}>+{tagsArray.length - maxTagCount}</span>
  447. );
  448. const sortableListItems = allTags.map((item, index) => ({
  449. item: item,
  450. key: tagsArray[index],
  451. }));
  452. if (active && draggable && sortableListItems.length > 0) {
  453. // helperClass:add styles to the helper(item being dragged) https://github.com/clauderic/react-sortable-hoc/issues/87
  454. // @ts-ignore skip SortableItem type check
  455. return <SortableList useDragHandle helperClass={`${prefixCls}-drag-item-move`} items={sortableListItems} onSortEnd={this.onSortEnd} axis={"xy"} />;
  456. }
  457. return (
  458. <>
  459. {tags}
  460. {
  461. restTags.length > 0 &&
  462. (
  463. showRestTagsPopover ?
  464. (
  465. <Popover
  466. content={restTags}
  467. showArrow
  468. trigger="hover"
  469. position="top"
  470. autoAdjustOverflow
  471. {...restTagsPopoverProps}
  472. >
  473. {restTagsContent}
  474. </Popover>
  475. ) : restTagsContent
  476. )
  477. }
  478. </>
  479. );
  480. }
  481. blur() {
  482. this.inputRef.current.blur();
  483. // unregister clickOutside event
  484. this.foundation.clickOutsideCallBack();
  485. }
  486. focus() {
  487. const { preventScroll, disabled } = this.props;
  488. this.inputRef.current.focus({ preventScroll });
  489. if (!disabled) {
  490. // register clickOutside event
  491. this.foundation.handleClick();
  492. }
  493. }
  494. render() {
  495. const {
  496. size,
  497. style,
  498. className,
  499. disabled,
  500. placeholder,
  501. validateStatus,
  502. } = this.props;
  503. const {
  504. focusing,
  505. hovering,
  506. tagsArray,
  507. inputValue,
  508. active,
  509. } = this.state;
  510. const tagInputCls = cls(prefixCls, className, {
  511. [`${prefixCls}-focus`]: focusing || active,
  512. [`${prefixCls}-disabled`]: disabled,
  513. [`${prefixCls}-hover`]: hovering && !disabled,
  514. [`${prefixCls}-error`]: validateStatus === 'error',
  515. [`${prefixCls}-warning`]: validateStatus === 'warning'
  516. });
  517. const inputCls = cls(`${prefixCls}-wrapper-input`);
  518. const wrapperCls = cls(`${prefixCls}-wrapper`);
  519. return (
  520. // eslint-disable-next-line
  521. <div
  522. ref={this.tagInputRef}
  523. style={style}
  524. className={tagInputCls}
  525. aria-disabled={disabled}
  526. aria-label={this.props['aria-label']}
  527. aria-invalid={validateStatus === 'error'}
  528. onMouseEnter={e => {
  529. this.handleInputMouseEnter(e);
  530. }}
  531. onMouseLeave={e => {
  532. this.handleInputMouseLeave(e);
  533. }}
  534. onClick={e => {
  535. this.handleClick(e);
  536. }}
  537. >
  538. {this.renderPrefix()}
  539. <div className={wrapperCls}>
  540. {this.renderTags()}
  541. <Input
  542. aria-label='input value'
  543. ref={this.inputRef as any}
  544. className={inputCls}
  545. disabled={disabled}
  546. value={inputValue}
  547. size={size}
  548. placeholder={tagsArray.length === 0 ? placeholder : ''}
  549. onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
  550. this.handleKeyDown(e);
  551. }}
  552. onChange={(v: string, e: React.ChangeEvent<HTMLInputElement>) => {
  553. this.handleInputChange(e);
  554. }}
  555. onBlur={(e: React.FocusEvent<HTMLInputElement>) => {
  556. this.handleInputBlur(e as any);
  557. }}
  558. onFocus={(e: React.FocusEvent<HTMLInputElement>) => {
  559. this.handleInputFocus(e as any);
  560. }}
  561. />
  562. </div>
  563. {this.renderClearBtn()}
  564. {this.renderSuffix()}
  565. </div>
  566. );
  567. }
  568. }
  569. export default TagInput;
  570. export { ValidateStatus };