feat(tooltip): tooltip component

This commit is contained in:
JeremyWuuuuu 2020-08-25 22:25:46 +08:00 committed by jeremywu
parent 1584f07f0c
commit af45dbd070
13 changed files with 452 additions and 282 deletions

View File

@ -1,5 +1,2 @@
import ClickOutside from './click-outside/index'
export { default as ClickOutside } from './click-outside'
export {
ClickOutside,
}

1
packages/hooks/index.ts Normal file
View File

@ -0,0 +1 @@
export { default as useEvents } from './use-events'

View File

@ -0,0 +1,12 @@
{
"name": "@element-plus/hooks",
"version": "0.0.0",
"main": "dist/index.js",
"license": "MIT",
"peerDependencies": {
"vue": "^3.0.0-rc.6"
},
"devDependencies": {
"@vue/test-utils": "^2.0.0-beta.0"
}
}

View File

@ -0,0 +1,23 @@
import { watch } from 'vue'
import { on, off } from '@element-plus/utils/dom'
import type { Ref } from 'vue'
type Events = Array<{
name: string
handler: (...args: any[]) => any
}>
export default (el: Ref<HTMLElement>, events: Events) => {
events.map(({ name, handler }) => {
on(el.value, name, handler)
})
watch(el, (_, __, onCleanup) => {
onCleanup(() => {
events.map(({ name, handler }) => {
off(el.value, name, handler)
})
})
})
}

View File

@ -83,8 +83,14 @@ describe('Popper.vue', () => {
let wrapper = _mount()
const selector = '[role="tooltip"]'
expect(wrapper.find(selector).exists()).toBe(false)
// Due to the parent node of popper is Transition so we should match the grandparent
expect(document.querySelector(selector).parentElement.parentElement).toBe(document.body)
/**
* Current layout of `ElPopper`
* --> Teleport
* --> mask
* --> transition
* --> popper
*/
wrapper = _mount({
appendToBody: false,
})

View File

@ -1,14 +1,7 @@
<script lang="ts">
import {
computed,
defineComponent,
getCurrentInstance,
h,
ref,
onBeforeUnmount,
onMounted,
onUpdated,
watch,
Fragment,
Teleport,
Transition,
@ -16,47 +9,32 @@ import {
vShow,
} from 'vue'
import { isArray } from '@vue/shared'
import { debounce } from 'lodash'
import { createPopper } from '@popperjs/core'
import { generateId } from '@element-plus/utils/util'
import { on, off } from '@element-plus/utils/dom'
import throwError from '@element-plus/utils/error'
import { UPDATE_MODEL_EVENT } from '@element-plus/utils/constants'
import { ClickOutside } from '@element-plus/directives'
import { default as usePopper, DEFAULT_TRIGGER, UPDATE_VALUE_EVENT } from './usePopper'
import useModifier from './useModifier'
import type { PropType } from 'vue'
import type { PropType, Ref } from 'vue'
import type { Effect, Offset, Placement, PopperInstance, PositioningStrategy, RefElement, Options } from './popper'
import type {
Effect,
Offset,
Options,
Placement,
PositioningStrategy,
TriggerType,
IPopperOptions,
} from './popper'
const stop = (e: Event) => e.stopPropagation()
const getTrigger = () => {
const { subTree: { children } } = getCurrentInstance()
// SubTree is formed by <slot name="trigger"/><popper />
// So that the trigger element is within the slot, we need to take it out of the slot in order to attach
// events on top of it
const targetSlot = children[0]
if (targetSlot.length > 1) {
console.warn('Popper will only be attached to the first child')
}
// This indicates if the slot is rendered with directives (e.g. v-if) or templates (e.g. <template />)
// if it's true, then the children needs to be taken by accessing targetSlots.children to get it
const trigger: HTMLElement = targetSlot.type === Fragment
? targetSlot.children[0].el
: targetSlot.el
if (!trigger) {
throwError(compName, 'Cannot find referrer to attach popper to')
}
return trigger
}
const compName = 'ElPopper'
export default defineComponent({
name: compName,
directives: {
ClickOutside,
},
props: {
arrowOffset: {
type: Number,
@ -88,7 +66,7 @@ export default defineComponent({
},
disabled: {
type: Boolean,
defualt: false,
default: false,
},
effect: {
type: String as PropType<Effect>,
@ -154,216 +132,50 @@ export default defineComponent({
type: String,
default: 'el-fade-in-linear',
},
trigger: {
type: [String, Array] as PropType<TriggerType | Array<TriggerType>>,
default: DEFAULT_TRIGGER,
},
tabIndex: {
type: String,
default: '0',
},
modelValue: {
type: Boolean,
default: undefined,
validator: val => typeof val === 'boolean',
},
value: {
type: Boolean,
default: false,
},
},
emits: [UPDATE_MODEL_EVENT],
setup(props, { slots, emit }) {
const popperRef = ref<RefElement>(null)
const arrowRef = ref<RefElement>(null)
const trigger = ref<RefElement>(null)
const exceptionState = ref(false)
const show = ref(false)
const popperId = ref(`el-tooltip-${generateId()}`)
const popperInstance = ref<Nullable<PopperInstance>>(null)
const timeout = ref<NodeJS.Timeout>(null)
const timeoutPending = ref<NodeJS.Timeout>(null)
const visible = computed(() => {
return props.manualMode ? props.value : !props.disabled && show.value
})
const popperOptions = computed(() => {
return {
modifierOptions: {
arrowOffset: props.arrowOffset,
arrowRef: arrowRef.value,
boundariesPadding: props.boundariesPadding,
cutoff: props.cutoff,
offset: props.offset,
showArrow: props.showArrow,
fallbackOptions: props.flip ? {} : { fallbackPlacements: [] },
},
placement: props.placement,
strategy: props.strategy,
}
})
const clearTimer = (timer: Ref<Nullable<NodeJS.Timeout>>) => {
clearTimeout(timer.value)
timer.value = null
}
if (!slots.trigger) {
emits: [UPDATE_VALUE_EVENT],
setup(props, ctx) {
if (!ctx.slots.trigger) {
throwError(compName, 'Trigger must be provided')
}
// this is a reference that we need to pass down to child component
// to obtain the child instance
function doDestroy(forceDestroy: boolean) {
/* istanbul ignore if */
if (!popperInstance.value || (visible.value && !forceDestroy)) return
detach()
}
function detach() {
popperInstance.value.destroy()
popperInstance.value = null
const _trigger = trigger.value
off(_trigger, 'mouseenter', _show)
off(_trigger, 'mouseleave', _hide)
off(_trigger, 'focus', handleFocus)
off(_trigger, 'blur', handleBlur)
trigger.value = null
}
const showPopper = () => {
if (!exceptionState.value || props.manualMode || props.disabled) return
clearTimer(timeout)
const handleHideAfter = () => {
if (props.hideAfter > 0) {
timeoutPending.value = setTimeout(() => {
show.value = false
}, props.hideAfter)
}
}
if (props.showAfter === 0) {
show.value = true
handleHideAfter()
} else {
timeout.value = setTimeout(() => {
show.value = true
handleHideAfter()
}, props.showAfter)
}
}
const debouncedClose = debounce(() => {
if (props.enterable && exceptionState.value) return
clearTimer(timeout)
if (timeoutPending.value !== null) {
clearTimer(timeoutPending)
}
show.value = false
if (props.disabled) {
doDestroy(true)
}
}, props.closeDelay)
function setExpectionState(state: boolean) {
if (!state) {
clearTimer(timeoutPending)
}
exceptionState.value = state
}
function _show() {
setExpectionState(true)
showPopper()
}
function _hide() {
setExpectionState(false)
debouncedClose()
}
function handleBlur() {
_hide()
}
function handleFocus() {
_show()
}
function initializePopper() {
const _trigger = getTrigger()
trigger.value = _trigger
popperInstance.value = createPopper(_trigger, popperRef.value,
props.popperOptions !== null
? props.popperOptions
: {
placement: popperOptions.value.placement,
onFirstUpdate: () => {
popperInstance.value.forceUpdate()
},
strategy: popperOptions.value.strategy,
modifiers: useModifier(popperOptions.value.modifierOptions),
})
_trigger.setAttribute('aria-describedby', popperId.value)
_trigger.setAttribute('tabindex', props.tabIndex)
_trigger.classList.add(props.class)
on(_trigger, 'mouseenter', _show)
on(_trigger, 'mouseleave', _hide)
on(_trigger, 'focus', handleFocus)
on(_trigger, 'blur', handleBlur)
}
watch(
() => visible.value,
val => {
if (popperInstance.value) {
popperInstance.value.update()
} else {
initializePopper()
}
if (props.manualMode) {
emit(UPDATE_MODEL_EVENT, val)
}
},
)
watch(() => popperOptions.value, val => {
if (!popperInstance.value) return
popperInstance.value.setOptions({
placement: val.placement,
strategy: val.strategy,
modifiers: useModifier(val.modifierOptions),
})
popperInstance.value.update()
})
onMounted(() => {
initializePopper()
})
onBeforeUnmount(() => {
doDestroy(true)
})
onUpdated(() => {
const _trigger = getTrigger()
if (_trigger !== trigger.value && popperInstance.value) {
detach()
}
if (popperInstance.value) {
popperInstance.value.update()
} else {
initializePopper()
}
})
const {
arrowRef,
clickMask,
doDestroy,
onShow,
onHide,
popperInstance,
popperId,
popperRef,
initializePopper,
visible,
} = usePopper(props as IPopperOptions, ctx)
return {
arrowRef,
clickMask,
popperId,
doDestroy,
_show,
_hide,
onShow,
onHide,
popperRef,
popperInstance,
initializePopper,
visible,
}
},
@ -408,8 +220,8 @@ export default defineComponent({
id: this.popperId,
ref: 'popperRef',
role: 'tooltip',
onMouseEnter: this._show,
onMouseLeave: this._hide,
onMouseEnter: this.onShow,
onMouseLeave: this.onHide,
onClick: stop,
},
[
@ -434,7 +246,17 @@ export default defineComponent({
{
to: 'body',
},
popper,
withDirectives(
h(
'div',
{
class: 'el-popper__mask',
onClick: this.clickMask,
},
popper,
),
[[ClickOutside, () => this.$emit(UPDATE_VALUE_EVENT, false)]],
),
)
: popper,
],
@ -444,6 +266,18 @@ export default defineComponent({
</script>
<style>
.el-popper__mask {
font-size: 14px;
font-weight: 400;
position: fixed;
z-index: 1000000;
top: 0px;
left: 0px;
bottom: 0px;
right: 0px;
visibility: hidden;
}
.el-popper {
position: absolute;
border-radius: 4px;
@ -453,6 +287,7 @@ export default defineComponent({
line-height: 1.2;
min-width: 10px;
word-wrap: break-word;
visibility: visible;
}
.el-popper__arrow,

View File

@ -1,10 +1,33 @@
import type { Placement, PositioningStrategy, Instance as PopperInstance, Options } from '@popperjs/core'
export type Effect = 'dark' | 'light';
export type RefElement = Nullable<HTMLElement>
export type Offset = [number, number] | number
export type {
Placement,
Instance as PopperInstance,
PositioningStrategy,
Options,
} from '@popperjs/core'
export { Placement, PositioningStrategy, PopperInstance, Options }
export type TriggerType = 'click' | 'hover' | 'focus' | 'contextMenu'
export type IPopperOptions = {
arrowOffset: number
boundariesPadding: number
class: string
closeDelay: number
cutoff: boolean
disabled: boolean
enterable: boolean
flip: boolean
hideAfter: number
manualMode: boolean
offset: number
placement: Placement
popperOptions: Options
showAfter: number
showArrow: boolean
strategy: PositioningStrategy
tabIndex: string
value: boolean
}

View File

@ -0,0 +1,243 @@
import { computed, Fragment, getCurrentInstance, ref, onMounted, onBeforeUnmount, onUpdated, watch } from 'vue'
import { debounce } from 'lodash'
import { createPopper } from '@popperjs/core'
import { generateId } from '@element-plus/utils/util'
import { off, addClass } from '@element-plus/utils/dom'
import throwError from '@element-plus/utils/error'
import useEvents from '@element-plus/hooks/use-events'
import useModifier from './useModifier'
import type { Ref } from 'vue'
import type { SetupContext } from '@vue/runtime-core'
import type { IPopperOptions, RefElement, PopperInstance } from './popper'
export const DEFAULT_TRIGGER = ['click', 'hover', 'focus', 'contextMenu']
export const UPDATE_VALUE_EVENT = 'updateValue'
const clearTimer = (timer: Ref<Nullable<NodeJS.Timeout>>) => {
clearTimeout(timer.value)
timer.value = null
}
const getTrigger = () => {
const { subTree: { children } } = getCurrentInstance()
// SubTree is formed by <slot name="trigger"/><popper />
// So that the trigger element is within the slot, we need to take it out of the slot in order to attach
// events on top of it
const targetSlot = children[0]
if (targetSlot.length > 1) {
console.warn('Popper will only be attached to the first child')
}
// This indicates if the slot is rendered with directives (e.g. v-if) or templates (e.g. <template />)
// if it's true, then the children needs to be taken by accessing targetSlots.children to get it
const trigger: HTMLElement = targetSlot.type === Fragment
? targetSlot.children[0].el
: targetSlot.el
if (!trigger) {
throwError('ElPopper', 'Cannot find referrer to attach popper to')
}
return trigger
}
export default <T extends IPopperOptions>(props: T, ctx: SetupContext) => {
const arrowRef = ref<RefElement>(null)
const trigger = ref<RefElement>(null)
const exceptionState = ref(false)
const popperInstance = ref<Nullable<PopperInstance>>(null)
const popperId = ref(`el-popper-${generateId()}`)
const popperRef = ref<RefElement>(null)
const show = ref(false)
const timeout = ref<NodeJS.Timeout>(null)
const timeoutPending = ref<NodeJS.Timeout>(null)
const popperOptions = computed(() => {
return {
modifierOptions: {
arrowOffset: props.arrowOffset,
arrowRef: arrowRef.value,
boundariesPadding: props.boundariesPadding,
cutoff: props.cutoff,
offset: props.offset,
showArrow: props.showArrow,
fallbackOptions: props.flip ? {} : { fallbackPlacements: [] },
},
placement: props.placement,
strategy: props.strategy,
}
})
const visible = computed(() => {
return props.manualMode ? props.value : !props.disabled && show.value
})
const showPopper = () => {
if (!exceptionState.value || props.manualMode || props.disabled) return
clearTimer(timeout)
const handleHideAfter = () => {
if (props.hideAfter > 0) {
timeoutPending.value = setTimeout(() => {
show.value = false
}, props.hideAfter)
}
}
if (props.showAfter === 0) {
show.value = true
handleHideAfter()
} else {
timeout.value = setTimeout(() => {
show.value = true
handleHideAfter()
}, props.showAfter)
}
}
const closePopper = debounce(() => {
if (props.enterable && exceptionState.value) return
clearTimer(timeout)
if (timeoutPending.value !== null) {
clearTimer(timeoutPending)
}
show.value = false
if (props.disabled) {
doDestroy(true)
}
}, props.closeDelay)
function onShow() {
setExpectionState(true)
showPopper()
}
function onHide() {
setExpectionState(false)
closePopper()
}
function setExpectionState(state: boolean) {
if (!state) {
clearTimer(timeoutPending)
}
exceptionState.value = state
}
function initializePopper() {
const _trigger = getTrigger()
trigger.value = _trigger
popperInstance.value = createPopper(_trigger, popperRef.value,
props.popperOptions !== null
? props.popperOptions
: {
placement: popperOptions.value.placement,
onFirstUpdate: () => {
popperInstance.value.forceUpdate()
},
strategy: popperOptions.value.strategy,
modifiers: useModifier(popperOptions.value.modifierOptions),
})
_trigger.setAttribute('aria-describedby', popperId.value)
_trigger.setAttribute('tabindex', props.tabIndex)
addClass(_trigger, props.class)
const events = [
{
name: 'mouseenter',
handler: onShow,
},
{
name: 'mouseleave',
handler: onHide,
},
{
name: 'focus',
handler: onShow,
},
{
name: 'blur',
handler: onHide,
},
]
useEvents(trigger, events)
}
function doDestroy(forceDestroy: boolean) {
/* istanbul ignore if */
if (!popperInstance.value || (visible.value && !forceDestroy)) return
detachPopper()
}
function clickMask() {
if (props.manualMode) {
ctx.emit(UPDATE_VALUE_EVENT, false)
}
}
function detachPopper() {
popperInstance.value.destroy()
popperInstance.value = null
const _trigger = trigger.value
off(_trigger, 'mouseenter', onShow)
off(_trigger, 'mouseleave', onHide)
off(_trigger, 'focus', onShow)
off(_trigger, 'blur', onHide)
trigger.value = null
}
watch(popperOptions, val => {
if (!popperInstance.value) return
popperInstance.value.setOptions({
placement: val.placement,
strategy: val.strategy,
modifiers: useModifier(val.modifierOptions),
})
popperInstance.value.update()
})
watch(visible,
() => {
if (popperInstance.value) {
popperInstance.value.update()
} else {
initializePopper()
}
},
)
onMounted(() => {
initializePopper()
})
onBeforeUnmount(() => {
doDestroy(true)
})
onUpdated(() => {
const _trigger = getTrigger()
if (_trigger !== trigger.value && popperInstance.value) {
detachPopper()
}
if (popperInstance.value) {
popperInstance.value.update()
} else {
initializePopper()
}
})
return {
clickMask,
doDestroy,
onShow,
onHide,
initializePopper,
arrowRef,
popperId,
popperInstance,
popperRef,
visible,
}
}

View File

@ -1,15 +1,42 @@
import { h, nextTick } from 'vue'
import { mount } from '@vue/test-utils'
import Tooltip from '../src/index.vue'
import type { VNode } from 'vue'
const AXIOM = 'Rem is the best girl'
const _mount = (props: any = {}, content: string | VNode = '') => mount(Tooltip, {
slots: {
default: () => h('div', AXIOM),
content: () => content,
},
props,
})
const selector = '.el-popper'
describe('Tooltip.vue', () => {
beforeEach(() => {
document.body.innerHTML = ''
})
test('render test', () => {
const wrapper = mount(Tooltip, {
slots: {
default: AXIOM,
},
const wrapper = _mount(undefined, AXIOM)
expect(wrapper.html()).toContain(AXIOM)
})
test('manual mode', async () => {
const wrapper = _mount({
manual: true,
modelValue: false,
}, AXIOM)
// since VTU does not provide any functionality for testing teleporting components
expect(document.querySelector(selector).getAttribute('style')).toContain('display: none')
await wrapper.setProps({
modelValue: true,
})
expect(wrapper.text()).toEqual(AXIOM)
await nextTick()
expect(document.querySelector(selector).getAttribute('style')).not.toContain('display: none')
})
})

View File

@ -2,10 +2,12 @@
<div class="box">
<div class="top">
<el-tooltip
v-model="model"
class="item"
effect="dark"
content="Top Left prompts info"
placement="top-start"
:manual="true"
>
<el-button>top-start</el-button>
</el-tooltip>
@ -107,6 +109,22 @@
</div>
</div>
</template>
<script>
import ClickOutside from '@element-plus/directives/click-outside'
export default {
directives: {
ClickOutside,
},
data() {
return {
model: true,
}
},
methods: {
},
}
</script>
<style>
.box {
width: 400px;

View File

@ -1,7 +1,8 @@
<script lang='ts'>
import { computed, defineComponent, h, watch } from 'vue'
import { defineComponent, h } from 'vue'
import { Popper as ElPopper } from '@element-plus/popper'
import { UPDATE_MODEL_EVENT } from '@element-plus/utils/constants'
import throwError from '@element-plus/utils/error'
import type { PropType } from 'vue'
import type {
@ -86,11 +87,6 @@ export default defineComponent({
type: String,
default: 'el-fade-in-linear',
},
value: {
type: Boolean,
default: undefined,
validator: val => typeof val === 'boolean',
},
visibleArrow: {
type: Boolean,
default: true,
@ -99,21 +95,18 @@ export default defineComponent({
emits: [UPDATE_MODEL_EVENT],
setup(props, ctx) {
// init here
const shouldShow = computed(() => {
return props.manual
? props.modelValue !== undefined
? props.modelValue
: props.value
: null
})
watch(shouldShow, (val, preVal) => {
if (val !== preVal) {
ctx.emit(UPDATE_MODEL_EVENT, val)
}
})
// when manual mode is true, v-model must be passed down
if (props.manual && typeof props.modelValue === 'undefined') {
throwError('[ElTooltip]', 'You need to pass a v-model to el-tooltip when `manual` is true')
}
const onUpdateValue = val => {
ctx.emit(UPDATE_MODEL_EVENT, val)
}
return {
shouldShow,
onUpdateValue,
}
},
render() {
@ -127,10 +120,10 @@ export default defineComponent({
manual,
offset,
openDelay,
onUpdateValue,
placement,
popperOptions,
showAfter,
shouldShow,
transition,
tabindex,
visibleArrow,
@ -144,7 +137,6 @@ export default defineComponent({
enterable,
hideAfter,
manualMode: manual,
modelValue: shouldShow,
offset,
placement,
showAfter: openDelay || showAfter, // this is for mapping API due to we decided to rename the current openDelay API to showAfter for better readability,
@ -152,6 +144,8 @@ export default defineComponent({
transition,
tabIndex: String(tabindex),
popperOptions, // Breakings!: Once popperOptions is provided, the whole popper is under user's control, ElPopper nolonger generates the default options for popper, this is by design if the user wants the full contorl on @PopperJS, read the doc @https://popper.js.org/docs/v2/
value: this.modelValue,
onUpdateValue,
},
{
default: () => ($slots.content ? $slots.content() : content),

View File

@ -1,20 +1,10 @@
import isServer from './isServer'
const SPECIAL_CHARS_REGEXP = /([\:\-\_]+(.))/g
const MOZ_HACK_REGEXP = /^moz([A-Z])/
import { camelize } from './util'
/* istanbul ignore next */
const trim = function(s: string) {
return (s || '').replace(/^[\s\uFEFF]+|[\s\uFEFF]+$/g, '')
}
/* istanbul ignore next */
const camelCase = function(name: string) {
return name
.replace(SPECIAL_CHARS_REGEXP, function(_, __, letter, offset) {
return offset ? letter.toUpperCase() : letter
})
.replace(MOZ_HACK_REGEXP, 'Moz$1')
}
/* istanbul ignore next */
export const on = function(
@ -110,7 +100,7 @@ export function removeClass(el: HTMLElement, cls: string): void {
/* istanbul ignore next */
// Here I want to use the type CSSStyleDeclaration, but the definition for CSSStyleDeclaration
// has { [index: number]: string } in its type annotation, which does not satisfy the method
// camelCase(s: string)
// camelize(s: string)
// Same as the return type
export const getStyle = function(
element: HTMLElement,
@ -118,7 +108,7 @@ export const getStyle = function(
): string {
if (isServer) return
if (!element || !styleName) return null
styleName = camelCase(styleName)
styleName = camelize(styleName)
if (styleName === 'float') {
styleName = 'cssFloat'
}
@ -145,7 +135,7 @@ export function setStyle(
}
}
} else {
styleName = camelCase(styleName)
styleName = camelize(styleName)
element.style[styleName] = value
}

View File

@ -1,5 +1,5 @@
import isServer from './isServer'
import { isObject, capitalize, hyphenate, looseEqual, extend } from '@vue/shared'
import { isObject, capitalize, hyphenate, looseEqual, extend, camelize } from '@vue/shared'
import { isEmpty, castArray, isEqual } from 'lodash'
import type { AnyFunction } from './types'
@ -108,6 +108,7 @@ export {
isEqual,
isObject,
capitalize,
camelize,
looseEqual,
extend,
}