diff --git a/devui/style/theme/_z-index.scss b/devui/style/theme/_z-index.scss new file mode 100644 index 0000000000000000000000000000000000000000..7846b41414ae87c544e46862f137a13b798ca240 --- /dev/null +++ b/devui/style/theme/_z-index.scss @@ -0,0 +1,12 @@ +// 临时层 +// 若存在遮罩,则遮罩基于对应z-index值-1 +$devui-z-index-full-page-overlay: var(--devui-z-index-full-page-overlay, 1080); // 全屏覆盖类元素 +$devui-z-index-dropdown: var(--devui-z-index-dropdown, 1052); // 下拉菜单,dropdown等 +$devui-z-index-pop-up: var(--devui-z-index-pop-up, 1060); // 提示类信息,popover,tooltip等 +$devui-z-index-modal: var(--devui-z-index-modal, 1050);// 弹窗, +$devui-z-index-drawer: var(--devui-z-index-drawer, 1040);// 抽屉板 +$devui-z-index-framework: var(--devui-z-index-framework, 1000);// 框架类元素,header,sideMenu等 + +// 内容层,根据需要设置,zIndex需小于临时层 + +// 背景层,根据需要设置,zIndex需小于内容层 diff --git a/devui/tags-input/__tests__/tags-input.spec.ts b/devui/tags-input/__tests__/tags-input.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..f51b36c74a4bf9dbc3ceac4e75ae581a14b7552f --- /dev/null +++ b/devui/tags-input/__tests__/tags-input.spec.ts @@ -0,0 +1,233 @@ +import { mount } from '@vue/test-utils'; +import { reactive, nextTick } from 'vue'; +import DTagsInput from '../src/tags-input'; + +const customMount = (state) => mount({ + components: { DTagsInput }, + template: ` + + `, + setup () { + return { + state + }; + } +}); + +describe('DTagsInput', () => { + it('tags-input render work', async () => { + const state = reactive({ + tags: [ + { cname: 'Y.Chen' }, + { cname: 'b' }, + { 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('.devui-tags').exists()).toBe(true); + expect(wrapper.find('.devui-tag-list').exists()).toBe(true); + expect(wrapper.find('.devui-input').exists()).toBe(true); + + const itemA = wrapper.find('.devui-tag-item'); + expect(itemA.exists()).toBe(true); + expect(itemA.text()).toBe('Y.Chen'); + + state.tags[0] = { cname: 'X.Zhang' }; + await nextTick(); + expect(itemA.text()).toBe('X.Zhang'); + }); + + it('tags-input show suggestion work', async () => { + const state = reactive({ + tags: [ + { cname: 'a' }, + ], + 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); + }); + + it('tags-input disabled work', async () => { + const tags = reactive([ + { cname: 'a' }, + ]); + const suggestionList = reactive([ + { cname: 'b' }, + ]); + const wrapper = mount(DTagsInput, { + props: { + tags, + suggestionList, + disabled: false + } + }); + + expect(wrapper.find('.devui-disabled').exists()).toBe(false); + expect(wrapper.find('.devui-input').isVisible()).toBe(true); + + await wrapper.setProps({ + 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); + }); + + it('tags-input maxTags work', () => { + const tags = reactive([ + { cname: 'a' }, + { cname: 'b' }, + ]); + const suggestionList = reactive([ + { cname: 'c' }, + ]); + const wrapper = mount(DTagsInput, { + props: { + tags, + suggestionList, + maxTags: 1 + } + }); + + expect(wrapper.find('input').attributes('disabled')).toBe(''); + }); + + it('tags-input removeTag work', async () => { + const state = reactive({ + tags: [ + { cname: 'a' }, + { cname: 'b' }, + ], + suggestionList: [ + { cname: 'c' }, + ] + }); + const wrapper = customMount(state); + const removeSvg = wrapper.find('.remove-button'); + await removeSvg.trigger('mousedown'); + expect(wrapper.findAll('.devui-tag-item').length).toBe(1); + expect(state.tags.length).toBe(1); + expect(state.suggestionList.length).toBe(2); + }); + + it('tags-input keydown work', async () => { + const state = reactive({ + tags: [ + { cname: 'a' }, + { cname: 'b' }, + ], + suggestionList: [ + { cname: 'c' }, + { cname: 'xyz' } + ] + }); + const wrapper = customMount(state); + const input = wrapper.find('input'); + await input.setValue('dfg'); + await input.trigger('keydown', { key: 'Enter' }); + expect(state.tags.length).toBe(3); + expect(state.suggestionList.length).toBe(2); + + await input.setValue('yz'); + await input.trigger('keydown', { key: 'Enter' }); + expect(state.tags.length).toBe(4); + expect(state.tags[3].cname).toBe('xyz'); + expect(state.suggestionList.length).toBe(1); + }); + + it('tags-input filter suggestion work', async () => { + const state = reactive({ + tags: [ + { cname: 'a' }, + { cname: 'b' }, + ], + suggestionList: [ + { cname: 'x' }, + { cname: 'xy' }, + { cname: 'xyz' } + ] + }); + const wrapper = customMount(state); + const input = wrapper.find('input'); + + await input.trigger('focus'); + expect(wrapper.findAll('.devui-suggestion-item').length).toBe(3); + + await input.setValue('xy'); + await input.trigger('input'); + expect(wrapper.findAll('.devui-suggestion-item').length).toBe(2); + + await input.setValue('xxx'); + await input.trigger('input'); + expect(wrapper.findAll('.devui-suggestion-item.devui-disabled').length).toBe(1); + }); + + it('tags-input click suggestion work', async () => { + const state = reactive({ + tags: [ + { cname: 'a' }, + { cname: 'b' }, + ], + suggestionList: [ + { cname: 'x' }, + { cname: 'yyy' }, + { cname: 'xyz' } + ] + }); + const wrapper = customMount(state); + await wrapper.find('input').trigger('focus'); + const yyy = wrapper.findAll('.devui-suggestion-item')[1]; + + await yyy.trigger('mousedown'); + expect(state.tags.length).toBe(3); + expect(state.tags[2].cname).toBe('yyy'); + expect(state.suggestionList.length).toBe(2); + }); + + it('tags-input arrow work', async () => { + const state = reactive({ + tags: [ + { cname: 'a' }, + { cname: 'b' }, + ], + suggestionList: [ + { cname: 'x' }, + { cname: 'yyy' }, + { cname: 'xyz' } + ] + }); + const wrapper = customMount(state); + const input = wrapper.find('input'); + await input.trigger('focus'); + + 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'); + + await input.trigger('keydown', { key: 'ArrowUp' }); + await input.trigger('keydown', { key: 'ArrowUp' }); + expect(wrapper.findAll('.devui-suggestion-item')[2].classes()).toContain('selected'); + + await input.trigger('keydown', { key: 'Enter' }); + expect(state.tags[2].cname).toBe('xyz'); + expect(state.suggestionList.length).toBe(2); + }); +}); diff --git a/devui/tags-input/demo/demo-basic.tsx b/devui/tags-input/demo/demo-basic.tsx new file mode 100644 index 0000000000000000000000000000000000000000..79a88d65c01140c952023b8f8b76d52c92a12764 --- /dev/null +++ b/devui/tags-input/demo/demo-basic.tsx @@ -0,0 +1,52 @@ +import { defineComponent, reactive } from 'vue'; +import DTagsInput from '../src/tags-input'; + +export default defineComponent({ + name: 'DemoBasic', + setup () { + const state = reactive({ + tagList: [ + { name: '马龙' }, + { name: '樊振东' }, + { name: '许昕' }, + { name: '陈梦' }, + { name: '孙颖莎' }, + { name: '刘诗雯' }, + { name: '王楚钦' }, + { name: '王曼昱' }, + ], + suggestionList: [ + { name: '丁宁' }, + { name: '何卓佳' }, + { name: '方博' }, + { name: '陈幸同' }, + { name: '王艺迪' }, + { name: '朱雨玲' }, + { name: '林高远' }, + { name: '梁靖崑' }, + ] + }); + + return { + state + }; + }, + render () { + const { + state + } = this; + + return ( +
+ + +
+ ); + } +}); diff --git a/devui/tags-input/demo/demo-disabled.tsx b/devui/tags-input/demo/demo-disabled.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e83473317d897c660ed89ce4a21566b4955e1ae1 --- /dev/null +++ b/devui/tags-input/demo/demo-disabled.tsx @@ -0,0 +1,28 @@ +import { defineComponent, ref } from 'vue'; +import DTagInput from '../src/tags-input'; + +export default defineComponent({ + name: 'DemoDisabled', + setup () { + const tags = ref([ + { name: '红双喜' }, + { name: '银河' }, + { name: '729' } + ]); + const suggestionList = ref([ + { name: '斯蒂卡' }, + { name: '蝴蝶' }, + { name: '多尼克' } + ]); + + return () => { + return ( + + ); + }; + } +}); diff --git a/devui/tags-input/demo/tags-input-demo.tsx b/devui/tags-input/demo/tags-input-demo.tsx index 479f5abead9a75d85bdc5c315e22b3c94f62542d..b9829246474601f7302d8eafeef1809fecb86d69 100644 --- a/devui/tags-input/demo/tags-input-demo.tsx +++ b/devui/tags-input/demo/tags-input-demo.tsx @@ -1,12 +1,25 @@ -import { defineComponent } from 'vue' +import { defineComponent } from 'vue'; +import { useDemo } from 'hooks/use-demo'; +import DemoBaisc from './demo-basic'; +import DemoBasicCode from './demo-basic?raw'; +import DemoDisabled from './demo-disabled'; +import DemoDisabledCode from './demo-disabled?raw'; export default defineComponent({ - name: 'd-tags-input-demo', - props: { - }, - setup(props, ctx) { - return () => { - return
devui-tags-input-demo
- } + name: 'DTagsInputDemo', + render () { + return useDemo([ + { + id: 'demo-basic', + title: '基本用法', + code: DemoBasicCode, + content: + }, { + id: 'demo-disabled', + title: '禁用', + code: DemoDisabledCode, + content: + } + ]); } }) \ No newline at end of file diff --git a/devui/tags-input/src/remove-btn.tsx b/devui/tags-input/src/remove-btn.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f58580b8025e2b329ba257e366b5697b5b1db2c2 --- /dev/null +++ b/devui/tags-input/src/remove-btn.tsx @@ -0,0 +1,16 @@ +export default ( + + + + + +); diff --git a/devui/tags-input/src/tags-input.scss b/devui/tags-input/src/tags-input.scss new file mode 100644 index 0000000000000000000000000000000000000000..fc3dd5ced40251b225860787ff0d2925cfd085a9 --- /dev/null +++ b/devui/tags-input/src/tags-input.scss @@ -0,0 +1,212 @@ +@import '../../style/theme/color'; +@import '../../style/theme/shadow'; +@import '../../style/theme/corner'; +@import '../../style/core/_font'; +@import '../../style/core/animation'; +@import '../../style/theme/z-index'; + +:host { + display: block; + outline: none; +} + +.devui-tags-host { + position: relative; + height: 100%; + outline: none; + + &:active { + outline: 0; + } +} + +.devui-form-control.devui-tags { + -moz-appearance: textfield; + -webkit-appearance: textfield; + padding: 2px 4px; + overflow: hidden; + word-wrap: break-word; + cursor: text; + background-color: $devui-base-bg; + border: 1px solid $devui-line; + border-radius: $devui-border-radius; + height: 100%; + transition: border-color .3s cubic-bezier(.645,.045,.355,1); + + &:hover { + border-color: $devui-list-item-hover-bg; + } + + &.focused { + outline: 0; + } + + &.devui-dropdown-origin:focus-within { + border-color: $devui-brand; + } + + .devui-tag-list { + margin: 0; + padding: 0; + list-style-type: none; + } + + .devui-tag-item { + margin: 1px; + padding: 0 10px; + display: inline-block; + min-height: 18px; + line-height: 18px; + border-radius: $devui-border-radius; + color: $devui-text; + background-color: $devui-label-bg; + position: relative; + border: 1px solid $devui-label-bg; + + span { + line-height: 1.5; + margin-right: 25px; + } + + .remove-button { + margin: 0 0 0 12px; + padding: 0; + border: none; + vertical-align: top; + font-size: $devui-font-size-page-title; + border-radius: 50%; + background-color: $devui-line; + width: 12px; + height: 12px; + display: inline-block; + line-height: 12px; + text-align: center; + transform: translateY(-50%); + position: absolute; + top: 50%; + right: 10px; + + svg path { + fill: $devui-light-text; //TODO: Color-Question + } + + &:hover { + text-decoration: none; + } + } + } + + &:not(.devui-disabled) { + .devui-tag-item { + cursor: pointer; + + span { + &:hover { + color: $devui-list-item-hover-text; + } + } + + .remove-button { + &:hover { + background-color: $devui-list-item-hover-text; + } + } + } + } + + &.devui-disabled { + border-color: $devui-disabled-line; + background-color: $devui-disabled-bg; + cursor: not-allowed; + + .devui-tag-item { + color: $devui-disabled-text; + background-color: $devui-disabled-bg; + border-color: $devui-disabled-line; + + span { + margin-right: 0; + } + + .remove-button { + background-color: $devui-disabled-line; + + svg path { + fill: $devui-light-text; + } + } + } + + .devui-tag-list { + min-height: 22px; + } + } + + input.devui-input { + border: 0; + outline: 0; + float: left; + width: 100%; + height: 22px; + font-size: $devui-font-size; + padding-left: 5px; + + &::-ms-clear { + display: none; + } + } +} + +.devui-tags-autocomplete { + position: absolute; + padding-bottom: 5px; + z-index: $devui-z-index-dropdown; + width: 100%; + background-color: $devui-connected-overlay-bg; + box-shadow: $devui-shadow-length-connected-overlay $devui-shadow; + + &.devui-dropdown-menu { + display: block; + margin: 4px 0; + } + + .devui-suggestion-list { + margin: 0; + padding: 0; + list-style-type: none; + max-height: 280px; + overflow-y: auto; + position: relative; + + .devui-suggestion-item { + padding: 5px 10px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-size: $devui-font-size; + line-height: 20px; + + &:not(.devui-disabled) { + cursor: pointer; + color: $devui-text; + // background-color: $devui-base-bg; + &:hover { + background-color: $devui-list-item-hover-bg; + } + + &.selected { + color: $devui-brand; + background-color: $devui-list-item-hover-bg; + } + } + } + } +} + +.devui-tags-autocomplete { + .devui-suggestion-list { + .devui-suggestion-item { + transition: color $devui-animation-duration-fast $devui-animation-ease-in-smooth, background-color $devui-animation-duration-fast $devui-animation-ease-in-smooth; + } + } +} diff --git a/devui/tags-input/src/tags-input.tsx b/devui/tags-input/src/tags-input.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f713924c9708faeb69543346bdbda80300038b70 --- /dev/null +++ b/devui/tags-input/src/tags-input.tsx @@ -0,0 +1,302 @@ +import { defineComponent, ref, computed, nextTick, watch, PropType } from 'vue'; +import './tags-input.scss'; +import removeBtnSvg from './remove-btn'; + +interface Suggestion { + __index: number + [x: string]: any +} + +const tagsInputProps = { + tags: { + type: Array as PropType, + default: (): [] => [] + }, + displayProperty: { + type: String, + default: 'name' + }, + placeholder: { + type: String, + default: '' + }, + maxTags: { + type: Number, + default: Number.MAX_SAFE_INTEGER + }, + maxTagsText: { + type: String, + default: '' + }, + spellcheck: { + type: Boolean, + default: true + }, + suggestionList: { + type: Array as PropType, + default: (): [] => [] + }, + disabled: { + type: Boolean, + default: false + }, + disabledText: { + type: String, + default: '' + }, + noData: { + type: String, + default: '' + }, + onValueChange: { + type: Function as PropType<(oldTags: any[], newTags: any[]) => void>, + default: undefined + }, + 'onUpdate:tags': { + type: Function as PropType<(v: any[]) => void>, + default: undefined + }, + 'onUpdate:suggestionList': { + type: Function as PropType<(v: any[]) => void>, + default: undefined + } +} as const; + +const KEYS_MAP = { + tab: 'Tab', + down: 'ArrowDown', + up: 'ArrowUp', + enter: 'Enter' +} as const; + +export default defineComponent({ + name: 'DTagsInput', + props: tagsInputProps, + emits: ['update:tags', 'update:suggestionList', 'valueChange'], + setup(props, ctx) { + const add = (arr: any[], target: any) => { + const res = Object.assign({}, target); + delete res.__index; + return arr.concat(res); + }; + const remove = (arr: any[], targetIdx: number) => { + const newArr = arr.slice(); + newArr.splice(targetIdx, 1); + return newArr; + }; + + const tagInputVal = ref(''); + const onInput = ($event: InputEvent) => { + const v = ($event.target as HTMLInputElement).value || ''; + tagInputVal.value = v.trim(); + }; + const mergedSuggestions = computed(() => { + const suggestions = props.suggestionList.map((item, index: number) => { + return { + __index: index, + ...item + }; + }); + if (tagInputVal.value === '') { + return suggestions; + } + return suggestions.filter(item => item[props.displayProperty].includes(tagInputVal.value)); + }); + + 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; + return; + } + selectIndex.value > 0 ? selectIndex.value-- : selectIndex.value = mergedSuggestions.value.length - 1; + }; + + const tagInputRef = ref(null); + const isInputBoxFocus = ref(false); + const onInputFocus = () => { + isInputBoxFocus.value = true; + }; + const onInputBlur = () => { + isInputBoxFocus.value = false; + }; + const handleEnter = () => { + let res = { [props.displayProperty]: tagInputVal.value }; + if (mergedSuggestions.value.length) { + const target = mergedSuggestions.value[selectIndex.value]; + res = target; + ctx.emit('update:suggestionList', remove(props.suggestionList, target.__index)); + } + + const newTags = add(props.tags, res); + ctx.emit('valueChange', props.tags, newTags); + ctx.emit('update:tags', newTags); + mergedSuggestions.value.length === 0 && (tagInputVal.value = ''); + }; + const onInputKeydown = ($event: KeyboardEvent) => { + switch ($event.key) { + case KEYS_MAP.tab: + case KEYS_MAP.enter: + handleEnter(); + break; + case KEYS_MAP.down: + onSelectIndexChange(true); + break; + case KEYS_MAP.up: + onSelectIndexChange(); + break; + default: + break; + } + }; + + const removeTag = ($event: MouseEvent, 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); + nextTick(() => { + tagInputRef.value?.focus(); + }); + }; + const onSuggestionItemClick = ($event: MouseEvent, 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); + ctx.emit('update:suggestionList', newSuggestions); + }; + + const isTagsLimit = computed(() => props.maxTags <= props.tags.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 + }; + }, + render () { + const { + tagInputVal, + isInputBoxFocus, + disabled, + disabledText, + isTagsLimit, + maxTagsText, + displayProperty, + tags, + onInputKeydown, + onInputFocus, + onInputBlur, + onInput, + onSuggestionItemClick, + removeTag, + placeholder, + spellcheck, + isShowSuggestion, + noData, + mergedSuggestions, + selectIndex + } = this; + + const inputBoxCls = { + 'devui-tags': true, + 'devui-form-control': true, + 'devui-dropdown-origin': true, + 'devui-dropdown-origin-open': isInputBoxFocus, + 'devui-disabled': disabled, + }; + const tagInputCls = { + input: true, + 'devui-input': true, + 'invalid-tag': false + }; + const tagInputStyle = [ + `display:${disabled ? 'none' : 'block'};` + ]; + + const noDataTpl =
  • + { noData } +
  • ; + + return ( +
    +
    + + onInput($event)} + placeholder={placeholder} + spellcheck={spellcheck} + disabled={isTagsLimit} + title={isTagsLimit ? maxTagsText : ''} /> +
    + { + !isShowSuggestion ? '' : ( +
    +
      + { + mergedSuggestions.length === 0 ? + noDataTpl : + mergedSuggestions.map((item: any, index: number) => { + return ( +
    • { + onSuggestionItemClick($event, index); + }}> + { item[displayProperty] } +
    • + ); + }) + } +
    +
    + ) + } +
    + ); + } +}); diff --git a/devui/tags-input/tags-input.tsx b/devui/tags-input/tags-input.tsx deleted file mode 100644 index e07e6982a45a3038f7894189ee8c60b678e5136a..0000000000000000000000000000000000000000 --- a/devui/tags-input/tags-input.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { defineComponent } from 'vue' - -export default defineComponent({ - name: 'd-tags-input', - props: { - }, - setup(props, ctx) { - return () => { - return
    devui-tags-input
    - } - } -}) \ No newline at end of file