🐛 Fix dashboard user menu submenu not closing on hover out (#10639)

The dashboard profile menu submenus (Help & Learning, Community &
Contributions, About Penpot) opened on pointer enter but nothing closed
them when the pointer left the option, leaving a stale submenu visible
until the whole menu closed.

Close the open submenu when the pointer leaves an expandable option or
its submenu, with a 200ms grace period (same approach as the workspace
context menu) so the submenu survives the pointer crossing the gap
between the parent menu and the floating submenu. Keyboard navigation
is unchanged and now covered by tests.

Closes #10549

AI-assisted-by: claude-fable-5

Signed-off-by: Akshit Nassa <nassaakshit@gmail.com>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
This commit is contained in:
AK 2026-07-10 05:21:21 -04:00 committed by GitHub
parent 3469867cf5
commit 89551b2415
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 144 additions and 14 deletions

View File

@ -28,6 +28,92 @@ test("Navigate to penpot changelog from profile menu", async ({ page }) => {
);
});
test("Submenu closes when hovering a menu option without submenu", async ({
page,
}) => {
const dashboardPage = new DashboardPage(page);
await dashboardPage.goToDashboard();
await dashboardPage.openProfileMenu();
await page.getByText("About Penpot").hover();
const changelogSubmenuItem = page.getByText("Penpot Changelog");
await expect(changelogSubmenuItem).toBeVisible();
await dashboardPage.userProfileOption.hover();
await expect(changelogSubmenuItem).toBeHidden();
});
test("Submenu stays open while moving the pointer into it", async ({
page,
}) => {
const dashboardPage = new DashboardPage(page);
await dashboardPage.goToDashboard();
await dashboardPage.openProfileMenu();
const aboutPenpotItem = page.getByText("About Penpot");
await aboutPenpotItem.hover();
const changelogSubmenuItem = page.getByText("Penpot Changelog");
await expect(changelogSubmenuItem).toBeVisible();
// Walk the pointer from the parent option into the submenu the way a
// real user does — gradually, crossing the gap between the two menus.
const from = await aboutPenpotItem.boundingBox();
const to = await changelogSubmenuItem.boundingBox();
await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2);
await page.mouse.move(to.x + to.width / 2, to.y + to.height / 2, {
steps: 20,
});
await expect(changelogSubmenuItem).toBeVisible();
});
test("Submenu closes when the pointer leaves the menu entirely", async ({
page,
}) => {
const dashboardPage = new DashboardPage(page);
await dashboardPage.goToDashboard();
await dashboardPage.openProfileMenu();
await page.getByText("About Penpot").hover();
const changelogSubmenuItem = page.getByText("Penpot Changelog");
await expect(changelogSubmenuItem).toBeVisible();
await dashboardPage.mainHeading.hover();
await expect(changelogSubmenuItem).toBeHidden();
});
test("Hovering another expandable option switches submenus", async ({
page,
}) => {
const dashboardPage = new DashboardPage(page);
await dashboardPage.goToDashboard();
await dashboardPage.openProfileMenu();
await page.getByText("Help & Learning").hover();
const helpCenterSubmenuItem = page.getByText("Help Center");
await expect(helpCenterSubmenuItem).toBeVisible();
await page.getByText("About Penpot").hover();
await expect(page.getByText("Penpot Changelog")).toBeVisible();
await expect(helpCenterSubmenuItem).toBeHidden();
});
test("Submenu opens with keyboard navigation", async ({ page }) => {
const dashboardPage = new DashboardPage(page);
await dashboardPage.goToDashboard();
await dashboardPage.openProfileMenu();
await dashboardPage.sidebarMenu
.getByRole("menuitem", { name: "Help & Learning" })
.press("Enter");
await expect(page.getByText("Help Center")).toBeVisible();
});
test("Opens release notes from current version from profile menu", async ({
page,
}) => {

View File

@ -27,7 +27,7 @@
(mf/defc internal-dropdown-menu*
{::mf/private true}
[{:keys [on-close children class id]}]
[{:keys [on-close children class id on-pointer-enter on-pointer-leave]}]
(assert (fn? on-close) "missing `on-close` prop")
@ -106,7 +106,12 @@
#(doseq [key keys]
(events/unlistenByKey key))))
[:ul {:class class :role "menu" :ref container} children]))
[:ul {:class class
:role "menu"
:ref container
:on-pointer-enter on-pointer-enter
:on-pointer-leave on-pointer-leave}
children]))
(mf/defc dropdown-menu*
[{:keys [show] :as props}]

View File

@ -1133,7 +1133,7 @@
(mf/defc help-learning-menu*
{::mf/private true}
[{:keys [on-close on-click]}]
[{:keys [on-close on-click on-pointer-enter on-pointer-leave]}]
(let [handle-click-url
(mf/use-fn
(fn [event]
@ -1150,7 +1150,9 @@
[:> dropdown-menu* {:show true
:class (stl/css :sub-menu :help-learning)
:on-close on-close}
:on-close on-close
:on-pointer-enter on-pointer-enter
:on-pointer-leave on-pointer-leave}
[:> dropdown-menu-item* {:class (stl/css :submenu-item)
:data-url "https://help.penpot.app"
@ -1177,7 +1179,7 @@
(mf/defc community-contributions-menu*
{::mf/private true}
[{:keys [on-close]}]
[{:keys [on-close on-pointer-enter on-pointer-leave]}]
(let [handle-click-url
(mf/use-fn
(fn [event]
@ -1191,7 +1193,9 @@
[:> dropdown-menu* {:show true
:class (stl/css :sub-menu :community)
:on-close on-close}
:on-close on-close
:on-pointer-enter on-pointer-enter
:on-pointer-leave on-pointer-leave}
[:> dropdown-menu-item* {:class (stl/css :submenu-item)
:data-url "https://github.com/penpot/penpot"
@ -1207,7 +1211,7 @@
(mf/defc about-penpot-menu*
{::mf/private true}
[{:keys [on-close]}]
[{:keys [on-close on-pointer-enter on-pointer-leave]}]
(let [version cf/version
show-release-notes
(mf/use-fn
@ -1230,7 +1234,9 @@
[:> dropdown-menu* {:show true
:class (stl/css :sub-menu :about)
:on-close on-close}
:on-close on-close
:on-pointer-enter on-pointer-enter
:on-pointer-leave on-pointer-leave}
[:> dropdown-menu-item* {:class (stl/css :submenu-item)
:on-click show-release-notes}
@ -1255,6 +1261,11 @@
show-profile-menu? (deref show-profile-menu*)
sub-menu* (mf/use-state false)
sub-menu (deref sub-menu*)
;; Tracks whether the pointer is over an expandable option or
;; its floating submenu, so the submenu survives the gap
;; between them while the pointer travels across.
hovering?* (mf/use-ref false)
version (:base cf/version)
close-sub-menu
@ -1320,6 +1331,24 @@
(keyword))]
(reset! sub-menu* menu))))
on-menu-pointer-enter
(mf/use-fn
(fn [event]
(mf/set-ref-val! hovering?* true)
(on-menu-click event)))
on-menu-pointer-leave
(mf/use-fn
(fn [_]
(mf/set-ref-val! hovering?* false)
(ts/schedule 200 #(when-not (mf/ref-val hovering?*)
(reset! sub-menu* nil)))))
on-sub-menu-pointer-enter
(mf/use-fn
(fn [_]
(mf/set-ref-val! hovering?* true)))
on-power-up-click
(mf/use-fn
(fn []
@ -1393,7 +1422,8 @@
:on-key-down (fn [event]
(when (kbd/enter? event)
(on-menu-click event)))
:on-pointer-enter on-menu-click
:on-pointer-enter on-menu-pointer-enter
:on-pointer-leave on-menu-pointer-leave
:data-testid "help-learning"
:id "help-learning"}
[:span {:class (stl/css :item-name)} (tr "labels.help-learning")]
@ -1404,7 +1434,8 @@
:on-key-down (fn [event]
(when (kbd/enter? event)
(on-menu-click event)))
:on-pointer-enter on-menu-click
:on-pointer-enter on-menu-pointer-enter
:on-pointer-leave on-menu-pointer-leave
:data-testid "community-contributions"
:id "community-contributions"}
[:span {:class (stl/css :item-name)} (tr "labels.community-contributions")]
@ -1415,7 +1446,8 @@
:on-key-down (fn [event]
(when (kbd/enter? event)
(on-menu-click event)))
:on-pointer-enter on-menu-click
:on-pointer-enter on-menu-pointer-enter
:on-pointer-leave on-menu-pointer-leave
:data-testid "about-penpot"
:id "about-penpot"}
@ -1440,13 +1472,20 @@
(when show-profile-menu?
(case sub-menu
:help-learning
[:> help-learning-menu* {:on-close close-sub-menu :on-click on-click}]
[:> help-learning-menu* {:on-close close-sub-menu
:on-click on-click
:on-pointer-enter on-sub-menu-pointer-enter
:on-pointer-leave on-menu-pointer-leave}]
:community-contributions
[:> community-contributions-menu* {:on-close close-sub-menu}]
[:> community-contributions-menu* {:on-close close-sub-menu
:on-pointer-enter on-sub-menu-pointer-enter
:on-pointer-leave on-menu-pointer-leave}]
:about-penpot
[:> about-penpot-menu* {:on-close close-sub-menu}]
[:> about-penpot-menu* {:on-close close-sub-menu
:on-pointer-enter on-sub-menu-pointer-enter
:on-pointer-leave on-menu-pointer-leave}]
nil))]))
(mf/defc sidebar*