fix document hook handle off-click overlay issues

This commit is contained in:
david 2026-09-17 21:59:43 +03:00
parent 929552afef
commit 5841ae8452
2 changed files with 95 additions and 17 deletions

View File

@ -1,6 +1,10 @@
import { registerEndpoint } from "@nuxt/test-utils/runtime"
import type { HocuspocusProvider } from "@hocuspocus/provider"
import { enableAutoUnmount, type VueWrapper } from "@vue/test-utils"
import {
enableAutoUnmount,
flushPromises,
type VueWrapper,
} from "@vue/test-utils"
import { setResponseStatus } from "h3"
import { afterEach, beforeEach, describe, it, vi } from "vitest"
import { toast } from "vue-sonner"
@ -388,6 +392,70 @@ describe("<NameEditor>", { concurrent: false }, () => {
expect(highlightPanels()).toHaveLength(0)
})
it("clears the mark when a click elsewhere closes the menu", async ({
expect,
}) => {
const wrapper = await mountEditor()
const handle = hookHandle(wrapper)
await handle.trigger("pointerdown", { button: 0 })
await handle.trigger("click")
await nextTick()
await handle.trigger("mouseleave")
// the menu's outside-click listener is attached a task after it opens
await new Promise((resolve) => setTimeout(resolve, 0))
document.body.dispatchEvent(
new PointerEvent("pointerdown", { bubbles: true, button: 0 }),
)
await flushPromises()
expect(highlightPanels()).toHaveLength(0)
})
it("keeps the mark on when a click on the hovered handle closes the menu", async ({
expect,
}) => {
const wrapper = await mountEditor()
const handle = hookHandle(wrapper)
await handle.trigger("mouseenter")
await handle.trigger("pointerdown", { button: 0 })
await handle.trigger("click")
await nextTick()
await handle.trigger("pointerdown", { button: 0 })
await handle.trigger("click")
await nextTick()
expect(handle.attributes("data-menu-open")).toBeUndefined()
expect(handle.attributes("data-hovering")).toBe("")
expect(highlightPanels()).toHaveLength(1)
})
it("marks the title when the hook handle takes keyboard focus", async ({
expect,
}) => {
const wrapper = await mountEditor()
const handle = hookHandle(wrapper)
vi.spyOn(handle.element, "matches").mockReturnValue(true)
await handle.trigger("focus")
expect(highlightPanels()).toHaveLength(1)
})
it("clears the mark when keyboard focus leaves the hook handle", async ({
expect,
}) => {
const wrapper = await mountEditor()
const handle = hookHandle(wrapper)
vi.spyOn(handle.element, "matches").mockReturnValue(true)
await handle.trigger("focus")
await handle.trigger("blur")
expect(highlightPanels()).toHaveLength(0)
})
it("carries both the wide and the narrow hook handle glyph", async ({
expect,
}) => {

View File

@ -94,8 +94,16 @@ const hookStatus = computed(() => {
return hooks.some((h) => Number(h.score) === 0) ? "stale" : "fresh"
})
const hookMenuOpen = ref(false)
const hoveringHookHandle = ref(false)
const rootElem = useTemplateRef<HTMLElement>("name-editor-root")
const hookHandleElem = useTemplateRef<HTMLElement>("hook-handle")
const hoveringHookHandle = useElementHover(hookHandleElem)
// the menu hands focus back to the handle when it closes, also after an
// outside click that never reaches the editor, so a plain focus would
// leave the panel on with no visible handle under it. The handle only
// shows for keyboard focus, and the panel follows the same rule.
const { focused: keyboardFocusOnHookHandle } = useFocus(hookHandleElem, {
focusVisible: true,
})
const { isEditable } = useEditorMeta()
const { isScrolling } = useWindowScroll()
const { show: showHighlight, hide: hideHighlight } = useHighlightOverlay()
@ -230,19 +238,22 @@ onBeforeUnmount(() => {
// the panel marks what the handle points at, so it follows the handle's
// hover and outlasts it for as long as the menu it opened is up
watch([hoveringHookHandle, hookMenuOpen], ([hovering, menuOpen]) => {
if (!hovering && !menuOpen) {
hideHighlight()
return
}
watch(
[hoveringHookHandle, keyboardFocusOnHookHandle, hookMenuOpen],
([hovering, focused, menuOpen]) => {
if (!hovering && !focused && !menuOpen) {
hideHighlight()
return
}
const rect = documentRect()
if (!rect) {
return
}
const rect = documentRect()
if (!rect) {
return
}
showHighlight(rect, DEFAULT_HIGHLIGHT_OVERLAY_PADDING)
})
showHighlight(rect, DEFAULT_HIGHLIGHT_OVERLAY_PADDING)
},
)
// the panel is placed from a viewport rect, so a scroll would leave it
// behind the title it covers
@ -394,8 +405,10 @@ async function executeReviewableAction() {
>
<ShadcnUiDropdownMenuTrigger as-child>
<button
ref="hook-handle"
type="button"
:data-menu-open="hookMenuOpen ? '' : undefined"
:data-hovering="hoveringHookHandle ? '' : undefined"
:class="
cn(
'group/hook-handle absolute top-0.5 right-full flex h-7 items-center pr-1.5',
@ -403,12 +416,9 @@ async function executeReviewableAction() {
'group-hover/name-row:pointer-events-auto group-hover/name-row:opacity-100',
'focus-visible:pointer-events-auto focus-visible:opacity-100',
'data-menu-open:pointer-events-auto data-menu-open:opacity-100',
'data-hovering:pointer-events-auto data-hovering:opacity-100',
)
"
@mouseenter="hoveringHookHandle = true"
@mouseleave="hoveringHookHandle = false"
@focus="hoveringHookHandle = true"
@blur="hoveringHookHandle = false"
>
<span
:data-hook-status="hookStatus"