/* eslint-disable */ import React, { useMemo } from "react"; import classNames from "classnames"; import clone from "clone"; import { Tag, TagValue } from "./Tag"; import { TagInput } from "./TagInput"; import { AttributeValue } from "./AttributeSelect"; import { withOutsideClick } from "../_util/with-outside-click"; import { withTranslation, WithTranslationProps } from "../i18n"; import { Bubble } from "../bubble"; import { Modal } from "../modal"; import { StyledProps } from "../_type"; import { TagSearchBoxContext } from "./TagSearchBoxContext"; import { Button } from "../button"; import { mergeRefs, withConfig, WithConfigProps } from "../util"; import { noop } from "../_util/noop"; export interface TagSearchBoxProps extends WithTranslationProps, WithConfigProps, StyledProps { /** * 要选择过滤的资源属性的集合 */ attributes?: AttributeValue[]; /** * 搜索框中默认包含的标签值的集合 */ defaultValue?: TagValue[]; /** * 配合 onChange 作为受控组件使用 */ value?: TagValue[]; /** * 当新增/修改/减少标签时调用此函数 * * **💡 用于触发搜索** */ onChange?: (tags: TagValue[]) => void; /** * 搜索框收起后宽度 * @default 210 */ minWidth?: string | number; /** * 是否禁用 * @default false * @since 2.4.1 */ disabled?: boolean; /** * 搜索框中提示语 * * @default "多个关键字用竖线 “|” 分隔,多个过滤标签用回车键分隔" (已处理国际化) */ tips?: string; /** * 资源属性选择下拉框提示 * * @default "选择资源属性进行过滤" (已处理国际化) */ attributesSelectTips?: string; /** * 隐藏帮助按钮 * * @default false */ hideHelp?: boolean; /** * 清空按钮点击回调 * * @since 2.2.2 */ onClearButtonClick?: (e: React.MouseEvent) => void; /** * 帮助按钮点击回调 * * 返回 `false` 阻止默认提示行为 * * @since 2.2.2 */ onHelpButtonClick?: (e: React.MouseEvent) => void | false; /** * 搜索按钮点击回调 * * @since 2.2.2 */ onSearchButtonClick?: (e: React.MouseEvent, value: TagValue[]) => void; /** * 使用禁用根据输入值过滤资源属性选择 * * **新增或修改标签时将展示全部资源属性** * * @since 2.4.0 * @default false */ disableAttributesFilter?: boolean; /** * 删除单个标签的回调 * * 返回 `false` 阻止删除 * * @since 2.7.4 */ onDeleteTag?: (tag: TagValue) => Promise | boolean; } export interface TagSearchBoxState { /** * 搜索框是否为展开状态 */ active: boolean; /** * 是否展示提示框 */ dialogActive: boolean; /** * 当前光标位置 */ curPos: number; /** * 当前光标(焦点)所在位置的元素类型 */ curPosType: FocusPosType; /** * 是否展示值选择组件 */ showSelect: boolean; /** * 已选标签 */ tags: TagValue[]; /** * 更新前 tags */ lastValue: TagValue[]; } /** * 焦点所在位置类型 */ export enum FocusPosType { INPUT, INPUT_EDIT, TAG, } let COUNTER = 0; @withOutsideClick("close") class ITagSearchBox extends React.Component< TagSearchBoxProps & { forwardRef: React.Ref }, TagSearchBoxState > { state: TagSearchBoxState = { active: false, dialogActive: false, curPos: 0, curPosType: FocusPosType.INPUT, showSelect: true, tags: this.props.defaultValue ? this.props.defaultValue.map(item => { item["_key"] = COUNTER++; return item; }) : [], lastValue: this.props.value, }; componentDidMount() { this.resetTagsState(this.props); } static getDerivedStateFromProps( props: TagSearchBoxProps, state: TagSearchBoxState ) { if ("value" in props && props.value !== state.lastValue) { const value = props.value.map(item => { if (!("_key" in item)) { item["_key"] = COUNTER++; } return item; }); return { tags: clone(value), lastValue: props.value }; } return null; } resetTagsState = (props: TagSearchBoxProps, callback?: Function) => { if ("value" in props) { const value = props.value.map(item => { if (!("_key" in item)) { item["_key"] = COUNTER++; } return item; }); this.setState({ tags: clone(value) }, () => { callback && callback(); }); } }; open = () => { const { disabled } = this.props; if (disabled) { return; } const { active } = this.state; const tags = this.state.tags || []; if (!active) { this.setState({ active: true }); // 展开时不激活 select 显示 this.setState({ curPosType: FocusPosType.INPUT, curPos: tags.length }); } else { this.handleTagEvent("click-input", tags.length); } this.setState({ showSelect: true }); setTimeout(() => { this[`tag-${tags.length}`]?.moveToEnd(); }, 100); }; close = () => { // 编辑未完成的取消编辑 const tags = this.state.tags.map((item, index) => { if (item["_edit"]) { this[`tag-${index}`]?.editDone(); item["_edit"] = false; } return item; }); this.setTags( tags, () => { this.setState({ showSelect: false }); if (this.state.active) { this.setState({ curPos: -1 }, () => this.setState({ active: false }, () => { if (this[`search-box`]) { this[`search-box`].scrollLeft = 0; } }) ); } }, false ); }; getValue = (tags: TagValue[]): TagValue[] => { const result = []; tags.forEach(item => { const { values, attr = null } = item; if (values.length > 0) { result.push({ attr, values, _key: item["_key"], _edit: item["_edit"] }); } }); return result; }; notify = (tags: TagValue[]) => { const { onChange = noop } = this.props; onChange(this.getValue(tags)); }; // Tags 发生变动 setTags(tags: TagValue[], callback?: Function, notify = true): void { const cb = () => { notify && this.notify(tags); callback && callback(); }; // 受控模式 if (notify && this.props.value) { this.resetTagsState(this.props, cb); } else { this.setState({ tags }, cb); } } /** * 点击清除按钮触发事件 */ handleClear = (e: React.MouseEvent): void => { e.stopPropagation(); const { onClearButtonClick = noop } = this.props; onClearButtonClick(e); const { tags } = this.state; const nextTags = tags.filter(i => i.attr && i.attr.removeable === false); const index = `tag-${nextTags.length}`; if (tags.length <= 0) { this[index]?.setInputValue(""); return; } this.setTags(nextTags, () => setTimeout(() => { this[index]?.setInputValue(""); this[index]?.focusInput(); }, 0) ); this.setState({ curPos: 0, curPosType: FocusPosType.INPUT }); // 刷新下拉列表位置 const input = this[`tag-${tags.length}`]; if (input) { input?.scheduleUpdate(); } }; /** * 点击帮助触发事件 */ handleHelp = e => { e.stopPropagation(); const { onHelpButtonClick = noop } = this.props; if (onHelpButtonClick(e) === false) { return; } this.setState({ dialogActive: true }); }; /** * 点击搜索触发事件 */ handleSearch = e => { if (!this.state.active) { // 如果监听了按钮点击,此时点击按钮不激活搜索框 if ("onSearchButtonClick" in this.props) { e.stopPropagation(); this.props.onSearchButtonClick(e, this.getValue(this.state.tags)); } return; } e.stopPropagation(); // 输入值生成标签操作会异步改变 tags // 此处保证 tags 状态变化完成后再进行回调 setTimeout(() => { const { onSearchButtonClick = noop } = this.props; onSearchButtonClick(e, this.getValue(this.state.tags)); }, 100); const { curPos, curPosType, tags } = this.state; let flag = false; const input = this[`tag-${tags.length}`]; if (input && input.addTagByInputValue) { if (input.addTagByInputValue()) { flag = true; } } for (let i = 0; i < tags.length; ++i) { if (!this[`tag-${i}`] || !this[`tag-${i}`].addTagByEditInputValue) return; if (tags[i]["_edit"] && this[`tag-${i}`].addTagByEditInputValue()) flag = true; } if (flag) return; this.notify(this.state.tags); input.focusInput(); }; /** * 处理Tag相关事件 */ handleTagEvent = async (type: string, index: number, payload?: any) => { const { active } = this.state; const tags = clone(this.state.tags); switch (type) { case "add": payload["_key"] = COUNTER++; tags.splice(++index, 0, payload); this.setTags(tags, () => { this[`tag-${index}`]?.focusInput(); }); this.setState({ showSelect: false }); break; case "edit": this[`tag-${index}`]?.editDone(); tags[index].attr = payload.attr; tags[index].values = payload.values; tags[index]["_edit"] = false; this.setTags(tags); index++; this.setState({ showSelect: false, curPosType: FocusPosType.INPUT }); break; case "edit-cancel": this[`tag-${index}`]?.editDone(); this.setTags(tags, () => null, false); this.setState({ showSelect: false, curPosType: FocusPosType.INPUT }); break; case "editing": if ("attr" in payload && tags[index]) tags[index].attr = payload.attr; if ("values" in payload && tags[index]) tags[index].values = payload.values; this.setTags(tags, null, false); break; case "del": if (payload === "keyboard") index--; if (!tags[index]) break; /** 传入onDeleteTag,就通过onDeleteTag控制是否删除 */ const canDelteTag = await this.props.onDeleteTag?.(tags[index]); if (this.props.onDeleteTag && !Boolean(canDelteTag)) break; // 检查不可移除 const { attr } = tags[index]; if (attr && attr?.removeable === false) { break; } tags.splice(index, 1); this.setTags(tags, () => { this.setState({ curPosType: FocusPosType.INPUT }); }); if (payload !== "edit") { this.setState({ showSelect: false }); } break; // payload 为点击位置 case "click": if (!active) { this.open(); return; } // 触发修改 const pos = payload; tags[index]["_edit"] = true; this.setTags( tags, () => { this.setState({ showSelect: true }, () => { this[`tag-${index}`]?.edit(pos); }); }, false ); this.setState({ curPosType: FocusPosType.INPUT_EDIT }); break; case "click-input": if (payload === "edit") { this.setState({ curPosType: FocusPosType.INPUT_EDIT }); } else { this.setState({ curPosType: FocusPosType.INPUT }); } if (!active) { this.setState({ active: true }); } this.setState({ showSelect: true }); break; } this.setState({ curPos: index }); }; render() { const { active, tags, curPos, curPosType, dialogActive, showSelect, } = this.state; const { t, config: { classPrefix }, className, style = {}, minWidth = 210, attributes = [], hideHelp, tips = t.tagSearchBoxTips, attributesSelectTips = t.tagSearchBoxSelectTitle, disableAttributesFilter, disabled, forwardRef, } = this.props; // 用于计算 focused 及 isFocused, 判断是否显示选择组件 // (直接使用 Input 组件内部 onBlur 判断会使得 click 时组件消失) let focusedInputIndex = -1; if ( curPosType === FocusPosType.INPUT || curPosType === FocusPosType.INPUT_EDIT ) { focusedInputIndex = curPos; } const tagList = tags.map((item, index) => { // 补全 attr 属性 attributes.forEach(attrItem => { if (item.attr && attrItem.key && attrItem.key == item.attr.key) { item.attr = Object.assign({}, item.attr, attrItem); } }); const selectedAttrKeys = []; tags.forEach(tag => { if ( tag.attr && item.attr && item["_edit"] && item.attr.key === tag.attr.key ) { return null; } if (tag.attr && tag.attr.key && !tag.attr.reusable) { selectedAttrKeys.push(tag.attr.key); } }); const useableAttributes = attributes.filter( item => selectedAttrKeys.indexOf(item.key) < 0 ); return ( (this[`tag-${index}`] = tag)} active={active} key={item["_key"]} attributes={useableAttributes} attr={item.attr} values={item.values} maxWidth={ this["search-wrap"] ? this["search-wrap"].clientWidth : null } focused={ focusedInputIndex === index && showSelect ? curPosType : null } dispatchTagEvent={(type, payload) => this.handleTagEvent(type, index, payload) } /> ); }); const selectedAttrKeys = tags .map(item => (item.attr && !item.attr.reusable ? item.attr.key : null)) .filter(item => !!item); const useableAttributes = attributes.filter( item => selectedAttrKeys.indexOf(item.key) < 0 ); tagList.push( (this[`tag-${tags.length}`] = input)} active={active} maxWidth={this["search-wrap"] ? this["search-wrap"].clientWidth : null} attributes={useableAttributes} isFocused={focusedInputIndex === tags.length && showSelect} dispatchTagEvent={(type, payload) => this.handleTagEvent(type, tags.length, payload) } /> ); return (
(this["search-wrap"] = div), forwardRef)} style={active ? style : { ...style, width: minWidth }} >
(this[`search-box`] = div)} style={disabled ? undefined : { cursor: "text" }} >
{tagList}
{tips}
{/* 清除按钮根据 hideHelp 添加类名确定位置 */} {!!active && tags.length > 0 && (
this.setState({ dialogActive: false })} className="ignore-outside-click" >
); } } export const TagSearchBox = React.forwardRef( (props: TagSearchBoxProps, ref: React.Ref) => { const C = useMemo(() => withConfig(withTranslation(ITagSearchBox)), []); return ; } ); TagSearchBox.displayName = "TagSearchBox";