♻️ Refactor dropdown-menu and make dropdown visibility exclusive (#6956)

* 🐛 Fix having multiple dropdown menus opened on dashboard page

* ♻️ Refactor dropdown-menu

Make it follow new standards and make it external api more usable,
not depending on manually provided list of ids.

This also implements the autoclosing of "other" active/open
dropdown-menu (or other similar components).

* 📎 Add PR feedback changes

* 🐛 Fix incorrect event handling on project-menu

* 🐛 Fix unexpected exception

---------

Co-authored-by: Andrey Antukh <niwi@niwi.nz>
This commit is contained in:
Elena Torró 2025-08-01 16:14:15 +02:00 committed by GitHub
parent 07b15819d4
commit c8f5ec4698
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 332 additions and 479 deletions

View File

@ -20,6 +20,7 @@
- Fix missing font when copy&paste a chunk of text [Taiga #11522](https://tree.taiga.io/project/penpot/issue/11522)
- Fix bad swap slot after two swaps [Taiga #11659](https://tree.taiga.io/project/penpot/issue/11659)
- Fix missing package for the penport_exporter Docker image [GitHub #7205](https://github.com/penpot/penpot/issues/7025)
- Fix issue where multiple dropdown menus could be opened simultaneously on the dashboard page [Taiga #11500](https://tree.taiga.io/project/penpot/issue/11500)
## 2.9.0 (Unreleased)

View File

@ -8,45 +8,44 @@
(:require
[app.common.data :as d]
[app.config :as cfg]
[app.main.store :as st]
[app.util.dom :as dom]
[app.util.globals :as globals]
[app.util.keyboard :as kbd]
[app.util.object :as obj]
[beicon.v2.core :as rx]
[goog.events :as events]
[goog.object :as gobj]
[potok.v2.core :as ptk]
[rumext.v2 :as mf])
(:import goog.events.EventType))
(mf/defc dropdown-menu-item*
{::mf/wrap-props false}
[props]
(let [props (-> (obj/clone props)
(obj/set! "role" "menuitem"))]
[{:keys [can-focus] :rest props}]
(let [can-focus (d/nilv can-focus true)
tab-index (if can-focus "0" "-1")
props (mf/spread-props props {:role "menuitem" :tab-index tab-index})]
[:> :li props]))
(mf/defc dropdown-menu'
{::mf/wrap-props false}
[props]
(let [children (gobj/get props "children")
on-close (gobj/get props "on-close")
ref (gobj/get props "container")
ids (gobj/get props "ids")
list-class (gobj/get props "list-class")
ids (filter some? ids)
on-click
(fn [event]
(let [target (dom/get-target event)
(mf/defc internal-dropdown-menu*
{::mf/private true}
[{:keys [on-close children class id]}]
;; MacOS ctrl+click sends two events: context-menu and click.
;; In order to not have two handlings we ignore ctrl+click for this platform
mac-ctrl-click? (and (cfg/check-platform? :macos) (kbd/ctrl? event))]
(when (and (not mac-ctrl-click?)
(not (.-data-no-close ^js target)))
(if ref
(let [parent (mf/ref-val ref)]
(when-not (or (not parent) (.contains parent target))
(on-close)))
(on-close)))))
(assert (fn? on-close) "missing `on-close` prop")
(let [on-click
(mf/use-fn
(mf/deps on-close)
(fn [event]
(let [target (dom/get-target event)
;; MacOS ctrl+click sends two events: context-menu and click.
;; In order to not have two handlings we ignore ctrl+click for this platform
mac-ctrl-click? (and (cfg/check-platform? :macos) (kbd/ctrl? event))]
(when (and (not mac-ctrl-click?)
(not (.-data-no-close ^js target))
(fn? on-close))
(on-close)))))
container
(mf/use-ref)
on-keyup
(fn [event]
@ -55,35 +54,49 @@
on-key-down
(fn [event]
(let [first-id (dom/get-element (first ids))
first-element (dom/get-element first-id)
len (count ids)]
(when-let [container (mf/ref-val container)]
(let [entries (vec (dom/query-all container "[role=menuitem]"))]
(when (kbd/home? event)
(when first-element
(dom/focus! first-element)))
(cond
(kbd/up-arrow? event)
(let [selected (dom/get-active)
index (d/index-of-pred entries #(identical? % selected))
target (if (nil? index)
(peek entries)
(or (get entries (dec index)) (peek entries)))]
(when (kbd/up-arrow? event)
(let [actual-selected (dom/get-active)
actual-id (dom/get-attribute actual-selected "id")
actual-index (d/index-of ids actual-id)
previous-id (if (= 0 actual-index)
(last ids)
(get ids (- actual-index 1) (last ids)))]
(dom/focus! (dom/get-element previous-id))))
(dom/focus! target))
(when (kbd/down-arrow? event)
(let [actual-selected (dom/get-active)
actual-id (dom/get-attribute actual-selected "id")
actual-index (d/index-of ids actual-id)
next-id (if (= (- len 1) actual-index)
(first ids)
(get ids (+ 1 actual-index) (first ids)))
node-item (dom/get-element next-id)]
(dom/focus! node-item)))
(kbd/down-arrow? event)
(let [selected (dom/get-active)
index (d/index-of-pred entries #(identical? % selected))
target (if (nil? index)
(first entries)
(or (get entries (inc index)) (first entries)))]
(dom/focus! target))
(when (kbd/tab? event)
(on-close))))]
(kbd/enter? event)
(let [selected (dom/get-active)]
(dom/prevent-default event)
(dom/click! selected))
(kbd/tab? event)
(on-close)))))]
(mf/with-effect [id]
(when id
(st/emit! (ptk/data-event :dropdown/open {:id id}))))
(mf/with-effect [on-close id]
(when id
(let [stream (->> st/stream
(rx/filter (ptk/type? :dropdown/open))
(rx/map deref)
(rx/filter #(not= id (:id %)))
(rx/take 1))
subs (rx/subs! nil nil on-close stream)]
(fn []
(rx/dispose! subs)))))
(mf/with-effect []
(let [keys [(events/listen globals/document EventType.CLICK on-click)
@ -93,21 +106,10 @@
#(doseq [key keys]
(events/unlistenByKey key))))
[:ul {:class list-class :role "menu"} children]))
[:ul {:class class :role "menu" :ref container} children]))
(mf/defc dropdown-menu
{::mf/props :obj}
[props]
(assert (fn? (gobj/get props "on-close")) "missing `on-close` prop")
(assert (boolean? (gobj/get props "show")) "missing `show` prop")
(mf/defc dropdown-menu*
[{:keys [show] :as props}]
(when show
[:> internal-dropdown-menu* props]))
(let [ids (obj/get props "ids")
ids (or ids
(->> (obj/get props "children")
(keep (fn [o]
(let [props (obj/get o "props")]
(obj/get props "id"))))))]
(when (gobj/get props "show")
(mf/element
dropdown-menu'
(mf/spread-props props {:ids ids})))))

View File

@ -84,7 +84,7 @@
::ev/origin "dashboard"})))))
[:div {:class (stl/css :dashboard-comments-section)}
[:& dropdown {:show show? :on-close on-hide-comments}
[:& dropdown {:show show? :on-close on-hide-comments :dropdown-id "dashboard-comments"}
[:div {:class (stl/css :dropdown :comments-section :comment-threads-section)}
[:div {:class (stl/css :header)}
[:h3 {:class (stl/css :header-title)} (tr "dashboard.notifications")]

View File

@ -21,6 +21,7 @@
[app.util.dom :as dom]
[app.util.i18n :as i18n :refer [tr]]
[beicon.v2.core :as rx]
[potok.v2.core :as ptk]
[rumext.v2 :as mf]))
(defn- get-project-name
@ -55,11 +56,11 @@
projects))
(mf/defc file-menu*
[{:keys [files on-edit on-menu-close top left navigate origin parent-id can-edit]}]
[{:keys [files on-edit on-close top left navigate origin parent-id can-edit]}]
(assert (seq files) "missing `files` prop")
(assert (fn? on-edit) "missing `on-edit` prop")
(assert (fn? on-menu-close) "missing `on-menu-close` prop")
(assert (fn? on-close) "missing `on-close` prop")
(assert (boolean? navigate) "missing `navigate` prop")
(let [is-lib-page? (= :libraries origin)
@ -161,13 +162,12 @@
(on-move-accept params team-id project-id))))))
add-shared
#(st/emit! (dd/set-file-shared (assoc file :is-shared true)))
(fn []
(st/emit! (dd/set-file-shared (assoc file :is-shared true))))
del-shared
(mf/use-fn
(mf/deps files)
(fn [_]
(run! #(st/emit! (dd/set-file-shared (assoc % :is-shared false))) files)))
(fn [_]
(run! #(st/emit! (dd/set-file-shared (assoc % :is-shared false))) files))
on-add-shared
(fn [event]
@ -186,32 +186,35 @@
:count-libraries file-count})))
on-export-files
(mf/use-fn
(mf/deps files)
(fn [format]
(st/emit! (with-meta (fexp/export-files files format)
{::ev/origin "dashboard"}))))
(fn [format]
(st/emit! (with-meta (fexp/export-files files format)
{::ev/origin "dashboard"})))
on-export-binary-files
(mf/use-fn
(mf/deps on-export-files)
(partial on-export-files :binfile-v1))
(partial on-export-files :binfile-v1)
on-export-binary-files-v3
(mf/use-fn
(mf/deps on-export-files)
(partial on-export-files :binfile-v3))
(partial on-export-files :binfile-v3)
on-export-standard-files
(mf/use-fn
(mf/deps on-export-files)
(partial on-export-files :legacy-zip))]
(partial on-export-files :legacy-zip)]
(mf/with-effect []
(->> (rp/cmd! :get-all-projects)
(rx/map group-by-team)
(rx/subs! #(reset! teams* %))))
(mf/with-effect [on-close]
(st/emit! (ptk/data-event :dropdown/open {:id "file-menu"}))
(let [stream (->> st/stream
(rx/filter (ptk/type? :dropdown/open))
(rx/map deref)
(rx/filter #(not= "file-menu" (:id %)))
(rx/take 1))
subs (rx/subs! nil nil on-close stream)]
(fn []
(rx/dispose! subs))))
(let [sub-options
(concat
(for [project current-projects]
@ -328,7 +331,7 @@
:handler on-delete})])]
[:> context-menu*
{:on-close on-menu-close
{:on-close on-close
:fixed (or (not= top 0) (not= left 0))
:show true
:min-width true

View File

@ -128,7 +128,7 @@
:left (- (:x (:menu-pos @local)) 180)
:top (:y (:menu-pos @local))
:on-edit on-edit
:on-menu-close on-menu-close
:on-close on-menu-close
:on-import on-import}])]]))
(mf/defc files-section*

View File

@ -439,7 +439,7 @@
:can-edit can-edit
:navigate true
:on-edit on-edit
:on-menu-close on-menu-close
:on-close on-menu-close
:origin origin
:parent-id (dm/str file-id "-action-menu")}]])]]]]]))

View File

@ -17,11 +17,12 @@
[app.main.ui.dashboard.import :as udi]
[app.util.dom :as dom]
[app.util.i18n :as i18n :refer [tr]]
[beicon.v2.core :as rx]
[potok.v2.core :as ptk]
[rumext.v2 :as mf]))
(mf/defc project-menu*
{::mf/props :obj}
[{:keys [project show on-edit on-menu-close top left on-import]}]
[{:keys [project show on-edit on-close top left on-import]}]
(let [top (or top 0)
left (or left 0)
@ -42,7 +43,8 @@
(with-meta project {:on-success on-duplicate-success}))))
toggle-pin
#(st/emit! (dd/toggle-project-pin project))
(fn []
(st/emit! (dd/toggle-project-pin project)))
on-move-success
(fn [team-id]
@ -63,25 +65,23 @@
(dcm/go-to-dashboard-recent :team-id team-id))))
on-delete
#(st/emit!
(modal/show
{:type :confirm
:title (tr "modals.delete-project-confirm.title")
:message (tr "modals.delete-project-confirm.message")
:accept-label (tr "modals.delete-project-confirm.accept")
:on-accept delete-fn}))
(fn []
(st/emit!
(modal/show {:type :confirm
:title (tr "modals.delete-project-confirm.title")
:message (tr "modals.delete-project-confirm.message")
:accept-label (tr "modals.delete-project-confirm.accept")
:on-accept delete-fn})))
file-input (mf/use-ref nil)
file-input
(mf/use-ref nil)
on-import-files
(mf/use-callback
(fn []
(dom/click (mf/ref-val file-input))))
(fn [] (dom/click! (mf/ref-val file-input)))
on-finish-import
(mf/use-callback
(fn []
(when (fn? on-import) (on-import))))
(mf/use-fn
(fn [] (when (fn? on-import) (on-import))))
options
[(when-not (:is-default project)
@ -116,9 +116,21 @@
:id "project-delete"
:handler on-delete})]]
(mf/with-effect [show on-close]
(when ^boolean show
(st/emit! (ptk/data-event :dropdown/open {:id "project-menu"}))
(let [stream (->> st/stream
(rx/filter (ptk/type? :dropdown/open))
(rx/map deref)
(rx/filter #(not= "project-menu" (:id %)))
(rx/take 1))
subs (rx/subs! nil nil on-close stream)]
(fn []
(rx/dispose! subs)))))
[:*
[:> context-menu*
{:on-close on-menu-close
{:on-close on-close
:show show
:fixed (or (not= top 0) (not= left 0))
:min-width true

View File

@ -271,7 +271,7 @@
:left (+ 24 (:x (:menu-pos @local)))
:top (:y (:menu-pos @local))
:on-edit on-edit-open
:on-menu-close on-menu-close
:on-close on-menu-close
:on-import on-import}])]]]
[:div {:class (stl/css :grid-container) :ref rowref}

View File

@ -8,7 +8,6 @@
(:require-macros [app.main.style :as stl])
(:require
[app.common.data.macros :as dm]
[app.common.spec :as us]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.main.data.auth :as da]
@ -21,7 +20,7 @@
[app.main.refs :as refs]
[app.main.router :as rt]
[app.main.store :as st]
[app.main.ui.components.dropdown-menu :refer [dropdown-menu
[app.main.ui.components.dropdown-menu :refer [dropdown-menu*
dropdown-menu-item*]]
[app.main.ui.components.link :refer [link]]
[app.main.ui.dashboard.comments :refer [comments-icon* comments-section]]
@ -37,7 +36,6 @@
[app.util.object :as obj]
[app.util.timers :as ts]
[beicon.v2.core :as rx]
[cljs.spec.alpha :as s]
[cuerdas.core :as str]
[goog.functions :as f]
[potok.v2.core :as ptk]
@ -194,7 +192,7 @@
:left (:x (:menu-pos local))
:top (:y (:menu-pos local))
:on-edit on-edit-open
:on-menu-close on-menu-close}]]))
:on-close on-menu-close}]]))
(mf/defc sidebar-search
[{:keys [search-term team-id] :as props}]
@ -273,47 +271,24 @@
:on-click on-clear-click}
search-icon])]))
(mf/defc teams-selector-dropdown-items
(mf/defc teams-selector-dropdown*
{::mf/wrap-props false}
[{:keys [team profile teams] :as props}]
(let [on-create-clicked
(mf/use-fn
#(st/emit! (modal/show :team-form {})))
[{:keys [team profile teams] :rest props}]
(let [on-create-click
(mf/use-fn #(st/emit! (modal/show :team-form {})))
team-selected
on-team-click
(mf/use-fn
(fn [event]
(let [team-id (-> (dom/get-current-target event)
(dom/get-data "value")
(uuid/parse))]
(st/emit! (dcm/go-to-dashboard-recent :team-id team-id)))))
(st/emit! (dcm/go-to-dashboard-recent :team-id team-id)))))]
handle-select-default
(mf/use-fn
(mf/deps profile team-selected)
(fn [event]
(when (kbd/enter? event)
(team-selected (:default-team-id profile) event))))
[:> dropdown-menu* props
handle-select-team
(mf/use-fn
(mf/deps team-selected)
(fn [event]
(when (kbd/enter? event)
(team-selected event))))
handle-creation-key-down
(mf/use-fn
(mf/deps on-create-clicked)
(fn [event]
(when (kbd/enter? event)
(on-create-clicked event))))]
[:*
[:> dropdown-menu-item* {:on-click team-selected
[:> dropdown-menu-item* {:on-click on-team-click
:data-value (:default-team-id profile)
:on-key-down handle-select-default
:id "teams-selector-default-team"
:class (stl/css :team-dropdown-item)}
[:span {:class (stl/css :penpot-icon)} i/logo-icon]
@ -322,12 +297,10 @@
tick-icon)]
(for [team-item (remove :is-default (vals teams))]
[:> dropdown-menu-item* {:on-click team-selected
[:> dropdown-menu-item* {:on-click on-team-click
:data-value (:id team-item)
:on-key-down handle-select-team
:id (str "teams-selector-" (:id team-item))
:class (stl/css :team-dropdown-item)
:key (str "teams-selector-" (:id team-item))}
:key (str (:id team-item))}
[:img {:src (cf/resolve-team-photo-url team-item)
:class (stl/css :team-picture)
:alt (:name team-item)}]
@ -342,21 +315,14 @@
(when (= (:id team-item) (:id team))
tick-icon)])
[:hr {:role "separator"
:class (stl/css :team-separator)}]
[:> dropdown-menu-item* {:on-click on-create-clicked
:on-key-down handle-creation-key-down
:id "teams-selector-create-team"
[:hr {:role "separator" :class (stl/css :team-separator)}]
[:> dropdown-menu-item* {:on-click on-create-click
:class (stl/css :team-dropdown-item :action)}
[:span {:class (stl/css :icon-wrapper)} add-icon]
[:span {:class (stl/css :team-text)} (tr "dashboard.create-new-team")]]]))
(s/def ::member-id ::us/uuid)
(s/def ::leave-modal-form
(s/keys :req-un [::member-id]))
(mf/defc team-options-dropdown
[{:keys [team profile] :as props}]
(mf/defc team-options-dropdown*
[{:keys [team profile] :rest props}]
(let [go-members #(st/emit! (dcm/go-to-dashboard-members))
go-invitations #(st/emit! (dcm/go-to-dashboard-invitations))
go-webhooks #(st/emit! (dcm/go-to-dashboard-webhooks))
@ -449,218 +415,125 @@
:title (tr "modals.delete-team-confirm.title")
:message (tr "modals.delete-team-confirm.message")
:accept-label (tr "modals.delete-team-confirm.accept")
:on-accept delete-fn})))
:on-accept delete-fn})))]
[:> dropdown-menu* props
handle-members
(mf/use-fn
(mf/deps go-members)
(fn [event]
(when (kbd/enter? event)
(go-members))))
handle-invitations
(mf/use-fn
(mf/deps go-invitations)
(fn [event]
(when (kbd/enter? event)
(go-invitations))))
handle-webhooks
(mf/use-fn
(mf/deps go-webhooks)
(fn [event]
(when (kbd/enter? event)
(go-webhooks))))
handle-settings
(mf/use-fn
(mf/deps go-settings)
(fn [event]
(when (kbd/enter? event)
(go-settings))))
handle-rename
(mf/use-fn
(mf/deps on-rename-clicked)
(fn [event]
(when (kbd/enter? event)
(on-rename-clicked))))
handle-leave-and-close
(mf/use-fn
(mf/deps leave-and-close)
(fn [event]
(when (kbd/enter? event)
(leave-and-close))))
handle-leave-as-owner-clicked
(mf/use-fn
(mf/deps on-leave-as-owner-clicked)
(fn [event]
(when (kbd/enter? event)
(on-leave-as-owner-clicked))))
handle-on-leave-clicked
(mf/use-fn
(mf/deps on-leave-clicked)
(fn [event]
(when (kbd/enter? event)
(on-leave-clicked))))
handle-on-delete-clicked
(mf/use-fn
(mf/deps on-delete-clicked)
(fn [event]
(when (kbd/enter? event)
(on-delete-clicked))))]
[:*
[:> dropdown-menu-item* {:on-click go-members
:on-key-down handle-members
:className (stl/css :team-options-item)
:id "teams-options-members"
:data-testid "team-members"}
:class (stl/css :team-options-item)
:data-testid "team-members"}
(tr "labels.members")]
[:> dropdown-menu-item* {:on-click go-invitations
:on-key-down handle-invitations
:className (stl/css :team-options-item)
:id "teams-options-invitations"
:data-testid "team-invitations"}
:class (stl/css :team-options-item)
:data-testid "team-invitations"}
(tr "labels.invitations")]
(when (contains? cf/flags :webhooks)
[:> dropdown-menu-item* {:on-click go-webhooks
:on-key-down handle-webhooks
:className (stl/css :team-options-item)
:id "teams-options-webhooks"}
[:> dropdown-menu-item* {:on-click go-webhooks
:class (stl/css :team-options-item)}
(tr "labels.webhooks")])
[:> dropdown-menu-item* {:on-click go-settings
:on-key-down handle-settings
:className (stl/css :team-options-item)
:id "teams-options-settings"
:data-testid "team-settings"}
:class (stl/css :team-options-item)
:data-testid "team-settings"}
(tr "labels.settings")]
[:hr {:class (stl/css :team-option-separator)}]
(when can-rename?
[:> dropdown-menu-item* {:on-click on-rename-clicked
:on-key-down handle-rename
:id "teams-options-rename"
:className (stl/css :team-options-item)
:data-testid "rename-team"}
:class (stl/css :team-options-item)
:data-testid "rename-team"}
(tr "labels.rename")])
(cond
(= (count members) 1)
[:> dropdown-menu-item* {:on-click leave-and-close
:on-key-down handle-leave-and-close
:className (stl/css :team-options-item)
:id "teams-options-leave-team"}
[:> dropdown-menu-item* {:on-click leave-and-close
:class (stl/css :team-options-item)}
(tr "dashboard.leave-team")]
(get-in team [:permissions :is-owner])
[:> dropdown-menu-item* {:on-click on-leave-as-owner-clicked
:on-key-down handle-leave-as-owner-clicked
:id "teams-options-leave-team"
:className (stl/css :team-options-item)
:data-testid "leave-team"}
:class (stl/css :team-options-item)
:data-testid "leave-team"}
(tr "dashboard.leave-team")]
(> (count members) 1)
[:> dropdown-menu-item* {:on-click on-leave-clicked
:on-key-down handle-on-leave-clicked
:className (stl/css :team-options-item)
:id "teams-options-leave-team"}
[:> dropdown-menu-item* {:on-click on-leave-clicked
:class (stl/css :team-options-item)}
(tr "dashboard.leave-team")])
(when (get-in team [:permissions :is-owner])
[:> dropdown-menu-item* {:on-click on-delete-clicked
:on-key-down handle-on-delete-clicked
:id "teams-options-delete-team"
:className (stl/css :team-options-item :warning)
:data-testid "delete-team"}
:class (stl/css :team-options-item :warning)
:data-testid "delete-team"}
(tr "dashboard.delete-team")])]))
(mf/defc sidebar-team-switch
[{:keys [team profile] :as props}]
(let [teams (mf/deref refs/teams)
teams-without-default (into {} (filter (fn [[_ v]] (= false (:is-default v))) teams))
team-ids (map #(str "teams-selector-" %) (keys teams-without-default))
ids (concat ["teams-selector-default-team"] team-ids ["teams-selector-create-team"])
show-team-opts-ddwn? (mf/use-state false)
show-teams-ddwn? (mf/use-state false)
can-rename? (or (get-in team [:permissions :is-owner]) (get-in team [:permissions :is-admin]))
options-ids ["teams-options-members"
"teams-options-invitations"
(when (contains? cf/flags :webhooks)
"teams-options-webhooks")
"teams-options-settings"
(when can-rename?
"teams-options-rename")
"teams-options-leave-team"
(when (get-in team [:permissions :is-owner])
"teams-options-delete-team")]
(let [teams (mf/deref refs/teams)
subscription
(get team :subscription)
;; _ (prn "--------------- sidebar-team-switch")
;; _ (app.common.pprint/pprint teams)
subscription-type
(get-subscription-type subscription)
handle-show-team-click
(fn [event]
(dom/stop-propagation event)
(swap! show-teams-ddwn? not)
(reset! show-team-opts-ddwn? false))
show-team-options-menu*
(mf/use-state false)
handle-show-team-keydown
(fn [event]
(when (or (kbd/space? event) (kbd/enter? event))
(dom/prevent-default event)
(reset! show-teams-ddwn? true)
(reset! show-team-opts-ddwn? false)
(ts/schedule-on-idle
(fn []
(let [first-element (dom/get-element (first ids))]
(when first-element
(dom/focus! first-element)))))))
show-team-options-menu?
(deref show-team-options-menu*)
close-team-opts-ddwn
show-teams-menu*
(mf/use-state false)
show-teams-menu?
(deref show-teams-menu*)
on-show-teams-click
(mf/use-fn
#(reset! show-team-opts-ddwn? false))
(fn [event]
(dom/stop-propagation event)
(swap! show-teams-menu* not)))
handle-show-opts-click
(fn [event]
(dom/stop-propagation event)
(swap! show-team-opts-ddwn? not)
(reset! show-teams-ddwn? false))
on-show-teams-keydown
(mf/use-fn
(fn [event]
(when (or (kbd/space? event)
(kbd/enter? event))
(dom/prevent-default event)
(dom/stop-propagation event)
(some-> (dom/get-current-target event)
(dom/click!)))))
handle-show-opts-keydown
(fn [event]
(when (or (kbd/space? event) (kbd/enter? event))
(dom/prevent-default event)
(reset! show-team-opts-ddwn? true)
(reset! show-teams-ddwn? false)
(ts/schedule-on-idle
(fn []
(let [first-element (dom/get-element (first options-ids))]
(when first-element
(dom/focus! first-element)))))))
close-team-options-menu
(mf/use-fn #(reset! show-team-options-menu* false))
handle-close-team
(fn []
(reset! show-teams-ddwn? false))
subscription (:subscription team)
subscription-type (get-subscription-type subscription)]
on-show-options-click
(mf/use-fn
(fn [event]
(dom/stop-propagation event)
(swap! show-team-options-menu* not)))
on-show-options-keydown
(mf/use-fn
(fn [event]
(when (or (kbd/space? event)
(kbd/enter? event))
(dom/prevent-default event)
(dom/stop-propagation event)
(some-> (dom/get-current-target event)
(dom/click!)))))
close-teams-menu
(mf/use-fn #(reset! show-teams-menu* false))]
[:div {:class (stl/css :sidebar-team-switch)}
[:div {:class (stl/css :switch-content)}
[:button {:class (stl/css :current-team)
:on-click handle-show-team-click
:on-key-down handle-show-team-keydown}
:on-click on-show-teams-click
:on-key-down on-show-teams-keydown}
(cond
(:is-default team)
[:div {:class (stl/css :team-name)}
@ -691,29 +564,28 @@
(when-not (:is-default team)
[:button {:class (stl/css :switch-options)
:on-click handle-show-opts-click
:on-click on-show-options-click
:aria-label "team-management"
:tab-index "0"
:on-key-down handle-show-opts-keydown}
:on-key-down on-show-options-keydown}
menu-icon])]
;; Teams Dropdown
[:& dropdown-menu {:show @show-teams-ddwn?
:on-close handle-close-team
:ids ids
:list-class (stl/css :dropdown :teams-dropdown)}
[:& teams-selector-dropdown-items {:ids ids
:team team
:profile profile
:teams teams}]]
[:> teams-selector-dropdown* {:show show-teams-menu?
:on-close close-teams-menu
:id "team-list"
:class (stl/css :dropdown :teams-dropdown)
:team team
:profile profile
:teams teams}]
[:& dropdown-menu {:show @show-team-opts-ddwn?
:on-close close-team-opts-ddwn
:ids options-ids
:list-class (stl/css :dropdown :options-dropdown)}
[:& team-options-dropdown {:team team
:profile profile}]]]))
[:> team-options-dropdown* {:show show-team-options-menu?
:on-close close-team-options-menu
:id "team-options"
:class (stl/css :dropdown :options-dropdown)
:team team
:profile profile}]]))
(mf/defc sidebar-content*
{::mf/private true
@ -806,7 +678,7 @@
(mf/use-layout-effect
(mf/deps pinned-projects)
(fn []
(let [dom (mf/ref-val container)
(let [dom (mf/ref-val container)
client-height (obj/get dom "clientHeight")
scroll-height (obj/get dom "scrollHeight")]
(reset! overflow* (> scroll-height client-height)))))
@ -878,15 +750,17 @@
(mf/defc profile-section*
{::mf/props :obj}
[{:keys [profile team]}]
(let [show* (mf/use-state false)
show (deref show*)
photo (cf/resolve-profile-photo-url profile)
(let [show-profile-menu* (mf/use-state false)
show-profile-menu? (deref show-profile-menu*)
photo
(cf/resolve-profile-photo-url profile)
on-click
(mf/use-fn
(fn [section event]
(dom/stop-propagation event)
(reset! show* false)
(reset! show-profile-menu* false)
(if (keyword? section)
(st/emit! (rt/nav section))
(st/emit! section))))
@ -917,24 +791,16 @@
(mf/use-fn
(fn [event]
(dom/stop-propagation event)
(swap! show* not)))
(swap! show-profile-menu* not)))
handle-key-down
(mf/use-fn
(fn [event]
(when (kbd/enter? event)
(reset! show* true))))
(reset! show-profile-menu* true))))
on-close
(fn [event]
(dom/stop-propagation event)
(reset! show* false))
handle-key-down-profile
(mf/use-fn
(fn [event]
(when (kbd/enter? event)
(on-click :settings-profile event))))
(mf/use-fn #(reset! show-profile-menu* false))
handle-click-url
(mf/use-fn
@ -943,40 +809,13 @@
(dom/get-data "url"))]
(dom/open-new-window url))))
handle-keydown-url
(mf/use-fn
(fn [event]
(let [url (-> (dom/get-current-target event)
(dom/get-data "url"))]
(when (kbd/enter? event)
(dom/open-new-window url)))))
handle-show-release-notes
(mf/use-fn
(mf/deps show-release-notes)
(fn [event]
(when (kbd/enter? event)
(show-release-notes))))
handle-feedback-click
(mf/use-fn #(on-click :settings-feedback %))
handle-feedback-keydown
(mf/use-fn
(fn [event]
(when (kbd/enter? event)
(on-click :settings-feedback event))))
handle-logout-click
(mf/use-fn
#(on-click (da/logout) %))
handle-logout-keydown
(mf/use-fn
(fn [event]
(when (kbd/enter? event)
(on-click (da/logout) event))))
handle-set-profile
(mf/use-fn
#(on-click :settings-profile %))
@ -997,7 +836,8 @@
:on-click on-power-up-click}
[:div {:class (stl/css :penpot-free)}
[:span (tr "dashboard.upgrade-plan.penpot-free")]
[:span {:class (stl/css :no-limits)} (tr "dashboard.upgrade-plan.no-limits")]]
[:span {:class (stl/css :no-limits)}
(tr "dashboard.upgrade-plan.no-limits")]]
[:div {:class (stl/css :power-up)}
(tr "subscription.dashboard.upgrade-plan.power-up")]])
@ -1020,85 +860,67 @@
:alt (:fullname profile)}]
[:span {:class (stl/css :profile-fullname)} (:fullname profile)]]
[:& dropdown-menu {:on-close on-close
:show show
:list-class (stl/css :profile-dropdown)}
[:li {:tab-index (if show "0" "-1")
:class (stl/css :profile-dropdown-item)
:on-click handle-set-profile
:on-key-down handle-key-down-profile
:data-testid "profile-profile-opt"}
[:> dropdown-menu* {:on-close on-close
:show show-profile-menu?
:id "profile-menu"
:class (stl/css :profile-dropdown)}
[:> dropdown-menu-item* {:class (stl/css :profile-dropdown-item)
:on-click handle-set-profile
:data-testid "profile-profile-opt"}
(tr "labels.your-account")]
[:li {:class (stl/css :profile-separator)}]
[:li {:class (stl/css :profile-dropdown-item)
:tab-index (if show "0" "-1")
:data-url "https://help.penpot.app"
:on-click handle-click-url
:on-key-down handle-keydown-url
:data-testid "help-center-profile-opt"}
[:> dropdown-menu-item* {:class (stl/css :profile-dropdown-item)
:data-url "https://help.penpot.app"
:on-click handle-click-url
:data-testid "help-center-profile-opt"}
(tr "labels.help-center")]
[:li {:tab-index (if show "0" "-1")
:class (stl/css :profile-dropdown-item)
:data-url "https://community.penpot.app"
:on-click handle-click-url
:on-key-down handle-keydown-url}
[:> dropdown-menu-item* {:class (stl/css :profile-dropdown-item)
:data-url "https://community.penpot.app"
:on-click handle-click-url}
(tr "labels.community")]
[:li {:tab-index (if show "0" "-1")
:class (stl/css :profile-dropdown-item)
:data-url "https://www.youtube.com/c/Penpot"
:on-click handle-click-url
:on-key-down handle-keydown-url}
[:> dropdown-menu-item* {:class (stl/css :profile-dropdown-item)
:data-url "https://www.youtube.com/c/Penpot"
:on-click handle-click-url}
(tr "labels.tutorials")]
[:li {:tab-index (if show "0" "-1")
:class (stl/css :profile-dropdown-item)
:on-click show-release-notes
:on-key-down handle-show-release-notes}
[:> dropdown-menu-item* {:tab-index "0"
:class (stl/css :profile-dropdown-item)
:on-click show-release-notes}
(tr "labels.release-notes")]
[:li {:class (stl/css :profile-separator)}]
[:li {:class (stl/css :profile-dropdown-item)
:tab-index (if show "0" "-1")
:data-url "https://penpot.app/libraries-templates"
:on-click handle-click-url
:on-key-down handle-keydown-url
:data-testid "libraries-templates-profile-opt"}
[:> dropdown-menu-item* {:class (stl/css :profile-dropdown-item)
:data-url "https://penpot.app/libraries-templates"
:on-click handle-click-url
:data-testid "libraries-templates-profile-opt"}
(tr "labels.libraries-and-templates")]
[:li {:tab-index (if show "0" "-1")
:class (stl/css :profile-dropdown-item)
:data-url "https://github.com/penpot/penpot"
:on-click handle-click-url
:on-key-down handle-keydown-url}
[:> dropdown-menu-item* {:class (stl/css :profile-dropdown-item)
:data-url "https://github.com/penpot/penpot"
:on-click handle-click-url}
(tr "labels.github-repo")]
[:li {:tab-index (if show "0" "-1")
:class (stl/css :profile-dropdown-item)
:data-url "https://penpot.app/terms"
:on-click handle-click-url
:on-key-down handle-keydown-url}
[:> dropdown-menu-item* {:class (stl/css :profile-dropdown-item)
:data-url "https://penpot.app/terms"
:on-click handle-click-url}
(tr "auth.terms-of-service")]
[:li {:class (stl/css :profile-separator)}]
(when (contains? cf/flags :user-feedback)
[:li {:class (stl/css :profile-dropdown-item)
:tab-index (if show "0" "-1")
:on-click handle-feedback-click
:on-key-down handle-feedback-keydown
:data-testid "feedback-profile-opt"}
[:> dropdown-menu-item* {:class (stl/css :profile-dropdown-item)
:on-click handle-feedback-click
:data-testid "feedback-profile-opt"}
(tr "labels.give-feedback")])
[:li {:class (stl/css :profile-dropdown-item :item-with-icon)
:tab-index (if show "0" "-1")
:on-click handle-logout-click
:on-key-down handle-logout-keydown
:data-testid "logout-profile-opt"}
[:> dropdown-menu-item* {:class (stl/css :profile-dropdown-item :item-with-icon)
:on-click handle-logout-click
:data-testid "logout-profile-opt"}
exit-icon
(tr "labels.logout")]]

View File

@ -304,7 +304,7 @@
[:div {:class (stl/css :rol-selector)}
[:span {:class (stl/css :rol-label)} (tr role)]])
[:& dropdown {:show @show? :on-close on-hide}
[:& dropdown {:show @show? :on-close on-hide :dropdown-id (str "member-role-" (:id member))}
[:ul {:class (stl/css :roles-dropdown)
:role "listbox"}
[:li {:on-click on-set-viewer
@ -341,7 +341,7 @@
:on-click on-show}
menu-icon]
[:& dropdown {:show @show? :on-close on-hide}
[:& dropdown {:show @show? :on-close on-hide :dropdown-id (str "member-actions-" (:id member))}
[:ul {:class (stl/css :actions-dropdown)}
(when is-you?
[:li {:on-click on-leave
@ -592,7 +592,7 @@
[:div {:class (stl/css :rol-selector)}
[:span {:class (stl/css :rol-label)} label]])
[:& dropdown {:show @show? :on-close on-hide}
[:& dropdown {:show @show? :on-close on-hide :dropdown-id "invitation-role-selector"}
[:ul {:class (stl/css :roles-dropdown)}
[:li {:data-role "admin"
:class (stl/css :rol-dropdown-item)
@ -692,7 +692,7 @@
:on-click on-show}
menu-icon]
[:& dropdown {:show @show? :on-close on-hide}
[:& dropdown {:show @show? :on-close on-hide :dropdown-id "invitation-actions"}
[:ul {:class (stl/css :actions-dropdown :invitations-dropdown)}
[:li {:on-click on-copy
:class (stl/css :action-dropdown-item)}
@ -982,7 +982,7 @@
[:button {:class (stl/css :menu-btn)
:on-click on-show}
menu-icon]
[:& dropdown {:show @show? :on-close on-hide}
[:& dropdown {:show @show? :on-close on-hide :dropdown-id "webhook-actions"}
[:ul {:class (stl/css :webhook-actions-dropdown)}
[:li {:on-click on-edit
:class (stl/css :webhook-dropdown-item)} (tr "labels.edit")]

View File

@ -28,7 +28,7 @@
[app.main.features :as features]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.components.dropdown-menu :refer [dropdown-menu dropdown-menu-item*]]
[app.main.ui.components.dropdown-menu :refer [dropdown-menu* dropdown-menu-item*]]
[app.main.ui.context :as ctx]
[app.main.ui.dashboard.subscription :refer [main-menu-power-up* get-subscription-type]]
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
@ -93,11 +93,12 @@
(st/emit! (modal/show {:type :onboarding}))
(st/emit! (modal/show {:type :release-notes :version version}))))))]
[:& dropdown-menu {:show true
:on-close on-close
:list-class (stl/css-case :sub-menu true
:help-info plugins?
:help-info-old (not plugins?))}
[:> dropdown-menu* {:show true
;; :id "workspace-help-menu"
:on-close on-close
:class (stl/css-case :sub-menu true
:help-info plugins?
:help-info-old (not plugins?))}
[:> dropdown-menu-item* {:class (stl/css :submenu-item)
:on-click nav-to-helpc-center
:on-key-down (fn [event]
@ -182,10 +183,11 @@
[{:keys [layout profile toggle-flag on-close toggle-theme]}]
(let [show-nudge-options (mf/use-fn #(modal/show! {:type :nudge-option}))]
[:& dropdown-menu {:show true
:list-class (stl/css-case :sub-menu true
:preferences true)
:on-close on-close}
[:> dropdown-menu* {:show true
;; :id "workspace-preferences-menu"
:class (stl/css-case :sub-menu true
:preferences true)
:on-close on-close}
[:> dropdown-menu-item* {:on-click toggle-flag
:class (stl/css :submenu-item)
:on-key-down (fn [event]
@ -312,10 +314,11 @@
(-> (dw/toggle-layout-flag :textpalette)
(vary-meta assoc ::ev/origin "workspace-menu")))))]
[:& dropdown-menu {:show true
:list-class (stl/css-case :sub-menu true
:view true)
:on-close on-close}
[:> dropdown-menu* {:show true
;; :id "workspace-view-menu"
:class (stl/css-case :sub-menu true
:view true)
:on-close on-close}
[:> dropdown-menu-item* {:class (stl/css :submenu-item)
:on-click toggle-flag
@ -430,10 +433,11 @@
perms (mf/use-ctx ctx/permissions)
can-edit (:can-edit perms)]
[:& dropdown-menu {:show true
:list-class (stl/css-case :sub-menu true
:edit true)
:on-close on-close}
[:> dropdown-menu* {:show true
;; :id "workspace-edit-menu"
:class (stl/css-case :sub-menu true
:edit true)
:on-close on-close}
[:> dropdown-menu-item* {:class (stl/css :submenu-item)
:on-click select-all
@ -592,10 +596,11 @@
(when (kbd/enter? event)
(on-export-frames event))))]
[:& dropdown-menu {:show true
:list-class (stl/css-case :sub-menu true
:file true)
:on-close on-close}
[:> dropdown-menu* {:show true
;; :id "workspace-file-menu"
:class (stl/css-case :sub-menu true
:file true)
:on-close on-close}
(if ^boolean shared?
(when can-edit
@ -690,9 +695,10 @@
(let [plugins (preg/plugins-list)
user-can-edit? (:can-edit (deref refs/permissions))
permissions-peek (deref refs/plugins-permissions-peek)]
[:& dropdown-menu {:show true
:list-class (stl/css-case :sub-menu true :plugins true)
:on-close on-close}
[:> dropdown-menu* {:show true
;; :id "workspace-plugins-menu"
:class (stl/css-case :sub-menu true :plugins true)
:on-close on-close}
[:> dropdown-menu-item* {:on-click open-plugins
:class (stl/css :submenu-item)
:on-key-down (fn [event]
@ -840,9 +846,10 @@
:on-click open-menu
:icon "menu"}]
[:& dropdown-menu {:show show-menu?
:on-close close-menu
:list-class (stl/css :menu)}
[:> dropdown-menu* {:show show-menu?
:id "workspace-menu"
:on-close close-menu
:class (stl/css :menu)}
[:> dropdown-menu-item* {:class (stl/css :menu-item)
:on-click on-menu-click
:on-key-down (fn [event]

View File

@ -11,7 +11,7 @@
[app.config :as cf]
[app.main.data.modal :as modal]
[app.main.refs :as refs]
[app.main.ui.components.dropdown-menu :refer [dropdown-menu
[app.main.ui.components.dropdown-menu :refer [dropdown-menu*
dropdown-menu-item*]]
[app.main.ui.components.title-bar :refer [title-bar]]
[app.main.ui.context :as ctx]
@ -123,9 +123,10 @@
:icon "import-export"
:variant "secondary"}
(tr "workspace.tokens.tools")]
[:& dropdown-menu {:show show-menu?
:on-close close-menu
:list-class (stl/css :import-export-menu)}
[:> dropdown-menu* {:show show-menu?
:on-close close-menu
:id "tokens-menu"
:class (stl/css :import-export-menu)}
(when can-edit?
[:> dropdown-menu-item* {:class (stl/css :import-export-menu-item)
:on-click on-modal-show}

View File

@ -469,6 +469,11 @@
(when (some? node)
(.focus node)))
(defn click!
[^js node]
(when (some? node)
(.click node)))
(defn focus?
[^js node]
(and node
@ -899,4 +904,4 @@
;; In theory We could disable this only for the workspace. However gets too unreliable.
;; It is better to be safe and disable for the dashboard as well.
(set! (.. js/document -documentElement -style -overscrollBehaviorX) "none")
(set! (.. js/document -body -style -overscrollBehaviorX) "none"))
(set! (.. js/document -body -style -overscrollBehaviorX) "none"))