import cns from 'classnames' import React, { useCallback, useLayoutEffect, useMemo, useRef, useState, useEffect, HTMLAttributes, ChangeEvent, KeyboardEvent } from 'react' import { usePrefix } from '../ConfigProvider' import Dropdown, { DropdownRef } from '../Dropdown' import { CommonProps, useInputSize, useInputStyle } from './input' import { CloseCircleFilledIcon } from '../icons' import { concat } from '../utils' import { useValue } from '../utils/hooks' import { SelectFetchFn } from '../Select' import { useActive } from '../Select/common' import OptionsOverlay, { OptionsOverlayProvider, OptionsOverlayCtxValue } from '../Select/Overlay' import MultiInputTag from '../internal/MultiInputTag' import { useOptionItems } from './AutoComplete' // TODO: 放到公共的地方 export interface Props extends CommonProps { /** * 是否有清空功能。开启后,正在输入的文本内容不为空时清空文本,否则清空所有标签 * @default false */ clearable?: boolean /** 预置标签选项获取逻辑 */ fetch?: SelectFetchFn /** 标签的自定义样式名 */ tagClassName?: string } export default function TagsInput({ value: valueFromProps, defaultValue = [], onChange, disabled = false, size, placeholder, validateStatus, clearable = false, fetch, tagClassName, className, style }: Props) { const prefix = useTagsInputPrefix() const { value, setValue } = useValue({ valueFromProps, defaultValue, onChange }) const { active, selectorRef: inputWrapperRef, overlayRef, setActive } = useActive(disabled) // 已有标签不能重复添加 const addTag = useCallback((tag: string) => { if (!value.includes(tag)) { setValue([...value, tag]) } }, [setValue, value]) /* 未保存的标签输入框,可自由输入文本,按回车添加标签 */ const { text, setText, renderTextInput, focusTextInput } = useTextInput() const handleTextInputPress = useCallback((e: KeyboardEvent) => { const unsavedTag = e.currentTarget.value // 回车保存当前输入的 Tag if (e.key === 'Enter' && unsavedTag) { // 阻止表单提交 e.preventDefault() addTag(unsavedTag) setText('') } // 当前输入的 Tag 为空时 Backspace -> 删除前一个 Tag if (e.key === 'Backspace' && !unsavedTag && value.length > 0) { setValue(value.slice(0, -1)) } }, [value, setValue, addTag, setText]) const textInput = renderTextInput(disabled, { placeholder: value.length === 0 ? placeholder : undefined, onKeyDown: handleTextInputPress }) /* 已添加的标签,点击标签右侧 x 可删除 */ const handleRemoveTag = useCallback((tag: string) => { setValue(value.filter(item => item !== tag)) focusTextInput() }, [setValue, value, focusTextInput]) const tags = useMemo(() => ( value.map(tag => ( handleRemoveTag(tag) }} className={tagClassName} > {tag} )) ), [value, disabled, handleRemoveTag, tagClassName]) /* 清空按钮,标签输入框内容不为空时清空文本,否则清空所有标签 */ const clearIcon = !disabled && clearable && ( 0} setValue={setValue} /> ) /* 预置标签下拉选择菜单,点击选项可添加/删除该预置标签 */ // 是否处于可选择预置标签状态 const selectActive = active && !!fetch const { items, loading, visible } = useOptionItems(selectActive, text, fetch!) // 调用 fetch 的地方一定有 fetch const handleSelect = useCallback((v: string[]) => { setValue(v) setText('') focusTextInput() }, [setValue, setText, focusTextInput]) const inputSize = useInputSize(size) const overlayCtxValue: OptionsOverlayCtxValue = useMemo(() => ({ selected: value, onSelect: handleSelect, multiple: true, size: inputSize }), [value, handleSelect, inputSize]) const optionsOverlay = ( ) const handleInputWrapperClick = useCallback((e: React.MouseEvent) => { if (disabled) return if (e.defaultPrevented) return setActive(true) }, [disabled, setActive]) // 编辑时自动聚焦到标签输入框;失焦时将标签输入框内容添加为标签 useEffect(() => { if (active) { focusTextInput() } else if (text) { if (disabled) return // TODO: 禁用导致的失焦不应该改变 value 吧?要不要清空 text 呢? addTag(text) setText('') } // eslint-disable-next-line react-hooks/exhaustive-deps }, [active]) // 在当前值发生变更时(可能导致 inputWrapper 高度变更)去调用 dropdown 的 forcePopupAlign // 确保 dropdown 相对 select 的位置是正确的 const dropdownRef = useRef(null) useLayoutEffect(() => { if (dropdownRef.current == null) return dropdownRef.current.forcePopupAlign() }, [value]) return (
{tags} {textInput}
{clearIcon}
) } function useTagsInputPrefix() { return usePrefix('tags-input') } function useTextInput() { const prefix = useTagsInputPrefix() const [text, setText] = useState('') const handleTextInputChange = useCallback((e: ChangeEvent) => { setText(e.target.value) }, []) const inputRef = useRef(null) const focusTextInput = useCallback(() => { inputRef.current?.focus() }, []) const renderTextInput = (disabled: boolean, extraProps?: HTMLAttributes) => ( ) return { text, setText, renderTextInput, focusTextInput } } function ClearIcon({ hasText, setText, focusTextInput, hasValue, setValue } : { hasText: boolean setText: (v: string) => void focusTextInput: () => void hasValue: boolean setValue: (v: string[], ...restV: never[]) => void }) { const prefix = useTagsInputPrefix() const clearClassName = concat(prefix, 'clear') const clearVisible = hasText || hasValue const classname = cns( clearClassName, !clearVisible && concat(clearClassName, 'invisible') ) function handleClick() { if (hasText) { setText('') } else { setValue([]) } focusTextInput() } return }