diff --git a/packages/devui-vue/devui/editable-select/__tests__/editable-select.spec.ts b/packages/devui-vue/devui/editable-select/__tests__/editable-select.spec.ts index aa694a308b..2a754c3677 100644 --- a/packages/devui-vue/devui/editable-select/__tests__/editable-select.spec.ts +++ b/packages/devui-vue/devui/editable-select/__tests__/editable-select.spec.ts @@ -1,8 +1,287 @@ import { mount } from '@vue/test-utils'; +import { reactive, ref } from 'vue'; import { EditableSelect } from '../index'; +const createData = (len = 5) => { + return reactive( + Array.from({ length: len }).map((_, index) => { + return { + label: `label${index}`, + value: index + }; + }) + ); +}; describe('editable-select test', () => { - it('editable-select init render', async () => { - // todo - }) -}) + afterEach(() => { + document.body.innerHTML = ''; + }); + + test('create', () => { + const wrapper = mount(EditableSelect); + + expect(wrapper.find('.devui-editable-select').exists()).toBe(true); + }); + + test('should render correctly', async () => { + const wrapper = mount({ + components: { + 'editable-select': EditableSelect + }, + template: ``, + setup() { + const value = ''; + const options = createData(); + return { + value, + options + }; + } + }); + + const input = wrapper.find('input'); + + expect(wrapper.get('.devui-dropdown-item').isVisible()).toBe(false); + + await input.trigger('click'); + + expect(wrapper.get('.devui-dropdown-item').isVisible()).toBe(true); + expect(wrapper.classes()).toContain('devui-select-open'); + + const options = wrapper.element.querySelectorAll('.devui-dropdown-item'); + + expect(options.length).toBe(5); + }); + + test('select on click ', async () => { + const wrapper = mount({ + components: { + 'editable-select': EditableSelect + }, + template: ``, + setup() { + const value = ref(''); + const options = createData(); + return { + value, + options + }; + } + }); + const options = wrapper.findAll('.devui-dropdown-item'); + + await options[0].trigger('click'); + + expect(wrapper.find('input').element.value).toBe('label0'); + }); + + test('disabled select', async () => { + const wrapper = mount({ + components: { + 'editable-select': EditableSelect + }, + template: `` + }); + expect(wrapper.find('input').element.disabled).toBe(true); + }); + + test('disabled option', async () => { + const wrapper = mount({ + components: { + 'editable-select': EditableSelect + }, + template: ``, + setup() { + const value = ref(''); + const options = reactive([ + { + label: 'label0', + value: 0 + }, + { + label: 'label1', + value: 1, + disabled: true + }, + { + label: 'label2', + value: 2, + disabled: false + } + ]); + return { + value, + options + }; + } + }); + const options = wrapper.findAll('.devui-dropdown-item'); + + expect(options[1].classes()).toContain('disabled'); + + await options[1].trigger('click'); + + expect(wrapper.find('input').element.value).toBe(''); + + await options[2].trigger('click'); + + expect(wrapper.find('input').element.value).toBe('label2'); + }); + + test('search', async () => { + const handleSearch = jest.fn(); + const wrapper = mount({ + components: { + 'editable-select': EditableSelect + }, + template: ``, + setup() { + const value = ref(''); + const options = createData(); + return { + value, + options, + handleSearch + }; + } + }); + const input = wrapper.find('input'); + await input.setValue('label'); + await input.trigger('input'); + expect(handleSearch).toBeCalled(); + }); + + test('filter option', async () => { + const wrapper = mount({ + components: { + 'editable-select': EditableSelect + }, + template: ``, + setup() { + const value = ref(''); + const options = createData(); + return { + value, + options + }; + } + }); + const input = wrapper.find('input'); + await input.setValue('label0'); + await input.trigger('input'); + expect(wrapper.findAll('.devui-dropdown-item').length).toBe(1); + }); + + test('custom filter options', async () => { + const filterOption = jest.fn(); + const wrapper = mount({ + components: { + 'editable-select': EditableSelect + }, + template: ``, + setup() { + const value = ref(''); + const options = createData(); + return { + value, + options, + filterOption + }; + } + }); + const input = wrapper.find('input'); + await input.setValue('label0'); + await input.trigger('input'); + expect(filterOption).toBeCalled(); + }); + + test('render slot', async () => { + const wrapper = mount({ + components: { + 'editable-select': EditableSelect + }, + template: ` + + + `, + setup() { + const value = ref(''); + const options = createData(); + return { + value, + options + }; + } + }); + const input = wrapper.find('input'); + const options = wrapper.findAll('.devui-dropdown-item'); + expect(options.length).toBe(5); + await input.setValue('aaa'); + await input.trigger('input'); + expect(wrapper.find('#noResultItemTemplate').exists()).toBe(true); + }); + + test('load more ', async () => { + const loadmore = jest.fn(); + 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); + }; + const wrapper = mount({ + components: { + 'editable-select': EditableSelect + }, + template: ``, + setup() { + const value = ref(''); + const options = createData(20); + return { + value, + options, + handleLoad: loadmore + }; + } + }); + const ul = wrapper.find('.devui-list-unstyled'); + await makeScroll(ul.element, 'scrollTop', 300); + expect(loadmore).toBeCalled(); + }); + test('keyboard operations', async () => { + const wrapper = mount({ + components: { + 'editable-select': EditableSelect + }, + template: ``, + setup() { + const value = ref(''); + const options = createData(); + return { + value, + options + }; + } + }); + const input = wrapper.find('input'); + await input.trigger('click'); + await input.trigger('keydown', { key: 'ArrowDown' }); + await input.trigger('keydown', { key: 'ArrowDown' }); + await input.trigger('keydown', { key: 'Enter' }); + expect(input.element.value).toBe('label2'); + }); +}); diff --git a/packages/devui-vue/devui/editable-select/index.ts b/packages/devui-vue/devui/editable-select/index.ts index de0ccd55ba..3468c0868e 100644 --- a/packages/devui-vue/devui/editable-select/index.ts +++ b/packages/devui-vue/devui/editable-select/index.ts @@ -1,16 +1,17 @@ -import type { App } from 'vue' -import EditableSelect from './src/editable-select' +import type { App } from 'vue'; +import EditableSelect from './src/editable-select'; + EditableSelect.install = function (app: App): void { - app.component(EditableSelect.name, EditableSelect) -} + app.component(EditableSelect.name, EditableSelect); +}; -export { EditableSelect } +export { EditableSelect }; export default { title: 'EditableSelect 可输入下拉选择框', category: '数据录入', - status: '10%', // TODO: 组件若开发完成则填入"已完成",并删除该注释 + status: '100%', install(app: App): void { - app.use(EditableSelect as any) + app.use(EditableSelect as any); } -} +}; diff --git a/packages/devui-vue/devui/editable-select/src/components/dropdown.tsx b/packages/devui-vue/devui/editable-select/src/components/dropdown.tsx deleted file mode 100644 index 7f1f029bad..0000000000 --- a/packages/devui-vue/devui/editable-select/src/components/dropdown.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { defineComponent, inject } from 'vue' -import { OptionItem, selectDropdownProps } from '../editable-select-types' -import { className } from '../utils' -export default defineComponent({ - name: 'DSelectDropdown', - props: selectDropdownProps, - setup(props) { - const select = inject('InjectionKey') as any - const { - props: selectProps, - dropdownRef, - visible, - selectOptionClick, - renderDefaultSlots, - renderEmptySlots, - selectedIndex, - hoverIndex, - loadMore - } = select - const { maxHeight } = selectProps - return () => { - const getLiCls = (item: OptionItem, index: number) => { - const { disabledKey } = selectProps - return className('devui-dropdown-item', { - disabled: disabledKey ? !!item[disabledKey] : false, - selected: selectedIndex.value === index, - 'devui-dropdown-bg': hoverIndex.value === index - }) - } - return ( -
- -
- ) - } - } -}) diff --git a/packages/devui-vue/devui/editable-select/src/composable/use-keyboard-select.ts b/packages/devui-vue/devui/editable-select/src/composable/use-keyboard-select.ts deleted file mode 100644 index 6f42ce891d..0000000000 --- a/packages/devui-vue/devui/editable-select/src/composable/use-keyboard-select.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { ComputedRef, nextTick, Ref } from 'vue' -import { OptionItem } from '../editable-select-types' -interface KeyboardSelectReturnType { - handleKeydown: (e: KeyboardEvent) => void -} - -export default function keyboardSelect( - dropdownRef: Ref, - visible: Ref, - hoverIndex: Ref, - selectedIndex: Ref, - filteredOptions: ComputedRef, - toggleMenu: () => void, - selectOptionClick: (e: KeyboardEvent, item: OptionItem) => void -): KeyboardSelectReturnType { - const updateHoverIndex = (index: number) => { - hoverIndex.value = index - } - const scrollToActive = (index: number) => { - const dropdownVal = dropdownRef.value - const li = dropdownVal.children[index] - - nextTick(() => { - if (li.scrollIntoViewIfNeeded) { - li.scrollIntoViewIfNeeded(false) - } else { - const containerInfo = dropdownVal.getBoundingClientRect() - const elementInfo = li.getBoundingClientRect() - if (elementInfo.bottom > containerInfo.bottom || elementInfo.top < containerInfo.top) { - li.scrollIntoView(false) - } - } - }) - } - const onKeyboardSelect = (e: KeyboardEvent) => { - const option = filteredOptions.value[hoverIndex.value] - selectOptionClick(e, option) - hoverIndex.value = selectedIndex.value - } - const handleKeydown = (e: KeyboardEvent) => { - const keyCode = e.key || e.code - let index = 0 - if (!visible.value) { - toggleMenu() - } - - if (keyCode === 'Backspace') { - return - } - - if (keyCode === 'ArrowUp') { - index = hoverIndex.value - 1 - if (index < 0) { - index = filteredOptions.value.length - 1 - } - } else if (keyCode === 'ArrowDown') { - index = hoverIndex.value + 1 - if (index > filteredOptions.value.length - 1) { - index = 0 - } - } - - if (keyCode === 'Enter') { - return onKeyboardSelect(e) - } - updateHoverIndex(index) - scrollToActive(index) - } - - return { - handleKeydown - } -} diff --git a/packages/devui-vue/devui/editable-select/src/composables/use-filter-options.ts b/packages/devui-vue/devui/editable-select/src/composables/use-filter-options.ts new file mode 100644 index 0000000000..87c2e3514a --- /dev/null +++ b/packages/devui-vue/devui/editable-select/src/composables/use-filter-options.ts @@ -0,0 +1,28 @@ +import { computed, Ref, ComputedRef } from 'vue'; +import { OptionObjectItem } from '../editable-select-type'; + +const getFilterFunc = () => (val: string, option: OptionObjectItem) => + option.label.toLocaleLowerCase().indexOf(val.toLocaleLowerCase()) > -1; + +export const userFilterOptions: ( + normalizeOptions: ComputedRef, + inputValue: Ref, + filteredOptions: boolean | ((val: string, option: OptionObjectItem) => boolean) +) => ComputedRef = (normalizeOptions, inputValue, filterOption) => + computed(() => { + const filteredOptions: OptionObjectItem[] = []; + + if (!inputValue.value || filterOption === false) { + return normalizeOptions.value; + } + + const filterFunc = typeof filterOption === 'function' ? filterOption : getFilterFunc(); + + normalizeOptions.value.forEach((option) => { + if (filterFunc(inputValue.value, option)) { + filteredOptions.push(option); + } + }); + + return filteredOptions; + }); diff --git a/packages/devui-vue/devui/editable-select/src/composables/use-input.ts b/packages/devui-vue/devui/editable-select/src/composables/use-input.ts new file mode 100644 index 0000000000..d553ad362a --- /dev/null +++ b/packages/devui-vue/devui/editable-select/src/composables/use-input.ts @@ -0,0 +1,22 @@ +import { SetupContext, Ref } from 'vue'; +interface userInputReturnType { + handleInput: (event: Event) => void +} +export const useInput: (inputValue: Ref, ctx: SetupContext) => userInputReturnType = ( + inputValue, + ctx +) => { + const onInputChange = (value: string) => { + ctx.emit('search', value); + }; + + const handleInput = (event: Event) => { + const value = (event.target as HTMLInputElement).value; + inputValue.value = value; + onInputChange(value); + }; + + return { + handleInput + }; +}; diff --git a/packages/devui-vue/devui/editable-select/src/composables/use-keyboard-select.ts b/packages/devui-vue/devui/editable-select/src/composables/use-keyboard-select.ts new file mode 100644 index 0000000000..45e6760d36 --- /dev/null +++ b/packages/devui-vue/devui/editable-select/src/composables/use-keyboard-select.ts @@ -0,0 +1,110 @@ +import { ComputedRef, nextTick, Ref } from 'vue'; +import { OptionObjectItem } from '../editable-select-type'; + +interface useKeyboardSelectReturnType { + handleKeydown: (event: KeyboardEvent) => void +} +export const useKeyboardSelect: ( + dropdownRef: Ref, + disabled: string, + visible: Ref, + hoverIndex: Ref, + selectedIndex: Ref, + options: ComputedRef, + toggleMenu: () => void, + closeMenu: () => void, + handleClick: (options: OptionObjectItem) => void +) => useKeyboardSelectReturnType = ( + dropdownRef, + disabled, + visible, + hoverIndex, + selectedIndex, + options, + toggleMenu, + closeMenu, + handleClick +) => { + const updateHoveringIndex = (index: number) => { + hoverIndex.value = index; + }; + const scrollToItem = (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 onKeyboardNavigation = (direction: string, newIndex?: number) => { + if (!newIndex) { + newIndex = hoverIndex.value; + } + if (!['ArrowDown', 'ArrowUp'].includes(direction)) return; + if (direction === 'ArrowUp') { + if (newIndex === 0) { + newIndex = options.value.length - 1; + scrollToItem(newIndex); + updateHoveringIndex(newIndex); + return; + } + newIndex = newIndex - 1; + } else if (direction === 'ArrowDown') { + if (newIndex === options.value.length - 1) { + newIndex = 0; + scrollToItem(newIndex); + updateHoveringIndex(newIndex); + return; + } + newIndex = newIndex + 1; + } + + const option = options.value[newIndex]; + if (option[disabled]) { + return onKeyboardNavigation(direction, newIndex); + } + scrollToItem(newIndex); + updateHoveringIndex(newIndex); + }; + + const handleKeydown = (event: KeyboardEvent) => { + const keyCode = event.key || event.code; + + if (options.value.length === 0) return; + + if (!visible.value) { + return toggleMenu(); + } + + const onKeydownEnter = () => { + handleClick(options.value[hoverIndex.value]); + closeMenu(); + }; + + const onKeydownEsc = () => { + closeMenu(); + }; + + switch (keyCode) { + case 'Enter': + onKeydownEnter(); + break; + case 'Escape': + onKeydownEsc(); + break; + default: + onKeyboardNavigation(keyCode); + } + }; + return { + handleKeydown + }; +}; diff --git a/packages/devui-vue/devui/editable-select/src/composables/use-lazy-load.ts b/packages/devui-vue/devui/editable-select/src/composables/use-lazy-load.ts new file mode 100644 index 0000000000..2580eaf9ae --- /dev/null +++ b/packages/devui-vue/devui/editable-select/src/composables/use-lazy-load.ts @@ -0,0 +1,25 @@ +import { Ref } from 'vue'; +import { OptionObjectItem } from '../editable-select-type'; + +interface useLazyLoadReturenType { + loadMore: () => void +} +export const useLazyLoad: ( + dropdownRef: Ref, + inputValue: Ref, + filterOtion: boolean | ((val: string, option: OptionObjectItem) => boolean), + load: (val: string) => void +) => useLazyLoadReturenType = (dropdownRef, inputValue, filterOtion, load) => { + const loadMore = () => { + if (filterOtion !== false) return; + + if ( + dropdownRef.value.clientHeight + dropdownRef.value.scrollTop >= + dropdownRef.value.scrollHeight + ) { + load(inputValue.value); + } + }; + + return { loadMore }; +}; diff --git a/packages/devui-vue/devui/editable-select/src/editable-select-type.ts b/packages/devui-vue/devui/editable-select/src/editable-select-type.ts new file mode 100644 index 0000000000..87a4508326 --- /dev/null +++ b/packages/devui-vue/devui/editable-select/src/editable-select-type.ts @@ -0,0 +1,7 @@ +export type OptionsType = Array; +export type OptionType = string | number | OptionObjectItem; +export interface OptionObjectItem { + label: string + value: string | number + [key: string]: any +} 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 2fb55f9525..8ee593c50d 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 @@ -1,74 +1,47 @@ -import type { PropType, ExtractPropTypes } from 'vue' -type HorizontalConnectionPos = 'left' | 'center' | 'right'; -type VerticalConnectionPos = 'top' | 'center' | 'bottom'; - -export interface ConnectionPosition { - originX: HorizontalConnectionPos - originY: VerticalConnectionPos - overlayX: HorizontalConnectionPos - overlayY: VerticalConnectionPos -} -export interface OptionItem { - name: string - [key: string]: any -} -export type Options = Array +import type { PropType, ExtractPropTypes } from 'vue'; +import { OptionObjectItem, OptionsType } from './editable-select-type'; export const editableSelectProps = { + /* test: { + type: Object as PropType<{ xxx: xxx }> + } */ appendToBody: { - type: Boolean, - default: false - }, - modelValue: { - type: [String, Number] as PropType + type: Boolean }, options: { - type: Array as PropType, + type: Array as PropType, default: () => [] }, - width: { - type: Number, - default: 450 - }, - maxHeight: { - type: Number - }, disabled: { - type: Boolean, - default: false - }, - disabledKey: { - type: String, - }, - remote: { - type: Boolean, - default: false + type: Boolean }, loading: { type: Boolean }, - enableLazyLoad: { - type: Boolean, - default: false + optionDisabledKey: { + type: String, + default: '' + }, + placeholder: { + type: String, + default: 'Search' }, - remoteMethod: { - type: Function as PropType<(inputValue: string) => Array> + modelValue: { + type: String + }, + width: { + type: Number }, - filterMethod: { - type: Function as PropType<(inputValue: string) => Array> + maxHeight: { + type: Number }, - searchFn: { - type: Function as PropType<(term: string) => Array>, + filterOption: { + type: [Function, Boolean] as PropType< + boolean | ((input: string, option: OptionObjectItem) => boolean) + > }, loadMore: { - type: Function as PropType<() => Array> + type: Function as PropType<(val: string) => void> } -} as const +} as const; -export const selectDropdownProps = { - options: { - type: Array as PropType, - default: () => [] - } -} as const -export type EditableSelectProps = ExtractPropTypes -export type SelectDropdownProps = ExtractPropTypes \ No newline at end of file +export type EditableSelectProps = ExtractPropTypes; diff --git a/packages/devui-vue/devui/editable-select/src/editable-select.scss b/packages/devui-vue/devui/editable-select/src/editable-select.scss index b064b86b46..ae067b20c3 100644 --- a/packages/devui-vue/devui/editable-select/src/editable-select.scss +++ b/packages/devui-vue/devui/editable-select/src/editable-select.scss @@ -2,61 +2,36 @@ @import '../../style/core/animation'; .devui-editable-select { - .devui-form-group { - input::-ms-clear { - display: none; - } - - ul.devui-list-unstyled { - margin: 0; - overflow-y: auto; - padding: 0; - } - - .devui-dropdown-bg { - background: $devui-list-item-hover-bg; - } - - .devui-popup-tips { - color: $devui-text-weak; - padding: 4px 12px; - } - - .devui-form-control { - outline: none; - padding-right: 24px; - } + .devui-select-chevron-icon { + display: inline-flex; + vertical-align: middle; + transition: transform $devui-animation-duration-slow $devui-animation-ease-in-out-smooth; } - .devui-select-open { - .devui-select-chevron-icon { - transform: rotate(180deg); - - svg path { - fill: $devui-text-weak; - } - } + input::-ms-clear { + display: none; } - .devui-form-control-feedback { - .devui-select-chevron-icon { - display: inline-flex; - vertical-align: middle; - transition: transform $devui-animation-duration-slow $devui-animation-ease-in-out-smooth; - } + .devui-no-data-tip { + user-select: none; + cursor: not-allowed; } - .devui-has-feedback > .devui-form-control-feedback { - line-height: 26px; + .devui-form-control { + outline: none; + padding-right: 24px; } - .devui-dropdown-bg.devui-dropdown-bg { - background-color: inherit; - } - // 下拉部分 .devui-dropdown-menu { width: 100%; display: block; + // TODO: 全局样式被污染,暂时只能这么写 + top: auto !important; + left: auto !important; + } + + .devui-dropdown-menu-cdk { + position: static; } .devui-dropdown-item { @@ -102,7 +77,8 @@ background-color: $devui-unavailable; } } - // 选项disabled + + /* 选项disabled */ .devui-dropdown-item.disabled, .devui-dropdown-item.disabled:hover { cursor: not-allowed; @@ -125,10 +101,30 @@ } } -.devui-dropdown { +.devui-editable-select.devui-select-open { + .devui-select-chevron-icon { + transform: rotate(180deg); + } + .devui-select-chevron-icon svg path { + fill: $devui-text-weak; + } +} +.devui-editable-select.devui-form-group.devui-has-feedback > .devui-form-control-feedback { + line-height: 26px; +} +.devui-editable-select-dropdown { .devui-dropdown-menu { width: 100%; display: block; + width: 100%; + display: block; + // TODO: 全局样式被污染,暂时只能这么写 + top: auto !important; + left: auto !important; + } + + .devui-dropdown-menu-cdk { + position: static; } .devui-dropdown-item { @@ -174,7 +170,8 @@ background-color: $devui-unavailable; } } - // 选项disabled + + /* 选项disabled */ .devui-dropdown-item.disabled, .devui-dropdown-item.disabled:hover { cursor: not-allowed; @@ -192,7 +189,7 @@ } .devui-popup-tips { - color: $devui-text-weak; + color: $devui-text-weak; // TODO: Color-Question padding: 4px 12px; } } 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 b137d25b9c..b9dec3c05e 100644 --- a/packages/devui-vue/devui/editable-select/src/editable-select.tsx +++ b/packages/devui-vue/devui/editable-select/src/editable-select.tsx @@ -1,238 +1,232 @@ import { defineComponent, - Transition, - ref, + withModifiers, computed, + ref, + Transition, + SetupContext, reactive, - toRefs, - provide, - renderSlot -} from 'vue' -import { - OptionItem, - editableSelectProps, - EditableSelectProps, - ConnectionPosition -} from './editable-select-types' -import SelectDropdown from './components/dropdown' -import './editable-select.scss' -import ClickOutside from '../../shared/devui-directive/clickoutside' -import { debounce } from 'lodash' -import { className } from './utils' -import keyboardSelect from './composable/use-keyboard-select' + watch +} from 'vue'; +import { editableSelectProps, EditableSelectProps } from './editable-select-types'; +import clickOutside from '../../shared/devui-directive/clickoutside'; +import { className } from '../src/utils/index'; +import './editable-select.scss'; +import { OptionObjectItem } from './editable-select-type'; +import { userFilterOptions } from './composables/use-filter-options'; +import { useInput } from './composables/use-input'; +import { useLazyLoad } from './composables/use-lazy-load'; +import { useKeyboardSelect } from './composables/use-keyboard-select'; export default defineComponent({ name: 'DEditableSelect', - directives: { ClickOutside }, + directives: { + clickOutside + }, props: editableSelectProps, - emits: ['update:modelValue'], - setup(props: EditableSelectProps, ctx) { - const renderDropdown = (condition: boolean, type: number) => { - if (!condition && type === 0) { - return ( - - - - ) - } else if (condition && type === 1) { + emits: ['update:modelValue', 'search', 'loadMore'], + setup(props: EditableSelectProps, ctx: SetupContext) { + const getItemCls = (option: OptionObjectItem, index: number) => { + const { optionDisabledKey: disabledKey } = props; + return className('devui-dropdown-item', { + disabled: disabledKey ? !!option[disabledKey] : false, + selected: index === selectIndex.value, + 'devui-dropdown-bg': index === hoverIndex.value + }); + }; + // 渲染下拉列表,根据appendToBody属性判断是否渲染在body下 + const renderDropdown = () => { + if (props.appendToBody) { return (
- +
+
    + {filteredOptions.value.map((option, index) => { + return ( +
  • { + e.stopPropagation(); + handleClick(option); + }} + > + {ctx.slots.itemTemplate ? ctx.slots.itemTemplate(option) : option.label} +
  • + ); + })} +
  • +
    {emptyText.value}
    +
  • +
+
- ) + ); + } else { + return ( + +
+
    + {filteredOptions.value.map((option, index) => { + return ( +
  • { + e.stopPropagation(); + handleClick(option); + }} + > + {ctx.slots.itemTemplate ? ctx.slots.itemTemplate(option) : option.label} +
  • + ); + })} +
  • +
    {emptyText.value}
    +
  • +
+
+
+ ); } - } - - const renderDefaultSlots = (item) => { - return ctx.slots.default ? renderSlot(ctx.slots, 'default', { item }) : item.name - } + }; + //Ref + const dopdownRef = ref(); + const origin = ref(); - const renderEmptySlots = () => { - return ctx.slots.empty ? renderSlot(ctx.slots, 'empty') : emptyText.value - } - - const origin = ref() - const dropdownRef = ref() - const visible = ref(false) - const inputValue = ref('') - const selectedIndex = ref(0) - const hoverIndex = ref(0) - const query = ref(props.modelValue) - const position = reactive({ + const position = reactive({ originX: 'left', originY: 'bottom', overlayX: 'left', overlayY: 'top' - }) - const wait = computed(() => (props.remote ? 300 : 0)) - - const emptyText = computed(() => { - const options = filteredOptions.value - if (!props.remote && options.length === 0) { - return '没有相关记录' - } - if (options.length === 0) { - return '没有数据' - } - return null - }) + }); + const visible = ref(false); + const inputValue = ref(props.modelValue); + const hoverIndex = ref(0); + const selectIndex = ref(0); + //标准化options,统一处理成[{}]的形式 const normalizeOptions = computed(() => { - let options: OptionItem - const { disabledKey } = props - disabledKey ? disabledKey : 'disabled' - return props.options.map((item) => { - if (typeof item !== 'object') { - options = { - name: item - } - return options + return props.options.map((option) => { + if (typeof option === 'object') { + return { + label: option.label ? option.label : option.value, + value: option.value, + ...option + }; } - return item - }) - }) + return { + label: option + '', + value: option + }; + }); + }); + //非远程搜索的情况下对数组进行过滤 + const filteredOptions = userFilterOptions(normalizeOptions, inputValue, props.filterOption); - const filteredOptions = computed(() => { - const isValidOption = (o: OptionItem) => { - const query = inputValue.value - const containsQueryString = query - ? o.name.toLocaleLowerCase().indexOf(query.toLocaleLowerCase()) >= 0 - : true - return containsQueryString + const emptyText = computed(() => { + let text: string; + if (props.filterOption !== false && !filteredOptions.value.length) { + text = '找不到相关记录'; + } else if (props.filterOption === false && !filteredOptions.value.length) { + text = '没有数据'; } - return normalizeOptions.value - .map((item) => { - if (props.remote || isValidOption(item)) { - return item - } - return null - }) - .filter((item) => item !== null) - }) - const findIndex = (o: OptionItem) => { - return normalizeOptions.value.findIndex((item) => { - return item.name === o.name - }) - } - - const handleClose = () => { - visible.value = false - } + return ctx.slots.noResultItemTemplate ? ctx.slots.noResultItemTemplate() : text; + }); + //下拉列表显影切换 const toggleMenu = () => { - if (!props.disabled) { - visible.value = !visible.value - } - } - - const onInputChange = (val: string) => { - if (props.filterMethod) { - props.filterMethod(val) - } else if (props.remote) { - props.remoteMethod(val) - } - } + visible.value = !visible.value; + }; - const debouncedOnInputChange = debounce(onInputChange, wait.value) + const closeMenu = () => { + visible.value = false; + }; + // 懒加载 + const { loadMore } = useLazyLoad(dopdownRef, inputValue, props.filterOption, props.loadMore); - const handleInput = (event) => { - const value = event.target.value - inputValue.value = value - query.value = value - if (props.remote) { - debouncedOnInputChange(value) - } else { - onInputChange(value) - } - } + //输入框变化后的逻辑 + const { handleInput } = useInput(inputValue, ctx); - const selectOptionClick = (e, item: OptionItem) => { - const { disabledKey } = props - if (disabledKey && item[disabledKey]) { - e.stopPropagation() - } else { - query.value = item.name - selectedIndex.value = findIndex(item) - inputValue.value = '' - ctx.emit('update:modelValue', item.name) - visible.value = false - } - } - - const loadMore = () => { - if (!props.enableLazyLoad) return - const dropdownVal = dropdownRef.value - if (dropdownVal.clientHeight + dropdownVal.scrollTop >= dropdownVal.scrollHeight) { - props.loadMore() - } - } - const { handleKeydown } = keyboardSelect( - dropdownRef, + const handleClick = (option: OptionObjectItem) => { + const { optionDisabledKey: disabledKey } = props; + if (disabledKey && !!option[disabledKey]) return; + ctx.emit('update:modelValue', option.label); + closeMenu(); + }; + // 键盘选择 + const { handleKeydown } = useKeyboardSelect( + dopdownRef, + props.optionDisabledKey, visible, hoverIndex, - selectedIndex, + selectIndex, filteredOptions, toggleMenu, - selectOptionClick - ) + closeMenu, + handleClick + ); - provide('InjectionKey', { - dropdownRef, - props: reactive({ - ...toRefs(props) - }), - visible, - emptyText, - selectedIndex, - hoverIndex, - loadMore, - selectOptionClick, - renderDefaultSlots, - renderEmptySlots - }) + watch( + () => props.modelValue, + (newVal) => { + inputValue.value = newVal; + } + ); return () => { const selectCls = className('devui-editable-select devui-form-group devui-has-feedback', { - 'devui-select-open': visible.value - }) - const inputCls = className( - 'devui-form-control devui-dropdown-origin devui-dropdown-origin-open', - { - disabled: props.disabled - } - ) - + 'devui-select-open': visible.value === true + }); return ( - <> -
- - + {renderDropdown()} +
+ ); + }; } -}) +}); diff --git a/packages/devui-vue/devui/editable-select/src/utils/index.ts b/packages/devui-vue/devui/editable-select/src/utils/index.ts index 91a72c870f..8b81777a72 100644 --- a/packages/devui-vue/devui/editable-select/src/utils/index.ts +++ b/packages/devui-vue/devui/editable-select/src/utils/index.ts @@ -1,31 +1,19 @@ -import { VNode } from 'vue'; - /** * 动态获取class字符串 * @param classStr 是一个字符串,固定的class名 * @param classOpt 是一个对象,key表示class名,value为布尔值,true则添加,否则不添加 * @returns 最终的class字符串 */ -export function className( + export function className( classStr: string, classOpt?: { [key: string]: boolean; } -): string { + ): string { let classname = classStr; if (typeof classOpt === 'object') { - Object.keys(classOpt).forEach((key) => { - classOpt[key] && (classname += ` ${key}`); - }); + Object.keys(classOpt).forEach((key) => { + classOpt[key] && (classname += ` ${key}`); + }); } - + return classname; -} -/** - * - * @param condition 渲染条件 - * @param node1 待渲染的组件 - * @param node2 - * @returns 最终被渲染的组件 - */ -export function renderCondition(condition: unknown, node1: VNode, node2?: VNode): VNode { - return !!condition ? node1 : node2; -} \ No newline at end of file + } \ No newline at end of file diff --git a/packages/devui-vue/devui/shared/devui-directive/clickoutside.ts b/packages/devui-vue/devui/shared/devui-directive/clickoutside.ts index 18be402099..bc4bf7e41c 100644 --- a/packages/devui-vue/devui/shared/devui-directive/clickoutside.ts +++ b/packages/devui-vue/devui/shared/devui-directive/clickoutside.ts @@ -5,30 +5,30 @@ *
*/ -import { inBrowser } from '../util/common-var' -import { on } from './utils' +import { inBrowser } from '../util/common-var'; +import { on } from './utils'; -const ctx = Symbol('@@clickoutside') -const nodeList = new Map() +const ctx = Symbol('@@clickoutside'); +const nodeList = new Map(); -let startClick -let nid = 0 +let startClick; +let nid = 0; let isFirst = true; function createDocumentHandler(el: HTMLElement, binding: Record, vnode: any) { if (inBrowser && isFirst) { isFirst = false; on(document, 'mousedown', (e: Event) => { - startClick = e - }) + startClick = e; + }); on(document, 'mouseup', (e: Event) => { for (const [id, node] of nodeList) { - node[ctx].documentHandler(e, startClick) + node[ctx].documentHandler(e, startClick); } - }) + }); } - return function(mouseup: Event, mousedown: Event) { + return function (mouseup: Event, mousedown: Event) { if ( !vnode || !binding.instance || @@ -40,30 +40,30 @@ function createDocumentHandler(el: HTMLElement, binding: Record, vn ) { return; } - el[ctx].bindingFn && el[ctx].bindingFn() - } + el[ctx].bindingFn && el[ctx].bindingFn(); + }; } const clickoutsideDirective = { beforeMount: function (el: HTMLElement, binding: Record, vnode: any) { - nid++ - nodeList.set(nid, el) + nid++; + nodeList.set(nid, el); el[ctx] = { nid, documentHandler: createDocumentHandler(el, binding, vnode), bindingFn: binding.value - } + }; }, updated: function (el: HTMLElement, binding: Record, vnode: any) { - el[ctx].documentHandler = createDocumentHandler(el, binding, vnode) - el[ctx].bindingFn = binding.value + el[ctx].documentHandler = createDocumentHandler(el, binding, vnode); + el[ctx].bindingFn = binding.value; }, unmounted: function (el: HTMLElement) { - nodeList.delete(el[ctx].nid) - delete el[ctx] + nodeList.delete(el[ctx].nid); + delete el[ctx]; } -} +}; -export default clickoutsideDirective +export default clickoutsideDirective; diff --git a/packages/devui-vue/docs/components/editable-select/index.md b/packages/devui-vue/docs/components/editable-select/index.md index f0a769a4ce..9beec0cd55 100644 --- a/packages/devui-vue/docs/components/editable-select/index.md +++ b/packages/devui-vue/docs/components/editable-select/index.md @@ -1,258 +1,321 @@ -# EditableSelect 可输入下拉选择框 - -同时支持输入和下拉选择的输入框。 - -### 何时使用 - -当需要同时支持用户输入数据和选择已有数据的时候使用,加入输入联想功能,方便用户搜索已有数据。 - -### 基本用法 - -通过 options 设置数据源。 - -:::demo - -```vue - - - -``` - -::: - -### 设置禁用选项 - -支持禁用指定数据。 -:::demo - -```vue - - -``` - -::: - -### 异步获取数据源并设置匹配方法 - -支持异步设置数据源并设置匹配方法。 -:::demo - -```vue - - -``` - -::: - -### 懒加载 - -:::demo - -```vue - - -``` - -::: - -### d-editable-select - -d-editable-select 参数 - -| 参数 | 类型 | 默认 | 说明 | 跳转 Demo | 全局配置 | -| -------------- | ------------- | ----- | -------------------------------------------------- | ----------------------------- | -------- | -| appendToBody | boolean | false | 可选,下拉是否 appendToBody | [基本用法](#基本用法) | | -| width | number | -- | 可选,控制下拉框宽度,搭配 appendToBody 使用(px) | [基本用法](#基本用法) | | -| v-model | string/number | -- | 绑定值 | [基本用法](#基本用法) | | -| options | Array | -- | 必选,数据列表 | [基本用法](#基本用法) | | -| disabled | boolean | false | 可选,值为 true 禁用下拉框 | [设置禁用选项](#设置禁用选项) | | -| disabledKey | string | -- | 可选,设置禁用选项的 Key 值 | [设置禁用选项](#设置禁用选项) | | -| maxHeight | number | -- | 可选,下拉菜单的最大高度(px) | [基本用法](#基本用法) | | -| remote | boolean | false | 可选,远程搜索 | | | -| enableLazyLoad | boolean | false | 可选,是否允许懒加载 | [懒加载](#懒加载) | | - -d-editable-select 事件 - -| 事件 | 类型 | 说明 | 跳转 Demo | -| ------------ | ---- | ------------------ | -------------------------------------------------------- | -| filterMethod | | 自定义筛选函数 | | -| remoteMethod | | 远程搜索对应的函数 | [异步获取数据源并设置匹配方法](#异步获取数据源并设置匹配方法) | -| loadMore | | 懒加载 | [懒加载](#懒加载) | - -d-editable-select 插槽 - -| name | 说明 | -| ------- | ------------------ | -| default | Option 模板 | -| empty | 无 Option 时的列表 | +# EditableSelect 可输入下拉选择框 + +同时支持输入和下拉选择的输入框。 + +### 何时使用 + +当需要同时支持用户输入数据和选择已有数据的时候使用,加入输入联想功能,方便用户搜索已有数据。 + +### 基本用法 + +通过 source 设置数据源。 +:::demo // todo 展开代码的内部描述 + +```vue + + + +``` + +::: + +### 设置禁用 + +:::demo + +```vue + + +``` + +::: + +### 自定义数据匹配方法 + +:::demo + +```vue + + +``` + +::: + +### 自定义模板展示 + +:::demo + +```vue + + +``` + +::: + +### 懒加载 + +:::demo + +```vue + + + +``` + +::: + +### d-editable-select + +d-editable-select 参数 + +| 参数 | 类型 | 默认 | 说明 | 跳转 Demo | +| ------------ | ---------------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------- | --------------------- | +| options | `Array` | [] | 可选数据列表 | [基本用法](#基本用法) | +| appendToBody | `boolean` | false | 可选,下拉是否 appendToBody | [基本用法](#基本用法) | +| placeholder | `string` | `Search` | 下拉框的默认提示文字 | [基本用法](#基本用法) | +| maxHeight | number | | | +| disabled | `boolean` | false | 可选,值为 true 禁用下拉框 | [设置禁用](#设置禁用) | +| disabledKey | `string` | -- | 可选,设置禁用选项的 Key 值 | [设置禁用](#设置禁用) | +| filterOption | `boolean\|(inputValue,options)=>boolean` | true | 当其为一个函数时,会接收 inputValue option 两个参数,当 option 符合筛选条件时,应返回 true,反之则返回 false。 | + +d-editable-select 事件 + +| 事件 | 类型 | 说明 | 跳转 Demo | +| -------- | ------------------------------ | ---------------------------------------- | ----------------------------------------- | +| loadMore | `(inputvalue:string)=>void ` | 懒加载触发事件,配合 enableLazyLoad 使用 | [懒加载](#懒加载) | +| search | ` (inputvalue:string)=>bolean` | 文本框值变化时回调 | [自定义数据匹配方法](#自定义数据匹配方法) | + +d-editable-select 插槽 +| 插槽名|说明 |跳转 Demo| +| ---- | -- | ------- | +| itemTemplate | 可选,下拉菜单条目的模板 | [自定义模板展示](#自定义模板展示) | +| noResultItemTemplate | 可选,下拉菜单条目搜索后没有结果的模板 | [自定义模板展示](#自定义模板展示) |