mirror of
https://github.com/element-plus/element-plus.git
synced 2026-03-13 07:51:17 +08:00
feat(components): [dropdown] a11y (#7946)
* feat(components): [dropdown] always use button trigger * feat(components): [dropdown] a11y unit tests * feat(components): [dropdown] pr comments
This commit is contained in:
@@ -79,21 +79,22 @@ dropdown/sizes
|
||||
|
||||
## Dropdown Attributes
|
||||
|
||||
| Attribute | Description | Type | Accepted Values | Default |
|
||||
| -------------- | ---------------------------------------------------------------------------------------------------- | --------------- | ------------------------------------------------------------------ | ------------------------------------------------------- |
|
||||
| type | menu button type, refer to `Button` Component, only works when `split-button` is true | string | — | — |
|
||||
| size | menu size, also works on the split button | string | large / default / small | default |
|
||||
| max-height | the max height of menu | string / number | — | — |
|
||||
| split-button | whether a button group is displayed | boolean | — | false |
|
||||
| disabled | Whether to disable | boolean | — | false |
|
||||
| placement | placement of pop menu | string | top/top-start/top-end/bottom/bottom-start/bottom-end | bottom |
|
||||
| trigger | how to trigger | string | hover/click/contextmenu | hover |
|
||||
| hide-on-click | whether to hide menu after clicking menu-item | boolean | — | true |
|
||||
| show-timeout | Delay time before show a dropdown (only works when trigger is `hover`) | number | — | 250 |
|
||||
| hide-timeout | Delay time before hide a dropdown (only works when trigger is `hover`) | number | — | 150 |
|
||||
| tabindex | [tabindex](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/tabindex) of Dropdown | number | — | 0 |
|
||||
| popper-class | custom class name for Dropdown's dropdown | string | — | — |
|
||||
| popper-options | [popper.js](https://popper.js.org/documentation.html) parameters | Object | refer to [popper.js](https://popper.js.org/documentation.html) doc | `{ boundariesElement: 'body', gpuAcceleration: false }` |
|
||||
| Attribute | Description | Type | Accepted Values | Default |
|
||||
| -------------- | --------------------------------------------------------------------------------------------------------------------- | --------------- | ------------------------------------------------------------------ | ------------------------------------------------------- |
|
||||
| type | menu button type, refer to `Button` Component, only works when `split-button` is true | string | — | — |
|
||||
| size | menu size, also works on the split button | string | large / default / small | default |
|
||||
| max-height | the max height of menu | string / number | — | — |
|
||||
| split-button | whether a button group is displayed | boolean | — | false |
|
||||
| disabled | Whether to disable | boolean | — | false |
|
||||
| placement | placement of pop menu | string | top/top-start/top-end/bottom/bottom-start/bottom-end | bottom |
|
||||
| trigger | how to trigger | string | hover/click/contextmenu | hover |
|
||||
| hide-on-click | whether to hide menu after clicking menu-item | boolean | — | true |
|
||||
| show-timeout | Delay time before show a dropdown (only works when trigger is `hover`) | number | — | 250 |
|
||||
| hide-timeout | Delay time before hide a dropdown (only works when trigger is `hover`) | number | — | 150 |
|
||||
| role | The ARIA role attribute for the dropdown menu. Depending on the use case, you may want to change this to 'navigation' | string | — | 'menu' |
|
||||
| tabindex | [tabindex](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/tabindex) of Dropdown | number | — | 0 |
|
||||
| popper-class | custom class name for Dropdown's dropdown | string | — | — |
|
||||
| popper-options | [popper.js](https://popper.js.org/documentation.html) parameters | Object | refer to [popper.js](https://popper.js.org/documentation.html) doc | `{ boundariesElement: 'body', gpuAcceleration: false }` |
|
||||
|
||||
## Dropdown Slots
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { describe, expect, test, vi } from 'vitest'
|
||||
import { rAF } from '@element-plus/test-utils/tick'
|
||||
import { EVENT_CODE } from '@element-plus/constants'
|
||||
import { ElTooltip } from '@element-plus/components/tooltip'
|
||||
import Button from '@element-plus/components/button'
|
||||
import Dropdown from '../src/dropdown.vue'
|
||||
import DropdownItem from '../src/dropdown-item.vue'
|
||||
import DropdownMenu from '../src/dropdown-menu.vue'
|
||||
@@ -15,6 +16,7 @@ const CONTEXTMENU = 'contextmenu'
|
||||
const _mount = (template: string, data, otherObj?) =>
|
||||
mount({
|
||||
components: {
|
||||
[Button.name]: Button,
|
||||
[Dropdown.name]: Dropdown,
|
||||
[DropdownItem.name]: DropdownItem,
|
||||
[DropdownMenu.name]: DropdownMenu,
|
||||
@@ -352,7 +354,7 @@ describe('Dropdown', () => {
|
||||
dropdown<i class="el-icon-arrow-down el-icon--right"></i>
|
||||
</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu ref="a">
|
||||
<el-dropdown-menu ref="dropdown-menu">
|
||||
<el-dropdown-item ref="d">Apple</el-dropdown-item>
|
||||
<el-dropdown-item>Orange</el-dropdown-item>
|
||||
<el-dropdown-item ref="c">Cherry</el-dropdown-item>
|
||||
@@ -365,7 +367,7 @@ describe('Dropdown', () => {
|
||||
() => ({})
|
||||
)
|
||||
await nextTick()
|
||||
const content = wrapper.findComponent({ ref: 'a' })
|
||||
const content = wrapper.findComponent({ ref: 'dropdown-menu' })
|
||||
const triggerElm = wrapper.find('.el-tooltip__trigger')
|
||||
await triggerElm.trigger(MOUSE_ENTER_EVENT)
|
||||
await rAF()
|
||||
@@ -482,6 +484,9 @@ describe('Dropdown', () => {
|
||||
const wrapper = _mount(
|
||||
`
|
||||
<el-dropdown>
|
||||
<span class="el-dropdown-link">
|
||||
Custom Attributes
|
||||
</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item data-custom-attribute="hello">Item</el-dropdown-item>
|
||||
@@ -604,4 +609,150 @@ describe('Dropdown', () => {
|
||||
expect(tooltipElement.vm.showAfter).toBe(0)
|
||||
expect(tooltipElement.vm.hideAfter).toBe(0)
|
||||
})
|
||||
|
||||
describe('accessibility', () => {
|
||||
test('Custom span trigger has proper attributes', async () => {
|
||||
const wrapper = _mount(
|
||||
`
|
||||
<el-dropdown>
|
||||
<span class="el-dropdown-link" data-test-ref="trigger">
|
||||
Dropdown List
|
||||
</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu ref="menu">
|
||||
<el-dropdown-item>Item</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
`,
|
||||
() => ({})
|
||||
)
|
||||
await nextTick()
|
||||
const trigger = wrapper.find('[data-test-ref="trigger"]')
|
||||
const menu = wrapper.findComponent({ ref: 'menu' })
|
||||
expect(trigger.attributes()['role']).toBe('button')
|
||||
expect(trigger.attributes()['tabindex']).toBe('0')
|
||||
expect(trigger.attributes()['aria-haspopup']).toBe('menu')
|
||||
expect(trigger.attributes()['id']).toBe(
|
||||
menu.attributes()['aria-labelledby']
|
||||
)
|
||||
expect(trigger.attributes()['aria-controls']).toBe(
|
||||
menu.attributes()['id']
|
||||
)
|
||||
})
|
||||
|
||||
test('ElButton trigger has proper attributes', async () => {
|
||||
const wrapper = _mount(
|
||||
`
|
||||
<el-dropdown>
|
||||
<el-button ref="trigger">
|
||||
Dropdown List
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu ref="menu">
|
||||
<el-dropdown-item>Item</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
`,
|
||||
() => ({})
|
||||
)
|
||||
await nextTick()
|
||||
const trigger = wrapper.findComponent({ ref: 'trigger' })
|
||||
const menu = wrapper.findComponent({ ref: 'menu' })
|
||||
expect(trigger.attributes()['role']).toBe('button')
|
||||
expect(trigger.attributes()['tabindex']).toBe('0')
|
||||
expect(trigger.attributes()['aria-haspopup']).toBe('menu')
|
||||
expect(trigger.attributes()['id']).toBe(
|
||||
menu.attributes()['aria-labelledby']
|
||||
)
|
||||
expect(trigger.attributes()['aria-controls']).toBe(
|
||||
menu.attributes()['id']
|
||||
)
|
||||
})
|
||||
|
||||
test('Split button trigger has proper attributes', async () => {
|
||||
const wrapper = _mount(
|
||||
`
|
||||
<el-dropdown split-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu ref="menu">
|
||||
<el-dropdown-item>Item</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
`,
|
||||
() => ({})
|
||||
)
|
||||
await nextTick()
|
||||
const trigger = wrapper.find('.el-dropdown__caret-button')
|
||||
const menu = wrapper.findComponent({ ref: 'menu' })
|
||||
expect(trigger.attributes()['role']).toBe('button')
|
||||
expect(trigger.attributes()['tabindex']).toBe('0')
|
||||
expect(trigger.attributes()['aria-haspopup']).toBe('menu')
|
||||
expect(trigger.attributes()['id']).toBe(
|
||||
menu.attributes()['aria-labelledby']
|
||||
)
|
||||
expect(trigger.attributes()['aria-controls']).toBe(
|
||||
menu.attributes()['id']
|
||||
)
|
||||
})
|
||||
|
||||
test('Menu items with "menu" role', async () => {
|
||||
const wrapper = _mount(
|
||||
`
|
||||
<el-dropdown split-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu ref="menu">
|
||||
<el-dropdown-item ref="menu-item">Item</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
`,
|
||||
() => ({})
|
||||
)
|
||||
const menu = wrapper.findComponent({ ref: 'menu' })
|
||||
const menuItem = menu.find('.el-dropdown-menu__item')
|
||||
expect(menu.attributes()['role']).toBe('menu')
|
||||
expect(menuItem.attributes()['role']).toBe('menuitem')
|
||||
})
|
||||
|
||||
test('Menu items with "navigation" role', async () => {
|
||||
const wrapper = _mount(
|
||||
`
|
||||
<el-dropdown split-button role="navigation">
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu ref="menu">
|
||||
<el-dropdown-item ref="menu-item">Item</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
`,
|
||||
() => ({})
|
||||
)
|
||||
const menu = wrapper.findComponent({ ref: 'menu' })
|
||||
const menuItem = menu.find('.el-dropdown-menu__item')
|
||||
expect(menu.attributes()['role']).toBe('navigation')
|
||||
expect(menuItem.attributes()['role']).toBe('link')
|
||||
})
|
||||
|
||||
test('Menu items with "group" role', async () => {
|
||||
const wrapper = _mount(
|
||||
`
|
||||
<el-dropdown split-button role="group">
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu ref="menu">
|
||||
<el-dropdown-item ref="menu-item">Item</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
`,
|
||||
() => ({})
|
||||
)
|
||||
const menu = wrapper.findComponent({ ref: 'menu' })
|
||||
const menuItem = menu.find('.el-dropdown-menu__item')
|
||||
expect(menu.attributes()['role']).toBe('group')
|
||||
expect(menuItem.attributes()['role']).toBe('button')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<li
|
||||
v-if="divided"
|
||||
role="separator"
|
||||
:class="ns.bem('menu', 'item', 'divided')"
|
||||
v-bind="$attrs"
|
||||
/>
|
||||
@@ -10,7 +11,7 @@
|
||||
:aria-disabled="disabled"
|
||||
:class="[ns.be('menu', 'item'), ns.is('disabled', disabled)]"
|
||||
:tabindex="tabIndex"
|
||||
role="menuitem"
|
||||
:role="role"
|
||||
@click="(e) => $emit('clickimpl', e)"
|
||||
@focus="handleFocus"
|
||||
@keydown="handleKeydown"
|
||||
@@ -24,7 +25,7 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, inject } from 'vue'
|
||||
import { computed, defineComponent, inject } from 'vue'
|
||||
import {
|
||||
ROVING_FOCUS_GROUP_ITEM_INJECTION_KEY,
|
||||
ROVING_FOCUS_ITEM_COLLECTION_INJECTION_KEY,
|
||||
@@ -38,6 +39,7 @@ import {
|
||||
DROPDOWN_COLLECTION_ITEM_INJECTION_KEY,
|
||||
dropdownItemProps,
|
||||
} from './dropdown'
|
||||
import { DROPDOWN_INJECTION_KEY } from './tokens'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'DropdownItemImpl',
|
||||
@@ -49,6 +51,8 @@ export default defineComponent({
|
||||
setup(_, { emit }) {
|
||||
const ns = useNamespace('dropdown')
|
||||
|
||||
const { role: menuRole } = inject(DROPDOWN_INJECTION_KEY, undefined)!
|
||||
|
||||
const { collectionItemRef: dropdownCollectionItemRef } = inject(
|
||||
DROPDOWN_COLLECTION_ITEM_INJECTION_KEY,
|
||||
undefined
|
||||
@@ -73,6 +77,15 @@ export default defineComponent({
|
||||
rovingFocusGroupItemRef
|
||||
)
|
||||
|
||||
const role = computed<string>(() => {
|
||||
if (menuRole.value === 'menu') {
|
||||
return 'menuitem'
|
||||
} else if (menuRole.value === 'navigation') {
|
||||
return 'link'
|
||||
}
|
||||
return 'button'
|
||||
})
|
||||
|
||||
const handleKeydown = composeEventHandlers((e: KeyboardEvent) => {
|
||||
const { code } = e
|
||||
if (code === EVENT_CODE.enter || code === EVENT_CODE.space) {
|
||||
@@ -89,6 +102,7 @@ export default defineComponent({
|
||||
dataset: {
|
||||
[COLLECTION_ITEM_SIGN]: '',
|
||||
},
|
||||
role,
|
||||
tabIndex,
|
||||
handleFocus,
|
||||
handleKeydown,
|
||||
|
||||
@@ -84,7 +84,7 @@ export default defineComponent({
|
||||
const handleClick = composeEventHandlers(
|
||||
(e: PointerEvent) => {
|
||||
emit('click', e)
|
||||
return e.defaultPrevented
|
||||
return e.type !== 'keydown' && e.defaultPrevented
|
||||
},
|
||||
(e) => {
|
||||
if (props.disabled) {
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
:class="dropdownKls"
|
||||
:style="rovingFocusGroupRootStyle"
|
||||
:tabindex="-1"
|
||||
role="menu"
|
||||
:role="role"
|
||||
:aria-labelledby="triggerId"
|
||||
@blur="onBlur"
|
||||
@focus="onFocus"
|
||||
@keydown="handleKeydown"
|
||||
@@ -46,7 +47,10 @@ export default defineComponent({
|
||||
undefined
|
||||
)!
|
||||
|
||||
const { contentRef } = inject(DROPDOWN_INJECTION_KEY, undefined)!
|
||||
const { contentRef, role, triggerId } = inject(
|
||||
DROPDOWN_INJECTION_KEY,
|
||||
undefined
|
||||
)!
|
||||
|
||||
const { collectionRef: dropdownCollectionRef, getItems } = inject(
|
||||
DROPDOWN_COLLECTION_INJECTION_KEY,
|
||||
@@ -122,6 +126,8 @@ export default defineComponent({
|
||||
rovingFocusGroupRootStyle,
|
||||
tabIndex,
|
||||
dropdownKls,
|
||||
role,
|
||||
triggerId,
|
||||
dropdownListWrapperRef,
|
||||
handleKeydown,
|
||||
onBlur,
|
||||
|
||||
@@ -41,6 +41,7 @@ export const dropdownProps = buildProps({
|
||||
type: definePropType<Partial<Options>>(Object),
|
||||
default: () => ({}),
|
||||
},
|
||||
id: String,
|
||||
size: {
|
||||
type: String,
|
||||
default: '',
|
||||
@@ -52,6 +53,7 @@ export const dropdownProps = buildProps({
|
||||
},
|
||||
loop: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showTimeout: {
|
||||
type: Number,
|
||||
@@ -77,6 +79,10 @@ export const dropdownProps = buildProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
role: {
|
||||
type: String,
|
||||
default: 'menu',
|
||||
},
|
||||
buttonProps: {
|
||||
type: definePropType<ButtonProps>(Object),
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<div :class="[ns.b(), ns.is('disabled', disabled)]">
|
||||
<el-tooltip
|
||||
ref="popperRef"
|
||||
:role="role"
|
||||
:effect="effect"
|
||||
:fallback-placements="['bottom', 'top']"
|
||||
:popper-options="popperOptions"
|
||||
@@ -12,6 +13,8 @@
|
||||
:popper-class="[ns.e('popper'), popperClass]"
|
||||
:reference-element="referenceElementRef?.$el"
|
||||
:trigger="trigger"
|
||||
:trigger-keys="triggerKeys"
|
||||
:trigger-target-el="contentRef"
|
||||
:show-after="trigger === 'hover' ? showTimeout : 0"
|
||||
:stop-popper-mouse-event="false"
|
||||
:virtual-ref="triggeringElementRef"
|
||||
@@ -21,8 +24,8 @@
|
||||
teleported
|
||||
pure
|
||||
persistent
|
||||
@show="$emit('visible-change', true)"
|
||||
@hide="$emit('visible-change', false)"
|
||||
@show="handleShowTooltip"
|
||||
@hide="handleHideTooltip"
|
||||
>
|
||||
<template #content>
|
||||
<el-scrollbar
|
||||
@@ -45,9 +48,9 @@
|
||||
</el-scrollbar>
|
||||
</template>
|
||||
<template v-if="!splitButton" #default>
|
||||
<div :class="dropdownTriggerKls">
|
||||
<el-only-child :id="triggerId" role="button" :tabindex="tabindex">
|
||||
<slot name="default" />
|
||||
</div>
|
||||
</el-only-child>
|
||||
</template>
|
||||
</el-tooltip>
|
||||
<template v-if="splitButton">
|
||||
@@ -58,17 +61,22 @@
|
||||
:size="dropdownSize"
|
||||
:type="type"
|
||||
:disabled="disabled"
|
||||
:tabindex="tabindex"
|
||||
@click="handlerMainButtonClick"
|
||||
>
|
||||
<slot name="default" />
|
||||
</el-button>
|
||||
<el-button
|
||||
:id="triggerId"
|
||||
ref="triggeringElementRef"
|
||||
v-bind="buttonProps"
|
||||
role="button"
|
||||
:size="dropdownSize"
|
||||
:type="type"
|
||||
:class="ns.e('caret-button')"
|
||||
:disabled="disabled"
|
||||
:tabindex="tabindex"
|
||||
:aria-label="t('el.dropdown.toggleDropdown')"
|
||||
>
|
||||
<el-icon :class="ns.e('icon')"><arrow-down /></el-icon>
|
||||
</el-button>
|
||||
@@ -91,9 +99,11 @@ import ElTooltip from '@element-plus/components/tooltip'
|
||||
import ElScrollbar from '@element-plus/components/scrollbar'
|
||||
import ElIcon from '@element-plus/components/icon'
|
||||
import ElRovingFocusGroup from '@element-plus/components/roving-focus-group'
|
||||
import { ElOnlyChild } from '@element-plus/components/slot'
|
||||
import { addUnit } from '@element-plus/utils'
|
||||
import { ArrowDown } from '@element-plus/icons-vue'
|
||||
import { useNamespace, useSize } from '@element-plus/hooks'
|
||||
import { EVENT_CODE } from '@element-plus/constants'
|
||||
import { useId, useLocale, useNamespace, useSize } from '@element-plus/hooks'
|
||||
import { ElCollection as ElDropdownCollection, dropdownProps } from './dropdown'
|
||||
import { DROPDOWN_INJECTION_KEY } from './tokens'
|
||||
|
||||
@@ -110,6 +120,7 @@ export default defineComponent({
|
||||
ElDropdownCollection,
|
||||
ElTooltip,
|
||||
ElRovingFocusGroup,
|
||||
ElOnlyChild,
|
||||
ElIcon,
|
||||
ArrowDown,
|
||||
},
|
||||
@@ -118,6 +129,7 @@ export default defineComponent({
|
||||
setup(props, { emit }) {
|
||||
const _instance = getCurrentInstance()
|
||||
const ns = useNamespace('dropdown')
|
||||
const { t } = useLocale()
|
||||
|
||||
const triggeringElementRef = ref()
|
||||
const referenceElementRef = ref()
|
||||
@@ -126,12 +138,18 @@ export default defineComponent({
|
||||
const scrollbar = ref(null)
|
||||
const currentTabId = ref<string | null>(null)
|
||||
const isUsingKeyboard = ref(false)
|
||||
const triggerKeys = [EVENT_CODE.enter, EVENT_CODE.space, EVENT_CODE.down]
|
||||
|
||||
const wrapStyle = computed<CSSProperties>(() => ({
|
||||
maxHeight: addUnit(props.maxHeight),
|
||||
}))
|
||||
const dropdownTriggerKls = computed(() => [ns.m(dropdownSize.value)])
|
||||
|
||||
const defaultTriggerId = useId().value
|
||||
const triggerId = computed<string>(() => {
|
||||
return props.id || defaultTriggerId
|
||||
})
|
||||
|
||||
function handleClick() {
|
||||
handleClose()
|
||||
}
|
||||
@@ -172,8 +190,20 @@ export default defineComponent({
|
||||
}
|
||||
}
|
||||
|
||||
function handleShowTooltip(event?: Event) {
|
||||
if (event?.type === 'keydown') {
|
||||
contentRef.value.focus()
|
||||
}
|
||||
emit('visible-change', true)
|
||||
}
|
||||
function handleHideTooltip() {
|
||||
emit('visible-change', false)
|
||||
}
|
||||
|
||||
provide(DROPDOWN_INJECTION_KEY, {
|
||||
contentRef,
|
||||
role: computed(() => props.role),
|
||||
triggerId,
|
||||
isUsingKeyboard,
|
||||
onItemEnter,
|
||||
onItemLeave,
|
||||
@@ -200,19 +230,25 @@ export default defineComponent({
|
||||
}
|
||||
|
||||
return {
|
||||
t,
|
||||
ns,
|
||||
scrollbar,
|
||||
wrapStyle,
|
||||
dropdownTriggerKls,
|
||||
dropdownSize,
|
||||
triggerId,
|
||||
triggerKeys,
|
||||
currentTabId,
|
||||
handleCurrentTabIdChange,
|
||||
handlerMainButtonClick,
|
||||
handleEntryFocus,
|
||||
handleClose,
|
||||
handleOpen,
|
||||
handleShowTooltip,
|
||||
handleHideTooltip,
|
||||
onFocusAfterTrapped,
|
||||
popperRef,
|
||||
contentRef,
|
||||
triggeringElementRef,
|
||||
referenceElementRef,
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { InjectionKey, Ref } from 'vue'
|
||||
import type { ComputedRef, InjectionKey, Ref } from 'vue'
|
||||
|
||||
export type ElDropdownInjectionContext = {
|
||||
contentRef: Ref<HTMLElement | null>
|
||||
role: ComputedRef<string>
|
||||
triggerId: ComputedRef<string>
|
||||
isUsingKeyboard: Ref<boolean>
|
||||
onItemLeave: (e: PointerEvent) => void
|
||||
onItemEnter: (e: PointerEvent) => void
|
||||
|
||||
@@ -43,6 +43,7 @@ export const usePopperCoreConfigProps = buildProps({
|
||||
|
||||
export const usePopperContentProps = buildProps({
|
||||
...usePopperCoreConfigProps,
|
||||
id: String,
|
||||
style: { type: definePropType<StyleValue>([String, Array, Object]) },
|
||||
className: { type: definePropType<ClassType>([String, Array, Object]) },
|
||||
effect: {
|
||||
@@ -72,6 +73,9 @@ export const usePopperContentProps = buildProps({
|
||||
referenceEl: {
|
||||
type: definePropType<HTMLElement>(Object),
|
||||
},
|
||||
triggerTargetEl: {
|
||||
type: definePropType<HTMLElement>(Object),
|
||||
},
|
||||
stopPopperMouseEvent: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
|
||||
@@ -3,9 +3,6 @@
|
||||
ref="popperContentRef"
|
||||
:style="contentStyle"
|
||||
:class="contentClass"
|
||||
:role="role"
|
||||
:aria-label="ariaLabel"
|
||||
:aria-modal="ariaModal"
|
||||
tabindex="-1"
|
||||
@mouseenter="(e) => $emit('mouseenter', e)"
|
||||
@mouseleave="(e) => $emit('mouseleave', e)"
|
||||
@@ -27,8 +24,19 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, inject, onMounted, provide, ref, unref, watch } from 'vue'
|
||||
import {
|
||||
computed,
|
||||
inject,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
provide,
|
||||
ref,
|
||||
toRefs,
|
||||
unref,
|
||||
watch,
|
||||
} from 'vue'
|
||||
import { NOOP } from '@vue/shared'
|
||||
import { isNil } from 'lodash-unified'
|
||||
import { createPopper } from '@popperjs/core'
|
||||
import ElFocusTrap from '@element-plus/components/focus-trap'
|
||||
import { useNamespace, useZIndex } from '@element-plus/hooks'
|
||||
@@ -37,6 +45,7 @@ import {
|
||||
POPPER_INJECTION_KEY,
|
||||
formItemContextKey,
|
||||
} from '@element-plus/tokens'
|
||||
import { isElement } from '@element-plus/utils'
|
||||
import { usePopperContentEmits, usePopperContentProps } from './content'
|
||||
import { buildPopperOptions, unwrapMeasurableEl } from './utils'
|
||||
|
||||
@@ -81,6 +90,8 @@ if (
|
||||
const contentZIndex = ref<number>(props.zIndex || nextZIndex())
|
||||
const trapped = ref<boolean>(false)
|
||||
|
||||
let triggerTargetAriaStopWatch: WatchStopHandle | undefined = undefined
|
||||
|
||||
const computedReference = computed(
|
||||
() => unwrapMeasurableEl(props.referenceEl) || unref(triggerRef)
|
||||
)
|
||||
@@ -194,6 +205,38 @@ onMounted(() => {
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.triggerTargetEl,
|
||||
(triggerTargetEl, prevTriggerTargetEl) => {
|
||||
triggerTargetAriaStopWatch?.()
|
||||
triggerTargetAriaStopWatch = undefined
|
||||
|
||||
const el = unref(triggerTargetEl || popperContentRef.value)
|
||||
const prevEl = unref(prevTriggerTargetEl || popperContentRef.value)
|
||||
|
||||
if (isElement(el)) {
|
||||
const { ariaLabel, id } = toRefs(props)
|
||||
triggerTargetAriaStopWatch = watch(
|
||||
[role, ariaLabel, ariaModal, id],
|
||||
(watches) => {
|
||||
;['role', 'aria-label', 'aria-modal', 'id'].forEach((key, idx) => {
|
||||
isNil(watches[idx])
|
||||
? el.removeAttribute(key)
|
||||
: el.setAttribute(key, watches[idx])
|
||||
})
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
}
|
||||
if (isElement(prevEl)) {
|
||||
;['role', 'aria-label', 'aria-modal', 'id'].forEach((key) => {
|
||||
prevEl.removeAttribute(key)
|
||||
})
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(() => props.visible, togglePopperAlive, { immediate: true })
|
||||
|
||||
watch(
|
||||
@@ -206,6 +249,11 @@ onMounted(() => {
|
||||
)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
triggerTargetAriaStopWatch?.()
|
||||
triggerTargetAriaStopWatch = undefined
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
/**
|
||||
* @description popper content element
|
||||
|
||||
@@ -6,14 +6,14 @@
|
||||
:aria-describedby="ariaDescribedby"
|
||||
:aria-expanded="ariaExpanded"
|
||||
:aria-haspopup="ariaHaspopup"
|
||||
:aria-owns="ariaOwns"
|
||||
>
|
||||
<slot />
|
||||
</el-only-child>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, inject, onMounted, watch } from 'vue'
|
||||
import { computed, inject, onBeforeUnmount, onMounted, watch } from 'vue'
|
||||
import { isNil } from 'lodash-unified'
|
||||
import { unrefElement } from '@vueuse/core'
|
||||
import { ElOnlyChild } from '@element-plus/components/slot'
|
||||
import { useForwardRef } from '@element-plus/hooks'
|
||||
@@ -21,6 +21,8 @@ import { POPPER_INJECTION_KEY } from '@element-plus/tokens'
|
||||
import { isElement } from '@element-plus/utils'
|
||||
import { usePopperTriggerProps } from './trigger'
|
||||
|
||||
import type { WatchStopHandle } from 'vue'
|
||||
|
||||
defineOptions({
|
||||
name: 'ElPopperTrigger',
|
||||
inheritAttrs: false,
|
||||
@@ -54,9 +56,7 @@ const ariaExpanded = computed<string | undefined>(() => {
|
||||
return ariaHaspopup.value ? `${props.open}` : undefined
|
||||
})
|
||||
|
||||
const ariaOwns = computed<string | undefined>(() => {
|
||||
return ariaHaspopup.value ? props.id : undefined
|
||||
})
|
||||
let virtualTriggerAriaStopWatch: WatchStopHandle | undefined = undefined
|
||||
|
||||
onMounted(() => {
|
||||
watch(
|
||||
@@ -74,6 +74,8 @@ onMounted(() => {
|
||||
watch(
|
||||
() => triggerRef.value,
|
||||
(el, prevEl) => {
|
||||
virtualTriggerAriaStopWatch?.()
|
||||
virtualTriggerAriaStopWatch = undefined
|
||||
if (isElement(el)) {
|
||||
;[
|
||||
'onMouseenter',
|
||||
@@ -96,6 +98,30 @@ onMounted(() => {
|
||||
)
|
||||
}
|
||||
})
|
||||
virtualTriggerAriaStopWatch = watch(
|
||||
[ariaControls, ariaDescribedby, ariaHaspopup, ariaExpanded],
|
||||
(watches) => {
|
||||
;[
|
||||
'aria-controls',
|
||||
'aria-describedby',
|
||||
'aria-haspopup',
|
||||
'aria-expanded',
|
||||
].forEach((key, idx) => {
|
||||
isNil(watches[idx])
|
||||
? el.removeAttribute(key)
|
||||
: el.setAttribute(key, watches[idx])
|
||||
})
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
}
|
||||
if (isElement(prevEl)) {
|
||||
;[
|
||||
'aria-controls',
|
||||
'aria-describedby',
|
||||
'aria-haspopup',
|
||||
'aria-expanded',
|
||||
].forEach((key) => prevEl.removeAttribute(key))
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -104,6 +130,11 @@ onMounted(() => {
|
||||
)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
virtualTriggerAriaStopWatch?.()
|
||||
virtualTriggerAriaStopWatch = undefined
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
/**
|
||||
* @description trigger element
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
:popper-class="popperClass"
|
||||
:popper-style="[popperStyle, contentStyle]"
|
||||
:reference-el="referenceEl"
|
||||
:trigger-target-el="triggerTargetEl"
|
||||
:visible="shouldShow"
|
||||
:z-index="zIndex"
|
||||
@mouseenter="onContentEnter"
|
||||
|
||||
@@ -8,7 +8,8 @@ import {
|
||||
useDelayedToggleProps,
|
||||
useNamespace,
|
||||
} from '@element-plus/hooks'
|
||||
import type Tooltip from './tooltip.vue'
|
||||
import { EVENT_CODE } from '@element-plus/constants'
|
||||
import type Tooltip from '../tooltip.vue'
|
||||
|
||||
import type { ExtractPropTypes } from 'vue'
|
||||
|
||||
@@ -62,6 +63,10 @@ export const useTooltipTriggerProps = buildProps({
|
||||
type: definePropType<Trigger | Trigger[]>([String, Array]),
|
||||
default: 'hover',
|
||||
},
|
||||
triggerKeys: {
|
||||
type: definePropType<string[]>(Array),
|
||||
default: () => [EVENT_CODE.enter, EVENT_CODE.space],
|
||||
},
|
||||
} as const)
|
||||
|
||||
export const useTooltipProps = buildProps({
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<el-tooltip-trigger
|
||||
:disabled="disabled"
|
||||
:trigger="trigger"
|
||||
:trigger-keys="triggerKeys"
|
||||
:virtual-ref="virtualRef"
|
||||
:virtual-triggering="virtualTriggering"
|
||||
>
|
||||
@@ -28,6 +29,7 @@
|
||||
:pure="pure"
|
||||
:raw-content="rawContent"
|
||||
:reference-el="referenceEl"
|
||||
:trigger-target-el="triggerTargetEl"
|
||||
:show-after="compatShowAfter"
|
||||
:strategy="strategy"
|
||||
:teleported="teleported"
|
||||
@@ -141,9 +143,11 @@ export default defineComponent({
|
||||
}
|
||||
}
|
||||
const open = ref(false)
|
||||
const toggleReason = ref<Event | undefined>(undefined)
|
||||
|
||||
const { show, hide } = useModelToggle({
|
||||
indicator: open,
|
||||
toggleReason,
|
||||
})
|
||||
|
||||
const { onOpen, onClose } = useDelayedToggle({
|
||||
@@ -160,30 +164,30 @@ export default defineComponent({
|
||||
id,
|
||||
open: readonly(open),
|
||||
trigger: toRef(props, 'trigger'),
|
||||
onOpen: () => {
|
||||
onOpen()
|
||||
onOpen: (event?: Event) => {
|
||||
onOpen(event)
|
||||
},
|
||||
onClose: () => {
|
||||
onClose()
|
||||
onClose: (event?: Event) => {
|
||||
onClose(event)
|
||||
},
|
||||
onToggle: () => {
|
||||
onToggle: (event?: Event) => {
|
||||
if (unref(open)) {
|
||||
onClose()
|
||||
onClose(event)
|
||||
} else {
|
||||
onOpen()
|
||||
onOpen(event)
|
||||
}
|
||||
},
|
||||
onShow: () => {
|
||||
emit('show')
|
||||
emit('show', toggleReason.value)
|
||||
},
|
||||
onHide: () => {
|
||||
emit('hide')
|
||||
emit('hide', toggleReason.value)
|
||||
},
|
||||
onBeforeShow: () => {
|
||||
emit('before-show')
|
||||
emit('before-show', toggleReason.value)
|
||||
},
|
||||
onBeforeHide: () => {
|
||||
emit('before-hide')
|
||||
emit('before-hide', toggleReason.value)
|
||||
},
|
||||
updatePopper,
|
||||
})
|
||||
|
||||
@@ -21,7 +21,6 @@
|
||||
import { defineComponent, inject, ref, toRef, unref } from 'vue'
|
||||
import { ElPopperTrigger } from '@element-plus/components/popper'
|
||||
import { composeEventHandlers } from '@element-plus/utils'
|
||||
import { EVENT_CODE } from '@element-plus/constants'
|
||||
import { useNamespace } from '@element-plus/hooks'
|
||||
import { TOOLTIP_INJECTION_KEY } from './tokens'
|
||||
import { useTooltipTriggerProps } from './tooltip'
|
||||
@@ -89,7 +88,8 @@ export default defineComponent({
|
||||
stopWhenControlledOrDisabled,
|
||||
(e: KeyboardEvent) => {
|
||||
const { code } = e
|
||||
if (code === EVENT_CODE.enter || code === EVENT_CODE.space) {
|
||||
if (props.triggerKeys.includes(code)) {
|
||||
e.preventDefault()
|
||||
onToggle(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ export const createModelToggleComposable = <T extends string>(name: T) => {
|
||||
|
||||
const useModelToggle = ({
|
||||
indicator,
|
||||
toggleReason,
|
||||
shouldHideWhenRouteChanges,
|
||||
shouldProceed,
|
||||
onShow,
|
||||
@@ -51,30 +52,35 @@ export const createModelToggleComposable = <T extends string>(name: T) => {
|
||||
// condition
|
||||
const isModelBindingAbsent = computed(() => props[name] === null)
|
||||
|
||||
const doShow = () => {
|
||||
const doShow = (event?: Event) => {
|
||||
if (indicator.value === true) {
|
||||
return
|
||||
}
|
||||
|
||||
indicator.value = true
|
||||
if (toggleReason) {
|
||||
toggleReason.value = event
|
||||
}
|
||||
if (isFunction(onShow)) {
|
||||
onShow()
|
||||
onShow(event)
|
||||
}
|
||||
}
|
||||
|
||||
const doHide = () => {
|
||||
const doHide = (event?: Event) => {
|
||||
if (indicator.value === false) {
|
||||
return
|
||||
}
|
||||
|
||||
indicator.value = false
|
||||
|
||||
if (toggleReason) {
|
||||
toggleReason.value = event
|
||||
}
|
||||
if (isFunction(onHide)) {
|
||||
onHide()
|
||||
onHide(event)
|
||||
}
|
||||
}
|
||||
|
||||
const show = () => {
|
||||
const show = (event?: Event) => {
|
||||
if (
|
||||
props.disabled === true ||
|
||||
(isFunction(shouldProceed) && !shouldProceed())
|
||||
@@ -88,11 +94,11 @@ export const createModelToggleComposable = <T extends string>(name: T) => {
|
||||
}
|
||||
|
||||
if (isModelBindingAbsent.value || !shouldEmit) {
|
||||
doShow()
|
||||
doShow(event)
|
||||
}
|
||||
}
|
||||
|
||||
const hide = () => {
|
||||
const hide = (event?: Event) => {
|
||||
if (props.disabled === true || !isClient) return
|
||||
|
||||
const shouldEmit = hasUpdateHandler.value && isClient
|
||||
@@ -102,7 +108,7 @@ export const createModelToggleComposable = <T extends string>(name: T) => {
|
||||
}
|
||||
|
||||
if (isModelBindingAbsent.value || !shouldEmit) {
|
||||
doHide()
|
||||
doHide(event)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,8 +184,9 @@ export type UseModelToggleProps = ExtractPropTypes<typeof useModelToggleProps>
|
||||
|
||||
export type ModelToggleParams = {
|
||||
indicator: Ref<boolean>
|
||||
toggleReason?: Ref<Event | undefined>
|
||||
shouldHideWhenRouteChanges?: Ref<boolean>
|
||||
shouldProceed?: () => boolean
|
||||
onShow?: () => void
|
||||
onHide?: () => void
|
||||
onShow?: (event?: Event) => void
|
||||
onHide?: (event?: Event) => void
|
||||
}
|
||||
|
||||
@@ -86,6 +86,9 @@ export default {
|
||||
noData: 'No data',
|
||||
placeholder: 'Select',
|
||||
},
|
||||
dropdown: {
|
||||
toggleDropdown: 'Toggle Dropdown',
|
||||
},
|
||||
cascader: {
|
||||
noMatch: 'No matching data',
|
||||
loading: 'Loading',
|
||||
|
||||
Reference in New Issue
Block a user