From 89551b24159672a6803dd5ab7e04171df7e6c61b Mon Sep 17 00:00:00 2001 From: AK <144495202+AKnassa@users.noreply.github.com> Date: Fri, 10 Jul 2026 05:21:21 -0400 Subject: [PATCH] :bug: 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 Co-authored-by: Andrey Antukh --- .../playwright/ui/specs/profile-menu.spec.js | 86 +++++++++++++++++++ .../app/main/ui/components/dropdown_menu.cljs | 9 +- .../src/app/main/ui/dashboard/sidebar.cljs | 63 +++++++++++--- 3 files changed, 144 insertions(+), 14 deletions(-) diff --git a/frontend/playwright/ui/specs/profile-menu.spec.js b/frontend/playwright/ui/specs/profile-menu.spec.js index 71bdbb4199..3c12fbb151 100644 --- a/frontend/playwright/ui/specs/profile-menu.spec.js +++ b/frontend/playwright/ui/specs/profile-menu.spec.js @@ -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, }) => { diff --git a/frontend/src/app/main/ui/components/dropdown_menu.cljs b/frontend/src/app/main/ui/components/dropdown_menu.cljs index aad1e9a47e..681682fdae 100644 --- a/frontend/src/app/main/ui/components/dropdown_menu.cljs +++ b/frontend/src/app/main/ui/components/dropdown_menu.cljs @@ -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}] diff --git a/frontend/src/app/main/ui/dashboard/sidebar.cljs b/frontend/src/app/main/ui/dashboard/sidebar.cljs index 420383aa60..bea1060183 100644 --- a/frontend/src/app/main/ui/dashboard/sidebar.cljs +++ b/frontend/src/app/main/ui/dashboard/sidebar.cljs @@ -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*