diff --git a/packages/devui-vue/devui/auto-complete/__tests__/auto-complete.spec.ts b/packages/devui-vue/devui/auto-complete/__tests__/auto-complete.spec.ts new file mode 100644 index 0000000000..85df305ba4 --- /dev/null +++ b/packages/devui-vue/devui/auto-complete/__tests__/auto-complete.spec.ts @@ -0,0 +1,570 @@ +import { mount } from '@vue/test-utils'; +import { nextTick, ref } from 'vue'; +import DAutoComplete from '../src/auto-complete'; + +// delay api +const wait = (delay = 300) => + new Promise(resolve => setTimeout(() => resolve(true), delay)) +describe('auto-complete', () => { + it('init render & KeyboardEvent ', async () => { + const wrapper = mount({ + components: {'d-auto-complete': DAutoComplete }, + template: ` + + `, + setup() { + const value = ref('') + const source = [ + 'C#', + 'C', + 'C++', + 'CPython', + 'CoffeeScript', + ] + return { + value, + source, + } + } + }) + expect(wrapper.find('.devui-auto-complete').exists()).toBe(true) + const input = wrapper.find('input') + expect(input.element.value).toBe('') + await input.trigger('click') + await nextTick() + expect(wrapper.find('.devui-select-open').exists()).toBe(true) + expect(wrapper.find('.devui-dropdown-item').exists()).toBe(false) + expect(wrapper.find('.devui-auto-complete').attributes('style')).toContain( + 'width: 450px' + ) + await input.setValue('c') + await nextTick() + expect(wrapper.find('.devui-dropdown-menu').exists()).toBe(true) + await wait(300) + await nextTick() + expect(wrapper.find('.devui-list-unstyled').element.childElementCount).toBe(5) + input.element.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' })) + await nextTick() + input.element.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' })) + await nextTick() + input.element.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp' })) + await nextTick() + input.element.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' })) + await nextTick() + input.element.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })) + await nextTick() + expect(wrapper.vm.value).toBe('C++') + input.element.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) + await nextTick() + expect(wrapper.find('.devui-select-open').exists()).toBe(false) + }) + it('disabled ', async () => { + const wrapper = mount({ + components: {'d-auto-complete': DAutoComplete }, + template: ` +
+ + +
+ `, + setup() { + const value = ref('') + const isDisabled = ref(false) + const source = [ + 'C#', + 'C', + 'C++', + 'CPython', + 'CoffeeScript', + ] + function toggle(){ + isDisabled.value= !isDisabled.value + } + return { + value, + source, + isDisabled, + toggle + } + } + }) + expect(wrapper.find('.devui-auto-complete').exists()).toBe(true) + const input = wrapper.find('input') + const button = wrapper.find('button') + expect(input.element.value).toBe('') + expect(button.element.innerHTML).toBe('Disable AutoComplete') + await input.trigger('click') + await nextTick() + await input.setValue('c') + await nextTick() + await wait(300) + await nextTick() + expect(wrapper.find('ul .selected').exists()).toBe(true) + const li = wrapper.find('ul .selected') + li.trigger('click') + await nextTick() + expect(wrapper.vm.value).toBe('C#') + expect(wrapper.find('.devui-select-open').exists()).toBe(false) + button.trigger('click') + await nextTick() + expect(button.element.innerHTML).toBe('Enable AutoComplete') + expect(input.element.disabled).toBe(true) + }) + it('Customized data matching method ', async () => { + const wrapper = mount({ + components: {'d-auto-complete': DAutoComplete }, + template: ` + + + + `, + setup() { + const value = ref('') + const mySource = ref([ + { + label:'C#', + disabled:false + },{ + label:'C++', + disabled:false + },{ + label:'CPython', + disabled:false + },{ + label:'Java', + disabled:false + },{ + label:'JavaScript', + disabled:false + },{ + label:'Go', + disabled:false + },{ + label:'Ruby', + disabled:false + },{ + label:'F#', + disabled:false + },{ + label:'TypeScript', + disabled:false + },{ + label:'SQL', + disabled:true + },{ + label:'LiveScript', + disabled:false + },{ + label:'CoffeeScript', + disabled:false + } + ]) + const formatter = (item) =>{ + return item.label; + } + //trem:input输入内容 + const searchFn =async (trem)=>{ + const arr = [] + await new Promise((resolve)=>{ + setTimeout(() => { + resolve(1) + }, 500); + }) + mySource.value.forEach((item) => { + let cur = item.label + cur = cur.toLowerCase() + if (cur.startsWith(trem)) { + arr.push(item) + } + }) + return arr + } + return { + value, + searchFn, + formatter + } + } + }) + expect(wrapper.find('.devui-auto-complete').exists()).toBe(true) + const input = wrapper.find('input') + expect(input.element.value).toBe('') + await input.trigger('click') + await nextTick() + expect(wrapper.find('.devui-select-open').exists()).toBe(true) + await input.setValue('c') + await nextTick() + await wait(300) + expect(wrapper.find('#devui-is-searching-template').exists()).toBe(true) + expect(wrapper.find('#devui-is-searching-template').element.innerHTML).toBe('c') + await wait(500) + await nextTick() + expect(wrapper.find('.devui-list-unstyled').element.childElementCount).toBe(4) + await input.setValue('s') + await nextTick() + await wait(300) + await nextTick() + await wait(500) + expect(wrapper.find('li.disabled').exists()).toBe(true) + expect(wrapper.find('li.disabled').element.innerHTML).toBe('SQL') + }) + + it('Customized template display', async () => { + const wrapper = mount({ + components: {'d-auto-complete': DAutoComplete }, + template: ` + + + + + `, + setup() { + const value = ref('') + const source = ref([ + 'C#', + 'C', + 'C++', + 'CPython', + 'Java', + 'JavaScript', + 'Go', + 'Python', + 'Ruby', + 'F#', + 'TypeScript', + 'SQL', + 'LiveScript', + 'CoffeeScript', + ]) + + return { + value, + source + } + } + }) + expect(wrapper.find('.devui-auto-complete').exists()).toBe(true) + const input = wrapper.find('input') + expect(input.element.value).toBe('') + await input.trigger('click') + await nextTick() + expect(wrapper.find('.devui-select-open').exists()).toBe(true) + await input.setValue('c') + await nextTick() + await wait(300) + expect(wrapper.find('.devui-list-unstyled').exists()).toBe(true) + expect(wrapper.find('.devui-list-unstyled').element.childElementCount).toBe(5) + expect(wrapper.find('.selected div').element.innerHTML).toBe(' 第0项: C#') + await input.setValue('cc') + await nextTick() + await wait(300) + await nextTick() + expect(wrapper.find('#noResultItemTemplate').exists()).toBe(true) + expect(wrapper.find('#noResultItemTemplate').element.innerHTML).toBe('cc') + }) + + it('selectValue & transInputFocusEmit ', async () => { + const transInputFocusEmitCB = jest.fn() + const selectValueCB = jest.fn() + const wrapper = mount({ + components: {'d-auto-complete': DAutoComplete }, + template: ` + + `, + setup() { + const value = ref('') + const source = [ + 'C#', + 'C', + 'C++', + 'CPython', + 'CoffeeScript', + ] + const selectValue = (e)=>{ + selectValueCB(e) + } + const transInputFocusEmit = (e)=>{ + transInputFocusEmitCB(e) + } + return { + value, + source, + selectValue, + transInputFocusEmit + } + } + }) + expect(wrapper.find('.devui-auto-complete').exists()).toBe(true) + const input = wrapper.find('input') + expect(input.element.value).toBe('') + await input.trigger('focus') + await nextTick() + await input.setValue('c') + await nextTick() + await wait(300) + await nextTick() + expect(transInputFocusEmitCB).toHaveBeenCalledTimes(1) + const li = wrapper.find('ul .selected') + li.trigger('click') + await nextTick() + expect(selectValueCB).toHaveBeenCalledTimes(1) + }) + it('allowEmptyValueSearch ', async () => { + const wrapper = mount({ + components: {'d-auto-complete': DAutoComplete }, + template: ` + + `, + setup() { + const value = ref('') + const allowEmptyValueSearch = ref(true) + const source = [ + 'C#', + 'C', + 'C++', + 'CPython', + 'CoffeeScript', + ] + + return { + value, + source, + allowEmptyValueSearch + } + } + }) + expect(wrapper.find('.devui-auto-complete').exists()).toBe(true) + const input = wrapper.find('input') + expect(input.element.value).toBe('') + await input.trigger('focus') + await nextTick() + expect(wrapper.find('ul').element.childElementCount).toBe(5) + }) + + it('appendToBody & appendToBodyDirections', async () => { + const wrapper = mount({ + components: {'d-auto-complete': DAutoComplete }, + template: ` + + `, + setup() { + const value = ref('') + const allowEmptyValueSearch = ref(true) + const source = [ + 'CC#', + 'C', + 'C++', + 'CPython', + 'CoffeeScript', + ] + const appendToBodyDirections = ref({ + originX: 'left', + originY: 'bottom', + overlayX: 'left', + overlayY: 'top', + }) + return { + value, + source, + allowEmptyValueSearch, + appendToBodyDirections + } + } + }) + expect(wrapper.find('.devui-auto-complete').exists()).toBe(true) + const input = wrapper.find('input') + expect(input.element.value).toBe('') + await input.trigger('focus') + await nextTick() + await input.setValue('c') + await nextTick() + await wait(300) + await nextTick() + expect(wrapper.find('ul').element.childElementCount).toBe(5) + expect(wrapper.find('.selected').element.innerHTML).toBe('CC#') + }) + + it('latestSource',async () => { + const wrapper = mount({ + components: {'d-auto-complete': DAutoComplete }, + template:` +
+ +
+ `, + setup(){ + const value = ref('') + const latestSource = ref(['JavaScript','TypeScript']) + const source = ref([ + 'C#', + 'C', + 'C++', + 'Java', + 'JavaScript' + ]) + + return { + value, + source, + latestSource + } + } + }) + expect(wrapper.find('.devui-auto-complete').exists()).toBe(true) + const input = wrapper.find('input') + expect(input.element.value).toBe('') + await input.trigger('click') + await nextTick() + expect(wrapper.find('ul .devui-popup-tips').exists()).toBe(true) + await input.setValue('j') + await wait(300) + await nextTick() + const li = wrapper.find('ul .selected') + li.trigger('click') + await nextTick() + expect(wrapper.vm.value).toBe('Java') + }) + it('enableLazyLoad',async () => { + const wrapper = mount({ + components: {'d-auto-complete': DAutoComplete }, + template:` +
+ +
+ `, + setup(){ + const value = ref('') + const source = ref([ + 'C#', + 'C', + 'C++', + 'CPython', + 'Java', + 'JavaScript', + 'Go', + 'Python', + 'Ruby', + 'F#', + 'TypeScript', + 'SQL', + 'LiveScript', + 'CoffeeScript', + 'C1', + 'C2', + 'C3', + 'C4', + 'C5', + 'C6', + 'C7', + ]) + const autoCompleteRef =ref(null) + + const loadMore = () => { + setTimeout(() => { + source.value.push('lazyData'+source.value.length) + autoCompleteRef.value?.loadFinish() + },3000) + } + return { + value, + source, + loadMore, + autoCompleteRef + } + } + }) + expect(wrapper.find('.devui-auto-complete').exists()).toBe(true) + const input = wrapper.find('input') + expect(input.element.value).toBe('') + await input.setValue('c') + await nextTick() + expect(wrapper.find('.devui-dropdown-menu').exists()).toBe(true) + await wait(300) + await nextTick() + expect(wrapper.find('.devui-dropdown-item').exists()).toBe(true) + const ul = wrapper.find('.devui-list-unstyled') + const makeScroll = async ( + dom: Element, + name: 'scrollTop', + offset: number + ) => { + const eventTarget = dom === document.documentElement ? window : dom + dom[name] = offset + const evt = new CustomEvent('scroll', { + detail: { + target: { + [name]: offset, + }, + }, + }) + eventTarget.dispatchEvent(evt) + return await wait(3000) + } + await makeScroll(ul.element, 'scrollTop', 500) + await nextTick() + expect(wrapper.vm.value).toBe('c') + await nextTick() + await input.setValue('') + const length = wrapper.vm.source.length + expect(wrapper.vm.source[length - 1]).toBe('lazyData21') + await input.setValue('la') + await wait(300) + await nextTick() + expect(wrapper.find('.devui-dropdown-item').element.innerHTML).toBe('lazyData21') + }) +}) + diff --git a/packages/devui-vue/devui/auto-complete/index.ts b/packages/devui-vue/devui/auto-complete/index.ts new file mode 100644 index 0000000000..ecfbe3545b --- /dev/null +++ b/packages/devui-vue/devui/auto-complete/index.ts @@ -0,0 +1,17 @@ +import type { App } from 'vue' +import AutoComplete from './src/auto-complete' + +AutoComplete.install = function(app: App): void { + app.component(AutoComplete.name, AutoComplete) +} + +export { AutoComplete } + +export default { + title: 'AutoComplete 自动补全', + category: '数据录入', + status: '100%', + install(app: App): void { + app.use(AutoComplete as any) + } +} diff --git a/packages/devui-vue/devui/auto-complete/src/auto-complete-types.ts b/packages/devui-vue/devui/auto-complete/src/auto-complete-types.ts new file mode 100644 index 0000000000..b3618cd2f2 --- /dev/null +++ b/packages/devui-vue/devui/auto-complete/src/auto-complete-types.ts @@ -0,0 +1,140 @@ +import type { PropType, ExtractPropTypes, InjectionKey, SetupContext, Ref } from 'vue' +const defaultFormatter = (item) => (item ? item.label || item.toString() : ''); +const defaultValueParse = (item) => item; +// appendToBody使用 +export type HorizontalConnectionPos = 'left' | 'center' | 'right'; +export type VerticalConnectionPos = 'top' | 'center' | 'bottom'; +export interface ConnectionPosition { + originX: HorizontalConnectionPos + originY: VerticalConnectionPos + overlayX: HorizontalConnectionPos + overlayY: VerticalConnectionPos +} +export const autoCompleteProps = { + modelValue: { + type: String, + default:'' + }, + source:{ + type :Array, + default:null + }, + allowEmptyValueSearch:{ + type:Boolean, + default:false + }, + appendToBody :{ + type:Boolean, + default:false + }, + appendToBodyDirections :{ + type: Object as PropType, + default: (): ConnectionPosition => ({ + originX: 'left', + originY: 'bottom', + overlayX: 'left', + overlayY: 'top', + }), + }, + disabled:{ + type:Boolean, + default:false + }, + delay:{ + type:Number, + default:300 + }, + disabledKey:{ + type:String, + default:null + }, + formatter: { + type:Function as PropType<(item: any) => string>, + default:defaultFormatter + }, + isSearching: { + type:Boolean, + default:false + }, + sceneType:{ + type:String, + default:null + }, + searchFn:{ + type:Function as PropType<(term: string) => Array>, + default:null + }, + tipsText:{ + type:String, + default:'最近输入' + }, + latestSource:{ + type:Array, + default:null + }, + valueParser:{ + type:Function as PropType<(item: any) => any>, + default:defaultValueParse + }, + enableLazyLoad: { + type:Boolean, + default:false + }, + dAutoCompleteWidth:{ + type: Number, + default:null + }, + showAnimation:{ + type:Boolean, + default:true + }, + maxHeight:{ + type:Number, + default:300 + }, + transInputFocusEmit:{ + type:Function as PropType<(any) => void>, + default:null + }, + selectValue:{ + type:Function as PropType<(any) => void>, + default:null + }, + loadMore:{ + type:Function as PropType<() => void>, + default:null + } +} as const + +export type AutoCompleteProps = ExtractPropTypes + +export interface AutoCompleteRootType { + ctx:SetupContext + props:AutoCompleteProps +} +export type SearchFnType = (term: string) => Array +export type FormatterType = (item: any) => string +export type DefaultFuncType = (any?) => any +export type HandleSearch = (term?:string | string,enableLazyLoad?:boolean) => void +export type RecentlyFocus = (latestSource:Array) => void +export type InputDebounceCb = (...rest:any) => Promise +export type TransInputFocusEmit = (any?: any) => void +export type SelectOptionClick = (any?: any) => void +//弹出选择框参数 +export type DropdownProps = { + props:AutoCompleteProps + searchList:Ref + searchStatus?:Ref + showNoResultItemTemplate:Ref + term?: string + visible: Ref + selectedIndex:Ref + selectOptionClick:HandleSearch + dropDownRef + showLoading:Ref + loadMore + latestSource + modelValue:Ref + hoverIndex:Ref +} +export const DropdownPropsKey:InjectionKey=Symbol('DropdownPropsKey') diff --git a/packages/devui-vue/devui/auto-complete/src/auto-complete.scss b/packages/devui-vue/devui/auto-complete/src/auto-complete.scss new file mode 100644 index 0000000000..fc4d56c667 --- /dev/null +++ b/packages/devui-vue/devui/auto-complete/src/auto-complete.scss @@ -0,0 +1,92 @@ +@import '../../style/mixins/index'; +@import '../../style/theme/color'; +.devui-auto-complete, +.devui-auto-complete-menu { + .devui-dropdown-menu { + left: 0 !important; + top: 0 !important; + } +} + +.active { + background: $devui-list-item-hover-bg; +} + +.devui-dropdown-menu { + width: 100%; + display: block; +} + +.devui-dropdown-menu-cdk { + position: static; +} + +.devui-dropdown-item { + cursor: pointer; + display: block; + width: 100%; + padding: 8px 12px; + clear: both; + border: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + line-height: 14px; +} + +.devui-dropdown-menu { + .devui-dropdown-item:not(.disabled) { + &.selected { + color: $devui-list-item-active-text; + background-color: $devui-list-item-active-bg; + } + } +} + +.devui-no-result-template, +.devui-is-searching-template { + display: block; + width: 100%; + padding: 8px 12px; + clear: both; + border: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + cursor: not-allowed; + background-color: $devui-disabled-bg; + color: $devui-disabled-text; + line-height: 14px; + + &:hover, + &:active, + &:hover:active { + background-color: $devui-unavailable; + } +} + +/* 选项disabled */ +.devui-dropdown-item.disabled, +.devui-dropdown-item.disabled:hover { + cursor: not-allowed; + color: $devui-disabled-text; +} + +ul.devui-list-unstyled { + margin: 0; + overflow-y: auto; +} + +.devui-dropdown-bg { + background: $devui-list-item-hover-bg; + color: $devui-list-item-hover-text; +} + +.devui-popup-tips { + color: $devui-text-weak; // TODO: Color-Question + padding: 4px 12px; +} + +.devui-dropdown-latestSource ul { + line-height: initial !important; +} diff --git a/packages/devui-vue/devui/auto-complete/src/auto-complete.tsx b/packages/devui-vue/devui/auto-complete/src/auto-complete.tsx new file mode 100644 index 0000000000..0f70a1e350 --- /dev/null +++ b/packages/devui-vue/devui/auto-complete/src/auto-complete.tsx @@ -0,0 +1,127 @@ +import { defineComponent, provide, reactive, Transition,toRefs, ref, SetupContext } from 'vue' +import { autoCompleteProps, AutoCompleteProps, DropdownPropsKey } from './auto-complete-types' +import useCustomTemplate from './composables/use-custom-template' +import useSearchFn from './composables/use-searchfn' +import useInputHandle from './composables/use-input-handle' +import useSelectHandle from './composables/use-select-handle' +import useLazyHandle from './composables/use-lazy-handle' +import useKeyBoardHandle from './composables/use-keyboard-select' +import './auto-complete.scss' +import DAutoCompleteDropdown from './components/dropdown' +import ClickOutside from '../../shared/devui-directive/clickoutside' +import {FlexibleOverlay} from '../../overlay/src/flexible-overlay' +export default defineComponent({ + name: 'DAutoComplete', + directives: { ClickOutside }, + props: autoCompleteProps, + emits: ['update:modelValue'], + setup(props: AutoCompleteProps, ctx:SetupContext) { + const { + disabled, + modelValue, + appendToBody, + dAutoCompleteWidth, + delay, + allowEmptyValueSearch, + formatter, + transInputFocusEmit, + selectValue, + source, + searchFn, + appendToBodyDirections, + latestSource, + showAnimation + } = toRefs(props) + + const {handleSearch,searchList,showNoResultItemTemplate,recentlyFocus} = useSearchFn(ctx,allowEmptyValueSearch,source,searchFn,formatter) + const {onInput,onFocus,inputRef,visible,searchStatus,handleClose,toggleMenu} = useInputHandle(ctx,searchList,showNoResultItemTemplate,modelValue,disabled,delay,handleSearch,transInputFocusEmit,recentlyFocus,latestSource) + const {selectedIndex,selectOptionClick} = useSelectHandle(ctx,searchList,selectValue,handleSearch,formatter,handleClose) + const {showLoading,dropDownRef,loadMore} = useLazyHandle(props,ctx,handleSearch) + const {customRenderSolts} = useCustomTemplate(ctx,modelValue) + const {hoverIndex,handlekeyDown} = useKeyBoardHandle(dropDownRef,visible,searchList,selectedIndex,searchStatus,showNoResultItemTemplate,selectOptionClick,handleClose) + provide(DropdownPropsKey, { + props, + visible, + term: '', + searchList:searchList, + selectedIndex, + searchStatus, + selectOptionClick, + dropDownRef, + showLoading, + loadMore, + latestSource, + modelValue, + showNoResultItemTemplate:showNoResultItemTemplate, + hoverIndex:hoverIndex + }) + const origin = ref() + const position = reactive({appendToBodyDirections:{}}) + position.appendToBodyDirections=appendToBodyDirections + const renderDropdown = () => { + if(appendToBody.value){ + return ( + +
0&&dAutoCompleteWidth.value+'px' + }} + > + + {customRenderSolts()} + +
+
+ ) + }else{ + return ( +
0&&dAutoCompleteWidth.value+'px' + }} + > + + + {customRenderSolts()} + + +
+ ) + } + + } + return () => { + return ( +
0&&dAutoCompleteWidth.value+'px' + }} + > + + {renderDropdown()} +
+ ) + } + } +}) diff --git a/packages/devui-vue/devui/auto-complete/src/components/dropdown.tsx b/packages/devui-vue/devui/auto-complete/src/components/dropdown.tsx new file mode 100644 index 0000000000..38dd7ed3a3 --- /dev/null +++ b/packages/devui-vue/devui/auto-complete/src/components/dropdown.tsx @@ -0,0 +1,103 @@ +import { defineComponent, inject } from 'vue' +import { DropdownPropsKey } from '../auto-complete-types' +import dLoading from '../../../loading/src/directive' +// 后续会对接自带下拉组件,相关功能将全部抽离 +export default defineComponent({ + name: 'DAutoCompleteDropdown', + directives: {dLoading}, + setup(props,ctx) { + const propsData = inject(DropdownPropsKey) + const { + visible, + selectedIndex, + selectOptionClick, + searchList, + searchStatus, + dropDownRef, + loadMore, + showLoading, + showNoResultItemTemplate, + latestSource, + modelValue, + hoverIndex + } = propsData + const { + disabled, + maxHeight, + appendToBody, + formatter, + disabledKey, + isSearching, + } = propsData.props + + const onSelect =(item:any)=>{ + if(item[disabledKey]){return} + selectOptionClick(item) + } + return () => { + return ( +
0)||(ctx.slots.noResultItemTemplate&&showNoResultItemTemplate.value)||(isSearching&&ctx.slots.searchingTemplate&&searchStatus.value)} + > +
    + {/* 搜索中展示 */} + { + isSearching&&ctx.slots.searchingTemplate&&searchStatus.value + &&
  • +
    + { + ctx.slots.searchingTemplate() + } +
    + +
  • + } + { + latestSource.value&&!modelValue.value&&
  • 最近输入
  • + } + {/* 展示 */} + { + !showNoResultItemTemplate.value&&!searchStatus.value&&searchList!=null&&searchList.value.length>0&&searchList.value.map((item,index)=>{ + return ( +
  • onSelect(item)} + class={[ + 'devui-dropdown-item',selectedIndex.value==index&&'selected', + {'disabled': disabledKey && item[disabledKey]}, + {'devui-dropdown-bg': hoverIndex.value== index}, + + ]} + title={formatter(item)} + key={formatter(item)} + > + { + ctx.slots.itemTemplate?ctx.slots.itemTemplate(item,index):formatter(item) + } +
  • + ) + }) + } + + {/* 没有匹配结果传入了noResultItemTemplate*/} + { + !searchStatus.value&&searchList.value.length==0&&ctx.slots.noResultItemTemplate&&showNoResultItemTemplate.value&& +
  • +
    + {ctx.slots.noResultItemTemplate()} +
    +
  • + } +
+
+ ) + } + + } +}) \ No newline at end of file diff --git a/packages/devui-vue/devui/auto-complete/src/composables/use-custom-template.ts b/packages/devui-vue/devui/auto-complete/src/composables/use-custom-template.ts new file mode 100644 index 0000000000..5ddd8a3ec9 --- /dev/null +++ b/packages/devui-vue/devui/auto-complete/src/composables/use-custom-template.ts @@ -0,0 +1,37 @@ +import { Ref, SetupContext } from 'vue'; + +export default function useCustomTemplate(ctx:SetupContext,modelValue:Ref): any { + const itemTemplate = (item, index) => { + const arr = { item, index } + if (ctx.slots.itemTemplate) { + return ctx.slots.itemTemplate(arr) + } + return null + } + const noResultItemTemplate = () => { + if (ctx.slots.noResultItemTemplate) { + return ctx.slots.noResultItemTemplate(modelValue.value) + } + return null + } + const searchingTemplate = () => { + if (ctx.slots.searchingTemplate) { + return ctx.slots.searchingTemplate(modelValue.value) + } + return null + } + const customRenderSolts = () => { + const slots = {} + if (ctx.slots.itemTemplate) { + slots['itemTemplate'] = itemTemplate + } + if (ctx.slots.noResultItemTemplate) { + slots['noResultItemTemplate'] = noResultItemTemplate + } + if (ctx.slots.searchingTemplate) { + slots['searchingTemplate'] = searchingTemplate + } + return slots + } + return {customRenderSolts} +} \ No newline at end of file diff --git a/packages/devui-vue/devui/auto-complete/src/composables/use-input-handle.ts b/packages/devui-vue/devui/auto-complete/src/composables/use-input-handle.ts new file mode 100644 index 0000000000..090a867276 --- /dev/null +++ b/packages/devui-vue/devui/auto-complete/src/composables/use-input-handle.ts @@ -0,0 +1,63 @@ +import { ref, Ref, SetupContext } from 'vue'; +import {HandleSearch,RecentlyFocus,InputDebounceCb,TransInputFocusEmit} from '../auto-complete-types' +export default function useInputHandle(ctx: SetupContext,searchList:Ref,showNoResultItemTemplate:Ref, modelValue:Ref,disabled:Ref,delay:Ref,handleSearch: HandleSearch, transInputFocusEmit:Ref,recentlyFocus:RecentlyFocus,latestSource:Ref>): any { + const visible = ref(false) + const inputRef = ref() + const searchStatus = ref(false) + const debounce =(cb:InputDebounceCb,time:number) =>{ + let timer + return (...args)=>{ + if(timer){ + clearTimeout(timer) + } + timer = setTimeout(async ()=>{ + searchStatus.value=true + await cb(...args) + searchStatus.value=false + },time) + } + } + const onInputCb = async(value:string)=>{ + await handleSearch(value) + visible.value = true + } + const onInputDebounce = debounce(onInputCb,delay.value) + const onInput =(e: Event) => { + const inp = e.target as HTMLInputElement + searchStatus.value=false + showNoResultItemTemplate.value=false + ctx.emit('update:modelValue', inp.value) + onInputDebounce(inp.value) + } + const onFocus =() => { + handleSearch(modelValue.value) + recentlyFocus(latestSource.value) + transInputFocusEmit.value && transInputFocusEmit.value() + } + const handleClose = ()=>{ + visible.value=false + searchStatus.value=false + showNoResultItemTemplate.value=false + } + const toggleMenu =()=>{ + if(!disabled.value){ + if(visible.value){ + handleClose() + }else{ + visible.value=true + if (ctx.slots.noResultItemTemplate&&searchList.value.length==0&&modelValue.value.trim()!='') { + showNoResultItemTemplate.value=true + } + } + } + } + return { + handleClose, + toggleMenu, + onInput, + onFocus, + inputRef, + visible, + searchStatus + } +} \ No newline at end of file diff --git a/packages/devui-vue/devui/auto-complete/src/composables/use-keyboard-select.ts b/packages/devui-vue/devui/auto-complete/src/composables/use-keyboard-select.ts new file mode 100644 index 0000000000..ad04b34793 --- /dev/null +++ b/packages/devui-vue/devui/auto-complete/src/composables/use-keyboard-select.ts @@ -0,0 +1,55 @@ +import { nextTick, ref, Ref } from 'vue'; +import { DefaultFuncType, SelectOptionClick } from '../auto-complete-types'; + +export default function useKeyBoardHandle(dropDownRef: Ref, visible: Ref, searchList: Ref>, selectedIndex: Ref, searchStatus: Ref, showNoResultItemTemplate: Ref, selectOptionClick: SelectOptionClick, handleClose: DefaultFuncType): any { + const hoverIndex = ref(selectedIndex.value??0) + const scrollToActive = (index: number) => { + const ul = dropDownRef.value + const li = ul.children[index] + nextTick(() => { + if (li.scrollIntoViewIfNeeded) { + li.scrollIntoViewIfNeeded(false) + } else { + const containerInfo = ul.getBoundingClientRect() + const elementInfo = li.getBoundingClientRect() + if (elementInfo.bottom > containerInfo.bottom || elementInfo.top < containerInfo.top) { + li.scrollIntoView(false) + } + } + }) + } + const handlekeyDown = (e: KeyboardEvent) => { + const keyCode = e.key || e.code + if (keyCode === 'Escape' && ( (visible.value && searchList.value.length) || searchStatus.value||showNoResultItemTemplate.value)) { + handleClose() + return + } + const status = visible.value && searchList.value.length && !searchStatus.value && !showNoResultItemTemplate.value + if (keyCode === 'ArrowDown' && status) { + if (hoverIndex.value === searchList.value.length - 1) { + hoverIndex.value = 0; + scrollToActive(hoverIndex.value); + return; + } + hoverIndex.value = hoverIndex.value + 1; + scrollToActive(hoverIndex.value); + } else if (keyCode === 'ArrowUp' && status) { + if (hoverIndex.value === 0) { + hoverIndex.value = searchList.value.length - 1; + scrollToActive(hoverIndex.value); + return; + } + hoverIndex.value = hoverIndex.value - 1; + scrollToActive(hoverIndex.value); + } + if (keyCode === 'Enter' && status) { + selectOptionClick(searchList.value[hoverIndex.value]) + hoverIndex.value=selectedIndex.value??0 + return + } + } + return { + hoverIndex, + handlekeyDown + } +} \ No newline at end of file diff --git a/packages/devui-vue/devui/auto-complete/src/composables/use-lazy-handle.ts b/packages/devui-vue/devui/auto-complete/src/composables/use-lazy-handle.ts new file mode 100644 index 0000000000..96add63862 --- /dev/null +++ b/packages/devui-vue/devui/auto-complete/src/composables/use-lazy-handle.ts @@ -0,0 +1,30 @@ +import { ref,SetupContext } from 'vue' +import { AutoCompleteProps,HandleSearch } from '../auto-complete-types' +export default function useLazyHandle(props: AutoCompleteProps,ctx:SetupContext,handleSearch:HandleSearch):any { + const showLoading = ref(false) + const dropDownRef = ref() + const loadMore = () => { + if(!props.enableLazyLoad && showLoading) return + const dropDownValue = dropDownRef.value + const height = dropDownValue.scrollHeight + const scrollTop = dropDownValue.clientHeight + dropDownValue.scrollTop + + if(scrollTop >= height && scrollTop >= props.maxHeight) { + props.loadMore() + showLoading.value = true + } + } + ctx.expose({loadFinish}) + + async function loadFinish (){ + await handleSearch(props.modelValue,props.enableLazyLoad) + showLoading.value = false + } + return { + showLoading, + dropDownRef, + loadMore, + } +} + + diff --git a/packages/devui-vue/devui/auto-complete/src/composables/use-searchfn.ts b/packages/devui-vue/devui/auto-complete/src/composables/use-searchfn.ts new file mode 100644 index 0000000000..8d90a98811 --- /dev/null +++ b/packages/devui-vue/devui/auto-complete/src/composables/use-searchfn.ts @@ -0,0 +1,45 @@ +import { ref, Ref, SetupContext } from 'vue'; +import { FormatterType, SearchFnType } from '../auto-complete-types'; +export default function useSearchFn(ctx: SetupContext,allowEmptyValueSearch:Ref,source:Ref>,searchFn:Ref,formatter:Ref): any { + const searchList = ref([]) + const showNoResultItemTemplate = ref(false) + const handleSearch = async (term: string,enableLazyLoad:boolean) => { + if (term == ''&&!allowEmptyValueSearch.value) { + searchList.value = [] + showNoResultItemTemplate.value=false + return + } + let arr = [] + term = term.toLowerCase() + if(enableLazyLoad) { + arr = source.value + }else if (!searchFn.value) { + source.value.forEach((item) => { + let cur = formatter.value(item) + cur = cur.toLowerCase() + if (cur.startsWith(term)) { + arr.push(item) + } + }) + } else { + arr = await searchFn.value(term) + } + searchList.value = arr + if(searchList.value.length==0){ + showNoResultItemTemplate.value=true + }else{ + showNoResultItemTemplate.value=false + } + } + const recentlyFocus = (latestSource:Array) => { + if(latestSource) { + searchList.value = latestSource + } + } + return { + handleSearch, + recentlyFocus, + searchList, + showNoResultItemTemplate + } +} diff --git a/packages/devui-vue/devui/auto-complete/src/composables/use-select-handle.ts b/packages/devui-vue/devui/auto-complete/src/composables/use-select-handle.ts new file mode 100644 index 0000000000..8261410c70 --- /dev/null +++ b/packages/devui-vue/devui/auto-complete/src/composables/use-select-handle.ts @@ -0,0 +1,25 @@ +import { ref, Ref, SetupContext } from 'vue'; +import { DefaultFuncType,FormatterType,HandleSearch } from '../auto-complete-types'; + +export default function useSelectHandle(ctx: SetupContext,searchList: Ref>, selectValue: Ref, handleSearch: HandleSearch,formatter: Ref,handleClose:DefaultFuncType): any { + const selectedIndex = ref(0) + const getListIndex = (item: string) => { + if (searchList.value.length == 0) { + return 0 + } + const ind = searchList.value.indexOf(item) + return ind == -1 ? 0 : ind + } + const selectOptionClick = async(item: any) => { + const cur = formatter.value(item) + ctx.emit('update:modelValue', cur) + handleClose() + await handleSearch(cur) + selectedIndex.value = getListIndex(cur) + selectValue.value && selectValue.value() + } + return { + selectedIndex, + selectOptionClick + } +} \ No newline at end of file diff --git a/packages/devui-vue/docs/components/auto-complete/index.md b/packages/devui-vue/docs/components/auto-complete/index.md new file mode 100644 index 0000000000..496d91a09f --- /dev/null +++ b/packages/devui-vue/docs/components/auto-complete/index.md @@ -0,0 +1,508 @@ +# AutoComplete 自动补全 + +联想用户可能需要的输入结果。 + +### 何时使用 + +当需要根据用户输入的部分字符推断出他可能想要输入的内容时。 + + +### 基本用法 +通过 source 设置自动完成的数据源。 +:::demo + +```vue + + + + + +``` + +::: + + +### 设置禁用 +通过 disabled 设置是否禁用。 +:::demo + +```vue + + + + + +``` + +::: + +### 自定义数据匹配方法 +通过 searchFn 自定义数据的匹配方法和返回的数据格式。 +:::demo + +```vue + + + + + +``` + +::: + +### 自定义模板展示 +通过 itemTemplate、noResultItemTemplate 自定义下拉框和无匹配提示。 +:::demo + +```vue + + + + + +``` + +::: + + +### 最近输入 + +通过 latestSource 设置最近输入。 + +:::demo + +```vue + + + + + +``` + +::: + + + +### 懒加载 +enableLazyLoad 开启懒加载 + +:::demo + +```vue + + + + + +``` + +::: + + +### d-auto-complete + +d-auto-complete 参数 + +| 参数 | 类型 | 默认 | 说明 | 跳转 Demo | 全局配置项 | +| :--------------------: | :-------------------------------------------------: | :----------------------------------------------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------: | :------------------------------------- | ---------- | +| source | `Array` | -- | 必选,有 searchFn 的情况下可以不必选 | [基本用法](#基本用法) | +| allowEmptyValueSearch | `boolean` | false | 可选,在绑定的输入框 value 为空时,是否进行搜索提示操作 | [基本用法](#基本用法) | +| appendToBody | `boolean` | false | 可选,下拉弹出是否 append to body | [基本用法](#基本用法) | +| appendToBodyDirections | `Object as PropType` | `{originX: 'left',originY: 'bottom',overlayX: 'left',overlayY: 'top',}` | 可选,指定下拉框与输入框的相对位置,ConnectionPosition 请参考 Overlay | [基本用法](#基本用法) | +| disabled | `boolean` | false | 可选,是否禁用指令 | [设置禁用](#设置禁用) | +| delay | `number` | 300 | 可选,只有在 delay 时间经过后并且未输入新值,才做搜索查询(`ms`) | [基本用法](#基本用法) | +| disabledKey | `string` | -- | 可选,禁用单个选项,当传入资源 source 选项类型为对象,比如设置为'disabled',则当对象的 disable 属性为 true 时,比如{ label: xxx, disabled: true },该选项将禁用 | [自定义数据匹配方法](#自定义数据匹配方法) | +| itemTemplate | `slot` | -- | 可选,自定义展示模板。slotProps:{ index: 下标索引, item: 当前项内容 }。 | [自定义模板展示](#自定义模板展示) | +| noResultItemTemplate | `slot` | -- | 可选,没有匹配项的展示结果。slotProps:输入内容。 | [自定义模板展示](#自定义模板展示) | +| formatter | `(item: any) => string` | [`defaultFormatter`](#defaultformatter) | 可选,格式化函数 | [自定义数据匹配方法](#自定义数据匹配方法) | +| isSearching | `boolean` | false | 可选,是否在搜索中,用于控制 searchingTemplate 是否显示 | [自定义数据匹配方法](#自定义数据匹配方法) | +| searchingTemplate | `slot` | -- | 可选,自定义搜索中显示模板。slotProps:输入内容。 | [自定义数据匹配方法](#自定义数据匹配方法) | +| sceneType | `string` | -- | 可选,值为 'select'、'suggest' | [启用懒加载](demo#auto-lazy-load) | +| searchFn | `(term: string) => Array` | [`defaultSearchFn`](#defaultsearchfn) | 可选,自定义搜索过滤 | [自定义数据匹配方法](#自定义数据匹配方法) | +| tipsText | `string` | '最近输入' | 可选,提示文字 | [设置禁用](demo#auto-disable) | +| latestSource | `Array` | -- | 可选, 最近输入 | [最近输入](demo#auto-latest) | +| valueParser | `(item: any) => any` | [`defaultValueParse`](#defaultvalueparse) | 可选, 对选中后数据进行处理 | [启用懒加载](demo#auto-lazy-load) | +| enableLazyLoad | `boolean` | false | 可选,是否允许懒加载 | [启用懒加载](demo#auto-lazy-load) | +| dAutoCompleteWidth | `number` | -- | 可选,调整宽度(`px`) |[基本用法](#基本用法) +| showAnimation | `boolean` | true | 可选,是否开启动画 | | ✔ | | | + +d-auto-complete 事件 + +| 参数 | 类型 | 说明 | 跳转 Demo | +| :-----------------: | :----------------------------------------------------------------------------------: | :------------------------------------------------------------------------------------------------------------------------------------: | :-------------------------------- | +| loadMore | `EventEmitter>` | 懒加载触发事件,配合`enableLazyLoad`使用,使用`$event.loadFinish()`关闭 loading 状态,其中\$event 为 AutoCompletePopupComponent 的实例 | [启用懒加载](demo#auto-lazy-load) | +| selectValue | `EventEmitter` | 可选,选择选项之后的回调函数 | [基本用法](#基本用法) | +| transInputFocusEmit | `EventEmitter` | 可选,Input focus 时回调函数 | [基本用法](#基本用法) | + + +# 接口 & 类型定义 + +### defaultSearchFn + +```ts +defaultSearchFn = (term) => { + return source.forEach((item)=>{ + let cur = formatter(item) + cur = cur.toLowerCase() + if(cur.startsWith(term)){ + arr.push(item) + } + }) + }; +``` +term 为输入的关键字。 + + +### defaultFormatter + +```ts +defaultFormatter = (item) => (item ? item.label || item.toString() : ''); +``` +item 为数据项。 + + +### defaultValueParse + +```ts +defaultValueParse = (item) => item; +``` +item 为数据项。 diff --git a/packages/devui-vue/docs/en-US/components/auto-complete/api-en.md b/packages/devui-vue/docs/en-US/components/auto-complete/api-en.md new file mode 100644 index 0000000000..ad5f795303 --- /dev/null +++ b/packages/devui-vue/docs/en-US/components/auto-complete/api-en.md @@ -0,0 +1,508 @@ +# AutoComplete + +Guess what users may need when entering. + +### When To Use + +When you need to deduce the content that a user may want to enter according to some characters entered by the user. + + +### Basic usage +Set source to the data source that is automatically completed. +:::demo + +```vue + + + + + +``` + +::: + + +### Disabled +You can set the disabled parameter to disable it in the text box and disable the options in the drop-down list box by using the disabledKey parameter. +:::demo + +```vue + + + + + +``` + +::: + +### Customized data matching method +You can use searchFn to customize the data matching method and the returned data format. +:::demo + +```vue + + + + + +``` + +::: + +### Customized template display +Use itemTemplate and noResultItemTemplate to customize the drop-down list box and display no matching message. +:::demo + +```vue + + + + + +``` + +::: + + +### Latest input + +Set latestSource to the latest input. + +:::demo + +```vue + + + + + +``` + + +::: + +### Enable lazy load + +enableLazyLoad: enables lazy loading. + +:::demo + +```vue + + + + + +``` + +::: + + + +### d-auto-complete + +d-auto-complete Parameter + +| Parameter | Type | Default | Description | Jump to Demo | Global Config | +| :--------------------: | :-------------------------------------------------: | :----------------------------------------------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------: | :------------------------------------- | ---------- | +| source | `Array` | -- | Required. This parameter is optional if searchFn is specified. | [Basic usage](#Basic usage) | +| allowEmptyValueSearch | `boolean` | false | Optional. indicates whether to display a search message when the bound text box value is empty. | [Basic usage](#Basic usage) | +| appendToBody | `boolean` | false | Optional. Whether to append to body is displayed in the drop-down list box. | [Basic usage](#Basic usage) | +| appendToBodyDirections | `Object as PropType` | `{originX: 'left',originY: 'bottom',overlayX: 'left',overlayY: 'top',}` | Optional. Specify the relative position of the drop-down box and the input box. for details about ConnectionPosition, see Overlay | [Basic usage](#Basic usage) | +| disabled | `boolean` | false | Optional. Indicating whether to disable commands. | [Disabled](#Disabled) | +| delay | `number` | 300 | Optional. The search is performed only after the delay time elapses and a new value is entered. (ms) | [Basic usage](#Basic usage) | +| disabledKey | `string` | -- | Optional. Disable a single option. If the input resource source option type is an object, for example, disabled, and the disable attribute of the object is true, for example, {label: xxx, disabled: true}, this option will be disabled | [Customized data matching method](#Customized data matching method) | +| itemTemplate | `slot` | -- | Optional. Customized display template | [Customized template display](#Customized template display) | +| noResultItemTemplate | `slot` | -- | Optional. No matching item is displayed. | [Customized template display](#Customized template display) | +| formatter | `(item: any) => string` | [`defaultFormatter`](#defaultformatter) | Optional. Formatting function | [Customized data matching method](#Customized data matching method) | +| isSearching | `boolean` | false | Optional. indicating whether the search template is displayed. | [Customized data matching method](#Customized data matching method) | +| searchingTemplate | `slot` | -- | Optional. The template is displayed in customized search. | [Customized data matching method](#Customized data matching method) | +| sceneType | `string` | -- | Optional. The value can be select or suggestion. | [Enable lazy load](demo#auto-lazy-load) | +| searchFn | `(term: string) => Array` | [`defaultSearchFn`](#defaultsearchfn) | Optional. Customized search filtering | [Customized data matching method](#Customized data matching method) | +| tipsText | `string` | 'Latest input' | Optional. prompt text | [Disabled](demo#auto-disable) | +| latestSource | `Array` | -- | Optional. Latest input | [Latest input](demo#auto-latest) | +| valueParser | `(item: any) => any` | [`defaultValueParse`](#defaultvalueparse) | (optional) Process selected data | [Enable lazy load](demo#auto-lazy-load) | +| enableLazyLoad | `boolean` | false | Optional. Whether lazy loading is allowed | [Enable lazy load](demo#auto-lazy-load) | +| dAutoCompleteWidth | `number` | -- | Optional. Adjust the width (px) |[Basic usage](#Basic usage) +| showAnimation | `boolean` | true | optional. Whether to enable animation. | | ✔ | | | + +dAutoComplete Event + +| Parameter | Type | Description | Jump to Demo | +| :-----------------: | :----------------------------------------------------------------------------------: | :------------------------------------------------------------------------------------------------------------------------------------: | :-------------------------------- | +| loadMore | `EventEmitter>` | : optional. It is a lazy loading trigger event. It is used together with enableLazyLoad. `$event.loadFinish()` is used to disable the loading status. $event is the instance of the pop-up component AutoCompletePopupComponent | [Enable lazy load](demo#auto-lazy-load) | +| selectValue | `EventEmitter` | (optional), callback function after selecting an option数 | [Basic usage](#Basic usage) | +| transInputFocusEmit | `EventEmitter` | (optional). Callback function for input focus | [Basic usage](#Basic usage) | + + +# Interface & Type Definition + +### defaultSearchFn + +```ts +defaultSearchFn = (term) => { + return source.forEach((item)=>{ + let cur = formatter(item) + cur = cur.toLowerCase() + if(cur.startsWith(term)){ + arr.push(item) + } + }) + }; +``` +term indicates the entered keyword. + + +### defaultFormatter + +```ts +defaultFormatter = (item) => (item ? item.label || item.toString() : ''); +``` +item indicates a data item. + + +### defaultValueParse + +```ts +defaultValueParse = (item) => item; +``` +item indicates a data item.