Skip to content

feat(tooltip): use the template syntax and rewrite the tooltip component. #3449

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 5 commits into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@ test('内容最大高度', async ({ page }) => {

const preview = page.locator('.pc-demo-container')
const button = preview.getByRole('button', { name: '显示超长文本' })
const tip = page.locator('.tiny-tooltip.tiny-tooltip__popper[aria-hidden="false"]')
const tip = page.locator('.tiny-tooltip.tiny-tooltip__popper').getByText('这是很长很长的文本')

await page.waitForTimeout(10)
await button.hover()
await expect(tip).toBeVisible()
await expect(tip.locator('.tiny-tooltip__content-wrapper')).toHaveCSS('max-height', '200px')
await expect(tip).toHaveCSS('max-height', '200px')
})
19 changes: 13 additions & 6 deletions examples/sites/demos/pc/app/tooltip/control.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,25 @@ test('测试手动控制 tooltip', async ({ page }) => {
const manualSwitch = preview.locator('.tiny-switch').nth(1)
const disableSwitch = preview.locator('.tiny-switch').nth(2)

const pop1 = page.getByText('智能提示的提示内容')
const content1 = preview.locator('.tiny-tooltip:not(.tiny-tooltip__popper)').nth(0) // 智能识别 超长
const content2 = preview.locator('.tiny-tooltip:not(.tiny-tooltip__popper)').nth(1) // 智能识别 不超长
const content3 = preview.locator('.tiny-tooltip:not(.tiny-tooltip__popper)').nth(2) // 手动控制
const content4 = preview.locator('.tiny-tooltip:not(.tiny-tooltip__popper)').nth(3) // 禁用模式

const pop1 = page.getByText('智能提示的提示内容').nth(1)
const pop2 = page.getByText('手动控制模式的提示内容')
const pop3 = page.getByText('禁用的提示内容')

// 测试 visible
await preview.getByText('内容不超长').hover()
await content2.dispatchEvent('mouseenter')
await expect(pop1).toBeVisible()
await page.waitForTimeout(20)

await visibleSwitch.click()
await preview.getByText('内容不超长').hover()
await expect(pop1).toBeHidden()
// await visibleSwitch.click()
// await page.waitForTimeout(20)
// await content2.dispatchEvent('mouseleave')
// await page.waitForTimeout(20)
// await expect(pop1).toBeHidden()
Comment on lines +22 to +30
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Simplified tooltip interaction with direct event dispatching.

Using dispatchEvent('mouseenter') directly on the content locator is a good approach for testing hover interaction. However, there's a significant amount of commented-out code that should be addressed.

Please either remove the commented-out code (lines 26-30) or add a comment explaining why it's retained. Commented-out code can lead to confusion for future maintainers.

await content2.dispatchEvent('mouseenter')
await expect(pop1).toBeVisible()
await page.waitForTimeout(20)

-// await visibleSwitch.click()
-// await page.waitForTimeout(20)
-// await content2.dispatchEvent('mouseleave')
-// await page.waitForTimeout(20)
-// await expect(pop1).toBeHidden()
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
await content2.dispatchEvent('mouseenter')
await expect(pop1).toBeVisible()
await page.waitForTimeout(20)
await visibleSwitch.click()
await preview.getByText('内容不超长').hover()
await expect(pop1).toBeHidden()
// await visibleSwitch.click()
// await page.waitForTimeout(20)
// await content2.dispatchEvent('mouseleave')
// await page.waitForTimeout(20)
// await expect(pop1).toBeHidden()
await content2.dispatchEvent('mouseenter')
await expect(pop1).toBeVisible()
await page.waitForTimeout(20)
🤖 Prompt for AI Agents
In examples/sites/demos/pc/app/tooltip/control.spec.js around lines 22 to 30,
there is commented-out code related to tooltip interaction that is no longer
needed. To improve code clarity and maintainability, either remove these
commented lines entirely or add a clear comment explaining why this code is kept
for future reference. This will prevent confusion for future maintainers.


await page.waitForTimeout(20)

Expand All @@ -32,7 +39,7 @@ test('测试手动控制 tooltip', async ({ page }) => {
await page.waitForTimeout(20)

// 测试禁用
await preview.getByText('我的内容很长很长').nth(2).hover()
await content4.hover()
await expect(pop3).toBeVisible()
await disableSwitch.click()
await expect(pop3).toBeHidden()
Expand Down
2 changes: 1 addition & 1 deletion examples/sites/demos/pc/app/tooltip/popper-options.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,5 @@ test('测试自定义 popper', async ({ page }) => {
await expect(tooltip).toBeVisible()

await page.mouse.move(0, 0)
await expect(tooltip).toHaveCount(0)
await expect(tooltip).toHaveCount(1) // 组件卸载时,会删除dom
})
2 changes: 1 addition & 1 deletion examples/sites/src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import './assets/index.less'
import './style.css'

// 覆盖默认的github markdown样式
import './assets/custom-markdown.css'
import './assets/custom-markdown.less'
import './assets/custom-block.less'
import './assets/md-preview.less'

Expand Down
48 changes: 48 additions & 0 deletions packages/renderless/src/tooltip/new-index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
export const handleRefEvent =
({ props, api }) =>
(type: string) => {
if (props.manual) return

if (type === 'mouseenter') {
api.cancelDelayHide()
api.delayShow()
} else if (type === 'mouseleave') {
api.cancelDelayShow()
api.delayHide()
}
}

export const handlePopEvent =
({ props, api }) =>
(type: string) => {
if (props.manual) return
if (!props.enterable) return

if (type === 'mouseenter') {
api.cancelDelayHide()
} else if (type === 'mouseleave') {
api.delayHide()
}
}

export const toggleShow =
({ state, props, emit, api }) =>
(isShow: boolean) => {
// 智能识别模式
if (props.visible === 'auto' && state.referenceElm?.firstElementChild) {
const { clientWidth, scrollWidth } = state.referenceElm.firstElementChild

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ensure state.referenceElm.firstElementChild is not null before accessing its properties to avoid potential runtime errors.

if (scrollWidth <= clientWidth) {
return
}
}

state.showPopper = isShow
if (props.manual) {
emit('update:modelValue', isShow)
}

// 自动隐藏: 如果显示,且要自动隐藏,则延时后关闭
if (!props.manual && props.hideAfter && isShow) {
api.delayHideAfter()
}
}
80 changes: 80 additions & 0 deletions packages/renderless/src/tooltip/new-vue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { handlePopEvent, handleRefEvent, toggleShow } from './new-index'
import { userPopper, useTimer } from '@opentiny/vue-hooks'
import { guid } from '@opentiny/utils'

export const api = ['state', 'handlePopEvent', 'handleRefEvent']

Comment on lines +5 to +6
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

toggleShow omitted from the public API list

tooltip.vue relies on the list in export const api to expose methods returned by
setup(...). toggleShow is created on line 52 but not exported in the array
declared on line 5, making it inaccessible to downstream consumers.

-export const api = ['state', 'handlePopEvent', 'handleRefEvent']
+export const api = [
+  'state',
+  'handlePopEvent',
+  'handleRefEvent',
+  'toggleShow'
+]

Also applies to: 43-53

🤖 Prompt for AI Agents
In packages/renderless/src/tooltip/new-vue.ts around lines 5 to 6 and 43 to 53,
the method toggleShow is defined but not included in the exported api array,
which limits its accessibility to downstream consumers. To fix this, add
'toggleShow' to the api array declared on line 5 so it is properly exposed as
part of the public API.

export const renderless = (
props,
{ ref, watch, toRefs, toRef, reactive, onBeforeUnmount, onDeactivated, onMounted, onUnmounted, inject },
{ vm, emit, slots, nextTick, parent }
) => {
const api = {} as any
const popperVmRef = {}
const { showPopper, updatePopper, popperElm, referenceElm } = userPopper({
emit,
props,
nextTick,
toRefs,
reactive,
parent: parent.$parent,
vm,
slots,
onBeforeUnmount,
onDeactivated,
watch,
popperVmRef
} as any)

showPopper.value = false // 初始为false
const state = reactive({
showPopper,
popperElm,
referenceElm,
tooltipId: guid('tiny-tooltip-', 4),
showContent: inject('showContent', null),
tipsMaxWidth: inject('tips-max-width', null)
})

const useTimerFn = useTimer({ onUnmounted, ref })
const toggleShowFn = toggleShow({ state, props, emit, api })
const { start: delayShow, clear: cancelDelayShow } = useTimerFn(() => toggleShowFn(true), toRef(props, 'openDelay'))
const { start: delayHide, clear: cancelDelayHide } = useTimerFn(() => toggleShowFn(false), toRef(props, 'closeDelay'))
const { start: delayHideAfter } = useTimerFn(() => toggleShowFn(false), toRef(props, 'hideAfter'))

Object.assign(api, {
state,
delayShow,
cancelDelayShow,
delayHide,
cancelDelayHide,
delayHideAfter,
handlePopEvent: handlePopEvent({ props, api }),
handleRefEvent: handleRefEvent({ props, api })
})
Comment on lines +45 to +54
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Consider including toggleShow in the API object

While toggleShowFn is used internally to handle visibility, it's not exposed in the API object, limiting component flexibility.

Object.assign(api, {
  state,
  delayShow,
  cancelDelayShow,
  delayHide,
  cancelDelayHide,
  delayHideAfter,
+ toggleShow: toggleShowFn,
  handlePopEvent: handlePopEvent({ props, api }),
  handleRefEvent: handleRefEvent({ props, api })
})
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
Object.assign(api, {
state,
delayShow,
cancelDelayShow,
delayHide,
cancelDelayHide,
delayHideAfter,
handlePopEvent: handlePopEvent({ props, api }),
handleRefEvent: handleRefEvent({ props, api })
})
Object.assign(api, {
state,
delayShow,
cancelDelayShow,
delayHide,
cancelDelayHide,
delayHideAfter,
toggleShow: toggleShowFn,
handlePopEvent: handlePopEvent({ props, api }),
handleRefEvent: handleRefEvent({ props, api })
})
🤖 Prompt for AI Agents
In packages/renderless/src/tooltip/new-vue.ts around lines 45 to 54, the
toggleShowFn function is used internally but not included in the exported API
object, which limits external control over tooltip visibility. To fix this, add
toggleShowFn to the Object.assign call that defines the api object properties,
exposing it as toggleShow or a similarly named property to allow components
using this API to toggle visibility directly.

watch(
() => props.modelValue,
(val) => {
if (props.manual) {
val ? delayShow() : delayHide()
}
}
)
onMounted(() => {
state.popperElm = vm.$refs.popperRef
state.referenceElm = vm.$refs.referenceRef
// 初始显示
if (props.manual && props.modelValue) {
nextTick(() => (state.showPopper = true))
}
})

// 历史遗留
vm.$on('tooltip-update', (el?: HTMLElement) => {
if (el) state.popperElm = el
if (props.modelValue) updatePopper()
})
onUnmounted(() => vm.$off('tooltip-update'))

return api
}
1 change: 1 addition & 0 deletions packages/vue-hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,4 @@ export { useUserAgent } from './src/useUserAgent'
export { useWindowSize } from './src/useWindowSize'
export { userPopper } from './src/vue-popper'
export { usePopup } from './src/vue-popup'
export { useTimer } from './src/useTimer'
36 changes: 36 additions & 0 deletions packages/vue-hooks/src/useTimer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/** 延时触发的通用定时器。 setTimeout/ debounce 的地方均由该函数代替。
* 比如按钮禁用, 1秒后修改disable为false
* 1、 防止连续触发
* 2、 组件卸载时,自动取消
* @example
* const {start: resetDisabled } = useTimer(()=> state.disabled=false, 1000)
* resetDisabled();
*
* const {start: debounceQuery } = useTimer((page)=> grid.query(page), 500)
* debounceQuery(1);
* debounceQuery(2); // 仅请求第2页
*/
export const useTimer =
({ onUnmounted, ref }) =>
(cb: (...args: any[]) => void, delay: any) => {
let timerId = 0
const $delay = ref(delay)

function start(...args: any[]) {
clear()
timerId = setTimeout(() => {
cb(...args)
timerId = 0
}, $delay.value)
}
function clear() {
if (timerId) {
clearTimeout(timerId)
timerId = 0
}
}

onUnmounted(() => clear())

return { start, clear, delay: $delay }
}
18 changes: 10 additions & 8 deletions packages/vue/src/tooltip/package.json
Original file line number Diff line number Diff line change
@@ -1,23 +1,25 @@
{
"name": "@opentiny/vue-tooltip",
"type": "module",
"version": "3.23.0",
"description": "",
"license": "MIT",
"sideEffects": false,
"main": "lib/index.js",
"module": "index.ts",
"sideEffects": false,
"type": "module",
"devDependencies": {
"@opentiny-internal/vue-test-utils": "workspace:*",
"vitest": "catalog:"
},
"scripts": {
"build": "pnpm -w build:ui $npm_package_name",
"//postversion": "pnpm build"
},
"dependencies": {
"@opentiny/vue-renderless": "workspace:~",
"@opentiny/utils": "workspace:~",
"@opentiny/vue-common": "workspace:~",
"@opentiny/vue-directive": "workspace:~",
"@opentiny/vue-renderless": "workspace:~",
"@opentiny/vue-theme": "workspace:~"
},
"license": "MIT"
"devDependencies": {
"@opentiny-internal/vue-test-utils": "workspace:*",
"vitest": "catalog:"
}
}
116 changes: 116 additions & 0 deletions packages/vue/src/tooltip/src/clickoutside.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/**
* Copyright (c) 2022 - present TinyVue Authors.
* Copyright (c) 2022 - present Huawei Cloud Computing Technologies Co., Ltd.
*
* Use of this source code is governed by an MIT-style license.
*
* THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL,
* BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR
* A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS.
*
*/

import { on, isServer } from '@opentiny/utils'

const nodeList = []
const nameSpace = '@@clickoutsideContext'
let startClick
let seed = 0

if (!isServer) {
on(document, 'mousedown', (event) => {
startClick = event
nodeList
.filter((node) => node[nameSpace].mousedownTrigger)
.forEach((node) => node[nameSpace].documentHandler(event, startClick))
})

on(document, 'mouseup', (event) => {
nodeList
.filter((node) => !node[nameSpace].mousedownTrigger)
.forEach((node) => node[nameSpace].documentHandler(event, node[nameSpace]?.mouseupTrigger ? event : startClick))
startClick = null
})
}
Comment on lines +13 to +34
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Ensure document event listeners are properly cleaned up

The implementation attaches global document event listeners, but there's no mechanism to remove these listeners when they're no longer needed. This could lead to memory leaks if the application repeatedly mounts and unmounts components using this directive.

Consider adding a mechanism to remove document event listeners when no elements are using the directive:

if (!isServer) {
+ let hasAttachedEvents = false;
+ const attachEvents = () => {
+   if (hasAttachedEvents) return;
+   hasAttachedEvents = true;
    on(document, 'mousedown', (event) => {
      startClick = event
      nodeList
        .filter((node) => node[nameSpace].mousedownTrigger)
        .forEach((node) => node[nameSpace].documentHandler(event, startClick))
    })

    on(document, 'mouseup', (event) => {
      nodeList
        .filter((node) => !node[nameSpace].mousedownTrigger)
        .forEach((node) => node[nameSpace].documentHandler(event, node[nameSpace]?.mouseupTrigger ? event : startClick))
      startClick = null
    })
+ }
+ 
+ const detachEvents = () => {
+   if (!hasAttachedEvents || nodeList.length > 0) return;
+   hasAttachedEvents = false;
+   document.removeEventListener('mousedown', /* stored handler reference */);
+   document.removeEventListener('mouseup', /* stored handler reference */);
+ }
+
+ attachEvents();
}

Then call detachEvents() in the unbind hook when nodeList.length === 0.

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In packages/vue/src/tooltip/src/clickoutside.ts between lines 13 and 34, the
global document event listeners for 'mousedown' and 'mouseup' are added but
never removed, risking memory leaks. Implement a detachEvents function that
removes these event listeners from the document. Then, in the directive's unbind
hook, check if nodeList is empty and call detachEvents to clean up the listeners
when no elements use the directive.


const createDocumentHandler = (el, binding, vnode) =>
function (mouseup = {}, mousedown = {}) {
let popperElm = vnode.context.popperElm || (vnode.context.state && vnode.context.state.popperElm)

if (
!mouseup?.target ||
!mousedown?.target ||
el.contains(mouseup.target) ||
el.contains(mousedown.target) ||
el === mouseup.target ||
(popperElm && (popperElm.contains(mouseup.target) || popperElm.contains(mousedown.target)))
) {
return
}

if (binding.expression && el[nameSpace].methodName && vnode.context[el[nameSpace].methodName]) {
vnode.context[el[nameSpace].methodName]()
} else {
el[nameSpace].bindingFn && el[nameSpace].bindingFn()
}
}

/**
* v-clickoutside
* @desc 点击元素外面才会触发的事件
* @example
* 两个修饰符,mousedown、mouseup
* 当没有修饰符时,需要同时满足在目标元素外同步按下和释放鼠标才会触发回调。
* ```html
* <div v-clickoutside="handleClose"> // 在元素外部点击时触发
* <div v-clickoutside.mousedown="handleClose"> // 在元素外部按下鼠标时触发
* <div v-clickoutside.mouseup="handleClose"> // 在元素外部松开鼠标时触发
* ```
*/
export default {
bind: (el, binding, vnode) => {
nodeList.push(el)
const id = seed++
const { modifiers, expression, value } = binding
const { mousedown = false, mouseup = false } = modifiers || {}
el[nameSpace] = {
id,
documentHandler: createDocumentHandler(el, binding, vnode),
methodName: expression,
bindingFn: value,
mousedownTrigger: mousedown,
mouseupTrigger: mouseup
}
},

update: (el, binding, vnode) => {
const { modifiers, expression, value } = binding
const { mousedown = false, mouseup = false } = modifiers || {}
el[nameSpace].documentHandler = createDocumentHandler(el, binding, vnode)
el[nameSpace].methodName = expression
el[nameSpace].bindingFn = value
el[nameSpace].mousedownTrigger = mousedown
el[nameSpace].mouseupTrigger = mouseup
},

unbind: (el) => {
if (el.nodeType !== Node.ELEMENT_NODE) {
return
}

let len = nodeList.length

for (let i = 0; i < len; i++) {
if (nodeList[i][nameSpace].id === el[nameSpace].id) {
nodeList.splice(i, 1)
break
}
}

if (nodeList.length === 0 && startClick) {
startClick = null
}

delete el[nameSpace]
}
}
Loading
Loading