123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614 |
- import React from 'react';
- import cls from 'classnames';
- import PropTypes from 'prop-types';
- import {
- noop,
- isString,
- isArray,
- isNull,
- isUndefined,
- isFunction
- } from 'lodash';
- import { cssClasses, strings } from '@douyinfe/semi-foundation/tagInput/constants';
- import '@douyinfe/semi-foundation/tagInput/tagInput.scss';
- import TagInputFoundation, { TagInputAdapter, OnSortEndProps } from '@douyinfe/semi-foundation/tagInput/foundation';
- import { ArrayElement } from '../_base/base';
- import { isSemiIcon } from '../_utils';
- import BaseComponent from '../_base/baseComponent';
- import Tag from '../tag';
- import Input from '../input';
- import Popover, { PopoverProps } from '../popover';
- import Paragraph from '../typography/paragraph';
- import { IconClear, IconHandle } from '@douyinfe/semi-icons';
- import { SortableContainer, SortableElement, SortableHandle } from 'react-sortable-hoc';
- export type Size = ArrayElement<typeof strings.SIZE_SET>;
- export type RestTagsPopoverProps = PopoverProps;
- type ValidateStatus = "default" | "error" | "warning";
- const SortableItem = SortableElement(props => props.item);
- const SortableList = SortableContainer(
- ({ items }) => {
- return (
- <div style={{ display: 'flex', flexFlow: 'row wrap', }}>
- {items.map((item, index) => (
- // @ts-ignore skip SortableItem type check
- <SortableItem key={item.key} index={index} item={item.item}></SortableItem>
- ))}
- </div>
- );
- });
- export interface TagInputProps {
- className?: string;
- defaultValue?: string[];
- disabled?: boolean;
- inputValue?: string;
- maxLength?: number;
- max?: number;
- maxTagCount?: number;
- showRestTagsPopover?: boolean;
- restTagsPopoverProps?: RestTagsPopoverProps;
- showContentTooltip?: boolean;
- allowDuplicates?: boolean;
- addOnBlur?: boolean;
- draggable?: boolean;
- expandRestTagsOnClick?: boolean;
- onAdd?: (addedValue: string[]) => void;
- onBlur?: (e: React.MouseEvent<HTMLInputElement>) => void;
- onChange?: (value: string[]) => void;
- onExceed?: ((value: string[]) => void);
- onFocus?: (e: React.MouseEvent<HTMLInputElement>) => void;
- onInputChange?: (value: string, e: React.MouseEvent<HTMLInputElement>) => void;
- onInputExceed?: ((value: string) => void);
- onKeyDown?: (e: React.MouseEvent<HTMLInputElement>) => void;
- onRemove?: (removedValue: string, idx: number) => void;
- placeholder?: string;
- insetLabel?: React.ReactNode;
- insetLabelId?: string;
- prefix?: React.ReactNode;
- renderTagItem?: (value: string, index: number, onClose: () => void) => React.ReactNode;
- separator?: string | string[] | null;
- showClear?: boolean;
- size?: Size;
- style?: React.CSSProperties;
- suffix?: React.ReactNode;
- validateStatus?: ValidateStatus;
- value?: string[] | undefined;
- autoFocus?: boolean;
- 'aria-label'?: string;
- preventScroll?: boolean
- }
- export interface TagInputState {
- tagsArray?: string[];
- inputValue?: string;
- focusing?: boolean;
- hovering?: boolean;
- active?: boolean
- }
- const prefixCls = cssClasses.PREFIX;
- class TagInput extends BaseComponent<TagInputProps, TagInputState> {
- static propTypes = {
- children: PropTypes.node,
- style: PropTypes.object,
- className: PropTypes.string,
- disabled: PropTypes.bool,
- allowDuplicates: PropTypes.bool,
- max: PropTypes.number,
- maxTagCount: PropTypes.number,
- maxLength: PropTypes.number,
- showRestTagsPopover: PropTypes.bool,
- restTagsPopoverProps: PropTypes.object,
- showContentTooltip: PropTypes.bool,
- defaultValue: PropTypes.array,
- value: PropTypes.array,
- inputValue: PropTypes.string,
- placeholder: PropTypes.string,
- separator: PropTypes.oneOfType([PropTypes.string, PropTypes.array]),
- showClear: PropTypes.bool,
- addOnBlur: PropTypes.bool,
- draggable: PropTypes.bool,
- expandRestTagsOnClick: PropTypes.bool,
- autoFocus: PropTypes.bool,
- renderTagItem: PropTypes.func,
- onBlur: PropTypes.func,
- onFocus: PropTypes.func,
- onChange: PropTypes.func,
- onInputChange: PropTypes.func,
- onExceed: PropTypes.func,
- onInputExceed: PropTypes.func,
- onAdd: PropTypes.func,
- onRemove: PropTypes.func,
- onKeyDown: PropTypes.func,
- size: PropTypes.oneOf(strings.SIZE_SET),
- validateStatus: PropTypes.oneOf(strings.STATUS),
- prefix: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
- suffix: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
- 'aria-label': PropTypes.string,
- preventScroll: PropTypes.bool,
- };
- static defaultProps = {
- showClear: false,
- addOnBlur: false,
- allowDuplicates: true,
- showRestTagsPopover: true,
- autoFocus: false,
- draggable: false,
- expandRestTagsOnClick: true,
- showContentTooltip: true,
- separator: ',',
- size: 'default' as const,
- validateStatus: 'default' as const,
- onBlur: noop,
- onFocus: noop,
- onChange: noop,
- onInputChange: noop,
- onExceed: noop,
- onInputExceed: noop,
- onAdd: noop,
- onRemove: noop,
- onKeyDown: noop,
- };
- inputRef: React.RefObject<HTMLInputElement>;
- tagInputRef: React.RefObject<HTMLDivElement>;
- foundation: TagInputFoundation;
- clickOutsideHandler: any;
- constructor(props: TagInputProps) {
- super(props);
- this.foundation = new TagInputFoundation(this.adapter);
- this.state = {
- tagsArray: props.defaultValue || [],
- inputValue: '',
- focusing: false,
- hovering: false,
- active: false,
- };
- this.inputRef = React.createRef();
- this.tagInputRef = React.createRef();
- this.clickOutsideHandler = null;
- }
- static getDerivedStateFromProps(nextProps: TagInputProps, prevState: TagInputState) {
- const { value, inputValue } = nextProps;
- const { tagsArray: prevTagsArray } = prevState;
- let tagsArray: string[];
- if (isArray(value)) {
- tagsArray = value;
- } else if ('value' in nextProps && !value) {
- tagsArray = [];
- } else {
- tagsArray = prevTagsArray;
- }
- return {
- tagsArray,
- inputValue: isString(inputValue) ? inputValue : prevState.inputValue
- };
- }
- get adapter(): TagInputAdapter {
- return {
- ...super.adapter,
- setInputValue: (inputValue: string) => {
- this.setState({ inputValue });
- },
- setTagsArray: (tagsArray: string[]) => {
- this.setState({ tagsArray });
- },
- setFocusing: (focusing: boolean) => {
- this.setState({ focusing });
- },
- toggleFocusing: (isFocus: boolean) => {
- const { preventScroll } = this.props;
- const input = this.inputRef && this.inputRef.current;
- if (isFocus) {
- input && input.focus({ preventScroll });
- } else {
- input && input.blur();
- }
- this.setState({ focusing: isFocus });
- },
- setHovering: (hovering: boolean) => {
- this.setState({ hovering });
- },
- setActive: (active: boolean) => {
- this.setState({ active });
- },
- getClickOutsideHandler: () => {
- return this.clickOutsideHandler;
- },
- notifyBlur: (e: React.MouseEvent<HTMLInputElement>) => {
- this.props.onBlur(e);
- },
- notifyFocus: (e: React.MouseEvent<HTMLInputElement>) => {
- this.props.onFocus(e);
- },
- notifyInputChange: (v: string, e: React.MouseEvent<HTMLInputElement>) => {
- this.props.onInputChange(v, e);
- },
- notifyTagChange: (v: string[]) => {
- this.props.onChange(v);
- },
- notifyTagAdd: (v: string[]) => {
- this.props.onAdd(v);
- },
- notifyTagRemove: (v: string, idx: number) => {
- this.props.onRemove(v, idx);
- },
- notifyKeyDown: e => {
- this.props.onKeyDown(e);
- },
- registerClickOutsideHandler: cb => {
- const clickOutsideHandler = (e: Event) => {
- const tagInputDom = this.tagInputRef && this.tagInputRef.current;
- const target = e.target as Element;
- if (tagInputDom && !tagInputDom.contains(target)) {
- cb(e);
- }
- };
- this.clickOutsideHandler = clickOutsideHandler;
- document.addEventListener('click', clickOutsideHandler, false);
- },
- unregisterClickOutsideHandler: () => {
- document.removeEventListener('click', this.clickOutsideHandler, false);
- this.clickOutsideHandler = null;
- },
- };
- }
- componentDidMount() {
- const { disabled, autoFocus, preventScroll } = this.props;
- if (!disabled && autoFocus) {
- this.inputRef.current.focus({ preventScroll });
- this.foundation.handleClick();
- }
- this.foundation.init();
- }
- handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
- this.foundation.handleInputChange(e);
- };
- handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
- this.foundation.handleKeyDown(e);
- };
- handleInputFocus = (e: React.MouseEvent<HTMLInputElement>) => {
- this.foundation.handleInputFocus(e);
- };
- handleInputBlur = (e: React.MouseEvent<HTMLInputElement>) => {
- this.foundation.handleInputBlur(e);
- };
- handleClearBtn = (e: React.MouseEvent<HTMLDivElement>) => {
- this.foundation.handleClearBtn(e);
- };
- /* istanbul ignore next */
- handleClearEnterPress = (e: React.KeyboardEvent<HTMLDivElement>) => {
- this.foundation.handleClearEnterPress(e);
- };
- handleTagClose = (idx: number) => {
- this.foundation.handleTagClose(idx);
- };
- handleInputMouseLeave = (e: React.MouseEvent<HTMLDivElement>) => {
- this.foundation.handleInputMouseLeave();
- };
- handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
- this.foundation.handleClick(e);
- };
- handleInputMouseEnter = (e: React.MouseEvent<HTMLDivElement>) => {
- this.foundation.handleInputMouseEnter();
- };
- handleClickPrefixOrSuffix = (e: React.MouseEvent<HTMLInputElement>) => {
- this.foundation.handleClickPrefixOrSuffix(e);
- };
- handlePreventMouseDown = (e: React.MouseEvent<HTMLInputElement>) => {
- this.foundation.handlePreventMouseDown(e);
- };
- renderClearBtn() {
- const { hovering, tagsArray, inputValue } = this.state;
- const { showClear, disabled } = this.props;
- const clearCls = cls(`${prefixCls}-clearBtn`, {
- [`${prefixCls}-clearBtn-invisible`]: !hovering || (inputValue === '' && tagsArray.length === 0) || disabled,
- });
- if (showClear) {
- return (
- <div
- role="button"
- tabIndex={0}
- aria-label="Clear TagInput value"
- className={clearCls}
- onClick={e => this.handleClearBtn(e)}
- onKeyPress={e => this.handleClearEnterPress(e)}
- >
- <IconClear />
- </div>
- );
- }
- return null;
- }
- renderPrefix() {
- const { prefix, insetLabel, insetLabelId } = this.props;
- const labelNode = prefix || insetLabel;
- if (isNull(labelNode) || isUndefined(labelNode)) {
- return null;
- }
- const prefixWrapperCls = cls(`${prefixCls}-prefix`, {
- [`${prefixCls}-inset-label`]: insetLabel,
- [`${prefixCls}-prefix-text`]: labelNode && isString(labelNode),
- // eslint-disable-next-line max-len
- [`${prefixCls}-prefix-icon`]: isSemiIcon(labelNode),
- });
- return (
- // eslint-disable-next-line jsx-a11y/no-static-element-interactions,jsx-a11y/click-events-have-key-events
- <div
- className={prefixWrapperCls}
- onMouseDown={this.handlePreventMouseDown}
- onClick={this.handleClickPrefixOrSuffix}
- id={insetLabelId} x-semi-prop="prefix"
- >
- {labelNode}
- </div>
- );
- }
- renderSuffix() {
- const { suffix } = this.props;
- if (isNull(suffix) || isUndefined(suffix)) {
- return null;
- }
- const suffixWrapperCls = cls(`${prefixCls}-suffix`, {
- [`${prefixCls}-suffix-text`]: suffix && isString(suffix),
- // eslint-disable-next-line max-len
- [`${prefixCls}-suffix-icon`]: isSemiIcon(suffix),
- });
- return (
- // eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions
- <div
- className={suffixWrapperCls}
- onMouseDown={this.handlePreventMouseDown}
- onClick={this.handleClickPrefixOrSuffix}
- x-semi-prop="suffix"
- >
- {suffix}
- </div>
- );
- }
- getAllTags = () => {
- const {
- size,
- disabled,
- renderTagItem,
- showContentTooltip,
- draggable,
- } = this.props;
- const { tagsArray, active } = this.state;
- const showIconHandler = active && draggable;
- const tagCls = cls(`${prefixCls}-wrapper-tag`, {
- [`${prefixCls}-wrapper-tag-size-${size}`]: size,
- [`${prefixCls}-wrapper-tag-icon`]: showIconHandler,
- });
- const typoCls = cls(`${prefixCls}-wrapper-typo`, {
- [`${prefixCls}-wrapper-typo-disabled`]: disabled,
- });
- const itemWrapperCls = cls({
- [`${prefixCls}-drag-item`]: showIconHandler,
- [`${prefixCls}-wrapper-tag-icon`]: showIconHandler,
- });
- const DragHandle = SortableHandle(() => <IconHandle className={`${prefixCls}-drag-handler`}></IconHandle>);
- return tagsArray.map((value, index) => {
- const elementKey = showIconHandler ? value : `${index}${value}`;
- const onClose = () => {
- !disabled && this.handleTagClose(index);
- };
- if (isFunction(renderTagItem)) {
- return showIconHandler? (<div className={itemWrapperCls} key={elementKey}>
- <DragHandle />
- {renderTagItem(value, index, onClose)}
- </div>) : renderTagItem(value, index, onClose);
- } else {
- return (
- <Tag
- className={tagCls}
- color="white"
- size={size === 'small' ? 'small' : 'large'}
- type="light"
- onClose={onClose}
- closable={!disabled}
- key={elementKey}
- visible
- aria-label={`${!disabled ? 'Closable ' : ''}Tag: ${value}`}
- >
- {showIconHandler && <DragHandle />}
- <Paragraph
- className={typoCls}
- ellipsis={{ showTooltip: showContentTooltip, rows: 1 }}
- >
- {value}
- </Paragraph>
- </Tag>
- );
- }
- });
- }
- onSortEnd = (callbackProps: OnSortEndProps) => {
- this.foundation.handleSortEnd(callbackProps);
- }
- renderTags() {
- const {
- disabled,
- maxTagCount,
- showRestTagsPopover,
- restTagsPopoverProps = {},
- draggable,
- expandRestTagsOnClick,
- } = this.props;
- const { tagsArray, active } = this.state;
- const restTagsCls = cls(`${prefixCls}-wrapper-n`, {
- [`${prefixCls}-wrapper-n-disabled`]: disabled,
- });
- const allTags = this.getAllTags();
- let restTags: Array<React.ReactNode> = [];
- let tags: Array<React.ReactNode> = [...allTags];
- if (( !active || !expandRestTagsOnClick) && maxTagCount && maxTagCount < allTags.length){
- tags = allTags.slice(0, maxTagCount);
- restTags = allTags.slice(maxTagCount);
- }
-
- const restTagsContent = (
- <span className={restTagsCls}>+{tagsArray.length - maxTagCount}</span>
- );
- const sortableListItems = allTags.map((item, index) => ({
- item: item,
- key: tagsArray[index],
- }));
- if (active && draggable && sortableListItems.length > 0) {
- // helperClass:add styles to the helper(item being dragged) https://github.com/clauderic/react-sortable-hoc/issues/87
- // @ts-ignore skip SortableItem type check
- return <SortableList useDragHandle helperClass={`${prefixCls}-drag-item-move`} items={sortableListItems} onSortEnd={this.onSortEnd} axis={"xy"} />;
- }
- return (
- <>
- {tags}
- {
- restTags.length > 0 &&
- (
- showRestTagsPopover ?
- (
- <Popover
- content={restTags}
- showArrow
- trigger="hover"
- position="top"
- autoAdjustOverflow
- {...restTagsPopoverProps}
- >
- {restTagsContent}
- </Popover>
- ) : restTagsContent
- )
- }
- </>
- );
- }
- blur() {
- this.inputRef.current.blur();
- // unregister clickOutside event
- this.foundation.clickOutsideCallBack();
- }
- focus() {
- const { preventScroll, disabled } = this.props;
- this.inputRef.current.focus({ preventScroll });
- if (!disabled) {
- // register clickOutside event
- this.foundation.handleClick();
- }
- }
- render() {
- const {
- size,
- style,
- className,
- disabled,
- placeholder,
- validateStatus,
- } = this.props;
- const {
- focusing,
- hovering,
- tagsArray,
- inputValue,
- active,
- } = this.state;
- const tagInputCls = cls(prefixCls, className, {
- [`${prefixCls}-focus`]: focusing || active,
- [`${prefixCls}-disabled`]: disabled,
- [`${prefixCls}-hover`]: hovering && !disabled,
- [`${prefixCls}-error`]: validateStatus === 'error',
- [`${prefixCls}-warning`]: validateStatus === 'warning'
- });
- const inputCls = cls(`${prefixCls}-wrapper-input`);
- const wrapperCls = cls(`${prefixCls}-wrapper`);
- return (
- // eslint-disable-next-line
- <div
- ref={this.tagInputRef}
- style={style}
- className={tagInputCls}
- aria-disabled={disabled}
- aria-label={this.props['aria-label']}
- aria-invalid={validateStatus === 'error'}
- onMouseEnter={e => {
- this.handleInputMouseEnter(e);
- }}
- onMouseLeave={e => {
- this.handleInputMouseLeave(e);
- }}
- onClick={e => {
- this.handleClick(e);
- }}
- >
- {this.renderPrefix()}
- <div className={wrapperCls}>
- {this.renderTags()}
- <Input
- aria-label='input value'
- ref={this.inputRef as any}
- className={inputCls}
- disabled={disabled}
- value={inputValue}
- size={size}
- placeholder={tagsArray.length === 0 ? placeholder : ''}
- onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
- this.handleKeyDown(e);
- }}
- onChange={(v: string, e: React.ChangeEvent<HTMLInputElement>) => {
- this.handleInputChange(e);
- }}
- onBlur={(e: React.FocusEvent<HTMLInputElement>) => {
- this.handleInputBlur(e as any);
- }}
- onFocus={(e: React.FocusEvent<HTMLInputElement>) => {
- this.handleInputFocus(e as any);
- }}
- />
- </div>
- {this.renderClearBtn()}
- {this.renderSuffix()}
- </div>
- );
- }
- }
- export default TagInput;
- export { ValidateStatus };
|