Skip to content
Merged
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
@@ -0,0 +1,8 @@
{
"type": "bug",
"message": "Hide tooltip when value dynamically changes to null",
"domain": "database",
"issue_number": 3501,
"bullet_points": [],
"created_at": "2025-10-30"
}
53 changes: 36 additions & 17 deletions web-frontend/modules/core/directives/tooltip.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,11 @@ export default {
el.tooltipOptions = getOptions(el, binding)
el.onClickOutsideCallback = null

if (el._tooltipEventsAdded) {
return
}
el._tooltipEventsAdded = true

el.updatePositionEvent = (event) => {
const rect = el.getBoundingClientRect()
const position = el.getAttribute('tooltip-position') || 'bottom'
Expand Down Expand Up @@ -162,6 +167,11 @@ export default {
if (el.tooltipOptions.contentIsHtml) {
el.tooltipContentElement.innerHTML = el.tooltipOptions.value
} else {
while (el.tooltipContentElement.firstChild) {
el.tooltipContentElement.removeChild(
el.tooltipContentElement.firstChild
)
}
el.tooltipContentElement.appendChild(
document.createTextNode(el.tooltipOptions.value)
)
Expand All @@ -179,17 +189,20 @@ export default {
el.removeTimeout()

// make tooltip content preserved if pointer hovers
el.tooltipContentElement.addEventListener('mouseenter', el.removeTimeout)
el.tooltipContentElement.addEventListener(
'mouseleave',
el.tooltipMoveLeaveEvent
)
// Only add event listeners if they haven't been added yet
if (!el._tooltipContentEventsAdded) {
el._tooltipContentEventsAdded = true
el.tooltipContentElement.addEventListener(
'mouseenter',
el.removeTimeout
)
el.tooltipContentElement.addEventListener(
'mouseleave',
el.tooltipMoveLeaveEvent
)
}

window.addEventListener('mousemove', el.tooltipMouseMoveEvent)

// When the user scrolls or resizes the window it could be possible that the
// element where the tooltip is anchored to has moved, so then the position
// needs to be updated. We only want to do this when the tooltip is visible.
window.addEventListener('scroll', el.updatePositionEvent, true)
window.addEventListener('resize', el.updatePositionEvent)

Expand All @@ -211,12 +224,9 @@ export default {
* visible, if duration is > 0.
*/
el.tooltipMoveLeaveEvent = () => {
// we should remove any pending timeout before setting new one, because timeout
// should be counted from the last mouse leave event.
el.removeTimeout()
el.tooltipTimeout = setTimeout(
el.tooltipClose,
// timeout from caller is in seconds. remember to convert to mseconds
el.tooltipOptions.duration * 1000
)
}
Expand All @@ -230,21 +240,23 @@ export default {
* actually closing the tooltip here
*/
el.tooltipClose = () => {
// cleanup actions: remove window handlers set with onClickOutside()
el.removeTooltipOutsideClickCallback()

if (el.tooltipElement) {
el.tooltipElement.parentNode.removeChild(el.tooltipElement)
if (el.tooltipElement.parentNode) {
el.tooltipElement.parentNode.removeChild(el.tooltipElement)
}
el.tooltipElement = null
el.tooltipContentElement = null
el._tooltipContentEventsAdded = false
}

window.removeEventListener('mousemove', el.tooltipMouseMoveEvent)
window.removeEventListener('scroll', el.updatePositionEvent, true)
window.removeEventListener('resize', el.updatePositionEvent)
el.removeTimeout()
}
// those event listeners should be bind all the time to the el element

el.addEventListener('mouseenter', el.tooltipMouseEnterEvent)
el.addEventListener('mouseleave', el.tooltipMoveLeaveEvent)
},
Expand All @@ -257,10 +269,17 @@ export default {
if (el.tooltipElement && el.tooltipElement.parentNode) {
el.tooltipElement.parentNode.removeChild(el.tooltipElement)
}

if (el._tooltipEventsAdded) {
el.removeEventListener('mouseenter', el.tooltipMouseEnterEvent)
el.removeEventListener('mouseleave', el.tooltipMoveLeaveEvent)
el._tooltipEventsAdded = false
}

el.tooltipElement = null
el.tooltipContentElement = null
el.removeEventListener('mouseenter', el.tooltipMouseEnterEvent)
el.removeEventListener('mouseleave', el.tooltipMoveLeaveEvent)
el._tooltipContentEventsAdded = false

window.removeEventListener('scroll', el.updatePositionEvent, true)
window.removeEventListener('resize', el.updatePositionEvent)
},
Expand Down
28 changes: 28 additions & 0 deletions web-frontend/stories/ButtonText.stories.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { config, withDesign } from 'storybook-addon-designs'
import { action } from '@storybook/addon-actions'

import ButtonText from '@baserow/modules/core/components/ButtonText'
import tooltip from '@baserow/modules/core/directives/tooltip'

<Meta
title="Baserow/Buttons/Text"
Expand Down Expand Up @@ -111,3 +112,30 @@ export const designConfig = {
## Props

<Props of={ButtonText} />

## Tooltip Test

export const TooltipTemplate = (args, { argTypes }) => ({
components: { ButtonText },
directives: { tooltip },
data() {
return {
test: false
}
},
template: `
<div>
<ButtonText v-tooltip="test ? null : 'hello'">
test {{ test ? 'without tooltip' : 'with tooltip' }}
</ButtonText>
<br><br>
<button @click="test = !test">Toggle Tooltip</button>
</div>
`,
})

<Canvas>
<Story name="With Tooltip">
{TooltipTemplate.bind({})}
</Story>
</Canvas>
60 changes: 60 additions & 0 deletions web-frontend/test/unit/core/components/tooltip.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { shallowMount } from '@vue/test-utils'
import ButtonText from '@baserow/modules/core/components/ButtonText'
import tooltip from '@baserow/modules/core/directives/tooltip'

describe('Tooltip directive', () => {
const Component = {
template: `
<ButtonText v-tooltip="tooltipValue">
test {{ tooltipValue ? 'with tooltip' : 'without tooltip' }}
</ButtonText>
`,
components: { ButtonText },
directives: { tooltip },
data() {
return {
tooltipValue: 'hello',
}
},
}

it('shows tooltip when value is provided', async () => {
const wrapper = shallowMount(Component)
const buttonText = wrapper.findComponent(ButtonText)

await buttonText.trigger('mouseenter')

expect(document.querySelector('.tooltip')).toBeTruthy()
})

it('hides tooltip when value is null', async () => {
const wrapper = shallowMount(Component)
const buttonText = wrapper.findComponent(ButtonText)

await wrapper.setData({ tooltipValue: null })

await buttonText.trigger('mouseenter')

expect(document.querySelector('.tooltip')).toBeFalsy()
})

it('dynamically shows/hides tooltip when value changes', async () => {
const wrapper = shallowMount(Component)
const buttonText = wrapper.findComponent(ButtonText)

await buttonText.trigger('mouseenter')
expect(document.querySelector('.tooltip')).toBeTruthy()

await wrapper.setData({ tooltipValue: null })
expect(document.querySelector('.tooltip')).toBeFalsy()

await wrapper.setData({ tooltipValue: 'new tooltip' })
await buttonText.trigger('mouseenter')
expect(document.querySelector('.tooltip')).toBeTruthy()
})

afterEach(() => {
const tooltips = document.querySelectorAll('.tooltip')
tooltips.forEach((tooltip) => tooltip.remove())
})
})
Loading