import React, { useState, useRef, useEffect, useCallback } from "react"; import classNames from "classnames"; import { StyledProps } from "../_type"; import { ControlledProps, useDefaultValue } from "../form"; import { CommonDropdownProps, DropdownProps } from "../dropdown"; import { useTranslation } from "../i18n"; import { useConfig } from "../_util/config-context"; import { Popover, TriggerProps } from "../popover"; import { Tag } from "../tag"; import { useOutsideClick } from "../_util/use-outside-click"; import { EmptyTip } from "../tips"; import { useDefault } from "../_util/use-default"; import { SelectOptionWithGroup } from "../select/SelectOption"; import { TagSelectInput } from "./TagSelectInput"; import { TagSelectBox, TagSelectBoxProps, getListItems } from "./TagSelectBox"; import { KeyMap } from "../_util/key-map"; import { noop } from "../_util/noop"; import { mergeEventProps } from "../_util/merge-event-props"; import { forwardRefWithStatics } from "../_util/forward-ref-with-statics"; import { mergeRefs } from "../util"; // 整个 标签选择输入框的 属性 export interface TagSelectProps extends ControlledProps< string[], React.SyntheticEvent, { event: React.SyntheticEvent; option: SelectOptionWithGroup } >, Omit, StyledProps { /** * 下拉选项列表 */ options?: SelectOptionWithGroup[]; /** * 是否仅能选择 `options` 中的值 * * @default false */ optionsOnly?: boolean; /** * 分组 * @since 2.3.0 */ groups?: { [groupKey: string]: React.ReactNode; }; /** * 自定义搜索筛选规则 * * 默认根据输入值筛选 */ filter?: (inputValue: string, option: SelectOptionWithGroup) => boolean; /** * 是否禁用 * @default false * @since 2.4.1 */ disabled?: boolean; /** * 搜索值变化回调 */ onSearch?: (inputValue: string) => void; /** * 输入框中的提示 * @default "请选择"(已处理国际化) */ placeholder?: string; /** * 弹出区域自定义类名 */ boxClassName?: DropdownProps["boxClassName"]; /** * 弹出区域自定义样式 */ boxStyle?: DropdownProps["boxStyle"]; /** * 状态提示 * * 可使用字符串或 [StatusTip](/component/tips) 相关组件 */ tips?: React.ReactNode; /** * 是否在选项选中后自动清空搜索值 * @default true */ autoClearSearchValue?: boolean; /** * 滚动到底部事件 * @since 2.1.0 */ onScrollBottom?: TagSelectBoxProps["onScrollBottom"]; /** * 当输入框获得焦点时调用此函数 */ onFocus?: (e: React.FocusEvent) => void; /** * 当输入框获得焦点时调用此函数 * @since 2.7.0 */ onBlur?: (e: React.FocusEvent) => void; /** * 弹出区域底部内容 * @since 2.7.0 */ footer?: React.ReactNode; /** * 隐藏 Tag 上关闭按钮 * @default false * @since 2.7.3 */ hideCloseButton?: boolean; /** * 删除单个标签的回调 * * 返回 `false` 阻止删除 * * @since 2.7.4 */ onDeleteTag?: (tag: SelectOptionWithGroup) => Promise | boolean; } // hooks 依赖了该值更新 const empty = []; export const TagSelect = forwardRefWithStatics( function TagSelect(props: TagSelectProps, ref: React.Ref) { const t = useTranslation(); const { classPrefix } = useConfig(); const { options = empty, optionsOnly, groups, value, onChange, onDeleteTag, placeholder = t.pleaseSelect, className, style, disabled, boxClassName, boxStyle = {}, placement = "bottom-start", placementOffset = 5, closeOnScroll = true, escapeWithReference, popupContainer, onSearch = noop, onFocus = noop, onBlur = noop, filter = (inputValue: string, { text, value }: SelectOptionWithGroup) => { const optionText = String(typeof text === "string" ? text : value); return optionText.includes(inputValue); }, defaultOpen = false, open, onOpenChange, autoClearSearchValue = true, overlayClassName, overlayStyle, onScrollBottom = noop, footer, hideCloseButton, ...restProps } = useDefaultValue(props, []); const [isOpened, setIsOpened] = useDefault(open, defaultOpen, onOpenChange); const [currentIndex, setCurrentIndex] = useState(0); const [wrapperWidth, setWrapperWidth] = useState(0); const inputRef = useRef(null); const [inputValue, setInputValue] = useState(""); const listRef = useRef(null); const filteredOptions = options .filter(options => filter(inputValue, options)) .filter(option => !value.includes(option.value)); // 支持输入任意值 if (!optionsOnly && inputValue.trim()) { // 是否有和搜索值相同的项 const hasEqualOption = options.find(({ text, value }) => { const optionText = String(typeof text === "string" ? text : value); return optionText.trim() === inputValue.trim(); }); // 是否有和搜索值相同的值 const hasEqualValue = value.find(v => v.trim() === inputValue.trim()); if (!hasEqualOption && !hasEqualValue) { filteredOptions.unshift({ value: inputValue.trim() }); } } // 列表收起行为 useEffect(() => { if (!isOpened) { if (!optionsOnly) { const content = inputValue.trim(); if (content && !value.includes(content)) { onChange([...value, content], { event: null, option: { value: content }, }); } } setInputValue(""); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [isOpened]); // options 变化时不影响已选择的 value 显示 const [displayOptions, setDisplayOptions] = useState< SelectOptionWithGroup[] >( value.map(v => options.find(option => option.value === v) || { value: v }) ); const updateDisplayOptions = useCallback( (value: string[]) => setDisplayOptions(displayOptions => { return value.map( v => options.find(option => option.value === v) || displayOptions.find(option => option.value === v) || { value: v, } ); }), [options] ); let { tips } = props; if (typeof tips === "undefined" && filteredOptions.length === 0) { tips = ; } const items = getListItems({ tips, options: filteredOptions, groups, }); const count = items.length; const getOptionIndex = useCallback( (current: number, step: number = 1) => { let flag = 1; let index = (current + step + count) % count; let item = items[index % count]; while (flag < count && (!item.option || item.option.disabled)) { flag += 1; index = (index + step + count) % count; item = items[index % count]; } return index; }, [count, items] ); // onChange 前更新 displayOptions 保证 onChange 中改变 options 时信息不丢失 const handleChange: TagSelectProps["onChange"] = (value, context) => { updateDisplayOptions(value); onChange(value, context); }; useEffect(() => updateDisplayOptions(value), [value, updateDisplayOptions]); // value 变化矫正高亮项(跳过 disable 项) useEffect(() => { if (currentIndex >= 0) { setCurrentIndex(getOptionIndex(currentIndex - 1)); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [value, inputValue]); // 渲染数据 return (
ref && setWrapperWidth(ref.clientWidth))} className={classNames(`${classPrefix}-tag-input`, className, { "is-active": !disabled && isOpened, "is-disabled": disabled, })} style={style} {...mergeEventProps(restProps, { onClick: () => { if (inputRef.current) { inputRef.current.focus(); } }, })} onFocus={noop} onBlur={noop} > { if (disabled) { return; } if (!visible && inputRef.current) { inputRef.current.blur(); } }} placement={placement} closeOnScroll={closeOnScroll} escapeWithReference={escapeWithReference} popupContainer={popupContainer} placementOffset={placementOffset} overlayClassName={overlayClassName} overlayStyle={overlayStyle} overlay={({ scheduleUpdate }) => ( { handleChange([...value, optionValue], context); if (autoClearSearchValue && inputValue) { setInputValue(""); setCurrentIndex(getOptionIndex(-1)); } }} scheduleUpdate={scheduleUpdate} className={boxClassName} style={boxStyle} tips={tips} onScrollBottom={onScrollBottom} footer={footer} /> )} >
{displayOptions.map(option => ( { event.stopPropagation(); /** 传入onDeleteTag,就通过onDeleteTag控制是否删除 */ const canDeleteTag = await onDeleteTag?.(option); if (disabled || (onDeleteTag && !canDeleteTag)) { return; } handleChange( value.filter(v => v !== option.value), { event, option, } ); } } > {option.text || option.value} ))} { if (!isOpened) { setIsOpened(true); } onFocus(e); }} onBlur={e => { if (isOpened) { setIsOpened(false); } onBlur(e); }} value={inputValue} onChange={value => { onSearch(value); setInputValue(value); setCurrentIndex(getOptionIndex(-1)); }} maxWidth={Math.max(wrapperWidth - 12, 0)} onKeyDown={(event: React.KeyboardEvent) => { const { option } = items[currentIndex % count] || {}; switch (event.key) { case KeyMap.Backspace: if ( event.currentTarget.value.length === 0 && value.length > 0 ) { handleChange(value.slice(0, -1), { event, option: options.find( option => option.value === value[value.length - 1] ), }); setCurrentIndex(getOptionIndex(-1)); } break; case KeyMap.Enter: event.preventDefault(); if (isOpened && option && !option.disabled) { handleChange([...value, option.value], { event, option, }); if (autoClearSearchValue && inputValue) { setInputValue(""); setCurrentIndex(getOptionIndex(-1)); } } break; case KeyMap.Up: event.preventDefault(); setCurrentIndex(c => { // fix拼音输入法完成后,按下向上箭头按键导致bug if (isNaN(c)) { return 0; } const index = getOptionIndex(c, -1); if (listRef.current) { listRef.current.scrollToItem(index); } return index; }); break; case KeyMap.Down: event.preventDefault(); setCurrentIndex(c => { // fix拼音输入法完成后,按下向上箭头按键导致bug if (isNaN(c)) { return 0; } const index = getOptionIndex(c); if (listRef.current) { listRef.current.scrollToItem(index); } return index; }); break; case KeyMap.Esc: if (inputRef.current) { inputRef.current.blur(); } break; } }} />
); }, { defaultLabelAlign: "middle", } ); TagSelect.displayName = "TagSelect"; function TagSelectTrigger({ overlayElementRef, childrenElementRef, visible, setVisible, closeDelay = 0, render, }: TriggerProps) { const { listen } = useOutsideClick([childrenElementRef, overlayElementRef]); listen(() => visible && setVisible(false, closeDelay)); return render({ overlayProps: {}, childrenProps: {}, }); } TagSelectTrigger.displayName = "TagSelectTrigger";