diff --git a/packages/devui-vue/devui/tag-input/__tests__/tag-input.spec.ts b/packages/devui-vue/devui/tag-input/__tests__/tag-input.spec.ts index 8779faef3d..6edb124071 100644 --- a/packages/devui-vue/devui/tag-input/__tests__/tag-input.spec.ts +++ b/packages/devui-vue/devui/tag-input/__tests__/tag-input.spec.ts @@ -1,24 +1,34 @@ import { mount } from '@vue/test-utils'; import { reactive, nextTick } from 'vue'; import DTagInput from '../src/tag-input'; +import { useNamespace } from '../../shared/hooks/use-namespace'; +import { Suggestion } from '../src/tag-input-types'; + +interface StateType { + tags: Array; + suggestionList: Array; +} jest.mock('../../locale/create', () => ({ createI18nTranslate: () => jest.fn(), })); -const customMount = (state) => mount({ +const ns = useNamespace('tag-input', true); + +const customMount = (state: StateType) => mount({ components: { DTagInput }, template: ` + displayProperty="cname" + > `, - setup () { + setup() { return { - state + state, }; - } + }, }); describe('DTagInput', () => { @@ -27,17 +37,16 @@ describe('DTagInput', () => { tags: [ { cname: 'Y.Chen' }, { cname: 'b' }, - { cname: 'c' } + { cname: 'c' }, ], suggestionList: [ { cname: 'd' }, { cname: 'e' }, { cname: 'f' }, - ] + ], }); const wrapper = customMount(state); - - expect(wrapper.find('.devui-tags-host').exists()).toBe(true); + expect(wrapper.find(ns.b()).exists()).toBe(true); expect(wrapper.find('.devui-tags').exists()).toBe(true); expect(wrapper.find('.devui-tag-list').exists()).toBe(true); expect(wrapper.find('.devui-input').exists()).toBe(true); @@ -49,6 +58,8 @@ describe('DTagInput', () => { state.tags[0] = { cname: 'X.Zhang' }; await nextTick(); expect(itemA.text()).toBe('X.Zhang'); + + wrapper.unmount(); }); it('tag-input show suggestion work', async () => { @@ -58,14 +69,19 @@ describe('DTagInput', () => { ], suggestionList: [ { cname: 'b' }, - ] + ], }); const wrapper = customMount(state); const input = wrapper.find('input.devui-input'); expect(wrapper.find('.devui-suggestion-list').exists()).toBe(false); await input.trigger('focus'); - expect(wrapper.find('.devui-suggestion-list').exists()).toBe(true); + + // 是否存在 devui-suggestion-list + const suggestionList = !!document.querySelectorAll('.devui-suggestion-list')[0]; + expect(suggestionList).toBe(true); + + wrapper.unmount(); }); it('tag-input disabled work', async () => { @@ -79,19 +95,21 @@ describe('DTagInput', () => { props: { tags, suggestionList, - disabled: false - } + disabled: false, + }, }); expect(wrapper.find('.devui-disabled').exists()).toBe(false); expect(wrapper.find('.devui-input').isVisible()).toBe(true); await wrapper.setProps({ - disabled: true + disabled: true, }); expect(wrapper.find('.devui-disabled').exists()).toBe(true); expect(wrapper.find('.devui-input').isVisible()).toBe(false); expect(wrapper.find('.remove-button').exists()).toBe(false); + + wrapper.unmount(); }); it('tag-input maxTags work', () => { @@ -104,13 +122,15 @@ describe('DTagInput', () => { ]); const wrapper = mount(DTagInput, { props: { - tags, + modelValue: tags, suggestionList, - maxTags: 1 - } + maxTags: 1, + }, }); expect(wrapper.find('input').attributes('disabled')).toBe(''); + + wrapper.unmount(); }); it('tag-input removeTag work', async () => { @@ -121,14 +141,16 @@ describe('DTagInput', () => { ], suggestionList: [ { cname: 'c' }, - ] + ], }); const wrapper = customMount(state); const removeSvg = wrapper.find('.remove-button'); - await removeSvg.trigger('mousedown'); + await removeSvg.trigger('click'); expect(wrapper.findAll('.devui-tag-item').length).toBe(1); expect(state.tags.length).toBe(1); expect(state.suggestionList.length).toBe(2); + + wrapper.unmount(); }); it('tag-input keydown work', async () => { @@ -139,8 +161,8 @@ describe('DTagInput', () => { ], suggestionList: [ { cname: 'c' }, - { cname: 'xyz' } - ] + { cname: 'xyz' }, + ], }); const wrapper = customMount(state); const input = wrapper.find('input'); @@ -154,6 +176,8 @@ describe('DTagInput', () => { expect(state.tags.length).toBe(4); expect(state.tags[3].cname).toBe('xyz'); expect(state.suggestionList.length).toBe(1); + + wrapper.unmount(); }); it('tag-input filter suggestion work', async () => { @@ -165,22 +189,27 @@ describe('DTagInput', () => { suggestionList: [ { cname: 'x' }, { cname: 'xy' }, - { cname: 'xyz' } - ] + { cname: 'xyz' }, + ], }); const wrapper = customMount(state); const input = wrapper.find('input'); await input.trigger('focus'); - expect(wrapper.findAll('.devui-suggestion-item').length).toBe(3); + let suggestionList = document.querySelectorAll('.devui-suggestion-item'); + expect(suggestionList.length).toBe(3); await input.setValue('xy'); await input.trigger('input'); - expect(wrapper.findAll('.devui-suggestion-item').length).toBe(2); + suggestionList = document.querySelectorAll('.devui-suggestion-item'); + expect(suggestionList.length).toBe(2); await input.setValue('xxx'); await input.trigger('input'); - expect(wrapper.findAll('.devui-suggestion-item.devui-disabled').length).toBe(1); + suggestionList = document.querySelectorAll('.devui-suggestion-item'); + expect(suggestionList.length).toBe(1); + + wrapper.unmount(); }); it('tag-input click suggestion work', async () => { @@ -192,17 +221,20 @@ describe('DTagInput', () => { suggestionList: [ { cname: 'x' }, { cname: 'yyy' }, - { cname: 'xyz' } - ] + { cname: 'xyz' }, + ], }); const wrapper = customMount(state); await wrapper.find('input').trigger('focus'); - const yyy = wrapper.findAll('.devui-suggestion-item')[1]; + const suggestionList = document.querySelectorAll('.devui-suggestion-item'); + const yyy = suggestionList[1]; + yyy.dispatchEvent(new Event('click')); - await yyy.trigger('mousedown'); expect(state.tags.length).toBe(3); expect(state.tags[2].cname).toBe('yyy'); expect(state.suggestionList.length).toBe(2); + + wrapper.unmount(); }); it('tag-input arrow work', async () => { @@ -214,24 +246,31 @@ describe('DTagInput', () => { suggestionList: [ { cname: 'x' }, { cname: 'yyy' }, - { cname: 'xyz' } - ] + { cname: 'xyz' }, + ], }); const wrapper = customMount(state); const input = wrapper.find('input'); await input.trigger('focus'); + let suggestionList = document.querySelectorAll('.devui-suggestion-item'); + // 获取焦点默认第一个选中 + expect(suggestionList[0].className).toContain('selected'); - expect(wrapper.findAll('.devui-suggestion-item')[0].classes()).toContain('selected'); - + // 按下 下箭头,选中第二个数组第一个 await input.trigger('keydown', { key: 'ArrowDown' }); - expect(wrapper.findAll('.devui-suggestion-item')[1].classes()).toContain('selected'); + suggestionList = document.querySelectorAll('.devui-suggestion-item'); + expect(suggestionList[1].className).toContain('selected'); await input.trigger('keydown', { key: 'ArrowUp' }); await input.trigger('keydown', { key: 'ArrowUp' }); - expect(wrapper.findAll('.devui-suggestion-item')[2].classes()).toContain('selected'); + suggestionList = document.querySelectorAll('.devui-suggestion-item'); + expect(suggestionList[2].className).toContain('selected'); + // 按下Enter选中数据 await input.trigger('keydown', { key: 'Enter' }); expect(state.tags[2].cname).toBe('xyz'); expect(state.suggestionList.length).toBe(2); + + wrapper.unmount(); }); }); diff --git a/packages/devui-vue/devui/tag-input/src/icon-remove.tsx b/packages/devui-vue/devui/tag-input/src/components/icon-remove.tsx similarity index 100% rename from packages/devui-vue/devui/tag-input/src/icon-remove.tsx rename to packages/devui-vue/devui/tag-input/src/components/icon-remove.tsx diff --git a/packages/devui-vue/devui/tag-input/src/composables/use-input-keydown.ts b/packages/devui-vue/devui/tag-input/src/composables/use-input-keydown.ts new file mode 100644 index 0000000000..02da1ed97d --- /dev/null +++ b/packages/devui-vue/devui/tag-input/src/composables/use-input-keydown.ts @@ -0,0 +1,38 @@ +import { TagInputProps, HandleEnter, OnSelectIndexChange, UseInputKeydownReturnTypes } from '../tag-input-types'; + +export const useInputKeydown = ( + props: TagInputProps, + handleEnter: HandleEnter, + onSelectIndexChange: OnSelectIndexChange): UseInputKeydownReturnTypes => { + + const KEYS_MAP = { + tab: 'Tab', + down: 'ArrowDown', + up: 'ArrowUp', + enter: 'Enter', + space: ' ', + } as const; + + const onInputKeydown = ($event: KeyboardEvent) => { + switch ($event.key) { + case KEYS_MAP.tab: + case KEYS_MAP.enter: + case KEYS_MAP.space: + if (!props.isAddBySpace && KEYS_MAP.space) { + return; + } + handleEnter(); + break; + case KEYS_MAP.down: + onSelectIndexChange(true); + break; + case KEYS_MAP.up: + onSelectIndexChange(false); + break; + default: + break; + } + }; + + return { onInputKeydown }; +}; diff --git a/packages/devui-vue/devui/tag-input/src/tag-input-types.ts b/packages/devui-vue/devui/tag-input/src/tag-input-types.ts index 1e18ceef34..41ee6c497b 100644 --- a/packages/devui-vue/devui/tag-input/src/tag-input-types.ts +++ b/packages/devui-vue/devui/tag-input/src/tag-input-types.ts @@ -2,11 +2,12 @@ import type { ExtractPropTypes, PropType } from 'vue'; export interface Suggestion { __index?: number; + [x: string]: unknown; } export const tagInputProps = { - tags: { + modelValue: { type: Array as PropType, default: (): [] => [], }, @@ -65,3 +66,10 @@ export const tagInputProps = { } as const; export type TagInputProps = ExtractPropTypes; + +export type HandleEnter = () => void; +export type OnSelectIndexChange = (isUp: boolean) => void; + +export interface UseInputKeydownReturnTypes { + onInputKeydown: (e: KeyboardEvent) => void; +} diff --git a/packages/devui-vue/devui/tag-input/src/tag-input.scss b/packages/devui-vue/devui/tag-input/src/tag-input.scss index 76b7630a87..b79c7edc30 100644 --- a/packages/devui-vue/devui/tag-input/src/tag-input.scss +++ b/packages/devui-vue/devui/tag-input/src/tag-input.scss @@ -5,7 +5,7 @@ outline: none; } -.#{$devui-prefix}-tags-host { +.#{$devui-prefix}-tag-input { position: relative; height: 100%; outline: none; @@ -153,18 +153,12 @@ } .#{$devui-prefix}-tags-autocomplete { - position: absolute; - padding-bottom: 5px; - z-index: $devui-z-index-dropdown; width: 100%; + padding: 8px; + border-radius: $devui-border-radius; background-color: $devui-connected-overlay-bg; box-shadow: $devui-shadow-length-connected-overlay $devui-shadow; - &.#{$devui-prefix}-dropdown-menu { - display: block; - margin: 4px 0; - } - .#{$devui-prefix}-suggestion-list { margin: 0; padding: 0; @@ -180,6 +174,7 @@ text-overflow: ellipsis; font-size: $devui-font-size; line-height: 20px; + border-radius: $devui-border-radius; &:not(.#{$devui-prefix}-disabled) { cursor: pointer; @@ -191,7 +186,7 @@ &.selected { color: $devui-brand; - background-color: $devui-list-item-hover-bg; + background-color: $devui-list-item-active-bg; } } } diff --git a/packages/devui-vue/devui/tag-input/src/tag-input.tsx b/packages/devui-vue/devui/tag-input/src/tag-input.tsx index 3f4fad5da7..c17e48da8f 100644 --- a/packages/devui-vue/devui/tag-input/src/tag-input.tsx +++ b/packages/devui-vue/devui/tag-input/src/tag-input.tsx @@ -1,30 +1,46 @@ -import { defineComponent, ref, computed, nextTick, watch, SetupContext, getCurrentInstance } from 'vue'; +import { + defineComponent, + ref, + computed, + nextTick, + watch, + SetupContext, + getCurrentInstance, + Teleport, + Transition, + onMounted, + onUnmounted, +} from 'vue'; import { createI18nTranslate } from '../../locale/create'; -import removeBtnSvg from './icon-remove'; +import { FlexibleOverlay } from '../../overlay/src/flexible-overlay'; +import { useNamespace } from '../../shared/hooks/use-namespace'; +import removeBtnSvg from './components/icon-remove'; import { Suggestion, TagInputProps, tagInputProps } from './tag-input-types'; import './tag-input.scss'; - -const KEYS_MAP = { - tab: 'Tab', - down: 'ArrowDown', - up: 'ArrowUp', - enter: 'Enter', - space: ' ', -} as const; +import { useInputKeydown } from './composables/use-input-keydown'; +import { onClickOutside } from '@vueuse/core'; export default defineComponent({ name: 'DTagInput', props: tagInputProps, - emits: ['update:tags', 'update:suggestionList', 'valueChange'], + emits: ['update:modelValue', 'update:suggestionList', 'change'], setup(props: TagInputProps, ctx: SetupContext) { const app = getCurrentInstance(); const t = createI18nTranslate('DTagInput', app); + const ns = useNamespace('tag-input'); + + const selectedTags = ref>([]); + watch(() => props.modelValue, () => { + selectedTags.value = props.modelValue; + }, { immediate: true, deep: true }); + const add = (arr: Suggestion[], target: Suggestion) => { const res = Object.assign({}, target); delete res.__index; return arr.concat(res); }; + const remove = (arr: Suggestion[], targetIdx: number) => { const newArr = arr.slice(); newArr.splice(targetIdx, 1); @@ -36,8 +52,9 @@ export default defineComponent({ const v = ($event.target as HTMLInputElement).value || ''; tagInputVal.value = v.trim(); }; + const mergedSuggestions = computed(() => { - let suggestions = props.suggestionList.map((item, index: number) => { + const suggestions = props.suggestionList.map((item, index: number) => { return { __index: index, ...item, @@ -46,15 +63,20 @@ export default defineComponent({ if (tagInputVal.value === '') { return suggestions; } - return (suggestions = props.caseSensitivity - ? suggestions.filter((item) => item[props.displayProperty].indexOf(tagInputVal.value) !== -1) - : suggestions.filter((item) => item[props.displayProperty].toLowerCase().indexOf(tagInputVal.value.toLowerCase()) !== -1)); + + // 大小写敏感 + if (props.caseSensitivity) { + return suggestions.filter((item) => item[props.displayProperty].indexOf(tagInputVal.value) !== -1); + } else { + return suggestions.filter((item) => item[props.displayProperty].toLowerCase().indexOf(tagInputVal.value.toLowerCase()) !== -1); + } }); const selectIndex = ref(0); watch(mergedSuggestions, () => { selectIndex.value = 0; }); + const onSelectIndexChange = (isUp = false) => { if (isUp) { selectIndex.value < mergedSuggestions.value.length - 1 ? selectIndex.value++ : (selectIndex.value = 0); @@ -63,20 +85,18 @@ export default defineComponent({ selectIndex.value > 0 ? selectIndex.value-- : (selectIndex.value = mergedSuggestions.value.length - 1); }; - const tagInputRef = ref(null); + const tagInputRef = ref(); const isInputBoxFocus = ref(false); const onInputFocus = () => { isInputBoxFocus.value = true; }; - const onInputBlur = () => { - isInputBoxFocus.value = false; - }; + const handleEnter = () => { let res = { [props.displayProperty]: tagInputVal.value }; if (tagInputVal.value === '' && mergedSuggestions.value.length === 0) { return false; } - if (props.tags.findIndex((item) => item[props.displayProperty] === tagInputVal.value) > -1) { + if (selectedTags.value.findIndex((item) => item[props.displayProperty] === tagInputVal.value) > -1) { tagInputVal.value = ''; return false; } @@ -90,173 +110,163 @@ export default defineComponent({ if (mergedSuggestions.value.length) { const target = mergedSuggestions.value[selectIndex.value]; res = target; - ctx.emit('update:suggestionList', remove(props.suggestionList, target.__index)); + ctx.emit('update:suggestionList', remove(props.suggestionList, target.__index as number)); } - const newTags = add(props.tags, res); - ctx.emit('valueChange', props.tags, newTags); - ctx.emit('update:tags', newTags); + const newTags = add(selectedTags.value, res); + ctx.emit('change', selectedTags.value, newTags); + ctx.emit('update:modelValue', newTags); mergedSuggestions.value.length === 0 && (tagInputVal.value = ''); }; - const onInputKeydown = ($event: KeyboardEvent) => { - switch ($event.key) { - case KEYS_MAP.tab: - case KEYS_MAP.enter: - case KEYS_MAP.space: - if (!props.isAddBySpace && KEYS_MAP.space) { - return; - } - handleEnter(); - break; - case KEYS_MAP.down: - onSelectIndexChange(true); - break; - case KEYS_MAP.up: - onSelectIndexChange(); - break; - default: - break; - } - }; - const removeTag = ($event: MouseEvent, tagIdx: number) => { + const { onInputKeydown } = useInputKeydown(props, handleEnter, onSelectIndexChange); + + const removeTag = ($event: Event, tagIdx: number) => { $event.preventDefault(); - ctx.emit('update:suggestionList', add(props.suggestionList, props.tags[tagIdx])); - const newTags = remove(props.tags, tagIdx); - ctx.emit('valueChange', props.tags, newTags); - ctx.emit('update:tags', newTags); + const newTags = remove(selectedTags.value, tagIdx); + ctx.emit('change', selectedTags.value, newTags); + ctx.emit('update:modelValue', newTags); + ctx.emit('update:suggestionList', add(props.suggestionList, selectedTags.value[tagIdx])); + nextTick(() => { tagInputRef.value?.focus(); + isInputBoxFocus.value = true; }); }; - const onSuggestionItemClick = ($event: MouseEvent, itemIndex: number) => { + + const onSuggestionItemClick = ($event: Event, itemIndex: number) => { $event.preventDefault(); const target = mergedSuggestions.value[itemIndex]; - const newTags = add(props.tags, target); - const newSuggestions = remove(props.suggestionList, target.__index); - ctx.emit('valueChange', props.tags, newTags); - ctx.emit('update:tags', newTags); + const newTags = add(selectedTags.value, target); + const newSuggestions = remove(props.suggestionList, target.__index as number); + ctx.emit('change', selectedTags.value, newTags); + ctx.emit('update:modelValue', newTags); ctx.emit('update:suggestionList', newSuggestions); }; - const isTagsLimit = computed(() => props.maxTags <= props.tags.length); + const isTagsLimit = computed(() => props.maxTags <= selectedTags.value.length); const isShowSuggestion = computed(() => { return !props.disabled && !isTagsLimit.value && isInputBoxFocus.value; }); - return { - tagInputRef, - tagInputVal, - isInputBoxFocus, - onInput, - onInputFocus, - onInputBlur, - removeTag, - onSuggestionItemClick, - onInputKeydown, - isShowSuggestion, - mergedSuggestions, - selectIndex, - isTagsLimit, - t, + // 已选择 tags 列表 + const chosenTags = () => { + return ; }; - }, - render() { - const { - tagInputVal, - isInputBoxFocus, - disabled, - disabledText, - isTagsLimit, - maxTagsText, - displayProperty, - tags, - onInputKeydown, - onInputFocus, - onInputBlur, - onInput, - onSuggestionItemClick, - removeTag, - placeholder, - spellcheck, - isShowSuggestion, - noData, - mergedSuggestions, - selectIndex, - maxTags, - t, - } = this; - const inputBoxCls = { - 'devui-tags': true, - 'devui-form-control': true, - 'devui-dropdown-origin': true, - 'devui-dropdown-origin-open': isInputBoxFocus, - 'devui-disabled': disabled, + + const origin = ref(); + // 获取容器宽度 + const dropdownWidth = ref('0'); + const updateDropdownWidth = () => { + dropdownWidth.value = origin?.value?.clientWidth ? origin.value.clientWidth + 'px' : '100%'; }; + + onMounted(() => { + updateDropdownWidth(); + window.addEventListener('resize', updateDropdownWidth); + }); + + onUnmounted(() => { + window.removeEventListener('resize', updateDropdownWidth); + }); + + const dropdownRef = ref(); + // 点击外部关闭suggestionList + onClickOutside( + dropdownRef, + () => { + isInputBoxFocus.value = false; + }, + { ignore: [origin] }, + ); + + // 选择建议列表 + const suggestionList = () => { + const showNoData = mergedSuggestions.value.length === 0; + const suggestionListItem = mergedSuggestions.value.map((item: Suggestion, index: number) => { + return ( +
  • { + onSuggestionItemClick($event, index); + }} + > + {item[props.displayProperty]} +
  • + ); + }); + + const noDataTpl =
  • {props.noData}
  • ; + + return + + +
    +
      + {showNoData ? noDataTpl : suggestionListItem} +
    +
    +
    +
    +
    ; + }; + + const inputBoxCls = computed(() => { + return { + 'devui-tags': true, + 'devui-form-control': true, + 'devui-dropdown-origin': true, + 'devui-dropdown-origin-open': isInputBoxFocus.value, + 'devui-disabled': props.disabled, + }; + }); + const tagInputCls = { input: true, 'devui-input': true, 'invalid-tag': false, }; - const tagInputStyle = [`display:${disabled ? 'none' : 'block'};`]; - - const noDataTpl =
  • {noData}
  • ; + const tagInputStyle = computed(() => { + return [`display:${props.disabled ? 'none' : 'block'};`]; + }); - return ( -
    -
    - - onInput($event)} - placeholder={isTagsLimit ? `${maxTagsText || t('maxTagsText')} ${maxTags}` : placeholder} - spellcheck={spellcheck} - disabled={isTagsLimit} - /> -
    - {!isShowSuggestion ? ( - '' - ) : ( -
    -
      - {mergedSuggestions.length === 0 - ? noDataTpl - : mergedSuggestions.map((item: Suggestion, index: number) => { - return ( -
    • { - onSuggestionItemClick($event, index); - }}> - {item[displayProperty]} -
    • - ); - })} -
    -
    - )} + return () => (
    +
    + {chosenTags()} + onInput($event)} + placeholder={isTagsLimit.value ? `${props.maxTagsText || t('maxTagsText')} ${props.maxTags}` : props.placeholder} + spellCheck={props.spellcheck} + disabled={isTagsLimit.value} + />
    - ); + {suggestionList()} +
    ); }, }); diff --git a/packages/devui-vue/docs/components/tag-input/index.md b/packages/devui-vue/docs/components/tag-input/index.md index 5462d79f47..3a95c76e32 100644 --- a/packages/devui-vue/docs/components/tag-input/index.md +++ b/packages/devui-vue/docs/components/tag-input/index.md @@ -11,16 +11,17 @@ :::demo ```vue +