diff --git a/package.json b/package.json index 47bf4b7aee..2607b9602b 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "precommit": "lint-staged", "scripts": "better-scripts", "dev": "pnpm scripts run dev", + "dev:site": "pnpm scripts run dev:site", "build": "pnpm scripts run build", "build:lib": "pnpm scripts run build:lib", "build:lib:theme": "pnpm scripts run build:lib:theme", diff --git a/packages/devui-vue/devui/category-search/src/category-search-types.ts b/packages/devui-vue/devui/category-search/src/category-search-types.ts index 03e2431639..eb75581785 100644 --- a/packages/devui-vue/devui/category-search/src/category-search-types.ts +++ b/packages/devui-vue/devui/category-search/src/category-search-types.ts @@ -3,280 +3,285 @@ import type { ExtractPropTypes, PropType, InjectionKey, SetupContext, Ref } from export type CategorySearchTagType = 'radio' | 'checkbox' | 'label' | 'textInput' | 'numberRange' | 'keyword'; export type StyleType = 'default' | 'gray'; export interface ITagOption { - /** - * 选项,label和color默认都会取对应的 filterKey 和 colorKey,如未设置取默认值 - */ - label?: string; // 通用默认属性,用于设置分类名称 - color?: string; // label 专用,用于设置标签颜色 - [propName: string]: any; + /** + * 选项,label和color默认都会取对应的 filterKey 和 colorKey,如未设置取默认值 + */ + label?: string; // 通用默认属性,用于设置分类名称 + color?: string; // label 专用,用于设置标签颜色 + [propName: string]: any; } export interface ICategorySearchTagItem { - /** - * 搜索字段,tag的键,用于区分不同的分类,需要唯一 - */ - field: string; - /** - * tag 键的显示值 - */ - label: string; - /** - * 配置项可生产的tag类型 - */ - type?: CategorySearchTagType; - /** - * 配置项所属的分组 - */ - group?: string; - /** - * tag 值的选择项数据 - */ - options?: Array; - /** - * 用于显示的 tag 值的键值,如未设置默认取label - */ - filterKey?: string | 'label'; - /** - * 用于显示的label类型中色值的键值,如未设置默认取color - */ - colorKey?: string | 'color'; - /** - * 已选中值 - */ - value?: { - label?: string; - value?: string | ITagOption | Array; - cache?: string | ITagOption | Array; - [propName: string]: any; - }; - /** - * dateRange 类型是否显示时分秒 - */ - showTime?: boolean; - /** - * dateRange 类型默认激活开始或者结束日期 - */ - activeRangeType?: 'start' | 'end'; - /** - * textInput 类型设置最大长度 - */ - maxLength?: number; - /** - * textInput | numberRange 类型设置占位符,numberRange需传入对象分别设置左右 - */ - placeholder?: string | { left: string; right: string }; - /** - * textInput 表单校验规则 - */ - validateRules?: any[]; - /** - * treeSelect 类型是否为多选,并显示已选择列表 - */ - multiple?: boolean; - /** - * treeSelect 类型是否显示搜索框 - */ - searchable?: boolean; - /** - * treeSelect 类型设置搜索框占位符 - */ - searchPlaceholder?: string; - /** - * treeSelect 类型自定义搜索方法,参数为搜索关键字和d-operable-tree组件实例 - */ - searchFn?: (value: string, treeInstance: any) => boolean | Array; - /** - * treeSelect 类型相关配置,请参考treeSelect组件API中同名配置 - */ - treeNodeIdKey?: string; - treeNodeChildrenKey?: string; - treeNodeTitleKey?: string; - disabledKey?: string; - leafOnly?: boolean; - iconParentOpen?: string; - iconParentClose?: string; - iconLeaf?: string; - [propName: string]: any; + /** + * 搜索字段,tag的键,用于区分不同的分类,需要唯一 + */ + field: string; + /** + * tag 键的显示值 + */ + label: string; + /** + * 配置项可生产的tag类型 + */ + type?: CategorySearchTagType; + /** + * 配置项所属的分组 + */ + group?: string; + /** + * tag 值的选择项数据 + */ + options?: Array; + /** + * 用于显示的 tag 值的键值,如未设置默认取label + */ + filterKey?: string | 'label'; + /** + * 用于显示的label类型中色值的键值,如未设置默认取color + */ + colorKey?: string | 'color'; + /** + * 已选中值 + */ + value?: { + label?: string; + value?: string | ITagOption | Array; + cache?: string | ITagOption | Array; + [propName: string]: any; + }; + /** + * dateRange 类型是否显示时分秒 + */ + showTime?: boolean; + /** + * dateRange 类型默认激活开始或者结束日期 + */ + activeRangeType?: 'start' | 'end'; + /** + * textInput 类型设置最大长度 + */ + maxLength?: number; + /** + * textInput | numberRange 类型设置占位符,numberRange需传入对象分别设置左右 + */ + placeholder?: string | { left: string; right: string }; + /** + * textInput 表单校验规则 + */ + validateRules?: any[]; + /** + * treeSelect 类型是否为多选,并显示已选择列表 + */ + multiple?: boolean; + /** + * treeSelect 类型是否显示搜索框 + */ + searchable?: boolean; + /** + * treeSelect 类型设置搜索框占位符 + */ + searchPlaceholder?: string; + /** + * treeSelect 类型自定义搜索方法,参数为搜索关键字和d-operable-tree组件实例 + */ + searchFn?: (value: string, treeInstance: any) => boolean | Array; + /** + * treeSelect 类型相关配置,请参考treeSelect组件API中同名配置 + */ + treeNodeIdKey?: string; + treeNodeChildrenKey?: string; + treeNodeTitleKey?: string; + disabledKey?: string; + leafOnly?: boolean; + iconParentOpen?: string; + iconParentClose?: string; + iconLeaf?: string; + [propName: string]: any; } export interface SearchConfig { - keyword?: boolean; - keywordDescription?: (searchKey: string) => string; - field?: boolean; - fieldDescription?: (label: string) => string; - category?: boolean; - categoryDescription?: string; + keyword?: boolean; + keywordDescription?: (searchKey: string) => string; + field?: boolean; + fieldDescription?: (label: string) => string; + category?: boolean; + categoryDescription?: string; } export interface TextConfig { - keywordName?: string; - createFilter?: string; - filterTitle?: string; - labelConnector?: string; - noCategoriesAvailable?: string; + keywordName?: string; + createFilter?: string; + filterTitle?: string; + labelConnector?: string; + noCategoriesAvailable?: string; } export interface ExtendConfig { - show?: boolean; - clear?: { - show?: boolean; - disabled?: boolean; - }; - save?: { - show?: boolean; - disabled?: boolean; - }; - more?: { - show?: boolean; - disabled?: boolean; - }; + show?: boolean; + clear?: { + show?: boolean; + disabled?: boolean; + }; + save?: { + show?: boolean; + disabled?: boolean; + }; + more?: { + show?: boolean; + disabled?: boolean; + }; +} +export interface ITagContext { + toggle: (status?: boolean) => void; } export const categorySearchProps = { - category: { - type: Array as PropType, - default: () => [] - }, - defaultSearchField: { - type: Array as PropType, - default: () => [] - }, - selectedTags: { - type: Array as PropType, - default: () => [] - }, - toggleScrollToTail: { - type: Boolean, - default: false, - }, - searchKey: { - type: String, - default: '' - }, - placeholder: { - type: String, - default: '' - }, - inputReadOnly: { - type: Boolean, - default: false - }, - tagMaxWidth: { - type: Number, - }, - beforeTagChange: { - type: Function as PropType<(tag: ICategorySearchTagItem, searchKey: string, operation: string) => boolean | Promise>, - }, - showSearchCategory: { - type: [Boolean, Object] as PropType, - default: true, - }, - categoryInGroup: { - type: Boolean, - default: false, - }, - groupOrderConfig: { - type: Array as PropType, - default: () => [] - }, - filterNameRules: { - type: Array as PropType[]>, - }, - textConfig: { - type: Object as PropType, - default: () => ({ - keywordName: '', - createFilter: '', - filterTitle: '', - labelConnector: '|', - noCategoriesAvailable: '' - }), - }, - extendConfig: { - type: Object as PropType, - }, - styleType: { - type: String as PropType, - default: 'default' - } + category: { + type: Array as PropType, + default: () => [], + }, + defaultSearchField: { + type: Array as PropType, + default: () => [], + }, + selectedTags: { + type: Array as PropType, + default: () => [], + }, + toggleScrollToTail: { + type: Boolean, + default: false, + }, + searchKey: { + type: String, + default: '', + }, + placeholder: { + type: String, + default: '', + }, + inputReadOnly: { + type: Boolean, + default: false, + }, + tagMaxWidth: { + type: Number, + }, + beforeTagChange: { + type: Function as PropType<(tag: ICategorySearchTagItem, searchKey: string, operation: string) => boolean | Promise>, + }, + showSearchCategory: { + type: [Boolean, Object] as PropType, + default: true, + }, + categoryInGroup: { + type: Boolean, + default: false, + }, + groupOrderConfig: { + type: Array as PropType, + default: () => [], + }, + filterNameRules: { + type: Array as PropType[]>, + }, + textConfig: { + type: Object as PropType, + default: () => ({ + keywordName: '', + createFilter: '', + filterTitle: '', + labelConnector: '|', + noCategoriesAvailable: '', + }), + }, + extendConfig: { + type: Object as PropType, + }, + styleType: { + type: String as PropType, + default: 'default', + }, }; export type CategorySearchProps = ExtractPropTypes; export interface CategorySearchInjection { - rootCtx: SetupContext; - rootRef: Ref; - id: Ref; - innerTextConfig: Ref; - tagMaxWidth: Ref | undefined; - inputReadOnly: Ref; - placeholder: Ref; - innerSearchKey: Ref; - innerSelectedTags: Ref; - isHover: Ref; - isFocus: Ref; - enterSearch: Ref; - showNoDataTips: Ref; - showSearchCategory: Ref; - showSearchConfig: Ref; - categoryDisplay: Ref; - searchField: Ref; - currentSearchCategory: Ref; - ComponentMap: Record; - currentSelectTag: Ref; - filterNameRules: Ref[] | undefined> | undefined; - joinLabelTypes: string[]; - chooseItem: (tag: ICategorySearchTagItem, chooseItem: ITagOption) => void; - onSearchKeyTagClick: () => void; - clearFilter: (e: Event) => void; - onCategoryItemClick: (item: ICategorySearchTagItem) => void; - removeTag: (tag: ICategorySearchTagItem, event?: Event) => void; - chooseItems: (tag: ICategorySearchTagItem) => void; - getTextInputValue: (tag: ICategorySearchTagItem, inputValue: string) => void; - getNumberRangeValue: (tag: ICategorySearchTagItem, rangeValue: number[]) => void; - createFilterFn: (filterName: string) => void; - searchKeyChangeEvent: (e: Event) => void; - searchInputValue: (event: Event) => void; - searchCategory: (item: ICategorySearchTagItem) => void; - showCurrentSearchCategory: (tag: ICategorySearchTagItem) => void; - onInputBackspace: () => void; - onInputToggle: () => void; + rootCtx: SetupContext; + rootRef: Ref; + id: Ref; + innerTextConfig: Ref; + tagMaxWidth: Ref | undefined; + inputReadOnly: Ref; + placeholder: Ref; + innerSearchKey: Ref; + innerSelectedTags: Ref; + isHover: Ref; + isFocus: Ref; + enterSearch: Ref; + showNoDataTips: Ref; + showSearchCategory: Ref; + showSearchConfig: Ref; + categoryDisplay: Ref; + searchField: Ref; + currentSearchCategory: Ref; + ComponentMap: Record; + currentSelectTag: Ref; + filterNameRules: Ref[] | undefined> | undefined; + joinLabelTypes: string[]; + chooseItem: (tag: ICategorySearchTagItem, chooseItem: ITagOption) => void; + onSearchKeyTagClick: () => void; + clearFilter: (e: Event) => void; + onCategoryItemClick: (item: ICategorySearchTagItem) => void; + removeTag: (tag: ICategorySearchTagItem, event?: Event) => void; + chooseItems: (tag: ICategorySearchTagItem) => void; + getTextInputValue: (tag: ICategorySearchTagItem, inputValue: string) => void; + getNumberRangeValue: (tag: ICategorySearchTagItem, rangeValue: number[]) => void; + createFilterFn: (filterName: string) => void; + searchKeyChangeEvent: (e: Event) => void; + searchInputValue: (event: Event) => void; + searchCategory: (item: ICategorySearchTagItem) => void; + showCurrentSearchCategory: (tag: ICategorySearchTagItem) => void; + onInputBackspace: () => void; + onInputToggle: () => void; + addTagContext: (field: string, context: ITagContext) => void; + removeTagContext: (field: string) => void; } export const categorySearchInjectionKey: InjectionKey = Symbol('d-category-search'); export const categorySearchDropdownProps = { - item: { - type: Object as PropType, - default: () => ({}) - }, - isJoinLabelType: { - type: Boolean, - default: false - }, + item: { + type: Object as PropType, + default: () => ({}), + }, + isJoinLabelType: { + type: Boolean, + default: false, + }, }; export type CategorySearchDropdownProps = ExtractPropTypes; export const categorySearchTagProps = { - item: { - type: Object as PropType, - default: () => ({}) - }, - isJoinLabelType: { - type: Boolean, - default: false - }, + item: { + type: Object as PropType, + default: () => ({}), + }, + isJoinLabelType: { + type: Boolean, + default: false, + }, }; export type CategorySearchTagProps = ExtractPropTypes; // radio | checkbox | label | textInput 类型,弹出层组件接收的参数 export const typeMenuProps = { - tag: { - type: Object as PropType, - default: () => ({}) - } -} + tag: { + type: Object as PropType, + default: () => ({}), + }, +}; export type TypeMenuProps = ExtractPropTypes; // clear | save | more 扩展图标组件接收的参数 export const extendIconProps = { - disabled: { - type: Boolean, - default: false - } -} -export type ExtendIconProps = ExtractPropTypes; \ No newline at end of file + disabled: { + type: Boolean, + default: false, + }, +}; +export type ExtendIconProps = ExtractPropTypes; diff --git a/packages/devui-vue/devui/category-search/src/components/category-search-tag-dropdown.tsx b/packages/devui-vue/devui/category-search/src/components/category-search-tag-dropdown.tsx index 07149f1f9d..2a9ed25167 100644 --- a/packages/devui-vue/devui/category-search/src/components/category-search-tag-dropdown.tsx +++ b/packages/devui-vue/devui/category-search/src/components/category-search-tag-dropdown.tsx @@ -1,54 +1,71 @@ -import { defineComponent, toRefs, inject, h, ref } from 'vue'; +import { defineComponent, toRefs, inject, h, ref, onMounted, onUnmounted } from 'vue'; import { Dropdown } from '../../../dropdown'; import CategorySearchTag from './category-search-tag'; import { categorySearchDropdownProps, categorySearchInjectionKey } from '../category-search-types'; import type { CategorySearchDropdownProps, CategorySearchInjection, ICategorySearchTagItem } from '../category-search-types'; export default defineComponent({ - name: 'DCategorySearchDropdown', - props: categorySearchDropdownProps, - setup(props: CategorySearchDropdownProps) { - const { item, isJoinLabelType } = toRefs(props); - const { rootCtx, ComponentMap, onSearchKeyTagClick } = inject(categorySearchInjectionKey) as CategorySearchInjection; - const isVisible = ref(false); - const checkType = (tag: ICategorySearchTagItem | undefined) => { - return tag && tag.type === 'radio' ? 'all' : 'blank'; - } - const onTagClick = () => { - isVisible.value = !isVisible.value; - }; - const onToggle = (status: boolean) => { - isVisible.value = status; - }; - const onDropdownClose = () => { - isVisible.value = false; - }; + name: 'DCategorySearchDropdown', + props: categorySearchDropdownProps, + setup(props: CategorySearchDropdownProps) { + const { item, isJoinLabelType } = toRefs(props); + const { rootCtx, ComponentMap, onSearchKeyTagClick, addTagContext, removeTagContext } = inject( + categorySearchInjectionKey + ) as CategorySearchInjection; + const isVisible = ref(false); + const checkType = (tag: ICategorySearchTagItem | undefined) => { + return tag && tag.type === 'radio' ? 'all' : 'blank'; + }; + const onTagClick = () => { + isVisible.value = !isVisible.value; + }; + const onToggle = (status: boolean) => { + isVisible.value = status; + }; + const onDropdownClose = () => { + isVisible.value = false; + }; + const toggle = (status?: boolean) => { + if (typeof status === 'boolean') { + isVisible.value = status; + } else { + onTagClick(); + } + }; - return () => - item.value.type !== 'keyword' ? ( - - {{ - default: () => ( -
  • - -
  • - ), - menu: () => - rootCtx.slots[`${item.value.field}Menu`]?.({ tagOption: item.value, close: onDropdownClose }) || - h(ComponentMap[item.value.type!], { tag: item.value, onClose: onDropdownClose }) - }} -
    - ) : ( -
  • - -
  • - ); - }, -}); \ No newline at end of file + onMounted(() => { + addTagContext(item.value.field, { toggle }); + }); + + onUnmounted(() => { + removeTagContext(item.value.field); + }); + + return () => + item.value.type !== 'keyword' ? ( + + {{ + default: () => ( +
  • + +
  • + ), + menu: () => + rootCtx.slots[`${item.value.field}Menu`]?.({ tagOption: item.value, close: onDropdownClose }) || + h(ComponentMap[item.value.type!], { tag: item.value, onClose: onDropdownClose }), + }} +
    + ) : ( +
  • + +
  • + ); + }, +}); diff --git a/packages/devui-vue/devui/category-search/src/composables/use-category-search.ts b/packages/devui-vue/devui/category-search/src/composables/use-category-search.ts index 3ee68fe250..0577414f55 100644 --- a/packages/devui-vue/devui/category-search/src/composables/use-category-search.ts +++ b/packages/devui-vue/devui/category-search/src/composables/use-category-search.ts @@ -7,725 +7,747 @@ import LabelMenu from '../components/label-menu'; import TextInputMenu from '../components/text-input-menu'; import NumberRangeMenu from '../components/number-range-menu'; import { categorySearchInjectionKey } from '../category-search-types'; -import type { ExtendConfig, CategorySearchProps, ICategorySearchTagItem, SearchConfig, ITagOption, TextConfig } from '../category-search-types'; +import type { + ExtendConfig, + CategorySearchProps, + ICategorySearchTagItem, + SearchConfig, + ITagOption, + TextConfig, + ITagContext, +} from '../category-search-types'; import { DELAY, SearchKeyField, DROPDOWN_ANIMATION_TIMEOUT, getSearchMessage, getFindingMessage, COLORS } from '../category-search-const'; let ID_SEED = 0; export function useCategorySearch(props: CategorySearchProps, ctx: SetupContext) { - const { - category, - tagMaxWidth, - textConfig, - inputReadOnly, - placeholder, - searchKey, - selectedTags, - styleType, - categoryInGroup, - groupOrderConfig, - defaultSearchField, - beforeTagChange, - toggleScrollToTail, - showSearchCategory, - filterNameRules, - extendConfig, - } = toRefs(props); - const innerCategory: Ref = ref([]); - const innerSelectedTags: Ref = ref([]); - const innerTextConfig: Ref = ref({}); - const id = ref(ID_SEED++); - const isHover = ref(false); - const isFocus = ref(false); - const enterSearch = ref(false); - const showNoDataTips = ref(false); - const innerSearchKey = ref(searchKey.value); - const scrollBarRef = ref(); - const rootRef = ref(); - const inputRef = ref(); - const showSearchConfig: Ref = ref({ keyword: true, field: true, category: true }); - const categoryDisplay: Ref = ref([]); - const searchField: Ref = ref([]); - const currentSearchCategory: Ref = ref([]); - const currentSelectTag: Ref = ref(); - const joinLabelTypes = ['checkbox', 'label']; - const valueIsArrayTypes = ['dateRange', 'numberRange', 'treeSelect', 'checkbox', 'label']; - const ComponentMap: Record = { - radio: RadioMenu, - checkbox: CheckboxMenu, - label: LabelMenu, - textInput: TextInputMenu, - numberRange: NumberRangeMenu, - }; - const operationConfig: ExtendConfig = reactive({ - clear: { show: true }, - save: { show: true }, - more: { show: false }, - }); - let scrollToTailFlag = true; // 是否在更新标签内容后滚动至输入框的开关 - let isSearchCategory = false; - let categoryOrder: any[] = []; - let categoryDictionary: Record = {}; - let searchKeyCache = ''; - let blurTimer: any; // 失焦关闭下拉延时器,失焦后立刻展开下拉需清除该延时 - - const containerClasses = computed(() => ({ - 'dp-category-search-container': true, - [`dp-category-search-id-${id.value}`]: true, - 'container-hover': isHover.value && !isFocus.value, - 'dp-gray-style': styleType.value === 'gray', - })); - - const showExtendedConfig = computed(() => operationConfig.show ?? Boolean(innerSelectedTags.value.length || innerSearchKey.value)); - - const removeTag = (tag: ICategorySearchTagItem, event?: Event) => { - canChange(tag, 'delete').then((val) => { - if (!val) { - if (beforeTagChange?.value && event) { - event.stopPropagation(); - } - return; - } - tag = resetValue(tag); - innerSelectedTags.value = innerSelectedTags.value.filter((item) => item.field !== tag.field); - const result = getSelectedTagsExceptKeyword(); - if (tag.type === 'keyword') { - innerSearchKey.value = innerSearchKey.value === searchKeyCache ? '' : innerSearchKey.value; - searchKeyCache = ''; - enterSearch.value = innerSearchKey.value !== ''; - ctx.emit('search', { selectedTags: result, searchKey: innerSearchKey.value }); - } else { - resolveCategoryDisplay(tag, 'add'); - ctx.emit('selectedTagsChange', { selectedTags: result, currentChangeTag: tag, operation: 'delete' }); - } - currentSelectTag.value = undefined; - }); - }; - - const onSearch = () => { - ctx.emit('search', { selectedTags: getSelectedTagsExceptKeyword(), searchKey: setSearchKeyTag() }); - isFocus.value = true; - }; - - // radio 单选 处理选中项方法 - const chooseItem = (tag: ICategorySearchTagItem, chooseItem: ITagOption) => { - afterDropdownClosed(); - const key = tag.filterKey || 'label'; - tag.value = { value: chooseItem, cache: cloneDeep(chooseItem) }; - tag.value[key] = chooseItem[key]; - tag.title = setTitle(tag, 'radio'); - updateSelectedTags(tag); - }; - - // checkbox | label 多选 处理选中项方法 - const chooseItems = (tag: ICategorySearchTagItem) => { - afterDropdownClosed(); - const key = tag.filterKey || 'label'; - if (tag.type === 'label') { - tag.value!.value = tag.value!.value!.map((item) => { - const res = item.split('_'); - return { - $label: item, - [tag.filterKey || 'label']: res[0], - [tag.colorKey || 'color']: res[1], - }; - }); - } - const result = getItemValue(tag.value!.value, key); - if (result) { - tag.title = setTitle(tag, 'checkbox', result); - tag.value![key] = result; - tag.value!.cache = cloneDeep(tag.value!.value); - updateSelectedTags(tag); - } else { - removeTag(tag); - } - }; - - // textInput 文本输入框 处理选中项方法 - const getTextInputValue = (tag: ICategorySearchTagItem, inputValue: string) => { - afterDropdownClosed(); - tag.value![tag.filterKey || 'label'] = tag.value!.cache = tag.value!.value = inputValue; - tag.title = setTitle(tag, 'textInput'); - updateSelectedTags(tag); - }; - - // numberRange 数字范围 处理选中项方法 - const getNumberRangeValue = (tag: ICategorySearchTagItem, rangeValue: number[]) => { - afterDropdownClosed(); - const startNum = rangeValue[0] || 0; - const endNum = rangeValue[1] || 0; - tag.value!.value = [startNum, endNum]; - tag.value!.cache = [startNum, endNum]; - tag.value![tag.filterKey || 'label'] = `${startNum} - ${endNum}`; - tag.title = setTitle(tag, 'numberRange'); - updateSelectedTags(tag); - }; - - const onSearchKeyTagClick = () => { - innerSearchKey.value = searchKeyCache; - inputRef.value.focus(); - ctx.emit('searchKeyChange', innerSearchKey.value); - }; - - // 清空 - const clearFilter = (event: Event) => { - if (innerSelectedTags.value.length) { - innerSelectedTags.value.forEach((item) => resetValue(item)); - innerSelectedTags.value = []; - } - if (innerSearchKey.value || searchKeyCache) { - innerSearchKey.value = ''; - searchKeyCache = ''; - } - if (currentSelectTag.value) { - currentSelectTag.value = undefined; - } - ctx.emit('selectedTagsChange', { selectedTags: [], currentChangeTag: undefined, operation: 'clear' }); - ctx.emit('clearAll', event); - initCategoryDisplay(); - }; - - // 保存 - const createFilterFn = (filterName: string) => { - ctx.emit('createFilter', { name: filterName, selectedTags: getSelectedTagsExceptKeyword(), keyword: innerSearchKey.value }); - }; - - const onCategoryItemClick = (item: ICategorySearchTagItem) => { - updateSelectedTags(item, false); - setTimeout(() => { - currentSelectTag.value = item; - if (currentSelectTag.value.type === 'label') { - currentSelectTag.value = mergeToLabel(currentSelectTag.value); - } - currentSelectTag.value.title = setTitle(currentSelectTag.value, currentSelectTag.value.type || '', ''); - inputRef.value.openMenu(); - inputRef.value.focus(); - }, DROPDOWN_ANIMATION_TIMEOUT); - }; - - const searchKeyChangeEvent = (event: Event) => { - innerSearchKey.value = (event.target as HTMLInputElement).value; - enterSearch.value = Boolean(innerSearchKey.value); - currentSearchCategory.value = innerSearchKey.value - ? innerCategory.value.filter((item) => item['label'].toLowerCase().includes(innerSearchKey.value.toLowerCase())) - : []; - ctx.emit('searchKeyChange', innerSearchKey.value); - }; - - const searchInputValue = (event: Event) => { - event.preventDefault(); - event.stopPropagation(); - // 当有分类正在选择时输入关键字不处理 - if (!currentSelectTag.value) { - ctx.emit('search', { - selectedTags: getSelectedTagsExceptKeyword(), - searchKey: setSearchKeyTag(), - }); - } - }; - - const searchCategory = (item: ICategorySearchTagItem) => { - if (valueIsArrayTypes.includes(item.type || '')) { - return; - } - updateFieldValue(item, innerSearchKey.value); - updateSelectedTags(item); - innerSearchKey.value = ''; - enterSearch.value = false; - finishChoose(); - }; - - const showCurrentSearchCategory = (tag: ICategorySearchTagItem) => { - isSearchCategory = true; - innerSearchKey.value = ''; - inputRef.value.closeMenu(); - chooseCategory(tag); - setTimeout(() => { - isFocus.value = true; - enterSearch.value = false; - }, DELAY); - }; - - const onInputBackspace = () => { - if (innerSearchKey.value) { - return; - } - if (currentSelectTag.value) { - currentSelectTag.value = undefined; - inputRef.value.closeMenu(); - return; - } - if (innerSelectedTags.value.length) { - const tag = innerSelectedTags.value[innerSelectedTags.value.length - 1]; - removeTag(tag); - } - inputRef.value.closeMenu(); - }; - - const onInputToggle = () => { - showNoDataTips.value = categoryDisplay.value.every((item) => item.isSelected); - }; - - watch( - searchKey, - () => { - innerSearchKey.value = searchKey.value; - searchKeyCache = searchKey.value; - setSearchKeyTag(false); - }, - { immediate: true } - ); - - watch( - [selectedTags, category, defaultSearchField], - () => { - innerSelectedTags.value = cloneDeep(selectedTags.value); - innerCategory.value = cloneDeep(category.value); - init(); - }, - { immediate: true, deep: true } - ); - - watch( - textConfig, - () => { - innerTextConfig.value = textConfig.value; - innerTextConfig.value.createFilter = innerTextConfig.value.createFilter || '保存过滤器'; - innerTextConfig.value.filterTitle = innerTextConfig.value.filterTitle || '过滤器标题'; - showSearchConfig.value.keywordDescription = showSearchCategory.value.keywordDescription || getSearchMessage; - showSearchConfig.value.fieldDescription = showSearchCategory.value.fieldDescription || getFindingMessage; - showSearchConfig.value.categoryDescription = showSearchCategory.value.categoryDescription || '请选择筛选条件:'; - setTimeout(() => { - const keyword = innerSelectedTags.value.find((item) => item.field === SearchKeyField); - if (keyword) { - keyword.label = innerTextConfig.value.keywordName || '关键字'; - keyword.title = `${keyword.label}:${keyword.value?.label}`; - } - }); - }, - { immediate: true, deep: true } - ); - - watch( - showSearchCategory, - () => { - const customConfig = - typeof showSearchCategory.value === 'boolean' - ? { - keyword: showSearchCategory.value, - field: showSearchCategory.value, - category: showSearchCategory.value, - } - : showSearchCategory.value; - showSearchConfig.value = { ...showSearchConfig.value, ...customConfig }; - }, - { immediate: true, deep: true } - ); - - watch( - () => extendConfig?.value, - () => { - merge(operationConfig, extendConfig?.value || {}); - }, - { immediate: true, deep: true } - ); - - ctx.expose({ - chooseItem, - chooseItems, - getTextInputValue, - getNumberRangeValue, - searchCategory, - }); - - onMounted(() => scrollToTail(true)); - - provide(categorySearchInjectionKey, { - rootRef, - rootCtx: ctx, - id, - innerTextConfig, - tagMaxWidth, - inputReadOnly, - placeholder, - innerSearchKey, - innerSelectedTags, - isHover, - isFocus, - enterSearch, - showSearchCategory, - categoryDisplay, - showSearchConfig, - showNoDataTips, - searchField, - currentSearchCategory, - ComponentMap, - currentSelectTag, - filterNameRules, - joinLabelTypes, - chooseItem, - onSearchKeyTagClick, - clearFilter, - onCategoryItemClick, - removeTag, - chooseItems, - getTextInputValue, - getNumberRangeValue, - createFilterFn, - searchKeyChangeEvent, - searchInputValue, - searchCategory, - showCurrentSearchCategory, - onInputBackspace, - onInputToggle, - }); - - function init() { - setValue(innerCategory.value); - setValue(innerSelectedTags.value, true); - initCategoryDisplay(true); - if (defaultSearchField.value.length && innerCategory.value.length) { - searchField.value = innerCategory.value.filter( - (item) => defaultSearchField.value.includes(item.field) && !valueIsArrayTypes.includes(item.type || '') - ); - } - // 初始化时判断已选中分类中最后一项是否赋值,未赋值则识别为正在处理的分类,优先显示赋值下拉列表 - if (innerSelectedTags.value.length) { - const [lastItem] = innerSelectedTags.value.slice(-1); - const isNull = lastItem.value?.[lastItem.filterKey || 'label'] === undefined; - currentSelectTag.value = - isNull && (lastItem.value?.value === undefined || (Array.isArray(lastItem.value.value) && lastItem.value.value.length === 0)) - ? lastItem - : undefined; - } - if (searchKeyCache) { - innerSearchKey.value = searchKeyCache; - setSearchKeyTag(false); - } - } - - function updateFieldValue(field: ICategorySearchTagItem, value: any) { - const result: Record = {}; - const filterKey = field.filterKey || 'label'; - const colorKey = field.colorKey || 'color'; - result[filterKey] = value; - if (field.type === 'radio') { - field.value!.value = { [filterKey]: value }; - } - if (field.type === 'textInput') { - field.value!.value = value; - } - if (field.type === 'label') { - if (field.options![0] && !field.options![0].$label) { - mergeToLabel(field); - } - result[colorKey] = COLORS[Math.floor(COLORS.length * Math.random())]; - result['$label'] = `${value}_${result[colorKey]}`; - } - if (joinLabelTypes.includes(field.type || '')) { - field.value!.value = [result]; - } - field.value![filterKey] = value; - field.value!.cache = cloneDeep(field.value!.value); - field.title = setTitle(field, field.type || '', value); - } - - function chooseCategory(item: ICategorySearchTagItem) { - // 点选分组名称不处理 - if (item.groupLength !== undefined) { - return; - } - setTimeout(() => { - currentSelectTag.value = item; - if (currentSelectTag.value.type === 'label') { - currentSelectTag.value = mergeToLabel(currentSelectTag.value); - } - currentSelectTag.value.title = setTitle(currentSelectTag.value, currentSelectTag.value.type || '', ''); - inputRef.value.openMenu(); - }, DROPDOWN_ANIMATION_TIMEOUT); - updateSelectedTags(item, false); - } - - function clearCurrentSelectTagFromSearch() { - if (currentSelectTag.value) { - if (isSearchCategory) { - isSearchCategory = false; - setTimeout(finishChoose, DELAY); - } - } - } - - function finishChoose() { - currentSelectTag.value = undefined; - inputRef.value.focus(); - } - - function afterDropdownClosed() { - setTimeout(() => { - currentSelectTag.value = undefined; - }, DROPDOWN_ANIMATION_TIMEOUT + 100); - } - - function resolveCategoryDisplay(tag: ICategorySearchTagItem, type: string) { - if (tag.field === SearchKeyField || !categoryDictionary[tag.field]) { - return; - } - handleGroupLength(tag, type === 'delete'); - categoryDictionary[tag.field].isSelected = type === 'delete'; - } - - function resetValue(tag: ICategorySearchTagItem) { - tag.value = valueIsArrayTypes.includes(tag.type || '') ? { value: [] } : { value: undefined }; - tag.value[tag.filterKey || 'label'] = undefined; - return tag; - } - - function getSelectedTagsExceptKeyword(): ICategorySearchTagItem[] { - return showSearchConfig.value.keyword - ? innerSelectedTags.value.filter((item) => item.field !== SearchKeyField) - : innerSelectedTags.value; - } - - function canChange(tag: ICategorySearchTagItem, operation: 'delete' | 'add') { - let changeResult = Promise.resolve(true); - if (beforeTagChange?.value) { - const result = beforeTagChange.value(tag, innerSearchKey.value, operation); - if (typeof result !== 'undefined') { - if (typeof result === 'boolean') { - changeResult = Promise.resolve(result); - } else { - changeResult = result; - } - } - } - return changeResult; - } - - function initCategoryDisplay(isInit = false) { - const selectedTagsField = innerSelectedTags.value.map((item) => item.field); - if (isInit) { - innerCategory.value = cloneDeep(innerCategory.value) || []; - categoryOrder = []; - categoryDictionary = {}; - initGroupAndDictionary(); - initCategoryOrder(); - } - categoryDisplay.value = categoryOrder.map((item) => { - item.isSelected = selectedTagsField.includes(item.field); - handleGroupLength(item, item.isSelected, isInit); - return item; - }); - showNoDataTips.value = categoryDisplay.value.every((item) => item.isSelected); - } - - function handleGroupLength(tag: ICategorySearchTagItem, isSelected: boolean, isInit = false) { - if (categoryInGroup.value && tag.group) { - const group = categoryDictionary[tag.group]; - const len = group.groupLength; - group.groupLength = isSelected ? len - 1 : isInit ? len : len + 1; - group.isSelected = group.groupLength === 0; - } - } - - function initGroupAndDictionary() { - innerCategory.value.forEach((item) => { - if (categoryInGroup.value && item.group) { - if (categoryDictionary[item.group]) { - categoryDictionary[item.group].groupLength++; - } else { - categoryDictionary[item.group] = { groupName: item.group, groupLength: 1, children: [] }; - } - categoryDictionary[item.group].children.push(item); - } - categoryDictionary[item.field] = item; - }); - } - - function initCategoryOrder() { - const keys = groupOrderConfig.value.length ? groupOrderConfig.value : Object.keys(categoryDictionary); - keys.forEach((key) => { - const item = categoryDictionary[key]; - if (item) { - if (categoryInGroup.value) { - if (item.groupName) { - categoryOrder.push(item, ...item.children); - } else if (!item.group) { - categoryOrder.push(item); - } - } else { - categoryOrder.push(item); - } - } - }); - } - - function setSearchKeyTag(isSearch = true) { - const result = innerSearchKey.value || searchKeyCache; - if (showSearchConfig.value.keyword) { - const existingSearchKeyTag = innerSelectedTags.value.find((tag) => tag.field === SearchKeyField); - if (existingSearchKeyTag && !isSearch && innerSearchKey.value === '') { - removeTag(existingSearchKeyTag); - } else if (innerSearchKey.value && innerSearchKey.value !== existingSearchKeyTag?.value?.value) { - createSearchKeyTag(isSearch); - } - } - innerSearchKey.value = ''; - if (isSearch) { - setTimeout(() => { - enterSearch.value = false; - }, DELAY); - } - return result; - } - - function createSearchKeyTag(isSearch: boolean) { - const label = innerTextConfig.value.keywordName || '关键字'; - const searchKeyTag: ICategorySearchTagItem = { - options: [], - field: SearchKeyField, - label: label, - type: 'keyword', - title: `${label}:${innerSearchKey.value}`, - value: { - label: innerSearchKey.value, - value: innerSearchKey.value, - cache: innerSearchKey.value, - }, - }; - updateSelectedTags(searchKeyTag, isSearch); - searchKeyCache = innerSearchKey.value; - innerSearchKey.value = ''; - } - - function updateSelectedTags(tag: ICategorySearchTagItem, valueChanged = true) { - canChange(tag, 'add').then((val) => { - if (!val) { - return; - } - const index = innerSelectedTags.value.map((item) => item.field).indexOf(tag.field); - if (index > -1) { - if (!tag.value?.value) { - // 通过输入选择分类时避免空值覆盖已选值 - merge(tag, innerSelectedTags.value[index]); - } - innerSelectedTags.value[index] = tag; - } else { - innerSelectedTags.value.push(tag); - } - if (valueChanged) { - // 只在新增标签时位移滚动条 - if (scrollToTailFlag) { - setTimeout(scrollToTail); - } - ctx.emit('selectedTagsChange', { - selectedTags: getSelectedTagsExceptKeyword(), - currentChangeTag: tag, - operation: 'add', - }); - isSearchCategory = false; - } else { - resolveCategoryDisplay(tag, 'delete'); - } - }); - } - - // 判断滚动条是否存在,如果存在自动滚动到末尾的输入框 - function scrollToTail(isInit?: boolean) { - const dom = scrollBarRef.value; - if (toggleScrollToTail.value && dom && dom.scrollWidth > dom.clientWidth) { - if (isInit) { - dom.scrollLeft = dom.scrollWidth - dom.clientWidth; - } else { - inputRef.value.scrollIntoView(); - } - } else if (!isInit) { - // 初始化不聚焦,避免展开下拉 - inputRef.value.focus(); - } - } - - function setValue(data: ICategorySearchTagItem[], isSelectedTags = false) { - if (Array.isArray(data) && data.length) { - data.forEach((item) => { - if (isSelectedTags && innerCategory.value) { - let result = ''; - const originItem = innerCategory.value.find((categoryItem) => categoryItem.field === item.field); - mergeWith(item, originItem, mergeCheck); - if (item.value?.value) { - item.value.cache = cloneDeep(item.value.value); - result = joinLabelTypes.includes(item.type || '') ? getItemValue(item.value.value, item.filterKey || 'label') : ''; - } - item.title = setTitle(item, item.type || '', result); - if (item.type === 'label' && item.options?.[0] && !item.options[0].$label) { - mergeToLabel(item); - } - } else { - item = initCategoryItem(item); - } - }); - } - } - - function setTitle(tag: ICategorySearchTagItem, type: string, result?: string) { - return joinLabelTypes.includes(type) - ? `${tag.label}: ${result || ''}` - : `${tag.label}: ${result || (tag.value && tag.value[tag.filterKey || 'label']) || ''}`; - } - - function mergeCheck(objValue: any, srcValue: any, key: string) { - if (key === 'options' && objValue !== srcValue) { - return srcValue; - } - } - - // checkbox | label 将选中项对应filterKey的值合并的方法,当前多选已通过data展示,可考虑移除 - function getItemValue(value: any, key: string) { - if (value && Array.isArray(value)) { - const result = value.map((item) => item[key]); - return result.join(','); - } - return ''; - } - - // label 合并名称和颜色字段赋给tag,待[tag]支持传入对象后可移除 - function mergeToLabel(obj: ICategorySearchTagItem) { - if (obj?.options && Array.isArray(obj.options)) { - obj.options.forEach((item) => { - item.$label = `${item[obj.filterKey || 'label']}_${item[obj.colorKey || 'color']}`; - }); - } - return obj; - } - - // 初始化tag的value属性:{filterKey | label, value, data} - function initCategoryItem(item: ICategorySearchTagItem) { - const preValue: Record = valueIsArrayTypes.includes(item.type || '') ? { value: [] } : { value: undefined }; - preValue[item.filterKey || 'label'] = undefined; - if (item.value) { - for (const prop in preValue) { - if (item.value[prop] === undefined) { - item.value[prop] = preValue[prop]; - } - } - } else { - item.value = preValue; - } - item.value.cache = (item.value.value && typeof item.value.value === 'object' && cloneDeep(item.value.value)) || item.value.value; - return item; - } - - return { - rootRef, - scrollBarRef, - inputRef, - isHover, - containerClasses, - innerSelectedTags, - joinLabelTypes, - showExtendedConfig, - operationConfig, - removeTag, - onSearch, - }; + const { + category, + tagMaxWidth, + textConfig, + inputReadOnly, + placeholder, + searchKey, + selectedTags, + styleType, + categoryInGroup, + groupOrderConfig, + defaultSearchField, + beforeTagChange, + toggleScrollToTail, + showSearchCategory, + filterNameRules, + extendConfig, + } = toRefs(props); + const innerCategory: Ref = ref([]); + const innerSelectedTags: Ref = ref([]); + const innerTextConfig: Ref = ref({}); + const id = ref(ID_SEED++); + const isHover = ref(false); + const isFocus = ref(false); + const enterSearch = ref(false); + const showNoDataTips = ref(false); + const innerSearchKey = ref(searchKey.value); + const scrollBarRef = ref(); + const rootRef = ref(); + const inputRef = ref(); + const showSearchConfig: Ref = ref({ keyword: true, field: true, category: true }); + const categoryDisplay: Ref = ref([]); + const searchField: Ref = ref([]); + const currentSearchCategory: Ref = ref([]); + const currentSelectTag: Ref = ref(); + const joinLabelTypes = ['checkbox', 'label']; + const valueIsArrayTypes = ['dateRange', 'numberRange', 'treeSelect', 'checkbox', 'label']; + const ComponentMap: Record = { + radio: RadioMenu, + checkbox: CheckboxMenu, + label: LabelMenu, + textInput: TextInputMenu, + numberRange: NumberRangeMenu, + }; + const operationConfig: ExtendConfig = reactive({ + clear: { show: true }, + save: { show: true }, + more: { show: false }, + }); + let scrollToTailFlag = true; // 是否在更新标签内容后滚动至输入框的开关 + let isSearchCategory = false; + let categoryOrder: any[] = []; + let categoryDictionary: Record = {}; + let searchKeyCache = ''; + let blurTimer: any; // 失焦关闭下拉延时器,失焦后立刻展开下拉需清除该延时 + + const containerClasses = computed(() => ({ + 'dp-category-search-container': true, + [`dp-category-search-id-${id.value}`]: true, + 'container-hover': isHover.value && !isFocus.value, + 'dp-gray-style': styleType.value === 'gray', + })); + + const showExtendedConfig = computed(() => operationConfig.show ?? Boolean(innerSelectedTags.value.length || innerSearchKey.value)); + + const removeTag = (tag: ICategorySearchTagItem, event?: Event) => { + canChange(tag, 'delete').then((val) => { + if (!val) { + if (beforeTagChange?.value && event) { + event.stopPropagation(); + } + return; + } + tag = resetValue(tag); + innerSelectedTags.value = innerSelectedTags.value.filter((item) => item.field !== tag.field); + const result = getSelectedTagsExceptKeyword(); + if (tag.type === 'keyword') { + innerSearchKey.value = innerSearchKey.value === searchKeyCache ? '' : innerSearchKey.value; + searchKeyCache = ''; + enterSearch.value = innerSearchKey.value !== ''; + ctx.emit('search', { selectedTags: result, searchKey: innerSearchKey.value }); + } else { + resolveCategoryDisplay(tag, 'add'); + ctx.emit('selectedTagsChange', { selectedTags: result, currentChangeTag: tag, operation: 'delete' }); + } + currentSelectTag.value = undefined; + }); + }; + + const onSearch = () => { + ctx.emit('search', { selectedTags: getSelectedTagsExceptKeyword(), searchKey: setSearchKeyTag() }); + isFocus.value = true; + }; + + // radio 单选 处理选中项方法 + const chooseItem = (tag: ICategorySearchTagItem, chooseItem: ITagOption) => { + afterDropdownClosed(); + const key = tag.filterKey || 'label'; + tag.value = { value: chooseItem, cache: cloneDeep(chooseItem) }; + tag.value[key] = chooseItem[key]; + tag.title = setTitle(tag, 'radio'); + updateSelectedTags(tag); + }; + + // checkbox | label 多选 处理选中项方法 + const chooseItems = (tag: ICategorySearchTagItem) => { + afterDropdownClosed(); + const key = tag.filterKey || 'label'; + if (tag.type === 'label') { + tag.value!.value = tag.value!.value!.map((item) => { + const res = item.split('_'); + return { + $label: item, + [tag.filterKey || 'label']: res[0], + [tag.colorKey || 'color']: res[1], + }; + }); + } + const result = getItemValue(tag.value!.value, key); + if (result) { + tag.title = setTitle(tag, 'checkbox', result); + tag.value![key] = result; + tag.value!.cache = cloneDeep(tag.value!.value); + updateSelectedTags(tag); + } else { + removeTag(tag); + } + }; + + // textInput 文本输入框 处理选中项方法 + const getTextInputValue = (tag: ICategorySearchTagItem, inputValue: string) => { + afterDropdownClosed(); + tag.value![tag.filterKey || 'label'] = tag.value!.cache = tag.value!.value = inputValue; + tag.title = setTitle(tag, 'textInput'); + updateSelectedTags(tag); + }; + + // numberRange 数字范围 处理选中项方法 + const getNumberRangeValue = (tag: ICategorySearchTagItem, rangeValue: number[]) => { + afterDropdownClosed(); + const startNum = rangeValue[0] || 0; + const endNum = rangeValue[1] || 0; + tag.value!.value = [startNum, endNum]; + tag.value!.cache = [startNum, endNum]; + tag.value![tag.filterKey || 'label'] = `${startNum} - ${endNum}`; + tag.title = setTitle(tag, 'numberRange'); + updateSelectedTags(tag); + }; + + const onSearchKeyTagClick = () => { + innerSearchKey.value = searchKeyCache; + inputRef.value.focus(); + ctx.emit('searchKeyChange', innerSearchKey.value); + }; + + // 清空 + const clearFilter = (event: Event) => { + if (innerSelectedTags.value.length) { + innerSelectedTags.value.forEach((item) => resetValue(item)); + innerSelectedTags.value = []; + } + if (innerSearchKey.value || searchKeyCache) { + innerSearchKey.value = ''; + searchKeyCache = ''; + } + if (currentSelectTag.value) { + currentSelectTag.value = undefined; + } + ctx.emit('selectedTagsChange', { selectedTags: [], currentChangeTag: undefined, operation: 'clear' }); + ctx.emit('clearAll', event); + initCategoryDisplay(); + }; + + // 保存 + const createFilterFn = (filterName: string) => { + ctx.emit('createFilter', { name: filterName, selectedTags: getSelectedTagsExceptKeyword(), keyword: innerSearchKey.value }); + }; + + const onCategoryItemClick = (item: ICategorySearchTagItem) => { + updateSelectedTags(item, false); + setTimeout(() => { + currentSelectTag.value = item; + if (currentSelectTag.value.type === 'label') { + currentSelectTag.value = mergeToLabel(currentSelectTag.value); + } + currentSelectTag.value.title = setTitle(currentSelectTag.value, currentSelectTag.value.type || '', ''); + inputRef.value.openMenu(); + inputRef.value.focus(); + }, DROPDOWN_ANIMATION_TIMEOUT); + }; + + const searchKeyChangeEvent = (event: Event) => { + innerSearchKey.value = (event.target as HTMLInputElement).value; + enterSearch.value = Boolean(innerSearchKey.value); + currentSearchCategory.value = innerSearchKey.value + ? innerCategory.value.filter((item) => item['label'].toLowerCase().includes(innerSearchKey.value.toLowerCase())) + : []; + ctx.emit('searchKeyChange', innerSearchKey.value); + }; + + const searchInputValue = (event: Event) => { + event.preventDefault(); + event.stopPropagation(); + // 当有分类正在选择时输入关键字不处理 + if (!currentSelectTag.value) { + ctx.emit('search', { + selectedTags: getSelectedTagsExceptKeyword(), + searchKey: setSearchKeyTag(), + }); + } + }; + + const searchCategory = (item: ICategorySearchTagItem) => { + if (valueIsArrayTypes.includes(item.type || '')) { + return; + } + updateFieldValue(item, innerSearchKey.value); + updateSelectedTags(item); + innerSearchKey.value = ''; + enterSearch.value = false; + finishChoose(); + }; + + const showCurrentSearchCategory = (tag: ICategorySearchTagItem) => { + isSearchCategory = true; + innerSearchKey.value = ''; + inputRef.value.closeMenu(); + chooseCategory(tag); + setTimeout(() => { + isFocus.value = true; + enterSearch.value = false; + }, DELAY); + }; + + const onInputBackspace = () => { + if (innerSearchKey.value) { + return; + } + if (currentSelectTag.value) { + currentSelectTag.value = undefined; + inputRef.value.closeMenu(); + return; + } + if (innerSelectedTags.value.length) { + const tag = innerSelectedTags.value[innerSelectedTags.value.length - 1]; + removeTag(tag); + } + inputRef.value.closeMenu(); + }; + + const onInputToggle = () => { + showNoDataTips.value = categoryDisplay.value.every((item) => item.isSelected); + }; + + watch( + searchKey, + () => { + innerSearchKey.value = searchKey.value; + searchKeyCache = searchKey.value; + setSearchKeyTag(false); + }, + { immediate: true } + ); + + watch( + [selectedTags, category, defaultSearchField], + () => { + innerSelectedTags.value = cloneDeep(selectedTags.value); + innerCategory.value = cloneDeep(category.value); + init(); + }, + { immediate: true, deep: true } + ); + + watch( + textConfig, + () => { + innerTextConfig.value = textConfig.value; + innerTextConfig.value.createFilter = innerTextConfig.value.createFilter || '保存过滤器'; + innerTextConfig.value.filterTitle = innerTextConfig.value.filterTitle || '过滤器标题'; + showSearchConfig.value.keywordDescription = showSearchCategory.value.keywordDescription || getSearchMessage; + showSearchConfig.value.fieldDescription = showSearchCategory.value.fieldDescription || getFindingMessage; + showSearchConfig.value.categoryDescription = showSearchCategory.value.categoryDescription || '请选择筛选条件:'; + setTimeout(() => { + const keyword = innerSelectedTags.value.find((item) => item.field === SearchKeyField); + if (keyword) { + keyword.label = innerTextConfig.value.keywordName || '关键字'; + keyword.title = `${keyword.label}:${keyword.value?.label}`; + } + }); + }, + { immediate: true, deep: true } + ); + + watch( + showSearchCategory, + () => { + const customConfig = + typeof showSearchCategory.value === 'boolean' + ? { + keyword: showSearchCategory.value, + field: showSearchCategory.value, + category: showSearchCategory.value, + } + : showSearchCategory.value; + showSearchConfig.value = { ...showSearchConfig.value, ...customConfig }; + }, + { immediate: true, deep: true } + ); + + watch( + () => extendConfig?.value, + () => { + merge(operationConfig, extendConfig?.value || {}); + }, + { immediate: true, deep: true } + ); + + const tagContextMap: Record = {}; + const addTagContext = (field: string, context: ITagContext) => { + Reflect.defineProperty(tagContextMap, field, { value: context }); + }; + const removeTagContext = (field: string) => { + Reflect.deleteProperty(tagContextMap, field); + }; + const toggleTagMenu = (field: string, status?: boolean) => { + tagContextMap[field]?.toggle(status); + }; + + ctx.expose({ + chooseItem, + chooseItems, + getTextInputValue, + getNumberRangeValue, + searchCategory, + toggleTagMenu, + }); + + onMounted(() => scrollToTail(true)); + + provide(categorySearchInjectionKey, { + rootRef, + rootCtx: ctx, + id, + innerTextConfig, + tagMaxWidth, + inputReadOnly, + placeholder, + innerSearchKey, + innerSelectedTags, + isHover, + isFocus, + enterSearch, + showSearchCategory, + categoryDisplay, + showSearchConfig, + showNoDataTips, + searchField, + currentSearchCategory, + ComponentMap, + currentSelectTag, + filterNameRules, + joinLabelTypes, + chooseItem, + onSearchKeyTagClick, + clearFilter, + onCategoryItemClick, + removeTag, + chooseItems, + getTextInputValue, + getNumberRangeValue, + createFilterFn, + searchKeyChangeEvent, + searchInputValue, + searchCategory, + showCurrentSearchCategory, + onInputBackspace, + onInputToggle, + addTagContext, + removeTagContext, + }); + + function init() { + setValue(innerCategory.value); + setValue(innerSelectedTags.value, true); + initCategoryDisplay(true); + if (defaultSearchField.value.length && innerCategory.value.length) { + searchField.value = innerCategory.value.filter( + (item) => defaultSearchField.value.includes(item.field) && !valueIsArrayTypes.includes(item.type || '') + ); + } + // 初始化时判断已选中分类中最后一项是否赋值,未赋值则识别为正在处理的分类,优先显示赋值下拉列表 + if (innerSelectedTags.value.length) { + const [lastItem] = innerSelectedTags.value.slice(-1); + const isNull = lastItem.value?.[lastItem.filterKey || 'label'] === undefined; + currentSelectTag.value = + isNull && (lastItem.value?.value === undefined || (Array.isArray(lastItem.value.value) && lastItem.value.value.length === 0)) + ? lastItem + : undefined; + } + if (searchKeyCache) { + innerSearchKey.value = searchKeyCache; + setSearchKeyTag(false); + } + } + + function updateFieldValue(field: ICategorySearchTagItem, value: any) { + const result: Record = {}; + const filterKey = field.filterKey || 'label'; + const colorKey = field.colorKey || 'color'; + result[filterKey] = value; + if (field.type === 'radio') { + field.value!.value = { [filterKey]: value }; + } + if (field.type === 'textInput') { + field.value!.value = value; + } + if (field.type === 'label') { + if (field.options![0] && !field.options![0].$label) { + mergeToLabel(field); + } + result[colorKey] = COLORS[Math.floor(COLORS.length * Math.random())]; + result['$label'] = `${value}_${result[colorKey]}`; + } + if (joinLabelTypes.includes(field.type || '')) { + field.value!.value = [result]; + } + field.value![filterKey] = value; + field.value!.cache = cloneDeep(field.value!.value); + field.title = setTitle(field, field.type || '', value); + } + + function chooseCategory(item: ICategorySearchTagItem) { + // 点选分组名称不处理 + if (item.groupLength !== undefined) { + return; + } + setTimeout(() => { + currentSelectTag.value = item; + if (currentSelectTag.value.type === 'label') { + currentSelectTag.value = mergeToLabel(currentSelectTag.value); + } + currentSelectTag.value.title = setTitle(currentSelectTag.value, currentSelectTag.value.type || '', ''); + inputRef.value.openMenu(); + }, DROPDOWN_ANIMATION_TIMEOUT); + updateSelectedTags(item, false); + } + + function clearCurrentSelectTagFromSearch() { + if (currentSelectTag.value) { + if (isSearchCategory) { + isSearchCategory = false; + setTimeout(finishChoose, DELAY); + } + } + } + + function finishChoose() { + currentSelectTag.value = undefined; + inputRef.value.focus(); + } + + function afterDropdownClosed() { + setTimeout(() => { + currentSelectTag.value = undefined; + }, DROPDOWN_ANIMATION_TIMEOUT + 100); + } + + function resolveCategoryDisplay(tag: ICategorySearchTagItem, type: string) { + if (tag.field === SearchKeyField || !categoryDictionary[tag.field]) { + return; + } + handleGroupLength(tag, type === 'delete'); + categoryDictionary[tag.field].isSelected = type === 'delete'; + } + + function resetValue(tag: ICategorySearchTagItem) { + tag.value = valueIsArrayTypes.includes(tag.type || '') ? { value: [] } : { value: undefined }; + tag.value[tag.filterKey || 'label'] = undefined; + return tag; + } + + function getSelectedTagsExceptKeyword(): ICategorySearchTagItem[] { + return showSearchConfig.value.keyword + ? innerSelectedTags.value.filter((item) => item.field !== SearchKeyField) + : innerSelectedTags.value; + } + + function canChange(tag: ICategorySearchTagItem, operation: 'delete' | 'add') { + let changeResult = Promise.resolve(true); + if (beforeTagChange?.value) { + const result = beforeTagChange.value(tag, innerSearchKey.value, operation); + if (typeof result !== 'undefined') { + if (typeof result === 'boolean') { + changeResult = Promise.resolve(result); + } else { + changeResult = result; + } + } + } + return changeResult; + } + + function initCategoryDisplay(isInit = false) { + const selectedTagsField = innerSelectedTags.value.map((item) => item.field); + if (isInit) { + innerCategory.value = cloneDeep(innerCategory.value) || []; + categoryOrder = []; + categoryDictionary = {}; + initGroupAndDictionary(); + initCategoryOrder(); + } + categoryDisplay.value = categoryOrder.map((item) => { + item.isSelected = selectedTagsField.includes(item.field); + handleGroupLength(item, item.isSelected, isInit); + return item; + }); + showNoDataTips.value = categoryDisplay.value.every((item) => item.isSelected); + } + + function handleGroupLength(tag: ICategorySearchTagItem, isSelected: boolean, isInit = false) { + if (categoryInGroup.value && tag.group) { + const group = categoryDictionary[tag.group]; + const len = group.groupLength; + group.groupLength = isSelected ? len - 1 : isInit ? len : len + 1; + group.isSelected = group.groupLength === 0; + } + } + + function initGroupAndDictionary() { + innerCategory.value.forEach((item) => { + if (categoryInGroup.value && item.group) { + if (categoryDictionary[item.group]) { + categoryDictionary[item.group].groupLength++; + } else { + categoryDictionary[item.group] = { groupName: item.group, groupLength: 1, children: [] }; + } + categoryDictionary[item.group].children.push(item); + } + categoryDictionary[item.field] = item; + }); + } + + function initCategoryOrder() { + const keys = groupOrderConfig.value.length ? groupOrderConfig.value : Object.keys(categoryDictionary); + keys.forEach((key) => { + const item = categoryDictionary[key]; + if (item) { + if (categoryInGroup.value) { + if (item.groupName) { + categoryOrder.push(item, ...item.children); + } else if (!item.group) { + categoryOrder.push(item); + } + } else { + categoryOrder.push(item); + } + } + }); + } + + function setSearchKeyTag(isSearch = true) { + const result = innerSearchKey.value || searchKeyCache; + if (showSearchConfig.value.keyword) { + const existingSearchKeyTag = innerSelectedTags.value.find((tag) => tag.field === SearchKeyField); + if (existingSearchKeyTag && !isSearch && innerSearchKey.value === '') { + removeTag(existingSearchKeyTag); + } else if (innerSearchKey.value && innerSearchKey.value !== existingSearchKeyTag?.value?.value) { + createSearchKeyTag(isSearch); + } + } + innerSearchKey.value = ''; + if (isSearch) { + setTimeout(() => { + enterSearch.value = false; + }, DELAY); + } + return result; + } + + function createSearchKeyTag(isSearch: boolean) { + const label = innerTextConfig.value.keywordName || '关键字'; + const searchKeyTag: ICategorySearchTagItem = { + options: [], + field: SearchKeyField, + label: label, + type: 'keyword', + title: `${label}:${innerSearchKey.value}`, + value: { + label: innerSearchKey.value, + value: innerSearchKey.value, + cache: innerSearchKey.value, + }, + }; + updateSelectedTags(searchKeyTag, isSearch); + searchKeyCache = innerSearchKey.value; + innerSearchKey.value = ''; + } + + function updateSelectedTags(tag: ICategorySearchTagItem, valueChanged = true) { + canChange(tag, 'add').then((val) => { + if (!val) { + return; + } + const index = innerSelectedTags.value.map((item) => item.field).indexOf(tag.field); + if (index > -1) { + if (!tag.value?.value) { + // 通过输入选择分类时避免空值覆盖已选值 + merge(tag, innerSelectedTags.value[index]); + } + innerSelectedTags.value[index] = tag; + } else { + innerSelectedTags.value.push(tag); + } + if (valueChanged) { + // 只在新增标签时位移滚动条 + if (scrollToTailFlag) { + setTimeout(scrollToTail); + } + ctx.emit('selectedTagsChange', { + selectedTags: getSelectedTagsExceptKeyword(), + currentChangeTag: tag, + operation: 'add', + }); + isSearchCategory = false; + } else { + resolveCategoryDisplay(tag, 'delete'); + } + }); + } + + // 判断滚动条是否存在,如果存在自动滚动到末尾的输入框 + function scrollToTail(isInit?: boolean) { + const dom = scrollBarRef.value; + if (toggleScrollToTail.value && dom && dom.scrollWidth > dom.clientWidth) { + if (isInit) { + dom.scrollLeft = dom.scrollWidth - dom.clientWidth; + } else { + inputRef.value.scrollIntoView(); + } + } else if (!isInit) { + // 初始化不聚焦,避免展开下拉 + inputRef.value.focus(); + } + } + + function setValue(data: ICategorySearchTagItem[], isSelectedTags = false) { + if (Array.isArray(data) && data.length) { + data.forEach((item) => { + if (isSelectedTags && innerCategory.value) { + let result = ''; + const originItem = innerCategory.value.find((categoryItem) => categoryItem.field === item.field); + mergeWith(item, originItem, mergeCheck); + if (item.value?.value) { + item.value.cache = cloneDeep(item.value.value); + result = joinLabelTypes.includes(item.type || '') ? getItemValue(item.value.value, item.filterKey || 'label') : ''; + } + item.title = setTitle(item, item.type || '', result); + if (item.type === 'label' && item.options?.[0] && !item.options[0].$label) { + mergeToLabel(item); + } + } else { + item = initCategoryItem(item); + } + }); + } + } + + function setTitle(tag: ICategorySearchTagItem, type: string, result?: string) { + return joinLabelTypes.includes(type) + ? `${tag.label}: ${result || ''}` + : `${tag.label}: ${result || (tag.value && tag.value[tag.filterKey || 'label']) || ''}`; + } + + function mergeCheck(objValue: any, srcValue: any, key: string) { + if (key === 'options' && objValue !== srcValue) { + return srcValue; + } + } + + // checkbox | label 将选中项对应filterKey的值合并的方法,当前多选已通过data展示,可考虑移除 + function getItemValue(value: any, key: string) { + if (value && Array.isArray(value)) { + const result = value.map((item) => item[key]); + return result.join(','); + } + return ''; + } + + // label 合并名称和颜色字段赋给tag,待[tag]支持传入对象后可移除 + function mergeToLabel(obj: ICategorySearchTagItem) { + if (obj?.options && Array.isArray(obj.options)) { + obj.options.forEach((item) => { + item.$label = `${item[obj.filterKey || 'label']}_${item[obj.colorKey || 'color']}`; + }); + } + return obj; + } + + // 初始化tag的value属性:{filterKey | label, value, data} + function initCategoryItem(item: ICategorySearchTagItem) { + const preValue: Record = valueIsArrayTypes.includes(item.type || '') ? { value: [] } : { value: undefined }; + preValue[item.filterKey || 'label'] = undefined; + if (item.value) { + for (const prop in preValue) { + if (item.value[prop] === undefined) { + item.value[prop] = preValue[prop]; + } + } + } else { + item.value = preValue; + } + item.value.cache = (item.value.value && typeof item.value.value === 'object' && cloneDeep(item.value.value)) || item.value.value; + return item; + } + + return { + rootRef, + scrollBarRef, + inputRef, + isHover, + containerClasses, + innerSelectedTags, + joinLabelTypes, + showExtendedConfig, + operationConfig, + removeTag, + onSearch, + }; } diff --git a/packages/devui-vue/devui/editable-select/src/editable-select-types.ts b/packages/devui-vue/devui/editable-select/src/editable-select-types.ts index 345b825d75..22332314a7 100644 --- a/packages/devui-vue/devui/editable-select/src/editable-select-types.ts +++ b/packages/devui-vue/devui/editable-select/src/editable-select-types.ts @@ -87,6 +87,9 @@ export const editableSelectProps = { type: Boolean, default: true, }, + maxLength: { + type: Number, + }, } as const; export type EditableSelectProps = ExtractPropTypes; diff --git a/packages/devui-vue/devui/editable-select/src/editable-select.tsx b/packages/devui-vue/devui/editable-select/src/editable-select.tsx index c9f154502a..68898e5927 100644 --- a/packages/devui-vue/devui/editable-select/src/editable-select.tsx +++ b/packages/devui-vue/devui/editable-select/src/editable-select.tsx @@ -34,7 +34,7 @@ export default defineComponent({ const states = useSelectStates(); // data refs - const { appendToBody, disabled, modelValue, position, placeholder } = toRefs(props); + const { appendToBody, disabled, modelValue, position, placeholder, maxLength } = toRefs(props); const align = computed(() => (position.value.some((item) => item.includes('start') || item.includes('end')) ? 'start' : null)); // input事件 @@ -135,6 +135,7 @@ export default defineComponent({ disabled={disabled.value} placeholder={placeholder.value} value={states.inputValue} + maxlength={maxLength?.value} type="text" onInput={onInput} onFocus={handleFocus} diff --git a/packages/devui-vue/devui/editor-md/src/editor-md.tsx b/packages/devui-vue/devui/editor-md/src/editor-md.tsx index cf6c3a7d1f..662a6a6398 100644 --- a/packages/devui-vue/devui/editor-md/src/editor-md.tsx +++ b/packages/devui-vue/devui/editor-md/src/editor-md.tsx @@ -1,4 +1,4 @@ -import { defineComponent, toRefs, provide, ref, SetupContext, withModifiers } from 'vue'; +import { defineComponent, toRefs, provide, ref, SetupContext, withModifiers, computed } from 'vue'; import { Fullscreen } from '../../fullscreen'; import { useEditorMd } from './composables/use-editor-md'; import { useEditorMdTheme } from './composables/use-editor-md-theme'; @@ -34,6 +34,13 @@ export default defineComponent({ } = toRefs(props); const showFullscreen = ref(false); + const finalModelValue = computed(() => { + if (typeof maxlength.value === 'number') { + return modelValue.value.substring(0, maxlength.value); + } else { + return modelValue.value; + } + }); const { editorRef, @@ -80,7 +87,7 @@ export default defineComponent({ style={{ height: editorContainerHeight?.value + 'px' }}>
    {Boolean(maxlength?.value) && (
    - {modelValue.value.length || 0}/{maxlength.value} + {finalModelValue.value.length || 0}/{maxlength.value}
    )}
    @@ -101,7 +108,7 @@ export default defineComponent({ ref={renderRef} base-url={baseUrl.value} breaks={breaks.value} - content={modelValue.value} + content={finalModelValue.value} custom-parse={customParse.value} render-parse={renderParse.value} md-rules={mdRules.value} diff --git a/packages/devui-vue/devui/message/src/message.tsx b/packages/devui-vue/devui/message/src/message.tsx index bced2c2c7f..50b82a8fbc 100644 --- a/packages/devui-vue/devui/message/src/message.tsx +++ b/packages/devui-vue/devui/message/src/message.tsx @@ -1,4 +1,4 @@ -import { defineComponent, toRefs, Transition, computed,StyleValue, watch } from 'vue'; +import { defineComponent, toRefs, Transition, computed, StyleValue, watch } from 'vue'; import Close from './message-icon-close'; import { useNamespace } from '../../shared/hooks/use-namespace'; import { messageProps, MessageProps } from './message-types'; @@ -8,10 +8,11 @@ import './message.scss'; export default defineComponent({ name: 'DMessage', + inheritAttrs: false, props: messageProps, - emits: ['destroy','close'], - setup(props: MessageProps, { emit, slots }) { - const { visible, message, type, bordered, shadow, showClose } = toRefs(props); + emits: ['destroy', 'close'], + setup(props: MessageProps, { emit, attrs, slots }) { + const { visible, message, type, bordered, shadow, showClose } = toRefs(props); const ns = useNamespace('message'); let timer: NodeJS.Timeout | null = null; @@ -60,7 +61,7 @@ export default defineComponent({ ); const classes = computed(() => ({ - [ns.b()]:true, + [ns.b()]: true, [ns.m(type.value)]: true, })); @@ -68,78 +69,61 @@ export default defineComponent({ const lastOffset = computed(() => getLastOffset(props.id)); const styles = computed(() => { const messageStyles: StyleValue = {}; - if(!bordered.value){ + if (!bordered.value) { messageStyles['border'] = 'none'; } - if(!shadow.value){ + if (!shadow.value) { messageStyles['box-shadow'] = 'none'; } - return {...messageStyles,top: `${lastOffset.value}px`,}; + return { ...messageStyles, top: `${lastOffset.value}px` }; }); - const renderIcon = computed(()=>{ + const renderIcon = computed(() => { const iconClasses = computed(() => ({ [ns.e('image')]: true, [ns.em('image', type.value)]: true, })); return ( - !(!type.value || type.value === 'normal') - && - - { - type.value && - ( - (type.value === 'success' && ) || - (type.value === 'info' && ) || - (type.value === 'warning' && ) || - (type.value === 'error' && ) - ) - } - + !(!type.value || type.value === 'normal') && ( + + {type.value && + ((type.value === 'success' && ) || + (type.value === 'info' && ) || + (type.value === 'warning' && ) || + (type.value === 'error' && ))} + + ) ); }); - const renderText = computed(()=>{ + const renderText = computed(() => { const textClasses = computed(() => ({ [ns.e('content')]: true, [ns.em('content', type.value)]: true, })); - return ( - - { - message.value ? message.value : slots.default?.() - } - - ); + return {message.value ? message.value : slots.default?.()}; }); - const renderClose = computed(() =>{ + const renderClose = computed(() => { return ( - showClose.value && - - + showClose.value && ( + + + + ) ); }); - return () => { - return ( - - { - visible.value && ( -
    - { renderIcon.value } - { renderText.value } - { renderClose.value } -
    - ) - } -
    - ); - }; + return () => ( + + {visible.value && ( +
    + {renderIcon.value} + {renderText.value} + {renderClose.value} +
    + )} +
    + ); }, }); diff --git a/packages/devui-vue/devui/overlay/src/flexible-overlay/flexible-overlay-types.ts b/packages/devui-vue/devui/overlay/src/flexible-overlay/flexible-overlay-types.ts index 50001073ba..0f3445784d 100644 --- a/packages/devui-vue/devui/overlay/src/flexible-overlay/flexible-overlay-types.ts +++ b/packages/devui-vue/devui/overlay/src/flexible-overlay/flexible-overlay-types.ts @@ -19,12 +19,6 @@ export type OffsetOptions = { mainAxis?: number; crossAxis?: number }; export type Point = { x?: number; y?: number }; -export type UseOverlayFn = { - arrowRef: Ref; - overlayRef: Ref; - updatePosition: () => void; -}; - export type EmitEventFn = (event: 'positionChange' | 'update:modelValue', result?: unknown) => void; export interface Rect { @@ -70,6 +64,11 @@ export const flexibleOverlayProps = { type: Boolean, default: false, }, + // 是否和宿主元素的宽度保持一致 + fitOriginWidth: { + type: Boolean, + default: false, + }, }; export type FlexibleOverlayProps = ExtractPropTypes; diff --git a/packages/devui-vue/devui/overlay/src/flexible-overlay/index.tsx b/packages/devui-vue/devui/overlay/src/flexible-overlay/index.tsx index 347b0be591..cf82f6f8ee 100644 --- a/packages/devui-vue/devui/overlay/src/flexible-overlay/index.tsx +++ b/packages/devui-vue/devui/overlay/src/flexible-overlay/index.tsx @@ -12,7 +12,7 @@ export const FlexibleOverlay = defineComponent({ setup(props: FlexibleOverlayProps, { slots, attrs, emit, expose }) { const ns = useNamespace('flexible-overlay'); const { clickEventBubble } = toRefs(props); - const { arrowRef, overlayRef, updatePosition } = useOverlay(props, emit); + const { arrowRef, overlayRef, styles, updatePosition } = useOverlay(props, emit); expose({ updatePosition }); return () => @@ -20,6 +20,7 @@ export const FlexibleOverlay = defineComponent({
    ({}), [clickEventBubble.value ? '' : 'stop'])} onPointerup={withModifiers(() => ({}), ['stop'])}> diff --git a/packages/devui-vue/devui/overlay/src/flexible-overlay/use-flexible-overlay.ts b/packages/devui-vue/devui/overlay/src/flexible-overlay/use-flexible-overlay.ts index c5fe4f6942..c9c25a70f9 100644 --- a/packages/devui-vue/devui/overlay/src/flexible-overlay/use-flexible-overlay.ts +++ b/packages/devui-vue/devui/overlay/src/flexible-overlay/use-flexible-overlay.ts @@ -1,6 +1,6 @@ -import { ref, unref, watch, nextTick, onUnmounted, toRefs } from 'vue'; +import { ref, unref, watch, nextTick, onUnmounted, toRefs, computed } from 'vue'; import { arrow, computePosition, offset, flip } from '@floating-ui/dom'; -import { FlexibleOverlayProps, Placement, Point, UseOverlayFn, EmitEventFn, Rect } from './flexible-overlay-types'; +import { FlexibleOverlayProps, Placement, Point, EmitEventFn, Rect } from './flexible-overlay-types'; function adjustArrowPosition(isArrowCenter: boolean, point: Point, placement: Placement, originRect: Rect): Point { let { x, y } = point; @@ -23,10 +23,20 @@ function adjustArrowPosition(isArrowCenter: boolean, point: Point, placement: Pl return { x, y }; } -export function useOverlay(props: FlexibleOverlayProps, emit: EmitEventFn): UseOverlayFn { - const { position, showArrow } = toRefs(props); +export function useOverlay(props: FlexibleOverlayProps, emit: EmitEventFn) { + const { fitOriginWidth, position, showArrow } = toRefs(props); const overlayRef = ref(); const arrowRef = ref(); + const overlayWidth = ref(0); + let originObserver: ResizeObserver; + + const styles = computed(() => { + if (fitOriginWidth.value) { + return { width: overlayWidth.value + 'px' }; + } else { + return {}; + } + }); const updateArrowPosition = (arrowEl: HTMLElement, placement: Placement, point: Point, overlayEl: HTMLElement) => { const { x, y } = adjustArrowPosition(props.isArrowCenter, point, placement, overlayEl.getBoundingClientRect()); @@ -73,6 +83,27 @@ export function useOverlay(props: FlexibleOverlayProps, emit: EmitEventFn): UseO updatePosition(); } }; + + const updateWidth = (originEl: HTMLElement) => { + overlayWidth.value = originEl.getBoundingClientRect().width; + updatePosition(); + }; + + const observeOrigin = () => { + if (fitOriginWidth.value && typeof window !== 'undefined') { + const originEl = props.origin?.$el ?? props.origin; + if (originEl) { + originObserver = new window.ResizeObserver(() => updateWidth(originEl)); + originObserver.observe(originEl); + } + } + }; + + const unobserveOrigin = () => { + const originEl = props.origin?.$el ?? props.origin; + originEl && originObserver?.unobserve(originEl); + }; + watch( () => props.modelValue, () => { @@ -80,16 +111,19 @@ export function useOverlay(props: FlexibleOverlayProps, emit: EmitEventFn): UseO nextTick(updatePosition); window.addEventListener('scroll', scrollCallback, true); window.addEventListener('resize', updatePosition); + observeOrigin(); } else { window.removeEventListener('scroll', scrollCallback, true); window.removeEventListener('resize', updatePosition); + unobserveOrigin(); } } ); onUnmounted(() => { window.removeEventListener('scroll', scrollCallback, true); window.removeEventListener('resize', updatePosition); + unobserveOrigin(); }); - return { arrowRef, overlayRef, updatePosition }; + return { arrowRef, overlayRef, styles, updatePosition }; } diff --git a/packages/devui-vue/devui/select/src/components/select-content.tsx b/packages/devui-vue/devui/select/src/components/select-content.tsx index 2e05aab24e..896e6e1932 100644 --- a/packages/devui-vue/devui/select/src/components/select-content.tsx +++ b/packages/devui-vue/devui/select/src/components/select-content.tsx @@ -23,6 +23,7 @@ export default defineComponent({ const multipleCls = ns.e('multiple'); const multipleInputCls = ns.em('multiple', 'input'); const { + select, searchQuery, selectedData, isSelectDisable, @@ -99,6 +100,7 @@ export default defineComponent({ placeholder={placeholder.value} readonly={isReadOnly.value} disabled={isSelectDisable.value} + maxlength={select?.maxLength} onInput={queryFilter} onFocus={onFocus} onBlur={onBlur} @@ -114,6 +116,7 @@ export default defineComponent({ placeholder={placeholder.value} readonly={isReadOnly.value} disabled={isSelectDisable.value} + maxlength={select?.maxLength} onFocus={onFocus} onBlur={onBlur} onInput={queryFilter} diff --git a/packages/devui-vue/devui/select/src/composables/use-select-content.ts b/packages/devui-vue/devui/select/src/composables/use-select-content.ts index 5001f8385e..7936c16c55 100644 --- a/packages/devui-vue/devui/select/src/composables/use-select-content.ts +++ b/packages/devui-vue/devui/select/src/composables/use-select-content.ts @@ -1,13 +1,13 @@ import { computed, inject, ref, getCurrentInstance } from 'vue'; import { SELECT_TOKEN } from '../const'; import { FORM_ITEM_TOKEN } from '../../../form'; -import { OptionObjectItem, UseSelectContentReturnType } from '../select-types'; +import { OptionObjectItem } from '../select-types'; import { useNamespace } from '../../../shared/hooks/use-namespace'; import { className } from '../utils'; import { isFunction } from 'lodash'; import { createI18nTranslate } from '../../../locale/create'; -export default function useSelectContent(): UseSelectContentReturnType { +export default function useSelectContent() { const ns = useNamespace('select'); const select = inject(SELECT_TOKEN); const formItemContext = inject(FORM_ITEM_TOKEN, undefined); @@ -103,6 +103,7 @@ export default function useSelectContent(): UseSelectContentReturnType { }; return { + select, searchQuery, selectedData, isSelectDisable, diff --git a/packages/devui-vue/devui/select/src/select-types.ts b/packages/devui-vue/devui/select/src/select-types.ts index 5c1232005a..ad1f19d84f 100644 --- a/packages/devui-vue/devui/select/src/select-types.ts +++ b/packages/devui-vue/devui/select/src/select-types.ts @@ -107,38 +107,19 @@ export const selectProps = { type: Boolean, default: true, }, + menuClass: { + type: String, + default: '', + }, + maxLength: { + type: Number, + }, } as const; export type SelectProps = ExtractPropTypes; export type OptionModelValue = number | string; -export interface UseSelectReturnType { - selectDisabled: ComputedRef; - selectSize: ComputedRef; - originRef: Ref; - dropdownRef: Ref; - isOpen: Ref; - selectCls: ComputedRef; - mergeOptions: Ref; - selectedOptions: ComputedRef; - filterQuery: Ref; - emptyText: ComputedRef; - isLoading: Ref; - isShowEmptyText: ComputedRef; - handleClear: (e: MouseEvent) => void; - valueChange: (item: OptionObjectItem) => void; - handleClose: () => void; - updateInjectOptions: (item: Record, operation: string, isObject: boolean) => void; - tagDelete: (data: OptionObjectItem) => void; - onFocus: (e: FocusEvent) => void; - onBlur: (e: FocusEvent) => void; - isDisabled: (item: OptionObjectItem) => boolean; - toggleChange: (bool: boolean) => void; - debounceQueryFilter: (query: string) => void; - isShowCreateOption: ComputedRef; -} - export interface SelectContext extends SelectProps { selectDisabled: boolean; selectSize: string; @@ -182,26 +163,6 @@ export interface UseOptionReturnType { optionSelect: () => void; } -export interface UseSelectContentReturnType { - searchQuery: Ref; - selectedData: ComputedRef; - isSelectDisable: ComputedRef; - isSupportCollapseTags: ComputedRef; - isDisabledTooltip: ComputedRef; - isReadOnly: ComputedRef; - selectionCls: ComputedRef; - inputCls: ComputedRef; - tagSize: ComputedRef; - placeholder: ComputedRef; - isMultiple: ComputedRef; - displayInputValue: ComputedRef; - handleClear: (e: MouseEvent) => void; - tagDelete: (data: OptionObjectItem) => void; - onFocus: (e: FocusEvent) => void; - onBlur: (e: FocusEvent) => void; - queryFilter: (e: Event) => void; -} - export interface UseSelectFunctionReturn { isSelectFocus: Ref; blur: () => void; diff --git a/packages/devui-vue/devui/select/src/select.scss b/packages/devui-vue/devui/select/src/select.scss index f4ad2ddcd5..a5cc6be168 100644 --- a/packages/devui-vue/devui/select/src/select.scss +++ b/packages/devui-vue/devui/select/src/select.scss @@ -373,6 +373,7 @@ $select-item-min-height: 36px; font-size: $select-item-font-size; color: $devui-disabled-text; min-height: 22px; + line-height: 22px; } .#{$devui-prefix}-select__group { diff --git a/packages/devui-vue/devui/select/src/select.tsx b/packages/devui-vue/devui/select/src/select.tsx index b4866e5b2b..8d720d9ad9 100644 --- a/packages/devui-vue/devui/select/src/select.tsx +++ b/packages/devui-vue/devui/select/src/select.tsx @@ -10,7 +10,6 @@ import { Teleport, watch, withModifiers, - onUnmounted, nextTick, computed, } from 'vue'; @@ -79,7 +78,6 @@ export default defineComponent({ const isRender = ref(false); const currentPosition = ref('bottom'); const position = ref(['bottom-start', 'top-start']); - const dropdownWidth = ref('0'); const handlePositionChange = (pos: string) => { currentPosition.value = pos.split('-')[0] === 'top' ? 'top' : 'bottom'; @@ -89,14 +87,9 @@ export default defineComponent({ 'z-index': 'var(--devui-z-index-dropdown, 1052)', })); - const updateDropdownWidth = () => { - dropdownWidth.value = originRef?.value?.clientWidth ? originRef.value.clientWidth + 'px' : '100%'; - }; - watch(selectRef, (val) => { if (val) { originRef.value = val.$el; - updateDropdownWidth(); } }); @@ -110,17 +103,11 @@ export default defineComponent({ onMounted(() => { isRender.value = true; - updateDropdownWidth(); - window.addEventListener('resize', updateDropdownWidth); nextTick(() => { - dropdownContainer.value.addEventListener('scroll', scrollToBottom); + dropdownContainer.value?.addEventListener('scroll', scrollToBottom); }); }); - onUnmounted(() => { - window.removeEventListener('resize', updateDropdownWidth); - }); - provide( SELECT_TOKEN, reactive({ @@ -156,10 +143,12 @@ export default defineComponent({ origin={originRef.value} align="start" offset={4} + fit-origin-width position={position.value} onPositionChange={handlePositionChange} - style={styles.value}> -
    + style={styles.value} + class={props.menuClass}> +
      {isShowCreateOption.value && ( ))}
    + {(isLoading.value || isShowEmptyText.value) && (
    {ctx.slots?.empty && ctx.slots.empty()} diff --git a/packages/devui-vue/devui/select/src/use-select.ts b/packages/devui-vue/devui/select/src/use-select.ts index 685226079b..b9c72aedb3 100644 --- a/packages/devui-vue/devui/select/src/use-select.ts +++ b/packages/devui-vue/devui/select/src/use-select.ts @@ -1,6 +1,6 @@ import { ref, computed, Ref, inject, watch, onMounted, nextTick } from 'vue'; import type { SetupContext } from 'vue'; -import { SelectProps, OptionObjectItem, UseSelectReturnType } from './select-types'; +import { SelectProps, OptionObjectItem } from './select-types'; import { className, KeyType } from './utils'; import { useNamespace } from '../../shared/hooks/use-namespace'; import { onClickOutside } from '@vueuse/core'; @@ -15,7 +15,7 @@ export default function useSelect( blur: () => void, isSelectFocus: Ref, t: (path: string) => unknown -): UseSelectReturnType { +) { const formContext = inject(FORM_TOKEN, undefined); const formItemContext = inject(FORM_ITEM_TOKEN, undefined); const ns = useNamespace('select'); diff --git a/packages/devui-vue/docs/components/category-search/index.md b/packages/devui-vue/docs/components/category-search/index.md index 2830a311e7..8caf8ea2f6 100644 --- a/packages/devui-vue/docs/components/category-search/index.md +++ b/packages/devui-vue/docs/components/category-search/index.md @@ -401,6 +401,7 @@ export default defineComponent({ | chooseItems | 调用组件方法处理选中数据,针对`checkbox \| label`类型,参数为当前 tag | (tag: ICategorySearchTagItem) => void | | getTextInputValue | 调用组件方法处理选中数据,针对`textInput`类型,参数为当前 tag 和输入内容 | (tag: ICategorySearchTagItem, inputValue: string) => void | | getNumberRangeValue | 调用组件方法处理选中数据,针对`numberRange`类型,参数为当前 tag 和输入内容 | (tag: ICategorySearchTagItem, rangeValue: number[]) => void | +|toggleTagMenu|控制某个已选择tag所对应下拉框的展开收起状态,可通过`status`参数指定展开收起状态|`(field: string, status?: boolean) => void`| ### 类型定义 diff --git a/packages/devui-vue/docs/components/editable-select/index.md b/packages/devui-vue/docs/components/editable-select/index.md index 489f813459..fae0c2b70a 100644 --- a/packages/devui-vue/docs/components/editable-select/index.md +++ b/packages/devui-vue/docs/components/editable-select/index.md @@ -467,6 +467,7 @@ export default defineComponent({ | filter-method | `(inputValue:string)=>Array` | -- | 可选,自定义筛选方法 | [自定义匹配方法](#自定义筛选方法) | | remote-method | `(inputValue:string)=>Array` | -- | 可选,自定义远程搜索方法 | [远程搜索](#远程搜索) | |show-glow-style|`boolean`|true|可选,是否展示悬浮发光效果|| +|max-length|`number`|--|可选,输入框可输入的最大长度|| ### EditableSelect 事件 diff --git a/packages/devui-vue/docs/components/editor-md/index.md b/packages/devui-vue/docs/components/editor-md/index.md index e5404df882..f0ddf1ffa2 100644 --- a/packages/devui-vue/docs/components/editor-md/index.md +++ b/packages/devui-vue/docs/components/editor-md/index.md @@ -334,6 +334,38 @@ Long --> "Bob()" : ok ::: +### emoji渲染 + +通过配置`md-plugins` emoji插件,进行emoji表情渲染。具体使用方式参考示例代码。 + +:::demo + +```vue + + + +``` + +::: + ### 配置快速提示 :::demo 设置 hintConfig 后,可用于支持@选择用户等场景。 diff --git a/packages/devui-vue/docs/components/select/index.md b/packages/devui-vue/docs/components/select/index.md index 9c88e5439e..2e3609303a 100644 --- a/packages/devui-vue/docs/components/select/index.md +++ b/packages/devui-vue/docs/components/select/index.md @@ -532,6 +532,8 @@ export default defineComponent({ | loading-text | `string` | '加载中' | 可选, 远程搜索时显示的文本 | [远程加载数据](#远程加载数据) | | multiple-limit | `number` | '0' | 可选, multiple 属性设置为 true 时生效,表示用户最多可以选择的项目数, 为 0 则不限制 | [多选](#多选) | |show-glow-style|`boolean`|true|可选,是否展示悬浮发光效果|| +|menu-class|`string`|''|可选,自定义下拉菜单的样式名|| +|max-length|`number`|--|可选,可筛选时输入框可输入的最大长度|| ### Select 事件 diff --git a/packages/devui-vue/package.json b/packages/devui-vue/package.json index 2be68fe388..4cad83e7f8 100644 --- a/packages/devui-vue/package.json +++ b/packages/devui-vue/package.json @@ -1,6 +1,6 @@ { "name": "vue-devui", - "version": "1.6.15", + "version": "1.6.16", "license": "MIT", "description": "DevUI components based on Vite and Vue3", "keywords": [ @@ -25,6 +25,7 @@ "style": "style.css", "scripts": { "dev": "pnpm generate:theme && vitepress dev docs", + "dev:site": "vite --port 3010", "build": "pnpm generate:theme && node --max-old-space-size=8192 node_modules/vitepress/bin/vitepress.js build docs && cp public/* docs/.vitepress/dist/assets && cp docs/assets/* docs/.vitepress/dist/assets", "build:lib": "pnpm predev -- -e prod && pnpm build:components && pnpm release", "test": "jest --config jest.config.js", @@ -67,13 +68,14 @@ "lodash": "^4.17.21", "lodash-es": "^4.17.20", "markdown-it": "12.2.0", + "markdown-it-emoji": "^3.0.0", "markdown-it-plantuml": "^1.4.1", "mermaid": "9.1.1", "mitt": "^3.0.0", "monaco-editor": "0.34.0", "rxjs": "^7.8.1", - "vue": "^3.3.4", "uuid": "^9.0.1", + "vue": "^3.3.4", "vue-router": "^4.0.3", "xss": "^1.0.14" }, diff --git a/packages/devui-vue/site.html b/packages/devui-vue/site.html new file mode 100644 index 0000000000..ca649f0eec --- /dev/null +++ b/packages/devui-vue/site.html @@ -0,0 +1,13 @@ + + + + + + + Web端组件调试 + + +
    + + + diff --git a/packages/devui-vue/site/app.vue b/packages/devui-vue/site/app.vue new file mode 100644 index 0000000000..a00afdf56e --- /dev/null +++ b/packages/devui-vue/site/app.vue @@ -0,0 +1,15 @@ + + + diff --git a/packages/devui-vue/site/main.ts b/packages/devui-vue/site/main.ts new file mode 100644 index 0000000000..6273388b32 --- /dev/null +++ b/packages/devui-vue/site/main.ts @@ -0,0 +1,5 @@ +import { createApp } from 'vue'; +import DevUI from '../devui/vue-devui'; +import App from './app.vue'; + +createApp(App).use(DevUI).mount('#testWeb'); diff --git a/packages/devui-vue/vite.config.ts b/packages/devui-vue/vite.config.ts new file mode 100644 index 0000000000..b4e535fc94 --- /dev/null +++ b/packages/devui-vue/vite.config.ts @@ -0,0 +1,26 @@ +import { resolve } from 'path'; +import { defineConfig } from 'vite'; +import vueJsx from '@vitejs/plugin-vue-jsx'; +import vue from '@vitejs/plugin-vue'; +import svgLoader from 'vite-svg-loader'; + +export default defineConfig({ + resolve: { + alias: [ + { find: '@devui/theme', replacement: resolve(__dirname, '../devui-theme/src') }, + { find: '@devui/shared/components', replacement: resolve(__dirname, './devui') }, + { find: '@devui', replacement: resolve(__dirname, './devui') }, + { find: 'vue-devui', replacement: resolve(__dirname, './devui') }, + ], + }, + plugins: [vue(), vueJsx({}), svgLoader()], + optimizeDeps: { + exclude: ['lodash-es', 'mitt', 'async-validator', 'css-vars-ponyfill', 'rxjs', '@vueuse/core', '@floating-ui/dom', 'vue-router'], + }, + server: { + open: '/site.html', + fs: { + strict: false, + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 49a16e636d..e8d42f4d6a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -222,6 +222,9 @@ importers: markdown-it: specifier: 12.2.0 version: 12.2.0 + markdown-it-emoji: + specifier: ^3.0.0 + version: 3.0.0 markdown-it-plantuml: specifier: ^1.4.1 version: 1.4.1 @@ -7784,6 +7787,10 @@ packages: resolution: {integrity: sha512-39j7/9vP/CPCKbEI44oV8yoPJTpvfeReTn/COgRhSpNrjWF3PfP/JUxxB0hxV6ynOY8KH8Y8aX9NMDdo6z+6YQ==} dev: true + /markdown-it-emoji@3.0.0: + resolution: {integrity: sha512-+rUD93bXHubA4arpEZO3q80so0qgoFJEKRkRbjKX8RTdca89v2kfyF+xR3i2sQTwql9tpPZPOQN5B+PunspXRg==} + dev: false + /markdown-it-plantuml@1.4.1: resolution: {integrity: sha512-13KgnZaGYTHBp4iUmGofzZSBz+Zj6cyqfR0SXUIc9wgWTto5Xhn7NjaXYxY0z7uBeTUMlc9LMQq5uP4OM5xCHg==} dev: false diff --git a/scripts.json b/scripts.json index 9f88062b20..33466774f8 100644 --- a/scripts.json +++ b/scripts.json @@ -5,6 +5,11 @@ "desc": "Start a development server", "command": "pnpm --filter vue-devui dev" }, + "dev:site": { + "alias": "Dev:Site", + "desc": "Start a development server", + "command": "pnpm --filter vue-devui dev:site" + }, "build": { "alias": "Build", "desc": "Build theme and docs",