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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions docs/reference/interactions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
5 changes: 5 additions & 0 deletions src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
52 changes: 52 additions & 0 deletions src/__tests__/useNodeTooltip.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
})
13 changes: 13 additions & 0 deletions src/composables/useNodeTooltip.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading