diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c75e35..e1db90f 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 744120b..d0c3ffa 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 430a423..c9cccc4 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 3b6288b..84de8a6 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 97e62ab..274d114 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) {