diff --git a/frontend/resources/styles/common/base.scss b/frontend/resources/styles/common/base.scss index 4dafb79d5..ba3cfe671 100644 --- a/frontend/resources/styles/common/base.scss +++ b/frontend/resources/styles/common/base.scss @@ -64,6 +64,10 @@ a { } } +button { + font-family: "worksans", sans-serif; +} + p { font-size: $fs12; margin-bottom: 1rem; diff --git a/frontend/resources/styles/main/layouts/login.scss b/frontend/resources/styles/main/layouts/login.scss index 0c89db043..6183ba58a 100644 --- a/frontend/resources/styles/main/layouts/login.scss +++ b/frontend/resources/styles/main/layouts/login.scss @@ -73,6 +73,40 @@ form { margin: 2rem 0 0.5rem 0; + .accept-terms-and-privacy-wrapper { + position: relative; + .input-checkbox { + margin-bottom: 0; + } + .input-checkbox input[type="checkbox"] { + position: absolute; + display: block; + width: 20px; + height: 20px; + opacity: 0; + top: 22px; + } + label { + margin-left: 40px; + } + label:before { + position: absolute; + top: 15px; + left: -36px; + } + label:after { + position: absolute; + top: 15px; + left: -33px; + } + .input-checkbox input[type="checkbox"]:focus { + opacity: 100%; + } + .auth-links { + margin-left: 40px; + font-size: 0.75rem; + } + } } } diff --git a/frontend/resources/styles/main/partials/dashboard-grid.scss b/frontend/resources/styles/main/partials/dashboard-grid.scss index 501d87b9c..619b3de56 100644 --- a/frontend/resources/styles/main/partials/dashboard-grid.scss +++ b/frontend/resources/styles/main/partials/dashboard-grid.scss @@ -27,10 +27,15 @@ margin: $size-3 $size-4 $size-4 $size-2; position: relative; text-align: center; - a { + a, + button { width: 100%; font-weight: normal; } + button { + background-color: transparent; + border: none; + } @media #{$bp-max-1366} { height: 200px; flex: 1 0 230px; diff --git a/frontend/resources/styles/main/partials/dashboard.scss b/frontend/resources/styles/main/partials/dashboard.scss index f1c1eba42..f8cf3ed83 100644 --- a/frontend/resources/styles/main/partials/dashboard.scss +++ b/frontend/resources/styles/main/partials/dashboard.scss @@ -183,6 +183,7 @@ .dashboard-project-row { margin-bottom: $size-5; + position: relative; .project { align-items: center; @@ -211,6 +212,8 @@ font-size: $fs14; justify-content: space-between; cursor: pointer; + background-color: transparent; + border: none; .placeholder-icon { transform: rotate(-90deg); margin-left: 10px; @@ -297,6 +300,35 @@ opacity: 1; } } + + .show-more { + align-items: center; + color: $color-gray-30; + display: flex; + font-size: $fs14; + justify-content: space-between; + cursor: pointer; + background-color: transparent; + border: none; + position: absolute; + top: 9px; + right: 53px; + .placeholder-icon { + transform: rotate(-90deg); + margin-left: 10px; + svg { + height: 14px; + width: 14px; + fill: $color-gray-30; + } + } + &:hover { + color: $color-primary-dark; + svg { + fill: $color-primary-dark; + } + } + } } .recent-files-row-title-info { diff --git a/frontend/src/app/main/ui/auth/register.cljs b/frontend/src/app/main/ui/auth/register.cljs index e5c1190f2..bfb3dca09 100644 --- a/frontend/src/app/main/ui/auth/register.cljs +++ b/frontend/src/app/main/ui/auth/register.cljs @@ -245,16 +245,16 @@ :type "text"}]] (when (contains? @cf/flags :terms-and-privacy-checkbox) - [:div.fields-row + [:div.fields-row.input-visible.accept-terms-and-privacy-wrapper [:& fm/input {:name :accept-terms-and-privacy :class "check-primary" :type "checkbox"} [:span - (tr "auth.terms-privacy-agreement") - [:div - [:a {:href "https://penpot.app/terms" :target "_blank"} (tr "auth.terms-of-service")] - [:span ",\u00A0"] - [:a {:href "https://penpot.app/privacy" :target "_blank"} (tr "auth.privacy-policy")]]]]]) + (tr "auth.terms-privacy-agreement")]] + [:div.auth-links + [:a {:href "https://penpot.app/terms" :target "_blank"} (tr "auth.terms-of-service")] + [:span ",\u00A0"] + [:a {:href "https://penpot.app/privacy" :target "_blank"} (tr "auth.privacy-policy")]]]) [:& fm/submit-button {:label (tr "auth.register-submit") diff --git a/frontend/src/app/main/ui/components/context_menu.cljs b/frontend/src/app/main/ui/components/context_menu.cljs index d64ab95d8..4bf05203e 100644 --- a/frontend/src/app/main/ui/components/context_menu.cljs +++ b/frontend/src/app/main/ui/components/context_menu.cljs @@ -57,8 +57,8 @@ {node-height :height node-width :width} bounding_rect {window-height :height window-width :width} window_size target-offset-y (if (> (+ top node-height) window-height) - (- node-height) - 0) + (- node-height) + 0) target-offset-x (if (> (+ left node-width) window-width) (- node-width) 0)] @@ -85,9 +85,9 @@ props (obj/merge props #js {:on-close on-local-close})] (mf/use-effect - (mf/deps options) - #(swap! local assoc :levels [{:parent-option nil - :options options}])) + (mf/deps options) + #(swap! local assoc :levels [{:parent-option nil + :options options}])) (when (and open? (some? (:levels @local))) [:> dropdown' props diff --git a/frontend/src/app/main/ui/components/context_menu_a11y.cljs b/frontend/src/app/main/ui/components/context_menu_a11y.cljs new file mode 100644 index 000000000..fadaacc3d --- /dev/null +++ b/frontend/src/app/main/ui/components/context_menu_a11y.cljs @@ -0,0 +1,260 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns app.main.ui.components.context-menu-a11y + (:require + [app.common.data :as d] + [app.common.data.macros :as dm] + [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))) + +(mf/defc context-menu-a11y-item + {::mf/wrap-props false} + [props] + + (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 "klass") + key (gobj/get props "key") + data-test (gobj/get props "data-test")] + [:li {:id id + :class klass + :tab-index "0" + :on-key-down on-key-down + :on-click on-click + :key key + :role "menuitem" + :data-test data-test} + children])) + +(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}) + + on-local-close + (mf/use-callback + (fn [] + (swap! local assoc :levels [{:parent-option nil + :options options}]) + (on-close))) + + props (obj/merge props #js {:on-close on-local-close}) + + 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)) + (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 + target-offset-y (if (> (+ top node-height) window-height) + (- node-height) + 0) + target-offset-x (if (> (+ left node-width) window-width) + (- 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)))))) + + 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 [event] + (dom/stop-propagation event) + (swap! local update :levels pop))) + + 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)] + (when (kbd/home? event) + (when first-element + (dom/focus! first-element))) + + (when (kbd/enter? event) + (if is-back-option + (exit-submenu event) + + (if has-suboptions? + (do + (dom/stop-propagation event) + (swap! local update :levels + conj {:parent-option (:option-name option) + :options sub-options})) + + (do + (dom/stop-propagation event) + (option-handler event))))) + + (when (and is-back-option + (kbd/left-arrow? event)) + (exit-submenu event)) + + (when (and has-suboptions? (kbd/right-arrow? event)) + (dom/stop-propagation event) + (swap! local update :levels + conj {:parent-option (:option-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)))] + (dom/focus! (dom/get-element previous-id)))) + + (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) + (nth ids (+ 1 actual-index)))] + (dom/focus! (dom/get-element next-id)))) + + (when (or (kbd/esc? event) (kbd/tab? event)) + (on-close) + (dom/focus! (dom/get-element origin))))))] + + (mf/with-effect [options] + (swap! local assoc :levels [{:parent-option nil + :options options}])) + + (mf/with-effect [ids] + (tm/schedule-on-idle + (dom/focus! (dom/get-element (first ids))))) + + (when (and open? (some? (:levels @local))) + [:> dropdown' props + + (let [level (-> @local :levels peek) + original-options (:options level) + parent-original (:parent-option level)] + [:div.context-menu {:class (dom/classnames :is-open open? + :fixed fixed? + :is-selectable is-selectable) + :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.context-menu-items {:class (dom/classnames :min-width min-width?) + :role "menu" + :ref check-menu-offscreen} + (when-let [parent-option (:parent-option level)] + [:* + [:& context-menu-a11y-item + {:id "go-back-sub-option" + :tab-index "0" + :on-key-down (fn [event] + (dom/prevent-default event))} + [:div.context-menu-action.submenu-back + {:data-no-close true + :on-click exit-submenu} + [:span i/arrow-slide] + parent-option]] + [:li.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-test (:data-test option)] + (when option-name + (if (= option-name :separator) + [:li.separator {:key (dm/str "context-item-" index)}] + [:& context-menu-a11y-item + {:id id + :class (dom/classnames :is-selected (and selected (= option-name selected))) + :key (dm/str "context-item-" index) + :tab-index "0" + :on-key-down (fn [event] + (dom/prevent-default event))} + (if-not sub-options + [:a.context-menu-action {:on-click #(do (dom/stop-propagation %) + (on-close) + (option-handler %)) + :data-test data-test} + (if (and in-dashboard? (= option-name "Default")) + (tr "dashboard.default-team-name") + option-name)] + [:a.context-menu-action.submenu + {:data-no-close true + :on-click (enter-submenu option-name sub-options) + :data-test data-test} + option-name + [:span i/arrow-slide]])]))))])])]))) + +(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))) diff --git a/frontend/src/app/main/ui/components/dropdown_menu.cljs b/frontend/src/app/main/ui/components/dropdown_menu.cljs index 0123bec65..c397b5c26 100644 --- a/frontend/src/app/main/ui/components/dropdown_menu.cljs +++ b/frontend/src/app/main/ui/components/dropdown_menu.cljs @@ -25,7 +25,7 @@ on-key-down (gobj/get props "on-key-down") id (gobj/get props "id") klass (gobj/get props "klass") - key (gobj/get props "klass") + key (gobj/get props "key") data-test (gobj/get props "data-test")] [:li {:id id :class klass @@ -45,7 +45,7 @@ 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) diff --git a/frontend/src/app/main/ui/dashboard.cljs b/frontend/src/app/main/ui/dashboard.cljs index ea4acca7e..152859a06 100644 --- a/frontend/src/app/main/ui/dashboard.cljs +++ b/frontend/src/app/main/ui/dashboard.cljs @@ -8,6 +8,7 @@ (:require [app.common.colors :as clr] [app.common.data :as d] + [app.common.math :as mth] [app.common.spec :as us] [app.main.data.dashboard :as dd] [app.main.data.dashboard.shortcuts :as sc] @@ -83,6 +84,10 @@ container-size (* (+ 2 num-cards) card-width) ;; We need space for num-cards plus the libraries&templates link more-cards (> (+ @card-offset (* (+ 1 num-cards) card-width)) content-width) + visible-card-count (mth/floor (/ content-width 275)) + left-moves (/ @card-offset -275) + first-visible-card left-moves + last-visible-card (+ (- visible-card-count 1) left-moves) content-ref (mf/use-ref) toggle-collapse @@ -146,38 +151,78 @@ [:div.dashboard-templates-section {:class (when collapsed "collapsed")} [:div.title [:button {:tab-index "0" - :on-click toggle-collapse} + :on-click toggle-collapse + :on-key-down (fn [event] + (when (kbd/enter? event) + (dom/stop-propagation event) + (dom/prevent-default event) + (toggle-collapse)))} [:span (tr "dashboard.libraries-and-templates")] [:span.icon (if collapsed i/arrow-up i/arrow-down)]]] [:div.content {:ref content-ref - :style {:left @card-offset :width (str container-size "px")}} + :style {:left @card-offset :width (str container-size "px")}} + (for [num-item (range (count templates)) :let [item (nth templates num-item)]] - [:a.card-container {:tab-index "0" - :id (str/concat "card-container-" num-item) - :key (:id item) - :on-click #(import-template item) - :on-key-down (fn [event] - (when (kbd/enter? event) - (import-template item)))} + (let [is-visible? (and (>= num-item first-visible-card) (<= num-item last-visible-card))] + [:a.card-container {:tab-index (if (or (not is-visible?) collapsed) + "-1" + "0") + :id (str/concat "card-container-" num-item) + :key (:id item) + :on-click #(import-template item) + :on-key-down (fn [event] + (when (kbd/enter? event) + (dom/stop-propagation event) + (import-template item)))} + [:div.template-card + [:div.img-container + [:img {:src (:thumbnail-uri item) + :alt (:name item)}]] + [:div.card-name [:span (:name item)] [:span.icon i/download]]]])) + + (let [is-visible? (and (>= num-cards first-visible-card) (<= num-cards last-visible-card))] + [:div.card-container [:div.template-card [:div.img-container - [:img {:src (:thumbnail-uri item) - :alt (:name item)}]] - [:div.card-name [:span (:name item)] [:span.icon i/download]]]]) - - [:div.card-container - [:div.template-card - [:div.img-container - [:a {:tab-index "0" - :href "https://penpot.app/libraries-templates" :target "_blank" :on-click handle-template-link} - [:div.template-link - [:div.template-link-title (tr "dashboard.libraries-and-templates")] - [:div.template-link-text (tr "dashboard.libraries-and-templates.explore")]]]]]]] - (when (< @card-offset 0) - [:button.button.left {:on-click move-left} i/go-prev]) + [:a {:id (str/concat "card-container-" num-cards) + :tab-index (if (or (not is-visible?) collapsed) + "-1" + "0") + :href "https://penpot.app/libraries-templates.html" + :target "_blank" + :on-click handle-template-link + :on-key-down (fn [event] + (when (kbd/enter? event) + (dom/stop-propagation event) + (handle-template-link)))} + [:div.template-link + [:div.template-link-title (tr "dashboard.libraries-and-templates")] + [:div.template-link-text (tr "dashboard.libraries-and-templates.explore")]]]]]])] + (when (< @card-offset 0) + [:button.button.left {:tab-index (if collapsed + "-1" + "0") + :on-click move-left + :on-key-down (fn [event] + (when (kbd/enter? event) + (dom/stop-propagation event) + (move-left) + (let [first-element (dom/get-element (str/concat "card-container-" first-visible-card))] + (when first-element + (dom/focus! first-element)))))} i/go-prev]) (when more-cards - [:button.button.right {:on-click move-right - :aria-label (tr "labels.next")} i/go-next])])) + [:button.button.right {:tab-index (if collapsed + "-1" + "0") + :on-click move-right + :aria-label (tr "labels.next") + :on-key-down (fn [event] + (when (kbd/enter? event) + (dom/stop-propagation event) + (move-right) + (let [last-element (dom/get-element (str/concat "card-container-" last-visible-card))] + (when last-element + (dom/focus! last-element)))))} i/go-next])])) (mf/defc dashboard-content [{:keys [team projects project section search-term profile] :as props}] @@ -277,6 +322,7 @@ (let [events [(events/listen goog/global EventType.KEYDOWN (fn [event] (when (kbd/enter? event) + (dom/stop-propagation event) (st/emit! (dd/open-selected-file)))))]] (fn [] (doseq [key events] diff --git a/frontend/src/app/main/ui/dashboard/file_menu.cljs b/frontend/src/app/main/ui/dashboard/file_menu.cljs index ccda8cca2..7317a1947 100644 --- a/frontend/src/app/main/ui/dashboard/file_menu.cljs +++ b/frontend/src/app/main/ui/dashboard/file_menu.cljs @@ -12,7 +12,7 @@ [app.main.data.modal :as modal] [app.main.repo :as rp] [app.main.store :as st] - [app.main.ui.components.context-menu :refer [context-menu]] + [app.main.ui.components.context-menu-a11y :refer [context-menu-a11y]] [app.main.ui.context :as ctx] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] @@ -27,6 +27,10 @@ (tr "labels.drafts") (:name project))) +(defn get-project-id + [project] + (str (:id project))) + (defn get-team-name [team] (if (:is-default team) @@ -49,7 +53,7 @@ projects)) (mf/defc file-menu - [{:keys [files show? on-edit on-menu-close top left navigate? origin] :as props}] + [{:keys [files show? on-edit on-menu-close top left navigate? origin parent-id] :as props}] (assert (seq files) "missing `files` prop") (assert (boolean? show?) "missing `show?` prop") (assert (fn? on-edit) "missing `on-edit` prop") @@ -65,13 +69,10 @@ current-team-id (mf/use-ctx ctx/current-team-id) teams (mf/use-state nil) - current-team (get @teams current-team-id) other-teams (remove #(= (:id %) current-team-id) (vals @teams)) - current-projects (remove #(= (:id %) (:project-id file)) (:projects current-team)) - on-new-tab (fn [_] (let [path-params {:project-id (:project-id file) @@ -211,53 +212,101 @@ (rx/map group-by-team) (rx/subs #(when (mf/ref-val mounted-ref) (reset! teams %))))))) - + (when current-team - (let [sub-options (conj (vec (for [project current-projects] - [(get-project-name project) - (on-move (:id current-team) - (:id project))])) - (when (seq other-teams) - [(tr "dashboard.move-to-other-team") nil - (for [team other-teams] - [(get-team-name team) nil - (for [sub-project (:projects team)] - [(get-project-name sub-project) - (on-move (:id team) - (:id sub-project))])]) - "move-to-other-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))})})}])) options (if multi? - [[(tr "dashboard.duplicate-multi" file-count) on-duplicate nil "duplicate-multi"] + [{:option-name (tr "dashboard.duplicate-multi" file-count) + :id "file-duplicate-multi" + :option-handler on-duplicate + :data-test "duplicate-multi"} (when (or (seq current-projects) (seq other-teams)) - [(tr "dashboard.move-to-multi" file-count) nil sub-options "move-to-multi"]) - [(tr "dashboard.export-binary-multi" file-count) on-export-binary-files] - [(tr "dashboard.export-standard-multi" file-count) on-export-standard-files] + {:option-name (tr "dashboard.move-to-multi" file-count) + :id "file-move-multi" + :sub-options sub-options + :data-test "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 (:is-shared file) - [(tr "labels.unpublish-multi-files" file-count) on-del-shared nil "file-del-shared"]) + {:option-name (tr "labels.unpublish-multi-files" file-count) + :id "file-unpublish-multi" + :option-handler on-del-shared + :data-test "file-del-shared"}) (when (not is-lib-page?) - [:separator] - [(tr "labels.delete-multi-files" file-count) on-delete nil "delete-multi-files"])] + {:option-name :separator} + {:option-name (tr "labels.delete-multi-files" file-count) + :id "file-delete-multi" + :option-handler on-delete + :data-test "delete-multi-files"})] - [[(tr "dashboard.open-in-new-tab") on-new-tab] - [(tr "labels.rename") on-edit nil "file-rename"] - [(tr "dashboard.duplicate") on-duplicate nil "file-duplicate"] + [{:option-name (tr "dashboard.open-in-new-tab") + :id "file-open-new-tab" + :option-handler on-new-tab} + {:option-name (tr "labels.rename") + :id "file-rename" + :option-handler on-edit + :data-test "file-rename"} + {:option-name (tr "dashboard.duplicate") + :id "file-duplicate" + :option-handler on-duplicate + :data-test "file-duplicate"} (when (and (not is-lib-page?) (or (seq current-projects) (seq other-teams))) - [(tr "dashboard.move-to") nil sub-options "file-move-to"]) + {:option-name (tr "dashboard.move-to") + :id "file-move-to" + :sub-options sub-options + :data-test "file-move-to"}) (if (:is-shared file) - [(tr "dashboard.unpublish-shared") on-del-shared nil "file-del-shared"] - [(tr "dashboard.add-shared") on-add-shared nil "file-add-shared"]) - [:separator] - [(tr "dashboard.download-binary-file") on-export-binary-files nil "download-binary-file"] - [(tr "dashboard.download-standard-file") on-export-standard-files nil "download-standard-file"] + {:option-name (tr "dashboard.unpublish-shared") + :id "file-del-shared" + :option-handler on-del-shared + :data-test "file-del-shared"} + {:option-name (tr "dashboard.add-shared") + :id "file-add-shared" + :option-handler on-add-shared + :data-test "file-add-shared"}) + {:option-name :separator} + {:option-name (tr "dashboard.download-binary-file") + :id "file-download-binary" + :option-handler on-export-binary-files + :data-test "download-binary-file"} + {:option-name (tr "dashboard.download-standard-file") + :id "file-download-standard" + :option-handler on-export-standard-files + :data-test "download-standard-file"} (when (not is-lib-page?) - [:separator] - [(tr "labels.delete") on-delete nil "file-delete"])])] + {:option-name :separator} + {:option-name (tr "labels.delete") + :id "file-delete" + :option-handler on-delete + :data-test "file-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}])))) + [:& 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}])))) diff --git a/frontend/src/app/main/ui/dashboard/files.cljs b/frontend/src/app/main/ui/dashboard/files.cljs index 712183d96..fb7dc0936 100644 --- a/frontend/src/app/main/ui/dashboard/files.cljs +++ b/frontend/src/app/main/ui/dashboard/files.cljs @@ -39,7 +39,7 @@ on-menu-click (mf/use-fn (fn [event] - (let [position (dom/get-client-position event)] + (let [position (dom/get-client-position event)] (dom/prevent-default event) (swap! local assoc :menu-open true :menu-pos position)))) diff --git a/frontend/src/app/main/ui/dashboard/grid.cljs b/frontend/src/app/main/ui/dashboard/grid.cljs index 1bdaf8d02..eba451495 100644 --- a/frontend/src/app/main/ui/dashboard/grid.cljs +++ b/frontend/src/app/main/ui/dashboard/grid.cljs @@ -8,6 +8,7 @@ (:require [app.common.data.macros :as dm] [app.common.files.features :as ffeat] + [app.common.geom.point :as gpt] [app.common.logging :as log] [app.main.data.dashboard :as dd] [app.main.data.messages :as msg] @@ -251,7 +252,14 @@ (st/emit! (dd/clear-selected-files))) (st/emit! (dd/toggle-file-select file))) - (let [position (dom/get-client-position event)] + (let [client-position (dom/get-client-position event) + position (if (and (nil? (:y client-position)) (nil? (:x client-position))) + (let [target-element (dom/get-target event) + points (dom/get-bounding-rect target-element) + y (:top points) + x (:left points)] + (gpt/point x y)) + client-position)] (swap! local assoc :menu-open true :menu-pos position)))) @@ -277,7 +285,7 @@ (swap! local assoc :menu-open false))) [:li.grid-item.project-th - [:a + [:button {:tab-index "0" :class (dom/classnames :selected selected? :library library-view?) @@ -314,9 +322,11 @@ [:div.project-th-icon.menu {:tab-index "0" :ref menu-ref + :id (str file-id "-action-menu") :on-click on-menu-click :on-key-down (fn [event] (when (kbd/enter? event) + (dom/stop-propagation event) (on-menu-click event)))} i/actions (when selected? @@ -328,8 +338,8 @@ :on-edit on-edit :on-menu-close on-menu-close :origin origin - :dashboard-local dashboard-local}])]]]]])) - + :dashboard-local dashboard-local + :parent-id (str file-id "-action-menu")}])]]]]])) (mf/defc grid [{:keys [files project origin limit library-view? create-fn] :as props}] diff --git a/frontend/src/app/main/ui/dashboard/project_menu.cljs b/frontend/src/app/main/ui/dashboard/project_menu.cljs index 8804c52cd..5c749d52d 100644 --- a/frontend/src/app/main/ui/dashboard/project_menu.cljs +++ b/frontend/src/app/main/ui/dashboard/project_menu.cljs @@ -12,7 +12,7 @@ [app.main.data.modal :as modal] [app.main.refs :as refs] [app.main.store :as st] - [app.main.ui.components.context-menu :refer [context-menu]] + [app.main.ui.components.context-menu-a11y :refer [context-menu-a11y]] [app.main.ui.context :as ctx] [app.main.ui.dashboard.import :as udi] [app.util.dom :as dom] @@ -67,7 +67,7 @@ (let [data {:id (:id project) :team-id team-id} mdata {:on-success #(on-move-success team-id)}] #(st/emit! (dm/success (tr "dashboard.success-move-project")) - (dd/move-project (with-meta data mdata))))) + (dd/move-project (with-meta data mdata))))) delete-fn (fn [_] @@ -77,12 +77,12 @@ 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})) + (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) @@ -94,34 +94,54 @@ on-finish-import (mf/use-callback (fn [] - (when (fn? on-import) (on-import))))] + (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-test "project-rename"}) + (when-not (:is-default project) + {:option-name (tr "dashboard.duplicate") + :id "project-menu-duplicated" + :option-handler on-duplicate + :data-test "project-duplicate"}) + (when-not (:is-default project) + {:option-name (tr "dashboard.pin-unpin") + :id "project-menu-pin" + :option-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-test "project-move-to"}) + (when (some? on-import) + {:option-name (tr "dashboard.import") + :id "project-menu-import" + :option-handler on-import-files + :data-test "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-test "project-delete"})]] [:* [:& udi/import-form {:ref file-input :project-id (:id project) :on-finish-import on-finish-import}] - [:& context-menu + [:& 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 [(when-not (:is-default project) - [(tr "labels.rename") on-edit nil "project-rename"]) - (when-not (:is-default project) - [(tr "dashboard.duplicate") on-duplicate nil "project-duplicate"]) - (when-not (:is-default project) - [(tr "dashboard.pin-unpin") toggle-pin]) - (when (and (seq teams) (not (:is-default project))) - [(tr "dashboard.move-to") nil - (for [team teams] - [(:name team) (on-move (:id team))]) - "project-move-to"]) - (when (some? on-import) - [(tr "dashboard.import") on-import-files nil "file-import"]) - (when-not (:is-default project) - [:separator]) - (when-not (:is-default project) - [(tr "labels.delete") on-delete nil "project-delete"])]}]])) + :options options}]])) diff --git a/frontend/src/app/main/ui/dashboard/projects.cljs b/frontend/src/app/main/ui/dashboard/projects.cljs index dad357e67..f7a4eacb5 100644 --- a/frontend/src/app/main/ui/dashboard/projects.cljs +++ b/frontend/src/app/main/ui/dashboard/projects.cljs @@ -7,6 +7,7 @@ (ns app.main.ui.dashboard.projects (:require [app.common.data :as d] + [app.common.geom.point :as gpt] [app.common.math :as mth] [app.main.data.dashboard :as dd] [app.main.data.events :as ev] @@ -142,7 +143,7 @@ [:div.text [:h2.title (tr "dasboard.walkthrough-hero.title")] [:p.info (tr "dasboard.walkthrough-hero.info")] - [:a.btn-primary.action + [:a.btn-primary.action {:href " https://design.penpot.app/walkthrough" :target "_blank" :on-click handle-walkthrough-link} @@ -187,13 +188,23 @@ toggle-pin (mf/use-fn (mf/deps project) - #(st/emit! (dd/toggle-project-pin project))) + (fn [event] + (dom/stop-propagation event) + (st/emit! (dd/toggle-project-pin project)))) on-menu-click (mf/use-fn (fn [event] - (let [position (dom/get-client-position event)] - (dom/prevent-default event) + (dom/prevent-default event) + + (let [client-position (dom/get-client-position event) + position (if (and (nil? (:y client-position)) (nil? (:x client-position))) + (let [target-element (dom/get-target event) + points (dom/get-bounding-rect target-element) + y (:top points) + x (:left points)] + (gpt/point x y)) + client-position)] (swap! local assoc :menu-open true :menu-pos position)))) @@ -276,7 +287,7 @@ [:& project-menu {:project project :show? (:menu-open @local) - :left (:x (:menu-pos @local)) + :left (+ 24 (:x (:menu-pos @local))) :top (:y (:menu-pos @local)) :on-edit on-edit-open :on-menu-close on-menu-close @@ -291,12 +302,9 @@ (when-not (:is-default project) [:button.pin-icon.tooltip.tooltip-bottom {:class (when (:is-pinned project) "active") - :on-click toggle-pin + :on-click toggle-pin :alt (tr "dashboard.pin-unpin") :aria-label (tr "dashboard.pin-unpin") - :on-key-down (fn [event] - (when (kbd/enter? event) - (toggle-pin event))) :tab-index "0"} (if (:is-pinned project) i/pin-fill @@ -321,22 +329,27 @@ :tab-index "0" :on-key-down (fn [event] (when (kbd/enter? event) + (dom/stop-propagation event) (on-menu-click event)))} - i/actions]]] - - (when (and (> limit 0) - (> file-count limit)) - [:div.show-more {:on-click on-nav} - [:div.placeholder-label - (tr "dashboard.show-all-files")] - [:div.placeholder-icon i/arrow-down]])] + i/actions]]]] [:& line-grid {:project project :team team :files files :create-fn create-file - :limit limit}]])) + :limit limit}] + + (when (and (> limit 0) + (> file-count limit)) + [:button.show-more {:on-click on-nav + :tab-index "0" + :on-key-down (fn [event] + (when (kbd/enter? event) + (on-nav)))} + [:div.placeholder-label + (tr "dashboard.show-all-files")] + [:div.placeholder-icon i/arrow-down]])])) (def recent-files-ref diff --git a/frontend/src/app/util/dom.cljs b/frontend/src/app/util/dom.cljs index 0307321c5..85ebdab8f 100644 --- a/frontend/src/app/util/dom.cljs +++ b/frontend/src/app/util/dom.cljs @@ -147,7 +147,7 @@ [^js node selector] (loop [current node] - (if (or (nil? current) (.matches current selector) ) + (if (or (nil? current) (.matches current selector)) current (recur (.-parentElement current))))) @@ -175,10 +175,10 @@ (defn get-scroll-position [^js event] (when (some? event) - {:scroll-height (.-scrollHeight event) - :scroll-left (.-scrollLeft event) - :scroll-top (.-scrollTop event) - :scroll-width (.-scrollWidth event)})) + {:scroll-height (.-scrollHeight event) + :scroll-left (.-scrollLeft event) + :scroll-top (.-scrollTop event) + :scroll-width (.-scrollWidth event)})) (def get-target-val (comp get-value get-target)) @@ -309,6 +309,13 @@ (when (some? el) (.querySelectorAll el selector)))) +(defn get-element-offset-position + [^js node] + (when (some? node) + (let [x (.-offsetTop node) + y (.-offsetLeft node)] + (gpt/point x y)))) + (defn get-client-position [^js event] (let [x (.-clientX event) @@ -567,7 +574,7 @@ (let [extension (cm/mtype->extension mtype) opts {:suggestedName (str filename "." extension) :types [{:description description - :accept { mtype [(str "." extension)]}}]}] + :accept {mtype [(str "." extension)]}}]}] (-> (p/let [file-system (.showSaveFilePicker globals/window (clj->js opts)) writable (.createWritable file-system) @@ -576,9 +583,9 @@ _ (.write writable blob)] (.close writable)) (p/catch - #(when-not (and (= (type %) js/DOMException) - (= (.-name %) "AbortError")) - (trigger-download-uri filename mtype uri))))) + #(when-not (and (= (type %) js/DOMException) + (= (.-name %) "AbortError")) + (trigger-download-uri filename mtype uri))))) (trigger-download-uri filename mtype uri))) @@ -609,9 +616,9 @@ (defn animate! ([item keyframes duration] (animate! item keyframes duration nil)) ([item keyframes duration onfinish] - (let [animation (.animate item keyframes duration)] - (when onfinish - (set! (.-onfinish animation) onfinish))))) + (let [animation (.animate item keyframes duration)] + (when onfinish + (set! (.-onfinish animation) onfinish))))) (defn is-child? [^js node ^js candidate]