diff --git a/web/app/components/editor/NameEditor.nuxt.test.ts b/web/app/components/editor/NameEditor.nuxt.test.ts index 6642accb..32dce286 100644 --- a/web/app/components/editor/NameEditor.nuxt.test.ts +++ b/web/app/components/editor/NameEditor.nuxt.test.ts @@ -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("", { 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, }) => { diff --git a/web/app/components/editor/NameEditor.vue b/web/app/components/editor/NameEditor.vue index bd866377..434af719 100644 --- a/web/app/components/editor/NameEditor.vue +++ b/web/app/components/editor/NameEditor.vue @@ -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("name-editor-root") +const hookHandleElem = useTemplateRef("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() { >