diff --git a/.changeset/hover-card-focus-pointer-leave.md b/.changeset/hover-card-focus-pointer-leave.md new file mode 100644 index 0000000000..932b7a9f65 --- /dev/null +++ b/.changeset/hover-card-focus-pointer-leave.md @@ -0,0 +1,5 @@ +--- +"@zag-js/hover-card": patch +--- + +Keep the hover card open when the pointer leaves a trigger that remains focused. diff --git a/e2e/hover-card-multiple-trigger.e2e.ts b/e2e/hover-card-multiple-trigger.e2e.ts index 83bb21de56..80b79e6393 100644 --- a/e2e/hover-card-multiple-trigger.e2e.ts +++ b/e2e/hover-card-multiple-trigger.e2e.ts @@ -61,6 +61,24 @@ test.describe("hover-card / multiple triggers", () => { await expect(page.locator(content)).toContainText("Alice Johnson") }) + test("should remain open after pointer leaves the focused active trigger", async ({ page }) => { + await page.waitForLoadState("networkidle") + await page.locator(trigger(2)).focus() + await expect(page.locator(trigger(2))).toBeFocused() + await expect(page.locator(content)).toBeVisible() + await expect(page.locator(content)).toContainText("Bob Smith") + + await page.hover(trigger(2)) + await page.mouse.move(0, 0) + await page.waitForTimeout(500) + await expect(page.locator(trigger(2))).toBeFocused() + await expect(page.locator(content)).toBeVisible() + await expect(page.locator(content)).toContainText("Bob Smith") + + await page.locator(trigger(2)).evaluate((element) => element.blur()) + await expect(page.locator(content)).toBeHidden() + }) + test("should close hover card on escape", async ({ page }) => { await page.hover(trigger(1)) await expect(page.locator(content)).toBeVisible() diff --git a/e2e/hover-card.e2e.ts b/e2e/hover-card.e2e.ts index 14f645a0ca..af78d3f494 100644 --- a/e2e/hover-card.e2e.ts +++ b/e2e/hover-card.e2e.ts @@ -77,6 +77,21 @@ test("should remain open after blurring trigger if pointer opens card", async ({ await expect(page.locator(content)).not.toBeVisible() }) +test("should remain open after pointer leaves a focused trigger", async ({ page }) => { + await page.focus(trigger) + await page.waitForSelector(content) + + await page.hover(trigger) + await page.hover(testText) + await page.waitForTimeout(500) + + await expect(page.locator(trigger)).toBeFocused() + await expect(page.locator(content)).toBeVisible() + + await page.locator(trigger).evaluate((element) => element.blur()) + await expect(page.locator(content)).not.toBeVisible() +}) + test("should remain open after moving from trigger to content", async ({ page }) => { await page.hover(trigger) await page.waitForSelector(content) diff --git a/packages/machines/hover-card/src/hover-card.machine.ts b/packages/machines/hover-card/src/hover-card.machine.ts index 61f310709d..0805d90ef1 100644 --- a/packages/machines/hover-card/src/hover-card.machine.ts +++ b/packages/machines/hover-card/src/hover-card.machine.ts @@ -111,6 +111,10 @@ export const machine = createMachine({ target: "closed", }, POINTER_LEAVE: [ + { + guard: "isTriggerFocused", + actions: ["clearIsPointer"], + }, { guard: "isOpenControlled", // We trigger toggleVisibility manually since the `ctx.open` has not changed yet (at this point) @@ -161,9 +165,15 @@ export const machine = createMachine({ POINTER_ENTER: { actions: ["setIsPointer"], }, - POINTER_LEAVE: { - target: "closing", - }, + POINTER_LEAVE: [ + { + guard: "isTriggerFocused", + actions: ["clearIsPointer"], + }, + { + target: "closing", + }, + ], CLOSE: [ { guard: "isOpenControlled", @@ -231,6 +241,8 @@ export const machine = createMachine({ implementations: { guards: { isPointer: ({ context }) => !!context.get("isPointer"), + isTriggerFocused: ({ context, scope }) => + scope.isActiveElement(dom.getActiveTriggerEl(scope, context.get("triggerValue"))), isOpenControlled: ({ prop }) => prop("open") != null, },