From eb777740241f8bce2e4f4e445d12557eb59a430e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20Wacker?= Date: Thu, 24 Sep 2026 16:40:21 +0200 Subject: [PATCH] fix(tooltip): dismiss a preview whose row is gone --- CHANGELOG.md | 2 ++ docs/reference/interactions.md | 4 +++ src/App.vue | 5 +++ src/__tests__/useNodeTooltip.test.js | 52 ++++++++++++++++++++++++++++ src/composables/useNodeTooltip.js | 13 +++++++ 5 files changed, 76 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c75e35b..e1db90f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,8 @@ All notable changes to Graph Core are documented here. The format follows [Keep ### Fixed +- A hover preview no longer hangs on screen after the thing it describes is gone. The tooltip is positioned from an anchor in the page body rather than from the row itself, so a row that disappears - switching view, opening a container, a list reloading - never sends the mouse-leave that dismisses it, and the preview stayed over whatever came next. It is now dismissed on a view change, on opening a container, when the window loses focus, and when the pointer leaves the window. + - A timeline bar for a node with only a due date no longer offers an end handle. Such a bar runs from the due date to today, so its right edge is today rather than a date on the node: dragging it had nothing to write and the change was discarded, leaving the bar to spring back with no explanation. - A workspace you delete stays deleted. The two default workspaces were seeded on every startup, not only on a fresh database, so deleting one brought it back at the next launch. diff --git a/docs/reference/interactions.md b/docs/reference/interactions.md index 744120b0..d0c3ffab 100644 --- a/docs/reference/interactions.md +++ b/docs/reference/interactions.md @@ -33,6 +33,10 @@ Hovering over a node displays a preview tooltip with the node's title, metadata, | Space | Dismiss locked tooltip | | Escape | Dismiss locked tooltip | | Click elsewhere | Dismiss locked tooltip | +| Switch view, or open a container | Dismiss tooltip | +| Leave the window | Dismiss tooltip | + +A tooltip hangs off an anchor kept in the page body rather than off the row it describes, so it does not learn when that row goes away: switching view or reloading a list removes the row without it ever sending the mouse-leave that would dismiss the tooltip, and the tooltip would hang over whatever came next. The three rows above are the exits that do not depend on the row still being there. When a tooltip is locked: diff --git a/src/App.vue b/src/App.vue index 430a423e..c9cccc44 100644 --- a/src/App.vue +++ b/src/App.vue @@ -230,6 +230,11 @@ watch(showDetail, isOpen => { watch(sidebarVisible, isOpen => { if (isOpen) forceHideTooltip() }) +// Leaving a view unmounts the row the tooltip describes without that row ever +// sending mouseleave, so the tooltip would otherwise hang over the next view. +// Navigating into a container replaces the rows for the same reason. +watch(viewMode, () => forceHideTooltip()) +watch(currentContainerId, () => forceHideTooltip()) // Detached window const { diff --git a/src/__tests__/useNodeTooltip.test.js b/src/__tests__/useNodeTooltip.test.js index 3b6288b3..84de8a61 100644 --- a/src/__tests__/useNodeTooltip.test.js +++ b/src/__tests__/useNodeTooltip.test.js @@ -158,3 +158,55 @@ describe('useNodeTooltip Lock-in Feature', () => { }) }) }) + +/** + * A tooltip is anchored to a element kept in document.body, not to the row that + * triggered it, so nothing about that row reaching the end of its life reaches + * the tooltip. Switch view while one is up and the row unmounts without ever + * sending mouseleave, and the tooltip sits over the next view until something + * else happens to dismiss it. These are the ways out that do not depend on the + * trigger still being there. + */ +describe('a tooltip whose trigger is gone', () => { + beforeEach(() => vi.useFakeTimers()) + afterEach(() => { + vi.useRealTimers() + vi.clearAllMocks() + }) + + async function shown() { + const { useNodeTooltip } = await import('../composables/useNodeTooltip.js') + const t = useNodeTooltip({ shouldShowTooltip: () => true }) + t.showTooltip({ clientX: 10, clientY: 10 }, { id: 1, title: 'Row', type: 'group' }) + vi.advanceTimersByTime(600) + return t + } + + it('goes away when the window loses focus', async () => { + const t = await shown() + t.lockTooltip() + expect(t.isLocked()).toBe(true) + + window.dispatchEvent(new Event('blur')) + + expect(t.isLocked(), 'a blur left the tooltip up').toBe(false) + }) + + it('goes away when the pointer leaves the window', async () => { + const t = await shown() + t.lockTooltip() + + document.dispatchEvent(new Event('mouseleave')) + + expect(t.isLocked(), 'the pointer left the window and the tooltip stayed').toBe(false) + }) + + it('stops listening once the owner unmounts', async () => { + const remove = vi.spyOn(window, 'removeEventListener') + const { useNodeTooltip } = await import('../composables/useNodeTooltip.js') + useNodeTooltip({ shouldShowTooltip: () => true }).cleanup() + + expect(remove).toHaveBeenCalledWith('blur', expect.any(Function)) + remove.mockRestore() + }) +}) diff --git a/src/composables/useNodeTooltip.js b/src/composables/useNodeTooltip.js index 97e62aba..274d1142 100644 --- a/src/composables/useNodeTooltip.js +++ b/src/composables/useNodeTooltip.js @@ -207,7 +207,20 @@ export function useNodeTooltip(options = {}) { return locked } + // The tooltip hangs off an anchor kept in document.body, not off the row that + // triggered it, so a row that unmounts - a view switch, a refresh, a node + // moving - never sends the mouseleave that would dismiss it, and the tooltip + // outlives what it describes. These are the ways out that do not depend on + // the trigger still existing. + function dismissOrphan() { + forceHide() + } + window.addEventListener('blur', dismissOrphan) + document.addEventListener('mouseleave', dismissOrphan) + function cleanup() { + window.removeEventListener('blur', dismissOrphan) + document.removeEventListener('mouseleave', dismissOrphan) locked = false activeNodeId = null if (tooltipShowTimeout) {