diff --git a/frontend/src/app/main/refs.cljs b/frontend/src/app/main/refs.cljs index 3e6dbf02c3..af5207ffb1 100644 --- a/frontend/src/app/main/refs.cljs +++ b/frontend/src/app/main/refs.cljs @@ -244,6 +244,9 @@ [id] (l/derived #(wtts/get-workspace-theme id %) st/state)) +(def workspace-token-theme-groups + (l/derived wtts/get-workspace-theme-groups st/state)) + (def workspace-active-theme-ids (l/derived wtts/get-active-theme-ids st/state)) diff --git a/frontend/src/app/main/ui/workspace/tokens/common.cljs b/frontend/src/app/main/ui/workspace/tokens/common.cljs index cd955330c4..7f1c787796 100644 --- a/frontend/src/app/main/ui/workspace/tokens/common.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/common.cljs @@ -8,8 +8,19 @@ (:require-macros [app.main.style :as stl]) (:require [app.common.data :as d] + [app.common.data.macros :as dm] + [app.main.data.shortcuts :as dsc] + [app.main.store :as st] + [app.main.ui.components.dropdown :refer [dropdown]] + [app.main.ui.icons :as i] + [app.util.dom :as dom] + [app.util.globals :as globals] + [app.util.keyboard :as kbd] [cuerdas.core :as str] - [rumext.v2 :as mf])) + [goog.events :as events] + [rumext.v2 :as mf]) + (:import + goog.events.EventType)) ;; Helpers --------------------------------------------------------------------- @@ -30,11 +41,91 @@ [(keyword (str/camel (name k))) v] [k v]))))) +(defn direction-select + "Returns next `n` in `direction` while wrapping around at the last item at the count of `coll`. + + `direction` accepts `:up` or `:down`." + [direction n coll] + (let [last-n (dec (count coll)) + next-n (case direction + :up (dec n) + :down (inc n)) + wrap-around-n (cond + (neg? next-n) last-n + (> next-n last-n) 0 + :else next-n)] + wrap-around-n)) + +(defn use-arrow-highlight [{:keys [shortcuts-key options on-select]}] + (let [highlighted* (mf/use-state nil) + highlighted (deref highlighted*) + on-dehighlight #(reset! highlighted* nil) + on-keyup (fn [event] + (cond + (and (kbd/enter? event) highlighted) (on-select (nth options highlighted)) + (kbd/up-arrow? event) (do + (dom/prevent-default event) + (->> (direction-select :up (or highlighted 0) options) + (reset! highlighted*))) + (kbd/down-arrow? event) (do + (dom/prevent-default event) + (->> (direction-select :down (or highlighted -1) options) + (reset! highlighted*)))))] + (mf/with-effect [highlighted] + (let [shortcuts-key shortcuts-key + keys [(events/listen globals/document EventType.KEYUP on-keyup) + (events/listen globals/document EventType.KEYDOWN dom/prevent-default)]] + (st/emit! (dsc/push-shortcuts shortcuts-key {})) + (fn [] + (doseq [key keys] + (events/unlistenByKey key)) + (st/emit! (dsc/pop-shortcuts shortcuts-key))))) + {:highlighted highlighted + :on-dehighlight on-dehighlight})) + +(defn use-dropdown-open-state [] + (let [open? (mf/use-state false) + on-open (mf/use-fn #(reset! open? true)) + on-close (mf/use-fn #(reset! open? false)) + on-toggle (mf/use-fn #(swap! open? not))] + {:dropdown-open? @open? + :on-open-dropdown on-open + :on-close-dropdown on-close + :on-toggle-dropdown on-toggle})) + ;; Components ------------------------------------------------------------------ +(mf/defc dropdown-select + [{:keys [id _shortcuts-key options on-close element-ref on-select] :as props}] + (let [{:keys [highlighted on-dehighlight]} (use-arrow-highlight props)] + [:& dropdown {:show true + :on-close on-close} + [:> :div {:class (stl/css :dropdown) + :on-mouse-enter on-dehighlight + :ref element-ref} + [:ul {:class (stl/css :dropdown-list)} + (for [[index item] (d/enumerate options)] + (cond + (= :separator item) + [:li {:class (stl/css :separator) + :key (dm/str id "-" index)}] + :else + (let [{:keys [label selected? disabled?]} item + highlighted? (= highlighted index)] + [:li + {:key (str id "-" index) + :class (stl/css-case :dropdown-element true + :is-selected selected? + :is-highlighted highlighted?) + :data-label label + :disabled disabled? + :on-click #(on-select item)} + [:span {:class (stl/css :label)} label] + [:span {:class (stl/css :check-icon)} i/tick]])))]]])) + (mf/defc labeled-input {::mf/wrap-props false} - [{:keys [label input-props auto-complete? error?]}] + [{:keys [label input-props auto-complete? error? icon render-right]}] (let [input-props (cond-> input-props :always camel-keys ;; Disable auto-complete on form fields for proprietary password managers @@ -45,4 +136,6 @@ [:label {:class (stl/css-case :labeled-input true :labeled-input-error error?)} [:span {:class (stl/css :label)} label] - [:& :input input-props]])) + [:& :input input-props] + (when render-right + [:& render-right])])) diff --git a/frontend/src/app/main/ui/workspace/tokens/common.scss b/frontend/src/app/main/ui/workspace/tokens/common.scss index 9398a2bb23..bb067e6832 100644 --- a/frontend/src/app/main/ui/workspace/tokens/common.scss +++ b/frontend/src/app/main/ui/workspace/tokens/common.scss @@ -34,3 +34,82 @@ @extend .button-icon; } } + +.dropdown { + @extend .dropdown-wrapper; + max-height: $s-320; + width: 100%; + margin-top: $s-4; + + ul { + margin: 0; + } + + .separator { + margin: 0; + height: $s-12; + } + + .dropdown-element { + @extend .dropdown-element-base; + color: var(--menu-foreground-color-rest); + display: flex; + + & > span { + display: flex; + justify-content: flex-start; + align-content: center; + } + + .label, + .value { + width: fit-content; + } + + .label { + text-transform: unset; + flex: 1; + } + + .value { + text-align: right; + justify-content: flex-end; + flex: 0.6; + } + + .check-icon { + @include flexCenter; + translate: -$s-4 0; + svg { + @extend .button-icon-small; + visibility: hidden; + stroke: var(--icon-foreground); + } + } + + &.is-selected { + color: var(--menu-foreground-color); + .check-icon svg { + stroke: var(--menu-foreground-color); + visibility: visible; + } + } + + &:hover { + background-color: var(--menu-background-color-hover); + color: var(--menu-foreground-color-hover); + .check-icon svg { + stroke: var(--menu-foreground-color-hover); + } + } + &.is-highlighted { + background-color: var(--button-primary-background-color-rest); + span { + color: var(--button-primary-foreground-color-rest); + } + .check-icon svg { + stroke: var(--button-primary-foreground-color-rest); + } + } + } +} diff --git a/frontend/src/app/main/ui/workspace/tokens/modals/themes.cljs b/frontend/src/app/main/ui/workspace/tokens/modals/themes.cljs index 91c9498261..47e00588da 100644 --- a/frontend/src/app/main/ui/workspace/tokens/modals/themes.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/modals/themes.cljs @@ -13,13 +13,14 @@ [app.main.store :as st] [app.main.ui.components.radio-buttons :refer [radio-button radio-buttons]] [app.main.ui.icons :as i] - [app.main.ui.workspace.tokens.common :refer [labeled-input]] + [app.main.ui.workspace.tokens.common :refer [labeled-input] :as wtco] [app.main.ui.workspace.tokens.sets :as wts] [app.main.ui.workspace.tokens.token-set :as wtts] [app.util.dom :as dom] [rumext.v2 :as mf] [cuerdas.core :as str] - [app.main.ui.workspace.tokens.sets-context :as sets-context])) + [app.main.ui.workspace.tokens.sets-context :as sets-context] + [app.main.ui.shapes.group :as group])) (def ^:private chevron-icon (i/icon-xref :arrow (stl/css :chevron-icon))) @@ -108,8 +109,10 @@ "Create theme"]]])) (mf/defc edit-theme - [{:keys [token-sets theme on-back on-submit] :as props}] - (let [edit? (some? (:id theme)) + [{:keys [token-sets theme theme-groups on-back on-submit]}] + (let [{:keys [dropdown-open? on-open-dropdown on-close-dropdown on-toggle-dropdown]} (wtco/use-dropdown-open-state) + + edit? (some? (:id theme)) theme-state (mf/use-state {:token-sets token-sets :theme theme}) disabled? (-> (get-in @theme-state [:theme :name]) @@ -127,6 +130,7 @@ on-change-field (fn [field] (fn [e] (swap! theme-state (fn [st] (assoc-in st field (dom/get-target-val e)))))) + group-input-ref (mf/use-ref) on-update-group (on-change-field [:theme :group]) on-update-name (on-change-field [:theme :name]) on-save-form (mf/use-callback @@ -151,9 +155,30 @@ :on-click on-back} chevron-icon "Back"]] [:div {:class (stl/css :edit-theme-inputs-wrapper)} - [:& labeled-input {:label "Group" - :input-props {:default-value (:group theme) - :on-change on-update-group}}] + [:div {:class (stl/css :group-input-wrapper)} + (when dropdown-open? + [:& wtco/dropdown-select {:id ::groups-dropdown + :shortcuts-key ::groups-dropdown + :options (map (fn [group] + {:label group + :value group}) + theme-groups) + :on-select (fn [{:keys [value]}] + (set! (.-value (mf/ref-val group-input-ref)) value) + (swap! theme-state assoc-in [:theme :group] value)) + :on-close on-close-dropdown}]) + [:& labeled-input {:label "Group" + :input-props {:ref group-input-ref + :default-value (:group theme) + :on-change on-update-group} + :render-right (when (seq theme-groups) + (mf/fnc [] + [:button {:class (stl/css :group-drop-down-button) + :type "button" + :on-click (fn [e] + (dom/stop-propagation e) + (on-toggle-dropdown))} + i/arrow]))}]] [:& labeled-input {:label "Theme" :input-props {:default-value (:name theme) :on-change on-update-name}}]] @@ -189,20 +214,24 @@ [{:keys [state set-state]}] (let [{:keys [theme-id]} @state token-sets (mf/deref refs/workspace-ordered-token-sets) - theme (mf/deref (refs/workspace-token-theme theme-id))] + theme (mf/deref (refs/workspace-token-theme theme-id)) + theme-groups (mf/deref refs/workspace-token-theme-groups)] [:& edit-theme {:token-sets token-sets :theme theme + :theme-groups theme-groups :on-back #(set-state (constantly {:type :themes-overview})) :on-submit #(st/emit! (wdt/update-token-theme %))}])) (mf/defc create-theme [{:keys [set-state]}] (let [token-sets (mf/deref refs/workspace-ordered-token-sets) - theme {:name "" :sets #{}}] + theme {:name "" :sets #{}} + theme-groups (mf/deref refs/workspace-token-theme-groups)] [:& edit-theme {:token-sets token-sets :theme theme + :theme-groups theme-groups :on-back #(set-state (constantly {:type :themes-overview})) :on-submit #(st/emit! (wdt/create-token-theme %))}])) diff --git a/frontend/src/app/main/ui/workspace/tokens/modals/themes.scss b/frontend/src/app/main/ui/workspace/tokens/modals/themes.scss index 43844c3927..ca41bd82e4 100644 --- a/frontend/src/app/main/ui/workspace/tokens/modals/themes.scss +++ b/frontend/src/app/main/ui/workspace/tokens/modals/themes.scss @@ -211,3 +211,22 @@ hr { } } } + +.group-input-wrapper { + position: relative; +} + +.group-drop-down-button { + @include buttonStyle; + width: $s-24; + height: 100%; + + padding: 0; + margin: 0 $s-6; + + svg { + @extend .button-icon-small; + transform: rotate(90deg); + fill: var(--icon-foreground); + } +} diff --git a/frontend/src/app/main/ui/workspace/tokens/token_set.cljs b/frontend/src/app/main/ui/workspace/tokens/token_set.cljs index 62eb5def4b..ee2678c210 100644 --- a/frontend/src/app/main/ui/workspace/tokens/token_set.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/token_set.cljs @@ -18,6 +18,14 @@ (defn get-workspace-themes-index [state] (get-in state [:workspace-data :token-themes-index] {})) +(defn get-workspace-theme-groups [state] + (reduce + (fn [acc {:keys [group]}] + (if group + (conj acc group) + acc)) + #{} (vals (get-workspace-themes-index state)))) + (defn get-workspace-token-set-groups [state] (get-in state [:workspace-data :token-set-groups]))