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*