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:
opengraphica
2022-05-29 23:51:25 -04:00
committed by GitHub
parent 57ac1e605e
commit af732b124b
17 changed files with 381 additions and 62 deletions

View File

@@ -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

View File

@@ -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')
})
})
})

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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,

View File

@@ -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),
},

View File

@@ -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,
}

View File

@@ -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

View File

@@ -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,

View File

@@ -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

View File

@@ -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

View File

@@ -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"

View File

@@ -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({

View File

@@ -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,
})

View File

@@ -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)
}
}

View File

@@ -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
}

View File

@@ -86,6 +86,9 @@ export default {
noData: 'No data',
placeholder: 'Select',
},
dropdown: {
toggleDropdown: 'Toggle Dropdown',
},
cascader: {
noMatch: 'No matching data',
loading: 'Loading',