From 684d60efc8f52c3b20007ae81f30184215f31d30 Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Wed, 16 Sep 2026 20:24:00 +0200 Subject: [PATCH] Close a persistent rich tooltip on a second press of its trigger The bubble is a popover="auto" and the trigger is outside it, so the press on the trigger light-dismissed the open bubble and the click that followed opened it again: a second press never closed it, in any engine. Found by the new exit test. A close the browser made within 250ms is now taken for that press, as menu.js's REOPEN_GUARD_MS does, timed from beforetoggle because toggle is queued past the click; the morph test waits past the guard before pressing again, as the menu tests do. Co-Authored-By: Claude Opus 5 (1M context) --- resources/js/rich-tooltip.js | 24 +++++++++++++++++++++++- tests/Browser/CommunicationTest.php | 5 ++++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/resources/js/rich-tooltip.js b/resources/js/rich-tooltip.js index 986aeca8..2f777a42 100644 --- a/resources/js/rich-tooltip.js +++ b/resources/js/rich-tooltip.js @@ -18,6 +18,12 @@ const HOVER_DELAY_MS = 500 const LEAVE_GRACE_MS = 1500 +// A persistent bubble is a `popover="auto"`, and its trigger is outside it: the press on the +// trigger light-dismisses the open bubble, and the click that follows would open it again, so a +// second press never closed it. A close the browser made this recently is taken as that press, as +// menu.js's REOPEN_GUARD_MS does; `beforetoggle` times it, because `toggle` is queued past the click. +const REOPEN_GUARD_MS = 250 + document.addEventListener('alpine:init', () => { window.Alpine.data('materialRichTooltip', (persistent = false) => ({ timer: null, @@ -50,12 +56,28 @@ document.addEventListener('alpine:init', () => { wrapper.addEventListener('pointerenter', describe) if (persistent) { + let dismissedAt = -Infinity + let closingExplicitly = false + + bubble.addEventListener('beforetoggle', (event) => { + if (event.newState === 'closed' && !closingExplicitly) { + dismissedAt = performance.now() + } + + closingExplicitly = false + }) + wrapper.addEventListener('click', (event) => { if (bubble.contains(event.target)) { return } - open() ? bubble.hidePopover() : bubble.showPopover() + if (open()) { + closingExplicitly = true + bubble.hidePopover() + } else if (performance.now() - dismissedAt > REOPEN_GUARD_MS) { + bubble.showPopover() + } }) return diff --git a/tests/Browser/CommunicationTest.php b/tests/Browser/CommunicationTest.php index fe41f350..06f906fe 100644 --- a/tests/Browser/CommunicationTest.php +++ b/tests/Browser/CommunicationTest.php @@ -282,8 +282,11 @@ it('keeps a rich tooltip open while its action renders the component, and opens ->assertSeeIn('#renders', '1') ->assertScript("{$persistent}.matches(':popover-open')"); + // Past the reopen guard (rich-tooltip.js's REOPEN_GUARD_MS), which takes a press this soon after + // a light dismiss for the press that dismissed it, as menu.js's does. $page->click('#outside') - ->assertScript("! {$persistent}.matches(':popover-open')"); + ->assertScript("! {$persistent}.matches(':popover-open')") + ->wait(0.3); $page->click('@details') ->assertScript("{$persistent}.matches(':popover-open')");