diff --git a/package.json b/package.json index b7886f4b9c..dd71adca97 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "private": true, "version": "0.0.0", "scripts": { - "cz": "git add -A && npx git-cz", + "cz": "npx git-cz", "test": "jest", "gen": "bash ./scripts/gc.sh", "storybook": "start-storybook", diff --git a/packages/checkbox/doc/basic.vue b/packages/checkbox/doc/basic.vue index c85949fdbc..f6ac589664 100644 --- a/packages/checkbox/doc/basic.vue +++ b/packages/checkbox/doc/basic.vue @@ -5,7 +5,8 @@ {{ checked2 }} - + + {{ checked4 }} @@ -25,6 +26,7 @@ export default defineComponent({ checked1: false, checked2: true, checkList: ['Ha','A'], + checked4: 3, } }, methods: { diff --git a/packages/checkbox/src/checkbox-button.vue b/packages/checkbox/src/checkbox-button.vue index 761e1614eb..44943cf350 100644 --- a/packages/checkbox/src/checkbox-button.vue +++ b/packages/checkbox/src/checkbox-button.vue @@ -52,7 +52,7 @@ import { defineComponent, ref, computed, - // nextTick, + PropType, watch, } from 'vue' import { useCheckbox } from './useCheckbox' @@ -61,12 +61,11 @@ export default defineComponent({ name: 'ElCheckboxButton', props: { modelValue: { - type: [Object, Boolean], + type: [Object, Boolean, String, Number] as PropType | boolean | number>, default: () => undefined, }, label: { - type: [Object, Boolean, String], - default: () => ({}), + type: [Object, Boolean, String] as PropType | boolean | string>, }, indeterminate: Boolean, disabled: Boolean, diff --git a/packages/checkbox/src/checkbox.vue b/packages/checkbox/src/checkbox.vue index db0847d9fc..7c59ab27e4 100644 --- a/packages/checkbox/src/checkbox.vue +++ b/packages/checkbox/src/checkbox.vue @@ -64,7 +64,7 @@ import { getCurrentInstance, watch, onMounted, - // nextTick, + PropType, } from 'vue' import { useCheckbox } from './useCheckbox' @@ -72,12 +72,11 @@ export default defineComponent({ name: 'ElCheckbox', props: { modelValue: { - type: [Object, Boolean], + type: [Object, Boolean, String, Number] as PropType | boolean | number>, default: () => undefined, }, label: { - type: [Object, Boolean, String], - default: ' ', + type: [Object, Boolean, String] as PropType | boolean | string>, }, indeterminate: Boolean, disabled: Boolean, diff --git a/packages/checkbox/src/useCheckbox.ts b/packages/checkbox/src/useCheckbox.ts index e5acff0a4e..0ad020a86b 100644 --- a/packages/checkbox/src/useCheckbox.ts +++ b/packages/checkbox/src/useCheckbox.ts @@ -9,7 +9,6 @@ export const useCheckbox = () => { const focus = ref(false) const isGroup = computed(() => _checkboxGroup && _checkboxGroup.name === 'ElCheckboxGroup') const _elFormItemSize = computed(() => { - return (elFormItem || {} as any).elFormItemSize }) return { diff --git a/packages/dropdown/__tests__/dropdown.spec.ts b/packages/dropdown/__tests__/dropdown.spec.ts new file mode 100644 index 0000000000..275082d910 --- /dev/null +++ b/packages/dropdown/__tests__/dropdown.spec.ts @@ -0,0 +1,273 @@ +import { mount } from '@vue/test-utils' +import { eventKeys } from '@element-plus/utils/aria' +import Dropdown from '../src/dropdown.vue' +import DropdownItem from '../src/dropdown-item.vue' +import DropdownMenu from '../src/dropdown-menu.vue' + +const MOUSE_ENTER_EVENT = 'mouseenter' +const MOUSE_LEAVE_EVENT = 'mouseleave' +const CLICK = 'click' + +const _mount = (template: string, data, otherObj?) => mount({ + components: { + [Dropdown.name]: Dropdown, + [DropdownItem.name]: DropdownItem, + [DropdownMenu.name]: DropdownMenu, + }, + template, + data, + ...otherObj, +}) +const sleep = (time = 250) => new Promise(resolve => setTimeout(resolve, time)) +export const timeout = async (fn, time = 250) => { + await sleep(time) + fn() +} + +describe('Dropdown', () => { + test('create', async () => { + const wrapper = _mount( + ` + + + dropdown + + + + `, + () => ({}), + ) + const content = wrapper.findComponent({ ref: 'b' }).vm.$refs.popper as any + const triggerElm = wrapper.find('.el-dropdown-link') + expect(content.value).toBe(false) + await triggerElm.trigger(MOUSE_ENTER_EVENT) + await sleep() + expect(content.value).toBe(true) + await triggerElm.trigger(MOUSE_LEAVE_EVENT) + await sleep() + expect(content.value).toBe(false) + }) + + test('menu click', async () => { + const wrapper = _mount( + ` + + + dropdown + + + + `, + () => ({ + myCommandObject: { name: 'CommandC' }, + name: '', + }), + { + methods: { + commandHandler(command) { + this.name = command.name + }, + }, + }, + ) + // const content = wrapper.findComponent({ ref: 'b' }).vm.$refs.popper as any + const triggerElm = wrapper.find('.el-dropdown-link') + await triggerElm.trigger(MOUSE_ENTER_EVENT) + await sleep() + await wrapper.findComponent({ ref: 'c' }).trigger('click') + await sleep() + expect((wrapper.vm as any).name).toBe('CommandC') + }) + + test('trigger', async () => { + const wrapper = _mount( + ` + + + dropdown + + + + `, + () => ({ + myCommandObject: { name: 'CommandC' }, + name: '', + }), + ) + const content = wrapper.findComponent({ ref: 'b' }).vm.$refs.popper as any + const triggerElm = wrapper.find('.el-dropdown-link') + expect(content.value).toBe(false) + await triggerElm.trigger(MOUSE_ENTER_EVENT) + await sleep() + expect(content.value).toBe(false) + await triggerElm.trigger(CLICK) + await sleep() + expect(content.value).toBe(true) + }) + + test('split button', async () => { + const wrapper = _mount( + ` + + dropdown + + + `, + () => ({ + myCommandObject: { name: 'CommandC' }, + name: '', + }), + { + methods: { + handleClick() { + this.name = 'click' + }, + }, + }, + ) + const content = wrapper.findComponent({ ref: 'b' }).vm.$refs.popper as any + const triggerElm = wrapper.find('.el-dropdown__caret-button') + const button = wrapper.find('.el-button') + expect(content.value).toBe(false) + await button.trigger('click') + expect((wrapper.vm as any).name).toBe('click') + await triggerElm.trigger(MOUSE_ENTER_EVENT) + await sleep() + expect(content.value).toBe(true) + }) + + test('hide on click', async () => { + const wrapper = _mount( + ` + + + dropdown + + + + `, + () => ({}), + ) + + const content = wrapper.findComponent({ ref: 'b' }).vm.$refs.popper as any + const triggerElm = wrapper.find('.el-dropdown-link') + await triggerElm.trigger(MOUSE_ENTER_EVENT) + await sleep() + await wrapper.findComponent({ ref: 'c' }).trigger('click') + await sleep() + expect(content.value).toBe(true) + }) + + test('triggerElm keydown', async () => { + const wrapper = _mount( + ` + + + dropdown + + + + `, + () => ({}), + ) + + const content = wrapper.findComponent({ ref: 'b' }).vm.$refs.popper as any + const triggerElm = wrapper.find('.el-dropdown-link') + await triggerElm.trigger(MOUSE_ENTER_EVENT) + await sleep() + await triggerElm.trigger('keydown', { + keyCode: eventKeys.enter, + }) + await sleep() + expect(content.value).toBe(false) + + await triggerElm.trigger(MOUSE_ENTER_EVENT) + await sleep() + await triggerElm.trigger('keydown', { + keyCode: eventKeys.tab, + }) + await sleep() + expect(content.value).toBe(false) + }) + + test('dropdown menu keydown', async () => { + const wrapper = _mount( + ` + + + dropdown + + + + `, + () => ({}), + ) + + const content = wrapper.findComponent({ ref: 'a' }) + const triggerElm = wrapper.find('.el-dropdown-link') + await triggerElm.trigger(MOUSE_ENTER_EVENT) + await sleep() + await content.trigger('keydown', { + keyCode: eventKeys.down, + }) + await sleep() + expect(wrapper.findComponent({ ref: 'd' }).attributes('tabindex')).toBe('0') + + }) +}) diff --git a/packages/dropdown/doc/basic.vue b/packages/dropdown/doc/basic.vue new file mode 100644 index 0000000000..e99ce35660 --- /dev/null +++ b/packages/dropdown/doc/basic.vue @@ -0,0 +1,164 @@ + + + + + diff --git a/packages/dropdown/doc/index.stories.ts b/packages/dropdown/doc/index.stories.ts new file mode 100644 index 0000000000..28b7c1532b --- /dev/null +++ b/packages/dropdown/doc/index.stories.ts @@ -0,0 +1,6 @@ +export { default as BasicUsage } from './basic.vue' + +export default { + title: 'Dropdown', +} + diff --git a/packages/dropdown/index.ts b/packages/dropdown/index.ts new file mode 100644 index 0000000000..4656955ee5 --- /dev/null +++ b/packages/dropdown/index.ts @@ -0,0 +1,10 @@ +import { App } from 'vue' +import Dropdown from './src/dropdown.vue' +import DropdownItem from './src/dropdown-item.vue' +import DropdownMenu from './src/dropdown-menu.vue' + +export default (app: App): void => { + app.component(Dropdown.name, Dropdown) + app.component(DropdownItem.name, DropdownItem) + app.component(DropdownMenu.name, DropdownMenu) +} diff --git a/packages/dropdown/package.json b/packages/dropdown/package.json new file mode 100644 index 0000000000..f42bc8094d --- /dev/null +++ b/packages/dropdown/package.json @@ -0,0 +1,12 @@ +{ + "name": "@element-plus/dropdown", + "version": "0.0.0", + "main": "dist/index.js", + "license": "MIT", + "peerDependencies": { + "vue": "^3.0.0-rc.1" + }, + "devDependencies": { + "@vue/test-utils": "^2.0.0-beta.0" + } +} diff --git a/packages/dropdown/src/dropdown-item.vue b/packages/dropdown/src/dropdown-item.vue new file mode 100644 index 0000000000..7e13a180b3 --- /dev/null +++ b/packages/dropdown/src/dropdown-item.vue @@ -0,0 +1,49 @@ + + + diff --git a/packages/dropdown/src/dropdown-menu.vue b/packages/dropdown/src/dropdown-menu.vue new file mode 100644 index 0000000000..ee47c9eac6 --- /dev/null +++ b/packages/dropdown/src/dropdown-menu.vue @@ -0,0 +1,59 @@ + + + diff --git a/packages/dropdown/src/dropdown.d.ts b/packages/dropdown/src/dropdown.d.ts new file mode 100644 index 0000000000..d87a851858 --- /dev/null +++ b/packages/dropdown/src/dropdown.d.ts @@ -0,0 +1,18 @@ +import { + ComponentInternalInstance, + ComputedRef, + Ref, +} from 'vue' + +export interface IElDropdownInstance { + instance?: ComponentInternalInstance + dropdownSize?: ComputedRef + visible?: Ref + handleClick?: () => void + commandHandler?: (...arg) => void + show?: () => void + hide?: () => void + trigger?: ComputedRef + hideOnClick?: ComputedRef + triggerElm?: ComputedRef> +} diff --git a/packages/dropdown/src/dropdown.vue b/packages/dropdown/src/dropdown.vue new file mode 100644 index 0000000000..fa615e2a6b --- /dev/null +++ b/packages/dropdown/src/dropdown.vue @@ -0,0 +1,261 @@ + + diff --git a/packages/dropdown/src/useDropdown.ts b/packages/dropdown/src/useDropdown.ts new file mode 100644 index 0000000000..aba877516d --- /dev/null +++ b/packages/dropdown/src/useDropdown.ts @@ -0,0 +1,115 @@ +import { inject, computed, ref } from 'vue' +import { generateId } from '@element-plus/utils/util' +import { eventKeys } from '@element-plus/utils/aria' +import { on, addClass } from '@element-plus/utils/dom' +import { IElDropdownInstance } from './dropdown' + +export const useDropdown = () => { + const ELEMENT = null + const elDropdown = inject('elDropdown', {}) + const _elDropdownSize = computed(() => elDropdown?.dropdownSize) + + return { + ELEMENT, + elDropdown, + _elDropdownSize, + } +} + +export const initDropdownDomEvent = (dropdownChildren, triggerElm, _instance) => { + const menuItems = ref>(null) + const menuItemsArray = ref>(null) + const dropdownElm = ref>(null) + const listId = ref(`dropdown-menu-${generateId()}`) + dropdownElm.value = dropdownChildren?.subTree.el + + function removeTabindex() { + triggerElm.setAttribute('tabindex', '-1') + menuItemsArray.value?.forEach(item => { + item.setAttribute('tabindex', '-1') + }) + } + + function resetTabindex(ele) { + removeTabindex() + ele?.setAttribute('tabindex', '0') + } + + function handleTriggerKeyDown(ev: KeyboardEvent) { + /** + * https://developer.mozilla.org/zh-CN/docs/Web/API/KeyboardEvent/keyCode + * keyCode is deprecated, we should replace the api with event.key + * */ + const keyCode = ev.keyCode + if ([eventKeys.up, eventKeys.down].includes(keyCode)) { + removeTabindex() + resetTabindex(menuItems.value[0]) + menuItems.value[0].focus() + ev.preventDefault() + ev.stopPropagation() + } else if (keyCode === eventKeys.enter) { + _instance.handleClick() + } else if ([eventKeys.tab, eventKeys.esc].includes(keyCode)) { + _instance.hide() + } + } + + function handleItemKeyDown(ev) { + const keyCode = ev.keyCode + const target = ev.target + const currentIndex = menuItemsArray.value.indexOf(target) + const max = menuItemsArray.value.length - 1 + let nextIndex + if ([eventKeys.up, eventKeys.down].includes(keyCode)) { + if (keyCode === eventKeys.up) { + nextIndex = currentIndex !== 0 ? currentIndex - 1 : 0 + } else { + nextIndex = currentIndex < max ? currentIndex + 1 : max + } + removeTabindex() + resetTabindex(menuItems.value[nextIndex]) + menuItems.value[nextIndex].focus() + ev.preventDefault() + ev.stopPropagation() + } else if (keyCode === eventKeys.enter) { + triggerElmFocus() + target.click() + if (_instance.props.hideOnClick) { + _instance.hide() + } + } else if ([eventKeys.tab, eventKeys.esc].includes(keyCode)) { + _instance.hide() + triggerElmFocus() + } + } + + function initAria() { + dropdownElm.value.setAttribute('id', listId.value) + triggerElm.setAttribute('aria-haspopup', 'list') + triggerElm.setAttribute('aria-controls', listId.value) + if (!_instance.props.splitButton) { + triggerElm.setAttribute('role', 'button') + triggerElm.setAttribute('tabindex', _instance.tabindex) + addClass(triggerElm, 'el-dropdown-selfdefine') + } + } + + function initEvent() { + on(triggerElm, 'keydown', handleTriggerKeyDown) + on(dropdownElm.value, 'keydown', handleItemKeyDown, true) + } + + function initDomOperation() { + menuItems.value = dropdownElm.value.querySelectorAll("[tabindex='-1']") as unknown as HTMLButtonElement[] + menuItemsArray.value = [].slice.call(menuItems.value) + + initEvent() + initAria() + } + + function triggerElmFocus() { + triggerElm?.focus?.() + } + + initDomOperation() +} diff --git a/packages/element-plus/index.ts b/packages/element-plus/index.ts index cff7533fbe..ed49b8aa67 100644 --- a/packages/element-plus/index.ts +++ b/packages/element-plus/index.ts @@ -6,6 +6,7 @@ import ElButton from '@element-plus/button' import ElBadge from '@element-plus/badge' import ElCard from '@element-plus/card' import ElCheckbox from '@element-plus/checkbox' +import ElDropdown from '@element-plus/dropdown' import ElTag from '@element-plus/tag' import ElLayout from '@element-plus/layout' import ElDivider from '@element-plus/divider' @@ -38,6 +39,7 @@ export { ElCard, ElCheckbox, ElDivider, + ElDropdown, ElTag, ElTimeline, ElProgress, @@ -66,6 +68,7 @@ export default function install(app: App): void { ElBadge(app) ElCard(app) ElCheckbox(app) + ElDropdown(app) ElTag(app) ElLayout(app) ElDivider(app) diff --git a/packages/utils/dom.ts b/packages/utils/dom.ts index 61e9ac5324..d150e8f3b1 100644 --- a/packages/utils/dom.ts +++ b/packages/utils/dom.ts @@ -11,9 +11,10 @@ export const on = function( element: HTMLElement | Document | Window, event: string, handler: EventListenerOrEventListenerObject, + useCapture = false, ): void { if (element && event && handler) { - element.addEventListener(event, handler, false) + element.addEventListener(event, handler, useCapture) } } diff --git a/packages/utils/menu/menu-item.ts b/packages/utils/menu/menu-item.ts index 0da5e990e8..b7561d145e 100644 --- a/packages/utils/menu/menu-item.ts +++ b/packages/utils/menu/menu-item.ts @@ -43,7 +43,7 @@ class MenuItem { case keys.space: { prevDef = true - (event.currentTarget as HTMLElement).click() + ;(event.currentTarget as HTMLElement).click() break } }