Skip to content

Commit 9749f3f

Browse files
authored
fix(Popover): openOnHover trigger click behavior (#1921)
1 parent ef8db53 commit 9749f3f

File tree

4 files changed

+150
-2
lines changed

4 files changed

+150
-2
lines changed

.changeset/mean-groups-drum.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"bits-ui": patch
3+
---
4+
5+
fix(Popover): openOnHover trigger click while open behavior

docs/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
"scripts": {
88
"dev": "pnpm --reporter append-only --color \"/dev:/\"",
99
"dev:content": "velite dev --watch",
10-
"dev:svelte": "svelte-kit sync && vite dev",
10+
"dev:svelte": "svelte-kit sync && vite dev --port 5177",
1111
"dev:demos": "pnpm build:demos",
1212
"build": "pnpm build:content && pnpm build:search && pnpm build:demos && vite build && pnpm build:llms && vite build",
1313
"build:llms": "pnpx tsx ./other/build-llms-txt.ts",

packages/bits-ui/src/lib/bits/popover/popover.svelte.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ export class PopoverRootState {
5353
// hover tracking state
5454
openedViaHover = $state(false);
5555
hasInteractedWithContent = $state(false);
56+
hoverCooldown = $state(false);
5657
closeDelay = $state(0);
5758
#closeTimeout: number | null = null;
5859
#domContext: DOMContext | null = null;
@@ -221,7 +222,7 @@ export class PopoverTriggerState {
221222
this.#clearCloseTimeout();
222223
this.root.cancelDelayedClose();
223224

224-
if (this.root.opts.open.current) return;
225+
if (this.root.opts.open.current || this.root.hoverCooldown) return;
225226

226227
const delay = this.opts.openDelay.current;
227228
if (delay <= 0) {
@@ -241,6 +242,7 @@ export class PopoverTriggerState {
241242

242243
this.#isHovering = false;
243244
this.#clearOpenTimeout();
245+
this.root.hoverCooldown = false;
244246

245247
// let GraceArea handle the close - it will call handleHoverClose via onPointerExit
246248
// we just need to stop any pending open timer
@@ -259,6 +261,17 @@ export class PopoverTriggerState {
259261
return;
260262
}
261263

264+
// if closing while hovering with openOnHover enabled, set cooldown to prevent
265+
// immediate re-open via hover
266+
if (this.#isHovering && this.opts.openOnHover.current && this.root.opts.open.current) {
267+
this.root.hoverCooldown = true;
268+
}
269+
270+
// if clicking to open while in cooldown, reset cooldown (explicit open)
271+
if (this.root.hoverCooldown && !this.root.opts.open.current) {
272+
this.root.hoverCooldown = false;
273+
}
274+
262275
this.root.toggleOpen();
263276
}
264277

tests/src/tests/popover/popover.browser.test.ts

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -522,6 +522,68 @@ describe("openOnHover", () => {
522522
// should still be open
523523
await expectExists(t.getContent());
524524
});
525+
526+
it("should close on second click and not reopen immediately via hover", async () => {
527+
const t = setupHover({ triggerProps: { openOnHover: true, openDelay: 0, closeDelay: 0 } });
528+
529+
// hover opens
530+
await t.trigger.hover();
531+
await expectExists(t.getContent());
532+
533+
// first click converts to click-based
534+
await t.trigger.click();
535+
await expectExists(t.getContent());
536+
537+
// second click closes
538+
await t.trigger.click();
539+
await expectNotExists(t.getContent());
540+
541+
// should not reopen via hover while mouse is still over trigger
542+
await new Promise((r) => setTimeout(r, 50));
543+
await expectNotExists(t.getContent());
544+
});
545+
546+
it("should allow hover reopen after leaving and re-entering trigger", async () => {
547+
const t = setupHover({ triggerProps: { openOnHover: true, openDelay: 0, closeDelay: 0 } });
548+
549+
// hover opens
550+
await t.trigger.hover();
551+
await expectExists(t.getContent());
552+
553+
// first click converts
554+
await t.trigger.click();
555+
await expectExists(t.getContent());
556+
557+
// second click closes
558+
await t.trigger.click();
559+
await expectNotExists(t.getContent());
560+
561+
// leave trigger
562+
await page.getByTestId("outside").hover();
563+
564+
// re-enter trigger - hover should work again
565+
await t.trigger.hover();
566+
await expectExists(t.getContent());
567+
});
568+
569+
it("should allow click to explicitly reopen while in cooldown", async () => {
570+
const t = setupHover({ triggerProps: { openOnHover: true, openDelay: 0, closeDelay: 0 } });
571+
572+
// hover opens
573+
await t.trigger.hover();
574+
await expectExists(t.getContent());
575+
576+
// first click converts
577+
await t.trigger.click();
578+
579+
// second click closes
580+
await t.trigger.click();
581+
await expectNotExists(t.getContent());
582+
583+
// third click should explicitly reopen
584+
await t.trigger.click();
585+
await expectExists(t.getContent());
586+
});
525587
});
526588

527589
describe("openOnHover with forceMount", () => {
@@ -770,4 +832,72 @@ describe("openOnHover with forceMount", () => {
770832
// should still be open
771833
await expectExists(t.getContent());
772834
});
835+
836+
it("should close on second click and not reopen immediately via hover", async () => {
837+
const t = setupForceMountHover({
838+
triggerProps: { openOnHover: true, openDelay: 0, closeDelay: 0 },
839+
});
840+
841+
// hover opens
842+
await t.trigger.hover();
843+
await expectExists(t.getContent());
844+
845+
// first click converts to click-based
846+
await t.trigger.click();
847+
await expectExists(t.getContent());
848+
849+
// second click closes
850+
await t.trigger.click();
851+
await expectNotExists(t.getContent());
852+
853+
// should not reopen via hover while mouse is still over trigger
854+
await new Promise((r) => setTimeout(r, 50));
855+
await expectNotExists(t.getContent());
856+
});
857+
858+
it("should allow hover reopen after leaving and re-entering trigger", async () => {
859+
const t = setupForceMountHover({
860+
triggerProps: { openOnHover: true, openDelay: 0, closeDelay: 0 },
861+
});
862+
863+
// hover opens
864+
await t.trigger.hover();
865+
await expectExists(t.getContent());
866+
867+
// first click converts
868+
await t.trigger.click();
869+
await expectExists(t.getContent());
870+
871+
// second click closes
872+
await t.trigger.click();
873+
await expectNotExists(t.getContent());
874+
875+
// leave trigger
876+
await t.getOutside().hover();
877+
878+
// re-enter trigger - hover should work again
879+
await t.trigger.hover();
880+
await expectExists(t.getContent());
881+
});
882+
883+
it("should allow click to explicitly reopen while in cooldown", async () => {
884+
const t = setupForceMountHover({
885+
triggerProps: { openOnHover: true, openDelay: 0, closeDelay: 0 },
886+
});
887+
888+
// hover opens
889+
await t.trigger.hover();
890+
await expectExists(t.getContent());
891+
892+
// first click converts
893+
await t.trigger.click();
894+
895+
// second click closes
896+
await t.trigger.click();
897+
await expectNotExists(t.getContent());
898+
899+
// third click should explicitly reopen
900+
await t.trigger.click();
901+
await expectExists(t.getContent());
902+
});
773903
});

0 commit comments

Comments
 (0)