From 88d85706ad732dc78f752b049205ecf41233b762 Mon Sep 17 00:00:00 2001
From: Andrey Antukh <niwi@niwi.nz>
Date: Tue, 15 Oct 2024 17:51:29 +0200
Subject: [PATCH] :recycle: Refactor context-menu component

---
 .../main/ui/components/context_menu_a11y.cljs | 396 +++++++++---------
 .../src/app/main/ui/dashboard/file_menu.cljs  | 221 +++++-----
 frontend/src/app/main/ui/dashboard/fonts.cljs |  23 +-
 .../app/main/ui/dashboard/project_menu.cljs   |  77 ++--
 .../app/main/ui/settings/access_tokens.cljs   |  14 +-
 .../app/main/ui/workspace/sidebar/assets.cljs |  51 +--
 .../ui/workspace/sidebar/assets/colors.cljs   |  24 +-
 .../ui/workspace/sidebar/assets/common.cljs   |   9 +-
 .../workspace/sidebar/assets/components.cljs  |  34 +-
 .../ui/workspace/sidebar/assets/graphics.cljs |  18 +-
 .../ui/workspace/sidebar/assets/groups.cljs   |  12 +-
 .../sidebar/assets/typographies.cljs          |  30 +-
 12 files changed, 460 insertions(+), 449 deletions(-)

diff --git a/frontend/src/app/main/ui/components/context_menu_a11y.cljs b/frontend/src/app/main/ui/components/context_menu_a11y.cljs
index ea81d8d50..04475e7c2 100644
--- a/frontend/src/app/main/ui/components/context_menu_a11y.cljs
+++ b/frontend/src/app/main/ui/components/context_menu_a11y.cljs
@@ -9,90 +9,107 @@
   (:require
    [app.common.data :as d]
    [app.common.data.macros :as dm]
+   [app.common.schema :as sm]
    [app.main.refs :as refs]
    [app.main.ui.components.dropdown :refer [dropdown']]
    [app.main.ui.icons :as i]
    [app.util.dom :as dom]
    [app.util.i18n :as i18n :refer [tr]]
    [app.util.keyboard :as kbd]
-   [app.util.object :as obj]
    [app.util.timers :as tm]
-   [goog.object :as gobj]
    [rumext.v2 :as mf]))
 
-(defn generate-ids-group
-  [options parent-name]
-  (let  [ids (->> options
-                  (map :id)
-                  (filter some?))]
-    (if parent-name
-      (cons "go-back-sub-option" ids)
-      ids)))
+(def ^:private xf:options
+  (comp
+   (map :id)
+   (filter some?)))
 
-(mf/defc context-menu-a11y-item
-  {::mf/wrap-props false}
-  [props]
+(defn- generate-ids-group
+  [options has-parents?]
+  (let [ids (sequence xf:options options)
+        ids (if has-parents?
+              (cons "go-back-sub-option" ids)
+              ids)]
+    (vec ids)))
 
-  (let [children    (gobj/get props "children")
-        on-click    (gobj/get props "on-click")
-        on-key-down (gobj/get props "on-key-down")
-        id          (gobj/get props "id")
-        klass       (gobj/get props "class")
-        key-index   (gobj/get props "key-index")
-        data-testid   (gobj/get props "data-testid")]
-    [:li {:id id
-          :class klass
-          :tab-index "0"
-          :on-key-down on-key-down
-          :on-click on-click
-          :key key-index
-          :role "menuitem"
-          :data-testid data-testid}
-     children]))
+(def ^:private schema:option
+  [:schema {:registry
+            {::option
+             [:or
+              :nil
+              [:map [:name [:= :separator]]]
+              [:and
+               [:map
+                [:name :string]
+                [:id :string]
+                [:handler {:optional true} fn?]
+                [:options {:optional true}
+                 [:sequential [:ref ::option]]]]
+               [::sm/contains-any #{:handler :options}]]]}}
+   [:ref ::option]])
+
+(def ^:private valid-option?
+  (sm/lazy-validator schema:option))
+
+(mf/defc context-menu*
+  {::mf/props :obj}
+
+  [{:keys [show on-close options selectable selected
+           top left fixed min-width origin width]
+    :as props}]
+
+  (assert (every? valid-option? options) "expected valid options")
+  (assert (fn? on-close) "missing `on-close` prop")
+  (assert (boolean? show) "missing `show` prop")
+  (assert (vector? options) "missing `options` prop")
+
+  (let [width          (d/nilv width "initial")
+        min-width      (d/nilv min-width false)
+        left           (d/nilv left 0)
+        top            (d/nilv top 0)
 
-(mf/defc context-menu-a11y'
-  {::mf/wrap-props false}
-  [props]
-  (assert (fn? (gobj/get props "on-close")) "missing `on-close` prop")
-  (assert (boolean? (gobj/get props "show")) "missing `show` prop")
-  (assert (vector? (gobj/get props "options")) "missing `options` prop")
-  (let [open?          (gobj/get props "show")
-        on-close       (gobj/get props "on-close")
-        options        (gobj/get props "options")
-        is-selectable  (gobj/get props "selectable")
-        selected       (gobj/get props "selected")
-        top            (gobj/get props "top" 0)
-        left           (gobj/get props "left" 0)
-        fixed?         (gobj/get props "fixed?" false)
-        min-width?     (gobj/get props "min-width?" false)
-        origin         (gobj/get props "origin")
         route          (mf/deref refs/route)
         in-dashboard?  (= :dashboard-projects (:name (:data route)))
-        local          (mf/use-state {:offset-y 0
-                                      :offset-x 0
-                                      :levels nil})
-        width          (gobj/get props "width" "initial")
 
+        state*         (mf/use-state
+                        #(-> {:offset-y 0
+                              :offset-x 0
+                              :levels nil}))
+
+        state          (deref state*)
+        offset-x       (get state :offset-x)
+        offset-y       (get state :offset-y)
+        levels         (get state :levels)
 
         on-local-close
-        (mf/use-callback
+        (mf/use-fn
+         (mf/deps on-close)
          (fn []
-           (swap! local assoc :levels [{:parent-option nil
-                                        :options options}])
+           (swap! state* assoc :levels [{:parent nil
+                                         :options options}])
            (on-close)))
 
-        props (obj/merge props #js {:on-close on-local-close})
+        props
+        (mf/spread props :on-close on-local-close)
+
+        ids
+        (mf/with-memo [levels]
+          (let [last-level (last levels)]
+            (generate-ids-group (:options last-level)
+                                (:parent last-level))))
 
-        ids (generate-ids-group (:options (last (:levels @local))) (:parent-option (last (:levels @local))))
         check-menu-offscreen
-        (mf/use-callback
-         (mf/deps top (:offset-y @local) left (:offset-x @local))
+        (mf/use-fn
+         (mf/deps top left offset-x offset-y)
          (fn [node]
            (when (some? node)
-             (let [bounding_rect (dom/get-bounding-rect node)
-                   window_size (dom/get-window-size)
-                   {node-height :height node-width :width} bounding_rect
-                   {window-height :height window-width :width} window_size
+             (let [bounding-rect   (dom/get-bounding-rect node)
+                   window-size     (dom/get-window-size)
+                   node-height     (dm/get-prop bounding-rect :height)
+                   node-width      (dm/get-prop bounding-rect :width)
+                   window-height   (get window-size :height)
+                   window-width    (get window-size :width)
+
                    target-offset-y (if (> (+ top node-height) window-height)
                                      (- node-height)
                                      0)
@@ -100,74 +117,86 @@
                                      (- node-width)
                                      0)]
 
-               (when (or (not= target-offset-y (:offset-y @local)) (not= target-offset-x (:offset-x @local)))
-                 (swap! local assoc :offset-y target-offset-y :offset-x target-offset-x))))))
+               (when (or (not= target-offset-y offset-y)
+                         (not= target-offset-x offset-x))
+                 (swap! state* assoc
+                        :offset-y target-offset-y
+                        :offset-x target-offset-x))))))
 
+        ;; NOTE: this function is used for build navigation callbacks
+        ;; so we don't really need to use the use-fn here. It is not
+        ;; an efficient approach but this manages a reasonable small
+        ;; list of objects, so doing it this way has no real
+        ;; implications on performance but facilitates a lot the
+        ;; implementation
         enter-submenu
-        (mf/use-callback
-         (mf/deps options)
-         (fn [option-name sub-options]
-           (fn [event]
-             (dom/stop-propagation event)
-             (swap! local update :levels
-                    conj {:parent-option option-name
-                          :options sub-options}))))
-
-        exit-submenu
-        (mf/use-callback
+        (fn [name options]
+          (fn [event]
+            (dom/stop-propagation event)
+            (swap! state* update :levels conj {:parent name
+                                               :options options})))
+        on-submenu-exit
+        (mf/use-fn
          (fn [event]
            (dom/stop-propagation event)
-           (swap! local update :levels pop)))
+           (swap! state* update :levels pop)))
 
+        ;; NOTE: this function is used for build navigation callbacks
+        ;; so we don't really need to use the use-fn here. It is not
+        ;; an efficient approach but this manages a reasonable small
+        ;; list of objects, so doing it this way has no real
+        ;; implications on performance but facilitates a lot the
+        ;; implementation
         on-key-down
         (fn [options-original parent-original]
           (fn [event]
-            (let [ids (generate-ids-group options-original parent-original)
-                  first-id (dom/get-element (first ids))
-                  first-element (dom/get-element first-id)
-                  len (count ids)
-                  parent (dom/get-target event)
-                  parent-id (dom/get-attribute parent "id")
-                  option (first (filter  #(= parent-id (:id %)) options-original))
-                  sub-options (:sub-options option)
-                  has-suboptions? (some? (:sub-options option))
-                  option-handler (:option-handler option)
-                  is-back-option (= "go-back-sub-option" parent-id)]
+            (let [ids             (generate-ids-group options-original
+                                                      parent-original)
+                  first-id        (dom/get-element (first ids))
+                  first-element   (dom/get-element first-id)
+                  len             (count ids)
+
+                  parent          (dom/get-target event)
+                  parent-id       (dom/get-attribute parent "id")
+
+                  option          (d/seek #(= parent-id (:id %)) options-original)
+                  sub-options     (not-empty (:options option))
+                  handler         (:handler option)
+                  is-back-option? (= "go-back-sub-option" parent-id)]
+
               (when (kbd/home? event)
                 (when first-element
                   (dom/focus! first-element)))
 
               (when (kbd/enter? event)
-                (if is-back-option
-                  (exit-submenu event)
+                (if is-back-option?
+                  (on-submenu-exit event)
 
-                  (if has-suboptions?
+                  (if sub-options
                     (do
                       (dom/stop-propagation event)
-                      (swap! local update :levels
-                             conj {:parent-option (:option-name option)
-                                   :options sub-options}))
+                      (swap! state* update :levels conj {:parent (:name option)
+                                                         :options sub-options}))
 
                     (do
                       (dom/stop-propagation event)
-                      (option-handler event)))))
+                      (handler event)))))
 
-              (when (and is-back-option
-                         (kbd/left-arrow? event))
-                (exit-submenu event))
+              (when (and is-back-option? (kbd/left-arrow? event))
+                (on-submenu-exit event))
 
-              (when (and has-suboptions? (kbd/right-arrow? event))
+              (when (and sub-options (kbd/right-arrow? event))
                 (dom/stop-propagation event)
-                (swap! local update :levels
-                       conj {:parent-option (:option-name option)
-                             :options sub-options}))
+                (swap! state* update :levels conj {:parent (:name option)
+                                                   :options sub-options}))
+
               (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)
-                                    (nth ids (- actual-index 1)))]
+                      actual-id       (dom/get-attribute actual-selected "id")
+                      actual-index    (d/index-of ids actual-id)
+                      previous-id     (if (= 0 actual-index)
+                                        (last ids)
+                                        (nth ids (- actual-index 1)))]
                   (dom/focus! (dom/get-element previous-id))))
 
               (when (kbd/down-arrow? event)
@@ -180,98 +209,87 @@
                   (dom/focus! (dom/get-element next-id))))
 
               (when (or (kbd/esc? event) (kbd/tab? event))
-                (on-close)
+                (on-close event)
                 (dom/focus! (dom/get-element origin))))))]
 
     (mf/with-effect [options]
-      (swap! local assoc :levels [{:parent-option nil
-                                   :options options}]))
+      (swap! state* assoc :levels [{:parent nil
+                                    :options options}]))
 
     (mf/with-effect [ids]
       (tm/schedule-on-idle
        #(dom/focus! (dom/get-element (first ids)))))
 
-    (when (and open? (some? (:levels @local)))
+    (when (and show (some? levels))
       [:> dropdown' props
-       (let [level (-> @local :levels peek)
-             original-options (:options level)
-             parent-original (:parent-option level)]
-         [:div {:class (stl/css-case :is-selectable is-selectable
-                                     :context-menu true
-                                     :is-open open?
-                                     :fixed fixed?)
-                :style {:top (+ top (:offset-y @local))
-                        :left (+ left (:offset-x @local))}
-                :on-key-down (on-key-down original-options parent-original)}
-          (let [level (-> @local :levels peek)]
-            [:ul {:class (stl/css-case :min-width min-width?
-                                       :context-menu-items true)
-                  :style {:width width}
-                  :role "menu"
-                  :ref check-menu-offscreen}
-             (when-let [parent-option (:parent-option level)]
-               [:*
-                [:& context-menu-a11y-item
-                 {:id "go-back-sub-option"
-                  :class (stl/css :context-menu-item)
-                  :tab-index "0"
-                  :on-key-down (fn [event]
-                                 (dom/prevent-default event))}
-                 [:button {:class (stl/css :context-menu-action :submenu-back)
+       (let [level   (peek levels)
+             options (:options level)
+             parent  (:parent level)]
+
+         [:div {:class (stl/css-case
+                        :is-selectable selectable
+                        :context-menu true
+                        :is-open show
+                        :fixed fixed)
+                :style {:top (+ top offset-y)
+                        :left (+ left offset-x)}
+                :on-key-down (on-key-down options parent)}
+
+          [:ul {:class (stl/css-case :min-width min-width
+                                     :context-menu-items true)
+                :style {:width width}
+                :role "menu"
+                :ref check-menu-offscreen}
+
+           (when-let [parent (:parent level)]
+             [:*
+              [:li {:id "go-back-sub-option"
+                    :class (stl/css :context-menu-item)
+                    :role "menuitem"
+                    :tab-index "0"
+                    :on-key-down dom/prevent-default}
+               [:button {:class (stl/css :context-menu-action :submenu-back)
+                         :data-no-close true
+                         :on-click on-submenu-exit}
+                [:span {:class (stl/css :submenu-icon-back)} i/arrow]
+                parent]]
+
+              [:li {:class (stl/css :separator)}]])
+
+           (for [[index option] (d/enumerate (:options level))]
+             (let [name        (:name option)
+                   id          (:id option)
+                   sub-options (:options option)
+                   handler     (:handler option)]
+               (when name
+                 (if (= name :separator)
+                   [:li {:key (dm/str "context-item-" index)
+                         :class (stl/css :separator)}]
+                   [:li {:id id
+                         :key id
+                         :class (stl/css-case
+                                 :is-selected (and selected (= name selected))
+                                 :selected (and selected (= id selected))
+                                 :context-menu-item true)
+                         :tab-index "0"
+                         :role "menuitem"
+                         :on-key-down dom/prevent-default}
+                    (if-not sub-options
+                      [:a {:class (stl/css :context-menu-action)
+                           :on-click #(do (dom/stop-propagation %)
+                                          (on-close %)
+                                          (handler %))
+                           :data-testid id}
+                       (if (and in-dashboard? (= name "Default"))
+                         (tr "dashboard.default-team-name")
+                         name)
+
+                       (when (and selected (= id selected))
+                         [:span {:class (stl/css :selected-icon)} i/tick])]
+
+                      [:a {:class (stl/css :context-menu-action :submenu)
                            :data-no-close true
-                           :on-click exit-submenu}
-                  [:span {:class (stl/css :submenu-icon-back)} i/arrow]
-                  parent-option]]
-
-                [:li {:class (stl/css :separator)}]])
-
-             (for [[index option] (d/enumerate (:options level))]
-               (let [option-name (:option-name option)
-                     id (:id option)
-                     sub-options (:sub-options option)
-                     option-handler (:option-handler option)
-                     data-testid (:data-testid option)]
-                 (when option-name
-                   (if (= option-name :separator)
-                     [:li {:key (dm/str "context-item-" index)
-                           :class (stl/css :separator)}]
-                     [:& context-menu-a11y-item
-                      {:id id
-                       :key id
-                       :class (stl/css-case
-                               :is-selected (and selected (= option-name selected))
-                               :selected (and selected (= data-testid selected))
-                               :context-menu-item true)
-                       :key-index (dm/str "context-item-" index)
-                       :tab-index "0"
-                       :on-key-down (fn [event]
-                                      (dom/prevent-default event))}
-                      (if-not sub-options
-                        [:a {:class (stl/css :context-menu-action)
-                             :on-click #(do (dom/stop-propagation %)
-                                            (on-close)
-                                            (option-handler %))
-                             :data-testid data-testid}
-                         (if (and in-dashboard? (= option-name "Default"))
-                           (tr "dashboard.default-team-name")
-                           option-name)
-
-                         (when (and selected (= data-testid selected))
-                           [:span {:class (stl/css :selected-icon)} i/tick])]
-
-                        [:a {:class (stl/css :context-menu-action :submenu)
-                             :data-no-close true
-                             :on-click (enter-submenu option-name sub-options)
-                             :data-testid data-testid}
-                         option-name
-                         [:span {:class (stl/css :submenu-icon)} i/arrow]])]))))])])])))
-
-(mf/defc context-menu-a11y
-  {::mf/wrap-props false}
-  [props]
-  (assert (fn? (gobj/get props "on-close")) "missing `on-close` prop")
-  (assert (boolean? (gobj/get props "show")) "missing `show` prop")
-  (assert (vector? (gobj/get props "options")) "missing `options` prop")
-
-  (when (gobj/get props "show")
-    (mf/element context-menu-a11y' props)))
+                           :on-click (enter-submenu name sub-options)
+                           :data-testid id}
+                       name
+                       [:span {:class (stl/css :submenu-icon)} i/arrow]])]))))]])])))
diff --git a/frontend/src/app/main/ui/dashboard/file_menu.cljs b/frontend/src/app/main/ui/dashboard/file_menu.cljs
index aa87cb907..f7e46bf60 100644
--- a/frontend/src/app/main/ui/dashboard/file_menu.cljs
+++ b/frontend/src/app/main/ui/dashboard/file_menu.cljs
@@ -14,7 +14,7 @@
    [app.main.refs :as refs]
    [app.main.repo :as rp]
    [app.main.store :as st]
-   [app.main.ui.components.context-menu-a11y :refer [context-menu-a11y]]
+   [app.main.ui.components.context-menu-a11y :refer [context-menu*]]
    [app.main.ui.context :as ctx]
    [app.util.dom :as dom]
    [app.util.i18n :as i18n :refer [tr]]
@@ -221,113 +221,118 @@
                            (reset! teams %)))))))
 
     (when current-team
-      (let [sub-options (concat (vec (for [project current-projects]
-                                       {:option-name (get-project-name project)
-                                        :id (get-project-id project)
-                                        :option-handler (on-move (:id current-team)
-                                                                 (:id project))}))
-                                (when (seq other-teams)
-                                  [{:option-name (tr "dashboard.move-to-other-team")
-                                    :id "move-to-other-team"
-                                    :sub-options
-                                    (for [team other-teams]
-                                      {:option-name (get-team-name team)
-                                       :id (get-project-id team)
-                                       :sub-options
-                                       (for [sub-project (:projects team)]
-                                         {:option-name (get-project-name sub-project)
-                                          :id (get-project-id sub-project)
-                                          :option-handler (on-move (:id team)
-                                                                   (:id sub-project))})})}]))
+      (let [sub-options
+            (concat
+             (for [project current-projects]
+               {:name (get-project-name project)
+                :id (get-project-id project)
+                :handler (on-move (:id current-team)
+                                  (:id project))})
+             (when (seq other-teams)
+               [{:name (tr "dashboard.move-to-other-team")
+                 :id "move-to-other-team"
+                 :options
+                 (for [team other-teams]
+                   {:name (get-team-name team)
+                    :id (get-project-id team)
+                    :options
+                    (for [sub-project (:projects team)]
+                      {:name (get-project-name sub-project)
+                       :id (get-project-id sub-project)
+                       :handler (on-move (:id team)
+                                         (:id sub-project))})})}]))
 
-            options (if multi?
-                      [(when-not you-viewer?
-                         {:option-name    (tr "dashboard.duplicate-multi" file-count)
-                          :id             "file-duplicate-multi"
-                          :option-handler on-duplicate
-                          :data-testid      "duplicate-multi"})
-                       (when (and (or (seq current-projects) (seq other-teams))
-                                  (not you-viewer?))
-                         {:option-name    (tr "dashboard.move-to-multi" file-count)
-                          :id             "file-move-multi"
-                          :sub-options    sub-options
-                          :data-testid      "move-to-multi"})
-                       {:option-name    (tr "dashboard.export-binary-multi" file-count)
-                        :id             "file-binari-export-multi"
-                        :option-handler on-export-binary-files}
-                       {:option-name    (tr "dashboard.export-standard-multi" file-count)
-                        :id             "file-standard-export-multi"
-                        :option-handler on-export-standard-files}
-                       (when (and (:is-shared file)
-                                  (not you-viewer?))
-                         {:option-name    (tr "labels.unpublish-multi-files" file-count)
-                          :id             "file-unpublish-multi"
-                          :option-handler on-del-shared
-                          :data-testid      "file-del-shared"})
-                       (when (and (not is-lib-page?)
-                                  (not you-viewer?))
-                         {:option-name    :separator}
-                         {:option-name    (tr "labels.delete-multi-files" file-count)
-                          :id             "file-delete-multi"
-                          :option-handler on-delete
-                          :data-testid      "delete-multi-files"})]
+            options
+            (if multi?
+              [(when-not you-viewer?
+                 {:name    (tr "dashboard.duplicate-multi" file-count)
+                  :id      "duplicate-multi"
+                  :handler on-duplicate})
 
-                      [{:option-name    (tr "dashboard.open-in-new-tab")
-                        :id             "file-open-new-tab"
-                        :option-handler on-new-tab}
-                       (when (and (not is-search-page?)
-                                  (not you-viewer?))
-                         {:option-name    (tr "labels.rename")
-                          :id             "file-rename"
-                          :option-handler on-edit
-                          :data-testid      "file-rename"})
-                       (when (and (not is-search-page?)
-                                  (not you-viewer?))
-                         {:option-name    (tr "dashboard.duplicate")
-                          :id             "file-duplicate"
-                          :option-handler on-duplicate
-                          :data-testid      "file-duplicate"})
-                       (when (and (not is-lib-page?)
-                                  (not is-search-page?)
-                                  (or (seq current-projects) (seq other-teams))
-                                  (not you-viewer?))
-                         {:option-name    (tr "dashboard.move-to")
-                          :id             "file-move-to"
-                          :sub-options    sub-options
-                          :data-testid      "file-move-to"})
-                       (when (and (not is-search-page?)
-                                  (not you-viewer?))
-                         (if (:is-shared file)
-                           {:option-name    (tr "dashboard.unpublish-shared")
-                            :id             "file-del-shared"
-                            :option-handler on-del-shared
-                            :data-testid      "file-del-shared"}
-                           {:option-name    (tr "dashboard.add-shared")
-                            :id             "file-add-shared"
-                            :option-handler on-add-shared
-                            :data-testid      "file-add-shared"}))
-                       {:option-name   :separator}
-                       {:option-name    (tr "dashboard.download-binary-file")
-                        :id             "file-download-binary"
-                        :option-handler on-export-binary-files
-                        :data-testid      "download-binary-file"}
-                       {:option-name    (tr "dashboard.download-standard-file")
-                        :id             "file-download-standard"
-                        :option-handler on-export-standard-files
-                        :data-testid      "download-standard-file"}
-                       (when (and (not is-lib-page?) (not is-search-page?) (not you-viewer?))
-                         {:option-name   :separator}
-                         {:option-name    (tr "labels.delete")
-                          :id             "file-delete"
-                          :option-handler on-delete
-                          :data-testid      "file-delete"})])]
+               (when (and (or (seq current-projects) (seq other-teams))
+                          (not you-viewer?))
+                 {:name    (tr "dashboard.move-to-multi" file-count)
+                  :id      "file-move-multi"
+                  :options    sub-options})
 
-        [:& context-menu-a11y {:on-close on-menu-close
-                               :show show?
-                               :fixed? (or (not= top 0) (not= left 0))
-                               :min-width? true
-                               :top top
-                               :left left
-                               :options options
-                               :origin parent-id
-                               :workspace? false}]))))
+               {:name    (tr "dashboard.export-binary-multi" file-count)
+                :id      "file-binari-export-multi"
+                :handler on-export-binary-files}
+
+               {:name    (tr "dashboard.export-standard-multi" file-count)
+                :id      "file-standard-export-multi"
+                :handler on-export-standard-files}
+
+               (when (and (:is-shared file)
+                          (not you-viewer?))
+                 {:name    (tr "labels.unpublish-multi-files" file-count)
+                  :id      "file-unpublish-multi"
+                  :handler on-del-shared})
+
+               (when (and (not is-lib-page?)
+                          (not you-viewer?))
+                 {:name    :separator}
+                 {:name    (tr "labels.delete-multi-files" file-count)
+                  :id      "file-delete-multi"
+                  :handler on-delete})]
+
+              [{:name    (tr "dashboard.open-in-new-tab")
+                :id      "file-open-new-tab"
+                :handler on-new-tab}
+               (when (and (not is-search-page?)
+                          (not you-viewer?))
+                 {:name    (tr "labels.rename")
+                  :id      "file-rename"
+                  :handler on-edit})
+
+               (when (and (not is-search-page?)
+                          (not you-viewer?))
+                 {:name    (tr "dashboard.duplicate")
+                  :id      "file-duplicate"
+                  :handler on-duplicate})
+
+               (when (and (not is-lib-page?)
+                          (not is-search-page?)
+                          (or (seq current-projects) (seq other-teams))
+                          (not you-viewer?))
+                 {:name    (tr "dashboard.move-to")
+                  :id      "file-move-to"
+                  :options    sub-options})
+
+               (when (and (not is-search-page?)
+                          (not you-viewer?))
+                 (if (:is-shared file)
+                   {:name    (tr "dashboard.unpublish-shared")
+                    :id      "file-del-shared"
+                    :handler on-del-shared}
+                   {:name    (tr "dashboard.add-shared")
+                    :id      "file-add-shared"
+                    :handler on-add-shared}))
+
+               {:name   :separator}
+
+               {:name    (tr "dashboard.download-binary-file")
+                :id      "download-binary-file"
+                :handler on-export-binary-files}
+
+               {:name    (tr "dashboard.download-standard-file")
+                :id      "download-standard-file"
+                :handler on-export-standard-files}
+
+               (when (and (not is-lib-page?) (not is-search-page?) (not you-viewer?))
+                 {:name   :separator})
+
+               (when (and (not is-lib-page?) (not is-search-page?) (not you-viewer?))
+                 {:name    (tr "labels.delete")
+                  :id      "file-delete"
+                  :handler on-delete})])]
+
+        [:> context-menu*
+         {:on-close on-menu-close
+          :show show?
+          :fixed (or (not= top 0) (not= left 0))
+          :min-width true
+          :top top
+          :left left
+          :options options
+          :origin parent-id}]))))
diff --git a/frontend/src/app/main/ui/dashboard/fonts.cljs b/frontend/src/app/main/ui/dashboard/fonts.cljs
index 9c2f09cf2..1b6b87ad0 100644
--- a/frontend/src/app/main/ui/dashboard/fonts.cljs
+++ b/frontend/src/app/main/ui/dashboard/fonts.cljs
@@ -14,7 +14,7 @@
    [app.main.refs :as refs]
    [app.main.repo :as rp]
    [app.main.store :as st]
-   [app.main.ui.components.context-menu-a11y :refer [context-menu-a11y]]
+   [app.main.ui.components.context-menu-a11y :refer [context-menu*]]
    [app.main.ui.components.file-uploader :refer [file-uploader]]
    [app.main.ui.ds.product.empty-placeholder :refer [empty-placeholder*]]
    [app.main.ui.icons :as i]
@@ -250,21 +250,20 @@
    ::mf/private true}
   [{:keys [is-open on-close on-edit on-delete]}]
   (let [options (mf/with-memo [on-edit on-delete]
-                  [{:option-name    (tr "labels.edit")
-                    :id             "font-edit"
-                    :option-handler on-edit}
-                   {:option-name    (tr "labels.delete")
-                    :id             "font-delete"
-                    :option-handler on-delete}])]
-    [:& context-menu-a11y
+                  [{:name    (tr "labels.edit")
+                    :id      "font-edit"
+                    :handler on-edit}
+                   {:name    (tr "labels.delete")
+                    :id      "font-delete"
+                    :handler on-delete}])]
+    [:> context-menu*
      {:on-close on-close
       :show is-open
-      :fixed? false
-      :min-width? true
+      :fixed false
+      :min-width true
       :top -15
       :left -115
-      :options options
-      :workspace? false}]))
+      :options options}]))
 
 (mf/defc installed-font
   {::mf/props :obj
diff --git a/frontend/src/app/main/ui/dashboard/project_menu.cljs b/frontend/src/app/main/ui/dashboard/project_menu.cljs
index 36293395d..f3168a87c 100644
--- a/frontend/src/app/main/ui/dashboard/project_menu.cljs
+++ b/frontend/src/app/main/ui/dashboard/project_menu.cljs
@@ -11,7 +11,7 @@
    [app.main.data.notifications :as ntf]
    [app.main.refs :as refs]
    [app.main.store :as st]
-   [app.main.ui.components.context-menu-a11y :refer [context-menu-a11y]]
+   [app.main.ui.components.context-menu-a11y :refer [context-menu*]]
    [app.main.ui.context :as ctx]
    [app.main.ui.dashboard.import :as udi]
    [app.util.dom :as dom]
@@ -81,53 +81,50 @@
          (fn []
            (when (fn? on-import) (on-import))))
 
-        options [(when-not (:is-default project)
-                   {:option-name    (tr "labels.rename")
-                    :id             "project-menu-rename"
-                    :option-handler on-edit
-                    :data-testid      "project-rename"})
-                 (when-not (:is-default project)
-                   {:option-name    (tr "dashboard.duplicate")
-                    :id             "project-menu-duplicated"
-                    :option-handler on-duplicate
-                    :data-testid      "project-duplicate"})
-                 (when-not (:is-default project)
-                   {:option-name    (tr "dashboard.pin-unpin")
-                    :id             "project-menu-pin"
-                    :option-handler toggle-pin})
+        options
+        [(when-not (:is-default project)
+           {:name   (tr "labels.rename")
+            :id     "project-rename"
+            :handler on-edit})
+         (when-not (:is-default project)
+           {:name (tr "dashboard.duplicate")
+            :id   "project-duplicate"
+            :handler on-duplicate})
+         (when-not (:is-default project)
+           {:name (tr "dashboard.pin-unpin")
+            :id   "project-pin"
+            :handler toggle-pin})
 
-                 (when (and (seq teams) (not (:is-default project)))
-                   {:option-name    (tr "dashboard.move-to")
-                    :id             "project-menu-move-to"
-                    :sub-options     (for [team teams]
-                                       {:option-name    (:name team)
-                                        :id             (:name team)
-                                        :option-handler (on-move (:id team))})
-                    :data-testid      "project-move-to"})
-                 (when (some? on-import)
-                   {:option-name    (tr "dashboard.import")
-                    :id             "project-menu-import"
-                    :option-handler on-import-files
-                    :data-testid      "file-import"})
-                 (when-not (:is-default project)
-                   {:option-name    :separator})
-                 (when-not (:is-default project)
-                   {:option-name    (tr "labels.delete")
-                    :id             "project-menu-delete"
-                    :option-handler on-delete
-                    :data-testid      "project-delete"})]]
+         (when (and (seq teams) (not (:is-default project)))
+           {:name    (tr "dashboard.move-to")
+            :id      "project-move-to"
+            :options (for [team teams]
+                       {:name    (:name team)
+                        :id      (str "move-to-" (:id team))
+                        :handler (on-move (:id team))})})
+
+         (when (some? on-import)
+           {:name    (tr "dashboard.import")
+            :id      "file-import"
+            :handler on-import-files})
+         (when-not (:is-default project)
+           {:name :separator})
+         (when-not (:is-default project)
+           {:name    (tr "labels.delete")
+            :id      "project-delete"
+            :handler on-delete})]]
 
     [:*
-     [:& context-menu-a11y
+     [:> context-menu*
       {:on-close on-menu-close
        :show show?
-       :fixed? (or (not= top 0) (not= left 0))
-       :min-width? true
+       :fixed (or (not= top 0) (not= left 0))
+       :min-width true
        :top top
        :left left
-       :options options
-       :workspace false}]
+       :options options}]
      [:& udi/import-form {:ref file-input
                           :project-id (:id project)
                           :on-finish-import on-finish-import}]]))
 
+
diff --git a/frontend/src/app/main/ui/settings/access_tokens.cljs b/frontend/src/app/main/ui/settings/access_tokens.cljs
index f084b0366..deae25f54 100644
--- a/frontend/src/app/main/ui/settings/access_tokens.cljs
+++ b/frontend/src/app/main/ui/settings/access_tokens.cljs
@@ -12,7 +12,7 @@
    [app.main.data.notifications :as ntf]
    [app.main.data.users :as du]
    [app.main.store :as st]
-   [app.main.ui.components.context-menu-a11y :refer [context-menu-a11y]]
+   [app.main.ui.components.context-menu-a11y :refer [context-menu*]]
    [app.main.ui.components.forms :as fm]
    [app.main.ui.icons :as i]
    [app.util.dom :as dom]
@@ -195,9 +195,9 @@
   (let [local    (mf/use-state {:menu-open false})
         show?    (:menu-open @local)
         options  (mf/with-memo [on-delete]
-                   [{:option-name    (tr "labels.delete")
-                     :id             "access-token-delete"
-                     :option-handler on-delete}])
+                   [{:name    (tr "labels.delete")
+                     :id      "access-token-delete"
+                     :handler on-delete}])
 
         menu-ref (mf/use-ref)
 
@@ -224,11 +224,11 @@
               :on-click on-menu-click
               :on-key-down on-keydown}
      menu-icon
-     [:& context-menu-a11y
+     [:> context-menu*
       {:on-close on-menu-close
        :show show?
-       :fixed? true
-       :min-width? true
+       :fixed true
+       :min-width true
        :top "auto"
        :left "auto"
        :options options}]]))
diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets.cljs b/frontend/src/app/main/ui/workspace/sidebar/assets.cljs
index a1ec84728..7c4a6632c 100644
--- a/frontend/src/app/main/ui/workspace/sidebar/assets.cljs
+++ b/frontend/src/app/main/ui/workspace/sidebar/assets.cljs
@@ -13,7 +13,7 @@
    [app.main.data.workspace.assets :as dwa]
    [app.main.refs :as refs]
    [app.main.store :as st]
-   [app.main.ui.components.context-menu-a11y :refer [context-menu-a11y]]
+   [app.main.ui.components.context-menu-a11y :refer [context-menu*]]
    [app.main.ui.components.search-bar :refer [search-bar]]
    [app.main.ui.context :as ctx]
    [app.main.ui.icons :as i]
@@ -130,32 +130,26 @@
         on-menu-close
         (mf/use-fn #(swap! filters* assoc :open-menu false))
 
-        options (into [] (remove nil?
-                                 [{:option-name    (tr "workspace.assets.box-filter-all")
-                                   :id             "section-all"
-                                   :option-handler on-section-filter-change
-                                   :data-testid      "all"}
+        options
+        [{:name    (tr "workspace.assets.box-filter-all")
+          :id      "section-all"
+          :handler on-section-filter-change}
+         {:name    (tr "workspace.assets.components")
+          :id      "section-components"
+          :handler on-section-filter-change}
 
-                                  {:option-name    (tr "workspace.assets.components")
-                                   :id             "section-components"
-                                   :option-handler on-section-filter-change
-                                   :data-testid      "components"}
+         (when (not components-v2)
+           {:name    (tr "workspace.assets.graphics")
+            :id      "section-graphics"
+            :handler on-section-filter-change})
 
-                                  (when (not components-v2)
-                                    {:option-name    (tr "workspace.assets.graphics")
-                                     :id             "section-graphics"
-                                     :option-handler on-section-filter-change
-                                     :data-testid      "graphics"})
+         {:name    (tr "workspace.assets.colors")
+          :id      "section-colors"
+          :handler on-section-filter-change}
 
-                                  {:option-name    (tr "workspace.assets.colors")
-                                   :id             "section-color"
-                                   :option-handler on-section-filter-change
-                                   :data-testid      "colors"}
-
-                                  {:option-name    (tr "workspace.assets.typography")
-                                   :id             "section-typography"
-                                   :option-handler on-section-filter-change
-                                   :data-testid      "typographies"}]))]
+         {:name    (tr "workspace.assets.typography")
+          :id      "section-typographies"
+          :handler on-section-filter-change}]]
 
     [:article  {:class (stl/css :assets-bar)}
      [:div {:class (stl/css :assets-header)}
@@ -177,18 +171,17 @@
           :class (stl/css-case :section-button true
                                :opened menu-open?)}
          i/filter-icon]]
-       [:& context-menu-a11y
+       [:> context-menu*
         {:on-close on-menu-close
          :selectable true
          :selected section
          :show menu-open?
-         :fixed? true
-         :min-width? true
+         :fixed true
+         :min-width true
          :width size
          :top 158
          :left 18
-         :options options
-         :workspace? true}]
+         :options options}]
        [:button {:class (stl/css :sort-button)
                  :title (tr "workspace.assets.sort")
                  :on-click toggle-ordering}
diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets/colors.cljs b/frontend/src/app/main/ui/workspace/sidebar/assets/colors.cljs
index 06e12e3f0..dd8e0156f 100644
--- a/frontend/src/app/main/ui/workspace/sidebar/assets/colors.cljs
+++ b/frontend/src/app/main/ui/workspace/sidebar/assets/colors.cljs
@@ -240,21 +240,21 @@
         {:on-close on-close-menu
          :state @menu-state
          :options [(when-not (or multi-colors? multi-assets?)
-                     {:option-name    (tr "workspace.assets.rename")
-                      :id             "assets-rename-color"
-                      :option-handler rename-color-clicked})
+                     {:name    (tr "workspace.assets.rename")
+                      :id      "assets-rename-color"
+                      :handler rename-color-clicked})
                    (when-not (or multi-colors? multi-assets?)
-                     {:option-name    (tr "workspace.assets.edit")
-                      :id             "assets-edit-color"
-                      :option-handler edit-color-clicked})
+                     {:name    (tr "workspace.assets.edit")
+                      :id      "assets-edit-color"
+                      :handler edit-color-clicked})
 
-                   {:option-name    (tr "workspace.assets.delete")
-                    :id             "assets-delete-color"
-                    :option-handler delete-color}
+                   {:name    (tr "workspace.assets.delete")
+                    :id      "assets-delete-color"
+                    :handler delete-color}
                    (when-not multi-assets?
-                     {:option-name   (tr "workspace.assets.group")
-                      :id             "assets-group-color"
-                      :option-handler (on-group (:id color))})]}])
+                     {:name   (tr "workspace.assets.group")
+                      :id     "assets-group-color"
+                      :handler (on-group (:id color))})]}])
 
      (when ^boolean dragging?
        [:div {:class (stl/css :dragging)}])]))
diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets/common.cljs b/frontend/src/app/main/ui/workspace/sidebar/assets/common.cljs
index 25a6252aa..080e64c40 100644
--- a/frontend/src/app/main/ui/workspace/sidebar/assets/common.cljs
+++ b/frontend/src/app/main/ui/workspace/sidebar/assets/common.cljs
@@ -22,7 +22,7 @@
    [app.main.refs :as refs]
    [app.main.render :refer [component-svg component-svg-thumbnail]]
    [app.main.store :as st]
-   [app.main.ui.components.context-menu-a11y :refer [context-menu-a11y]]
+   [app.main.ui.components.context-menu-a11y :refer [context-menu*]]
    [app.main.ui.components.title-bar :refer [title-bar]]
    [app.main.ui.context :as ctx]
    [app.main.ui.icons :as i]
@@ -111,14 +111,13 @@
 (mf/defc assets-context-menu
   {::mf/wrap-props false}
   [{:keys [options state on-close]}]
-  [:& context-menu-a11y
+  [:> context-menu*
    {:show (:open? state)
-    :fixed? (or (not= (:top state) 0) (not= (:left state) 0))
+    :fixed (or (not= (:top state) 0) (not= (:left state) 0))
     :on-close on-close
     :top (:top state)
     :left (:left state)
-    :options options
-    :workspace? true}])
+    :options options}])
 
 (mf/defc section-icon
   {::mf/wrap-props false}
diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets/components.cljs b/frontend/src/app/main/ui/workspace/sidebar/assets/components.cljs
index 7461f4202..398bd9a10 100644
--- a/frontend/src/app/main/ui/workspace/sidebar/assets/components.cljs
+++ b/frontend/src/app/main/ui/workspace/sidebar/assets/components.cljs
@@ -559,26 +559,26 @@
        {:on-close on-close-menu
         :state @menu-state
         :options [(when (and local? (not (or multi-components? multi-assets? read-only?)))
-                    {:option-name    (tr "workspace.assets.rename")
-                     :id             "assets-rename-component"
-                     :option-handler on-rename})
+                    {:name    (tr "workspace.assets.rename")
+                     :id      "assets-rename-component"
+                     :handler on-rename})
                   (when (and local? (not (or multi-assets? read-only?)))
-                    {:option-name    (if components-v2
-                                       (tr "workspace.assets.duplicate-main")
-                                       (tr "workspace.assets.duplicate"))
-                     :id             "assets-duplicate-component"
-                     :option-handler on-duplicate})
+                    {:name    (if components-v2
+                                (tr "workspace.assets.duplicate-main")
+                                (tr "workspace.assets.duplicate"))
+                     :id     "assets-duplicate-component"
+                     :handler on-duplicate})
 
                   (when (and local? (not read-only?))
-                    {:option-name    (tr "workspace.assets.delete")
-                     :id             "assets-delete-component"
-                     :option-handler on-delete})
+                    {:name    (tr "workspace.assets.delete")
+                     :id      "assets-delete-component"
+                     :handler on-delete})
                   (when (and local? (not (or multi-assets? read-only?)))
-                    {:option-name   (tr "workspace.assets.group")
-                     :id             "assets-group-component"
-                     :option-handler on-group})
+                    {:name   (tr "workspace.assets.group")
+                     :id     "assets-group-component"
+                     :handler on-group})
 
                   (when (and components-v2 (not multi-assets?))
-                    {:option-name   (tr "workspace.shape.menu.show-main")
-                     :id             "assets-show-main-component"
-                     :option-handler on-show-main})]}]]]))
+                    {:name   (tr "workspace.shape.menu.show-main")
+                     :id     "assets-show-main-component"
+                     :handler on-show-main})]}]]]))
diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets/graphics.cljs b/frontend/src/app/main/ui/workspace/sidebar/assets/graphics.cljs
index f8c7e2b8a..a1bb804cb 100644
--- a/frontend/src/app/main/ui/workspace/sidebar/assets/graphics.cljs
+++ b/frontend/src/app/main/ui/workspace/sidebar/assets/graphics.cljs
@@ -418,13 +418,13 @@
          {:on-close on-close-menu
           :state @menu-state
           :options [(when-not (or multi-objects? multi-assets?)
-                      {:option-name    (tr "workspace.assets.rename")
-                       :id             "assets-rename-graphics"
-                       :option-handler on-rename})
-                    {:option-name    (tr "workspace.assets.delete")
-                     :id             "assets-delete-graphics"
-                     :option-handler on-delete}
+                      {:name    (tr "workspace.assets.rename")
+                       :id      "assets-rename-graphics"
+                       :handler on-rename})
+                    {:name    (tr "workspace.assets.delete")
+                     :id       "assets-delete-graphics"
+                     :handler on-delete}
                     (when-not multi-assets?
-                      {:option-name    (tr "workspace.assets.group")
-                       :id             "assets-group-graphics"
-                       :option-handler on-group})]}])]]))
+                      {:name    (tr "workspace.assets.group")
+                       :id      "assets-group-graphics"
+                       :handler on-group})]}])]]))
diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets/groups.cljs b/frontend/src/app/main/ui/workspace/sidebar/assets/groups.cljs
index 82d97180d..3bc12106a 100644
--- a/frontend/src/app/main/ui/workspace/sidebar/assets/groups.cljs
+++ b/frontend/src/app/main/ui/workspace/sidebar/assets/groups.cljs
@@ -59,12 +59,12 @@
        [:& cmm/assets-context-menu
         {:on-close on-close-menu
          :state @menu-state
-         :options [{:option-name    (tr "workspace.assets.rename")
-                    :id             "assets-rename-group"
-                    :option-handler #(on-rename % path last-path)}
-                   {:option-name    (tr "workspace.assets.ungroup")
-                    :id             "assets-ungroup-group"
-                    :option-handler  #(on-ungroup path)}]}]])))
+         :options [{:name    (tr "workspace.assets.rename")
+                    :id      "assets-rename-group"
+                    :handler #(on-rename % path last-path)}
+                   {:name    (tr "workspace.assets.ungroup")
+                    :id      "assets-ungroup-group"
+                    :handler  #(on-ungroup path)}]}]])))
 
 (defn group-assets
   "Convert a list of assets in a nested structure like this:
diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets/typographies.cljs b/frontend/src/app/main/ui/workspace/sidebar/assets/typographies.cljs
index 5b8f2a3a8..33b12a9c9 100644
--- a/frontend/src/app/main/ui/workspace/sidebar/assets/typographies.cljs
+++ b/frontend/src/app/main/ui/workspace/sidebar/assets/typographies.cljs
@@ -434,27 +434,27 @@
           {:on-close on-close-menu
            :state @menu-state
            :options [(when-not (or multi-typographies? multi-assets?)
-                       {:option-name    (tr "workspace.assets.rename")
-                        :id             "assets-rename-typography"
-                        :option-handler handle-rename-typography-clicked})
+                       {:name    (tr "workspace.assets.rename")
+                        :id      "assets-rename-typography"
+                        :handler handle-rename-typography-clicked})
 
                      (when-not (or multi-typographies? multi-assets?)
-                       {:option-name    (tr "workspace.assets.edit")
-                        :id             "assets-edit-typography"
-                        :option-handler handle-edit-typography-clicked})
+                       {:name    (tr "workspace.assets.edit")
+                        :id      "assets-edit-typography"
+                        :handler handle-edit-typography-clicked})
 
-                     {:option-name    (tr "workspace.assets.delete")
-                      :id             "assets-delete-typography"
-                      :option-handler handle-delete-typography}
+                     {:name    (tr "workspace.assets.delete")
+                      :id      "assets-delete-typography"
+                      :handler handle-delete-typography}
 
                      (when-not multi-assets?
-                       {:option-name    (tr "workspace.assets.group")
-                        :id             "assets-group-typography"
-                        :option-handler on-group})]}]
+                       {:name    (tr "workspace.assets.group")
+                        :id      "assets-group-typography"
+                        :handler on-group})]}]
 
          [:& cmm/assets-context-menu
           {:on-close on-close-menu
            :state @menu-state
-           :options [{:option-name   "show info"
-                      :id             "assets-rename-typography"
-                      :option-handler handle-edit-typography-clicked}]}])]]]))
+           :options [{:name   "show info"
+                      :id     "assets-rename-typography"
+                      :handler handle-edit-typography-clicked}]}])]]]))