feat: integrate use popper (#11045)

* feat: integrate use popper

* Integrate popper with use popper hook.
* Reorganize code for better readabilities.

* fix: contentStyle typing

* fix: test failure

* fix: slider placement testing

* refactor: slider test case refactoring

* fix: virtual triggering

---------

Co-authored-by: JeremyWuuuuu <15975785+JeremyWuuuuu@users.noreply.github.com>
This commit is contained in:
Jeremy 2023-01-31 11:58:52 +08:00 committed by GitHub
parent 15e09cebfa
commit e8bbdf974b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 308 additions and 165 deletions

View File

@ -1,4 +1,4 @@
import { nextTick, ref } from 'vue'
import { defineComponent, nextTick, ref } from 'vue'
import { mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { POPPER_INJECTION_KEY } from '@element-plus/tokens'
@ -14,6 +14,21 @@ const popperInjection = {
contentRef: ref(),
}
const TestComponent = defineComponent({
setup() {
return {
contentRef: ref(),
}
},
render() {
return (
<ElContent ref="contentRef" {...this.$attrs}>
{AXIOM}
</ElContent>
)
},
})
const mountContent = (props = {}) =>
mount(<ElContent {...props}>{AXIOM}</ElContent>, {
global: {
@ -23,6 +38,15 @@ const mountContent = (props = {}) =>
},
})
const mountWrappedContent = (props = {}) =>
mount(<TestComponent {...props} />, {
global: {
provide: {
[POPPER_INJECTION_KEY as symbol]: popperInjection,
},
},
})
describe('<ElPopperContent />', () => {
describe('with triggerRef provided', () => {
const triggerKls = 'el-popper__trigger'
@ -48,8 +72,16 @@ describe('<ElPopperContent />', () => {
expect(wrapper.html()).toContain(AXIOM)
expect(popperInjection.popperInstanceRef.value).toBeDefined()
expect(wrapper.classes()).toEqual(['el-popper', 'is-dark'])
expect(wrapper.vm.contentStyle).toHaveLength(3)
expect(wrapper.vm.contentStyle[0]).toHaveProperty('zIndex')
expect(wrapper.vm.contentStyle[1]).toBeUndefined()
expect(wrapper.vm.contentStyle[1]).toEqual({})
expect(wrapper.vm.contentStyle[2]).toEqual(
expect.objectContaining({
position: 'absolute',
top: '0',
left: '0',
})
)
})
it('should be able to be pure and themed', async () => {
@ -94,7 +126,6 @@ describe('<ElPopperContent />', () => {
describe('instantiate popper instance', () => {
it('should be able to update the current instance', async () => {
wrapper = mountContent()
await nextTick()
vi.spyOn(
@ -113,10 +144,11 @@ describe('<ElPopperContent />', () => {
})
it('should be able to update the reference node', async () => {
wrapper = mountContent()
const w = mountWrappedContent()
await nextTick()
const oldInstance = wrapper.vm.popperInstanceRef
const { contentRef } = w.vm
const oldInstance = contentRef.popperInstanceRef
const newRef = document.createElement('div')
newRef.classList.add('new-ref')
@ -124,13 +156,13 @@ describe('<ElPopperContent />', () => {
popperInjection.triggerRef.value = newRef
await nextTick()
expect(wrapper.vm.popperInstanceRef).not.toStrictEqual(oldInstance)
expect(contentRef.popperInstanceRef).not.toStrictEqual(oldInstance)
popperInjection.triggerRef.value = undefined
await nextTick()
expect(wrapper.vm.popperInstanceRef).toBeUndefined()
expect(contentRef.popperInstanceRef).toBeUndefined()
})
})
})

View File

@ -1,5 +1,10 @@
<template>
<span ref="arrowRef" :class="ns.e('arrow')" data-popper-arrow="" />
<span
ref="arrowRef"
:class="ns.e('arrow')"
:style="arrowStyle"
data-popper-arrow
/>
</template>
<script lang="ts" setup>
@ -16,7 +21,7 @@ defineOptions({
const props = defineProps(popperArrowProps)
const ns = useNamespace('popper')
const { arrowOffset, arrowRef } = inject(
const { arrowOffset, arrowRef, arrowStyle } = inject(
POPPER_CONTENT_INJECTION_KEY,
undefined
)!

View File

@ -0,0 +1,3 @@
export * from './use-content'
export * from './use-content-dom'
export * from './use-focus-trap'

View File

@ -0,0 +1,59 @@
import { computed, ref, unref } from 'vue'
import { useNamespace, useZIndex } from '@element-plus/hooks'
import type { CSSProperties, StyleValue } from 'vue'
import type { UsePopperReturn } from '@element-plus/hooks'
import type { UsePopperContentReturn } from './use-content'
import type { PopperContentProps } from '../content'
export const usePopperContentDOM = (
props: PopperContentProps,
{
attributes,
styles,
role,
}: Pick<UsePopperReturn, 'attributes' | 'styles'> &
Pick<UsePopperContentReturn, 'role'>
) => {
const { nextZIndex } = useZIndex()
const ns = useNamespace('popper')
const contentAttrs = computed(() => unref(attributes).popper)
const contentZIndex = ref<number>(props.zIndex || nextZIndex())
const contentClass = computed(() => [
ns.b(),
ns.is('pure', props.pure),
ns.is(props.effect),
props.popperClass,
])
const contentStyle = computed<StyleValue[]>(() => {
return [
{ zIndex: unref(contentZIndex) } as CSSProperties,
props.popperStyle || {},
unref(styles).popper as CSSProperties,
]
})
const ariaModal = computed<string | undefined>(() =>
role.value === 'dialog' ? 'false' : undefined
)
const arrowStyle = computed(
() => (unref(styles).arrow || {}) as CSSProperties
)
const updateZIndex = () => {
contentZIndex.value = props.zIndex || nextZIndex()
}
return {
ariaModal,
arrowStyle,
contentAttrs,
contentClass,
contentStyle,
contentZIndex,
updateZIndex,
}
}
export type UsePopperContentDOMReturn = ReturnType<typeof usePopperContentDOM>

View File

@ -0,0 +1,89 @@
import { computed, inject, onMounted, ref, unref, watch } from 'vue'
import { isUndefined } from 'lodash-unified'
import { usePopper } from '@element-plus/hooks'
import { POPPER_INJECTION_KEY } from '@element-plus/tokens'
import { buildPopperOptions, unwrapMeasurableEl } from '../utils'
import type { Modifier } from '@popperjs/core'
import type { PartialOptions } from '@element-plus/hooks'
import type { PopperContentProps } from '../content'
const DEFAULT_ARROW_OFFSET = 0
export const usePopperContent = (props: PopperContentProps) => {
const { popperInstanceRef, contentRef, triggerRef, role } = inject(
POPPER_INJECTION_KEY,
undefined
)!
const arrowRef = ref<HTMLElement>()
const arrowOffset = ref<number>()
const eventListenerModifier = computed(() => {
return {
name: 'eventListeners',
enabled: !!props.visible,
} as Modifier<'eventListeners', any>
})
const arrowModifier = computed(() => {
const arrowEl = unref(arrowRef)
const offset = unref(arrowOffset) ?? DEFAULT_ARROW_OFFSET
// Seems like the `phase` and `fn` is required by Modifier type
// But on its documentation they didn't specify that.
// Refer to https://popper.js.org/docs/v2/modifiers/arrow/
return {
name: 'arrow',
enabled: !isUndefined(arrowEl),
options: {
element: arrowEl,
padding: offset,
},
} as any
})
const options = computed<PartialOptions>(() => {
return {
onFirstUpdate: () => {
update()
},
...buildPopperOptions(props, [
unref(arrowModifier),
unref(eventListenerModifier),
]),
}
})
const computedReference = computed(
() => unwrapMeasurableEl(props.referenceEl) || unref(triggerRef)
)
const { attributes, state, styles, update, forceUpdate, instanceRef } =
usePopper(computedReference, contentRef, options)
watch(instanceRef, (instance) => (popperInstanceRef.value = instance))
onMounted(() => {
watch(
() => unref(computedReference)?.getBoundingClientRect(),
() => {
update()
}
)
})
return {
attributes,
arrowRef,
contentRef,
instanceRef,
state,
styles,
role,
forceUpdate,
update,
}
}
export type UsePopperContentReturn = ReturnType<typeof usePopperContent>

View File

@ -0,0 +1,61 @@
import { ref } from 'vue'
import type { SetupContext } from 'vue'
import type { PopperContentEmits, PopperContentProps } from '../content'
export const usePopperContentFocusTrap = (
props: PopperContentProps,
emit: SetupContext<PopperContentEmits>['emit']
) => {
const trapped = ref(false)
const focusStartRef = ref<'container' | 'first' | HTMLElement>()
const onFocusAfterTrapped = () => {
emit('focus')
}
const onFocusAfterReleased = (event: CustomEvent) => {
if (event.detail?.focusReason !== 'pointer') {
focusStartRef.value = 'first'
emit('blur')
}
}
const onFocusInTrap = (event: FocusEvent) => {
if (props.visible && !trapped.value) {
if (event.target) {
focusStartRef.value = event.target as typeof focusStartRef.value
}
trapped.value = true
}
}
const onFocusoutPrevented = (event: CustomEvent) => {
if (!props.trapping) {
if (event.detail.focusReason === 'pointer') {
event.preventDefault()
}
trapped.value = false
}
}
const onReleaseRequested = () => {
trapped.value = false
emit('close')
}
return {
focusStartRef,
trapped,
onFocusAfterReleased,
onFocusAfterTrapped,
onFocusInTrap,
onFocusoutPrevented,
onReleaseRequested,
}
}
export type UsePopperContentFocusTrapReturn = ReturnType<
typeof usePopperContentFocusTrap
>

View File

@ -1,6 +1,7 @@
<template>
<div
ref="popperContentRef"
ref="contentRef"
v-bind="contentAttrs"
:style="contentStyle"
:class="contentClass"
tabindex="-1"
@ -25,7 +26,6 @@
<script lang="ts" setup>
import {
computed,
inject,
onBeforeUnmount,
onMounted,
@ -36,20 +36,20 @@ import {
} 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'
import {
POPPER_CONTENT_INJECTION_KEY,
POPPER_INJECTION_KEY,
formItemContextKey,
} from '@element-plus/tokens'
import { isElement } from '@element-plus/utils'
import { popperContentEmits, popperContentProps } from './content'
import { buildPopperOptions, unwrapMeasurableEl } from './utils'
import {
usePopperContent,
usePopperContentDOM,
usePopperContentFocusTrap,
} from './composables'
import type { WatchStopHandle } from 'vue'
import type { CreatePopperInstanceParams } from './content'
defineOptions({
name: 'ElPopperContent',
@ -59,18 +59,39 @@ const emit = defineEmits(popperContentEmits)
const props = defineProps(popperContentProps)
const { popperInstanceRef, contentRef, triggerRef, role } = inject(
POPPER_INJECTION_KEY,
undefined
)!
const {
focusStartRef,
trapped,
onFocusAfterReleased,
onFocusAfterTrapped,
onFocusInTrap,
onFocusoutPrevented,
onReleaseRequested,
} = usePopperContentFocusTrap(props, emit)
const { attributes, arrowRef, contentRef, styles, instanceRef, role, update } =
usePopperContent(props)
const {
ariaModal,
arrowStyle,
contentAttrs,
contentClass,
contentStyle,
updateZIndex,
} = usePopperContentDOM(props, {
styles,
attributes,
role,
})
const formItemContext = inject(formItemContextKey, undefined)
const { nextZIndex } = useZIndex()
const ns = useNamespace('popper')
const popperContentRef = ref<HTMLElement>()
const focusStartRef = ref<'container' | 'first' | HTMLElement>('first')
const arrowRef = ref<HTMLElement>()
const arrowOffset = ref<number>()
provide(POPPER_CONTENT_INJECTION_KEY, {
arrowStyle,
arrowRef,
arrowOffset,
})
@ -87,54 +108,14 @@ 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)
)
const contentStyle = computed(
() => [{ zIndex: unref(contentZIndex) }, props.popperStyle] as any
)
const contentClass = computed(() => [
ns.b(),
ns.is('pure', props.pure),
ns.is(props.effect),
props.popperClass,
])
const ariaModal = computed<string | undefined>(() => {
return role && role.value === 'dialog' ? 'false' : undefined
})
const createPopperInstance = ({
referenceEl,
popperContentEl,
arrowEl,
}: CreatePopperInstanceParams) => {
const options = buildPopperOptions(props, {
arrowEl,
arrowOffset: unref(arrowOffset),
})
return createPopper(referenceEl, popperContentEl, options)
}
const updatePopper = (shouldUpdateZIndex = true) => {
unref(popperInstanceRef)?.update()
shouldUpdateZIndex && (contentZIndex.value = props.zIndex || nextZIndex())
update()
shouldUpdateZIndex && updateZIndex()
}
const togglePopperAlive = () => {
const monitorable = { name: 'eventListeners', enabled: props.visible }
unref(popperInstanceRef)?.setOptions?.((options) => ({
...options,
modifiers: [...(options.modifiers || []), monitorable],
}))
updatePopper(false)
if (props.visible && props.focusOnShow) {
trapped.value = true
@ -143,74 +124,7 @@ const togglePopperAlive = () => {
}
}
const onFocusAfterTrapped = () => {
emit('focus')
}
const onFocusAfterReleased = (event: CustomEvent) => {
if (event.detail?.focusReason !== 'pointer') {
focusStartRef.value = 'first'
emit('blur')
}
}
const onFocusInTrap = (event: FocusEvent) => {
if (props.visible && !trapped.value) {
if (event.target) {
focusStartRef.value = event.target as typeof focusStartRef.value
}
trapped.value = true
}
}
const onFocusoutPrevented = (event: CustomEvent) => {
if (!props.trapping) {
if (event.detail.focusReason === 'pointer') {
event.preventDefault()
}
trapped.value = false
}
}
const onReleaseRequested = () => {
trapped.value = false
emit('close')
}
onMounted(() => {
let updateHandle: WatchStopHandle
watch(
computedReference,
(referenceEl) => {
updateHandle?.()
const popperInstance = unref(popperInstanceRef)
popperInstance?.destroy?.()
if (referenceEl) {
const popperContentEl = unref(popperContentRef)!
contentRef.value = popperContentEl
popperInstanceRef.value = createPopperInstance({
referenceEl,
popperContentEl,
arrowEl: unref(arrowRef),
})
updateHandle = watch(
() => referenceEl.getBoundingClientRect(),
() => updatePopper(),
{
immediate: true,
}
)
} else {
popperInstanceRef.value = undefined
}
},
{
immediate: true,
}
)
watch(
() => props.triggerTargetEl,
(triggerTargetEl, prevTriggerTargetEl) => {
@ -243,15 +157,6 @@ onMounted(() => {
)
watch(() => props.visible, togglePopperAlive, { immediate: true })
watch(
() =>
buildPopperOptions(props, {
arrowEl: unref(arrowRef),
arrowOffset: unref(arrowOffset),
}),
(option) => popperInstanceRef.value?.setOptions(option)
)
})
onBeforeUnmount(() => {
@ -267,7 +172,7 @@ defineExpose({
/**
* @description popperjs instance
*/
popperInstanceRef,
popperInstanceRef: instanceRef,
/**
* @description method for updating popper
*/

View File

@ -2,27 +2,22 @@ import { isClient, unrefElement } from '@vueuse/core'
import type { ComponentPublicInstance } from 'vue'
import type { MaybeRef } from '@vueuse/core'
import type { Modifier } from '@popperjs/core'
import type { Measurable } from '@element-plus/tokens'
import type { PopperCoreConfigProps } from './content'
type ArrowProps = {
arrowEl: HTMLElement | undefined
arrowOffset: number | undefined
}
export const buildPopperOptions = (
props: PopperCoreConfigProps,
arrowProps: ArrowProps
modifiers: Modifier<any, any>[] = []
) => {
const { placement, strategy, popperOptions } = props
const options = {
placement,
strategy,
...popperOptions,
modifiers: genModifiers(props),
modifiers: [...genModifiers(props), ...modifiers],
}
attachArrow(options, arrowProps)
deriveExtraModifiers(options, popperOptions?.modifiers)
return options
}
@ -70,16 +65,6 @@ function genModifiers(options: PopperCoreConfigProps) {
]
}
function attachArrow(options: any, { arrowEl, arrowOffset }: ArrowProps) {
options.modifiers.push({
name: 'arrow',
options: {
element: arrowEl,
padding: arrowOffset ?? 5,
},
} as any)
}
function deriveExtraModifiers(
options: any,
modifiers: PopperCoreConfigProps['popperOptions']['modifiers']

View File

@ -90,11 +90,12 @@ describe('Slider', () => {
it('placement', async () => {
const TOOLTIP_CLASS = 'custom_tooltip'
const PLACEMENT = 'right'
const PLACEMENT = 'left'
mount(() => <Slider tooltip-class={TOOLTIP_CLASS} placement={PLACEMENT} />)
await nextTick()
await nextTick() // here
expect(
(document.querySelector(`.${TOOLTIP_CLASS}`) as HTMLElement).dataset

View File

@ -101,7 +101,7 @@ export const usePopper = (
})
return {
state: computed(() => unref(instanceRef)?.state),
state: computed(() => ({ ...(unref(instanceRef)?.state || {}) })),
styles: computed(() => unref(states).styles),
attributes: computed(() => unref(states).attributes),
update: () => unref(instanceRef)?.update(),
@ -119,7 +119,7 @@ function deriveState(state: State) {
const styles = fromPairs(
elements.map(
(element) =>
[element, state.elements[element] || {}] as [
[element, state.styles[element] || {}] as [
string,
State['styles'][keyof State['styles']]
]
@ -141,3 +141,5 @@ function deriveState(state: State) {
attributes,
}
}
export type UsePopperReturn = ReturnType<typeof usePopper>

View File

@ -1,4 +1,4 @@
import type { ComputedRef, InjectionKey, Ref } from 'vue'
import type { CSSProperties, ComputedRef, InjectionKey, Ref } from 'vue'
import type { Instance } from '@popperjs/core'
export type Measurable = {
@ -21,6 +21,7 @@ export type ElPopperInjectionContext = {
export type ElPopperContentInjectionContext = {
arrowRef: Ref<HTMLElement | undefined>
arrowOffset: Ref<number | undefined>
arrowStyle: ComputedRef<CSSProperties>
}
export const POPPER_INJECTION_KEY: InjectionKey<ElPopperInjectionContext> =