From 3e08afc28aec80727f4092bc7ed319da44d84b20 Mon Sep 17 00:00:00 2001 From: Kimo Knowles Date: Mon, 17 Jun 2024 22:20:29 +0200 Subject: [PATCH] [dropdown] Add polish & documentation --- .cljfmt.edn | 5 +- src/re_com/dropdown.cljs | 302 +++++++++++++++++++++++++--------- src/re_com/theme.cljs | 44 ++++- src/re_com/theme/default.cljs | 14 +- src/re_com/theme/util.cljs | 13 +- src/re_demo/core.cljs | 4 +- src/re_demo/dropdown.cljs | 51 ++++++ 7 files changed, 336 insertions(+), 97 deletions(-) create mode 100644 src/re_demo/dropdown.cljs diff --git a/.cljfmt.edn b/.cljfmt.edn index 9f77999b..6ba3998f 100644 --- a/.cljfmt.edn +++ b/.cljfmt.edn @@ -1,2 +1,3 @@ -{:extra-indents {re-com.theme/apply [[:inner 0]] - themed [[:inner 0]]}} +{:extra-indents {re-com.theme/apply [[:inner 0]] + themed [[:inner 0]] + re-com.theme/<-props [[:inner 0]]}} diff --git a/src/re_com/dropdown.cljs b/src/re_com/dropdown.cljs index c48e3212..cdbd474f 100644 --- a/src/re_com/dropdown.cljs +++ b/src/re_com/dropdown.cljs @@ -8,7 +8,7 @@ [re-com.util :as u :refer [deref-or-value position-for-id item-for-id ->v]] [re-com.box :refer [align-style flex-child-style v-box h-box box]] [re-com.validate :refer [vector-of-maps? css-style? html-attr? parts? number-or-string? log-warning - string-or-hiccup? position? position-options-list] :refer-macros [validate-args-macro]] + string-or-hiccup? position? position-options-list part?] :refer-macros [validate-args-macro]] [re-com.popover :refer [popover-tooltip]] [clojure.string :as string] [react :as react] @@ -19,11 +19,153 @@ ;; Inspiration: http://alxlit.name/bootstrap-chosen ;; Alternative: http://silviomoreto.github.io/bootstrap-select -(defn anchor-part [{:keys [label placeholder state theme]}] - [:a (theme/props {:state state :part ::anchor} theme) - (or label placeholder "Select an item")]) +(def dropdown-parts-desc + (when include-args-desc? + [{:impl "[v-box]" + :level 0 + :name :wrapper + :notes "Outer wrapper."} + {:name :backdrop + :impl "user-defined" + :level 1 + :notes "Transparent, clickable backdrop. Shown when the dropdown is open."} + {:name :anchor-wrapper + :impl "[box]" + :level 1 + :notes "Wraps the :anchor part. Opens or closes the dropdown when clicked."} + {:name :anchor + :impl "user-defined" + :level 2 + :notes "Displays the :label or :placeholder."} + {:name :body-wrapper + :impl "[box]" + :level 1 + :notes "Wraps the :body part. Provides intelligent positioning."} + {:name :body + :impl "user-defined" + :level 2 + :notes "Shown when the dropdown is open."}])) + +(def dropdown-parts + (when include-args-desc? + (-> (map :name dropdown-parts-desc) set))) + +(def dropdown-args-desc + (when include-args-desc? + [{:description "True when the dropdown is open." + :name :model + :required false + :type "boolean | r/atom"} + {:description + "Called when the dropdown opens or closes." + :name :on-change + :required false + :type "boolean -> nil" + :validate-fn fn?} + {:name :anchor + :type "part" + :validate-fn part? + :required? false + :description + [:span "String, hiccup or function. When a function, acceps keyword args " + [:code ":placholder"] ", " + [:code ":label"] ", " + [:code ":theme"] ", " + [:code ":parts"] ", " + [:code ":state"] " " + " and " + [:code ":transition!"] + ". Returns either a string or hiccup, which shows within the clickable dropdown box."]} + {:name :backdrop + :required? false + :type "part" + :validate-fn part? + :description (str "Displays when the dropdown is open. By default, renders a " + "transparent overlay. Clicking this overlay closes the dropdown. " + "When a function, :backdrop is passed the same keyword arguments " + "as :anchor.")} + {:name :body + :required? false + :type "part" + :validate-fn part? + :description (str "Displays when the dropdown is open. " + "Appears either above or below the :anchor, " + "depending on available screen-space. When a function, " + ":body is passed the same keyword arguments as :anchor.")} + {:name :disabled? + :required false + :type "boolean | r/atom"} + {:default 0 + :description "component's tabindex. A value of -1 removes from order" + :name :tab-index + :required false + :type "integer | string" + :validate-fn number-or-string?} + {:description "height of the :anchor-wrapper part" + :name :anchor-height + :required false + :type "integer | string" + :validate-fn number-or-string?} + {:description "height of the :body-wrapper part" + :name :height + :required false + :type "integer | string" + :validate-fn number-or-string?} + {:description "min-height of the :body-wrapper part" + :name :min-height + :required false + :type "integer | string" + :validate-fn number-or-string?} + {:description "max-height of the :body-wrapper part" + :name :max-height + :required false + :type "integer | string" + :validate-fn number-or-string?} + {:description "width of the :anchor-wrapper and :body-wrapper parts" + :name :width + :required false + :type "integer | string" + :validate-fn number-or-string?} + {:description "min-width of the :anchor-wrapper and :body-wrapper parts" + :name :min-width + :required false + :type "integer | string" + :validate-fn number-or-string?} + {:description "max-width of the :anchor-wrapper and :body-wrapper parts" + :name :max-width + :required false + :type "integer | string" + :validate-fn number-or-string?} + {:description (str "passed as a prop to the :anchor part. The default :anchor " + "part will display :label inside a the clickable dropdown box.") + :name :label + :required false + :type "string | hiccup"} + {:default "\"Select an item\"" + :description (str "passed as a prop to the :anchor part. The default :anchor part will " + "show :placeholder in the clickable box if there is no :label.") + :name :placeholder + :required false + :type "string | hiccup"} + {:description "See Parts section below." + :name :parts + :required false + :type "map" + :validate-fn (parts? dropdown-parts)} + {:name :theme + :description "alpha"} + {:name :main-theme + :description "alpha"} + {:name :theme-vars + :description "alpha"} + {:name :base-theme + :description "alpha"}])) + +(defn anchor [{:keys [label placeholder state theme transition!]}] + [:a (theme/props {:state state :part ::anchor :transition! transition!} theme) + (or label placeholder)]) -(defn backdrop-part [{:keys [state transition!]}] +(defn backdrop [{:keys [state transition!]}] (fn [{:keys [dropdown-open? state theme parts]}] [:div (theme/props {:transition! transition! :state state :part ::backdrop} theme)])) @@ -58,7 +200,7 @@ best-y (case v-pos :low a-h :high (- p-h))] [best-x best-y])) -(defn body-wrapper [{:keys [state parts theme anchor-ref popover-ref anchor-position]} & children] +(defn body-wrapper [{:keys [state theme anchor-ref popover-ref anchor-position]} & children] (let [set-popover-ref! #(reset! popover-ref %) optimize-position! #(reset! anchor-position (optimize-position! @anchor-ref @popover-ref)) mounted! #(do @@ -76,15 +218,12 @@ (let [[left top] (or @anchor-position [0 0])] (into [:div#popover - (-> - {:ref set-popover-ref! - :style {:z-index 99999 - :position "absolute" - :top (str top "px") - :left (str left "px") - :opacity (if @anchor-position 1 0) - :transition "opacity 0.2s"}} - (theme/apply {:state state :part ::body-wrapper} theme))] + (theme/apply {} + {:state (merge state {:top top + :left left + :ref set-popover-ref!}) + :part ::body-wrapper} + theme)] children)))}))) (defn dropdown @@ -94,72 +233,83 @@ (let [[focused? anchor-ref popover-ref anchor-position] (repeatedly #(reagent/atom nil)) anchor-ref! #(reset! anchor-ref %) transitionable (reagent/atom - (if @model :in :out))] + (if (deref-or-value model) :in :out))] (fn dropdown-render [& {:keys [disabled? on-change tab-index - width height min-width max-width min-height max-height anchor-height + anchor-height + model label placeholder anchor backdrop body - parts style theme main-theme theme-vars base-theme] + parts theme main-theme theme-vars base-theme + width] + :or {placeholder "Select an item"} :as args}] - (let [theme {:variables theme-vars - :base base-theme - :main main-theme - :user [theme (theme/parts parts)]} - state {:openable (if @model :open :closed) - :enable (if disabled? :disabled :enabled) - :tab-index tab-index - :focusable (if @focused? :focused :blurred) - :transitionable @transitionable} - open! (if on-change - (handler-fn (on-change true)) - (handler-fn (reset! model true))) - close! (if on-change - (handler-fn (on-change false)) - (handler-fn (reset! model false))) - transition! (fn [k] - ((case k - :toggle (if (-> state :openable (= :open)) open! close!) - :open open! - :close close! - :focus #(reset! focused? true) - :blur #(reset! focused? false) - :enter #(js/setTimeout (fn [] (reset! transitionable :in)) 50) - :exit #(js/setTimeout (fn [] (reset! transitionable :out)) 50)))) - themed (fn [part props] (theme/apply props - {:state state - :part part - :transition! transition!} - theme)) - part-props {:placeholder placeholder - :transition! transition! - :label label - :theme theme - :parts parts - :state state}] - [v-box - (themed ::wrapper - {:src (at) - :style {:height anchor-height} - :children - [(when (= :open (:openable state)) - [u/part backdrop part-props backdrop-part]) - [box - (themed ::anchor-wrapper - {:src (at) - :style {:padding "unset" - :width "100%"} - :attr {:ref anchor-ref! - :on-click #(swap! model not)} - :child [u/part anchor part-props anchor-part]})] - (when (= :open (:openable state)) - [body-wrapper {:anchor-ref anchor-ref - :popover-ref popover-ref - :anchor-position anchor-position - :parts parts - :state state - :theme theme} - [u/part body part-props]])]})])))) + (or (validate-args-macro dropdown-args-desc args) + (let [state {:openable (if (deref-or-value model) :open :closed) + :enable (if disabled? :disabled :enabled) + :tab-index tab-index + :focusable (if (deref-or-value focused?) :focused :blurred) + :transitionable @transitionable} + open! (if on-change + (handler-fn (on-change true)) + #(reset! model true)) + close! (if on-change + (handler-fn (on-change false)) + #(reset! model false)) + transition! (fn [k] + (case k + :toggle (if (-> state :openable (= :open)) + (close!) + (open!)) + :open (open!) + :close (close!) + :focus (reset! focused? true) + :blur (reset! focused? false) + :enter (js/setTimeout (fn [] (reset! transitionable :in)) 50) + :exit (js/setTimeout (fn [] (reset! transitionable :out)) 50))) + theme {:variables theme-vars + :base base-theme + :main main-theme + :user [theme + (theme/parts parts) + (theme/<-props (merge args {:height anchor-height}) + {:part ::anchor-wrapper + :exclude [:max-height :min-height]}) + (theme/<-props args + {:part ::body-wrapper + :include [:width :min-width + :min-height :max-height]})]} + themed (fn [part props & [special-theme]] + (theme/apply props + {:state state + :part part + :transition! transition!} + (or special-theme theme))) + part-props {:placeholder placeholder + :transition! transition! + :label label + :theme theme + :parts parts + :state state}] + [v-box + (themed ::wrapper + {:src (at) + :children + [(when (= :open (:openable state)) + [u/part backdrop part-props re-com.dropdown/backdrop]) + [box + (themed ::anchor-wrapper + {:src (at) + :attr {:ref anchor-ref!} + :child [u/part anchor part-props re-com.dropdown/anchor]})] + (when (= :open (:openable state)) + [body-wrapper {:anchor-ref anchor-ref + :popover-ref popover-ref + :anchor-position anchor-position + :parts parts + :state state + :theme theme} + [u/part body part-props]])]})]))))) (defn- move-to-new-choice "In a vector of maps (where each map has an :id), return the id of the choice offset posititions away diff --git a/src/re_com/theme.cljs b/src/re_com/theme.cljs index bb37bf16..12a91d00 100644 --- a/src/re_com/theme.cljs +++ b/src/re_com/theme.cljs @@ -1,5 +1,5 @@ (ns re-com.theme - (:refer-clojure :exclude [apply]) + (:refer-clojure :exclude [apply merge]) (:require [reagent.core :as r] [re-com.theme.util :as tu] @@ -28,19 +28,21 @@ (let [result (theme props ctx)] (if (vector? result) result [result ctx]))) +(defn merge [a {:keys [base main user main-variables user-variables base-variables] :as b}] + (cond-> a + base-variables (assoc :base-variables base-variables) + main-variables (assoc :main-variables main-variables) + user-variables (update :user-variables conj user-variables) + base (assoc :base base) + main (assoc :main main) + user (update :user conj user))) + (defn apply ([props ctx themes] (->> (if-not (map? themes) (update @registry :user conj themes) - (let [{:keys [base main user main-variables user-variables base-variables]} themes] - (cond-> @registry - base-variables (assoc :base-variables base-variables) - main-variables (assoc :main-variables main-variables) - user-variables (update :user-variables conj user-variables) - base (assoc :base base) - main (assoc :main main) - user (update :user conj user)))) + (merge @registry themes)) named->vec flatten (remove nil?) @@ -49,3 +51,27 @@ (defn props [ctx themes] (apply {} ctx themes)) + +(defn remove-keys [m ks] + (select-keys m (remove (set ks) (keys m)))) + +(defn <-props [outer-props + & {:keys [part exclude include] + :or {include [:style :attr :class + :width :min-width :max-width + :height :min-height :max-height] + exclude []}}] + (fn [props ctx _] + (let [outer-style-keys [:width :min-width :max-width + :height :max-height :min-width :min-height] + outer-attr-keys [:tab-index] + outer-props (cond-> outer-props + (seq include) (select-keys include) + (seq exclude) (remove-keys exclude))] + (cond-> props + (= part (:part ctx)) + (-> (merge-props (remove-keys outer-props (concat outer-style-keys outer-attr-keys))) + (update :style clojure.core/merge + (select-keys outer-props outer-style-keys)) + (update :attr clojure.core/merge + (select-keys outer-props outer-attr-keys))))))) diff --git a/src/re_com/theme/default.cljs b/src/re_com/theme/default.cljs index c2feba2f..96c83c04 100644 --- a/src/re_com/theme/default.cljs +++ b/src/re_com/theme/default.cljs @@ -1,6 +1,7 @@ (ns re-com.theme.default (:require [clojure.string :as str] + [re-com.util :as ru :refer [px]] [re-com.theme.util :refer [merge-props]] [re-com.dropdown :as-alias dropdown] [re-com.nested-grid :as-alias nested-grid] @@ -68,6 +69,7 @@ ::dropdown/anchor-wrapper {:attr {:tab-index (or (:tab-index state) 0) + :on-click #(transition! :toggle) :on-blur #(do (transition! :blur) (transition! :exit))} :style {:outline (when (and (= :focused (:focusable state)) @@ -96,9 +98,13 @@ :open 99998 nil)}} ::dropdown/body-wrapper - {:style {:position "absolute" + {:ref (:ref state) + :style {:position "absolute" + :top (px (:top state)) + :left (px (:left state)) :overflow-y "auto" - :overflow-x "visible"}} + :overflow-x "visible" + :z-index 99999}} ::nested-grid/cell-grid-container {:style {:position "relative" @@ -166,7 +172,7 @@ :color (:neutral $) :height md-2 :line-height md-2 - :padding "0 0 0 8px" + :padding "0 8px 0 8px" :text-decoration "none" :white-space "nowrap" :transition "border 0.2s box-shadow 0.2s"}}) @@ -253,7 +259,7 @@ :text-overflow "ellipsis"}} ::tree-select/dropdown-anchor - {:style {:padding "0 8px 0 0" + {:style {:padding "0 0 0 0" :overflow "hidden" :color foreground :cursor (if (-> state :enable (= :disabled)) diff --git a/src/re_com/theme/util.cljs b/src/re_com/theme/util.cljs index 046c4cab..fd1d301c 100644 --- a/src/re_com/theme/util.cljs +++ b/src/re_com/theme/util.cljs @@ -11,12 +11,15 @@ (every? vector? ms) (reduce into ms) :else (last ms)))) +(merge-props {:class "a" :style {:b 1}} + {:class "x" :style {:width 2} :width 200 :ref :REF}) + (defn parts [part->props] - (fn [props {:keys [part]}] - (if-let [v (or (get part->props part) - (get part->props (keyword (name part))))] - (merge-props props v) - props))) + (fn [props {:keys [part]}] + (if-let [v (or (get part->props part) + (get part->props (keyword (name part))))] + (merge-props props v) + props))) (defn args [{:keys [attr class style]}] (fn [props _] diff --git a/src/re_demo/core.cljs b/src/re_demo/core.cljs index ca7b30f1..ff23b70f 100644 --- a/src/re_demo/core.cljs +++ b/src/re_demo/core.cljs @@ -32,6 +32,7 @@ [re-demo.hyperlink :as hyperlink] [re-demo.hyperlink-href :as hyperlink-href] [re-demo.dropdowns :as dropdowns] + [re-demo.dropdown :as dropdown] [re-demo.alert-box :as alert-box] [re-demo.alert-list :as alert-list] [re-demo.tabs :as tabs] @@ -85,13 +86,14 @@ {:id :time :level :minor :label "Input Time" :panel input-time/panel} {:id :selection :level :major :label "Selection"} - {:id :dropdown :level :minor :label "Dropdown" :panel dropdowns/panel} + {:id :single-dropdown :level :minor :label "Dropdown" :panel dropdowns/panel} {:id :lists :level :minor :label "Selection List" :panel selection-list/panel} {:id :multi-select :level :minor :label "Multi-select List" :panel multi-select/panel} {:id :tag-dropdown :level :minor :label "Tag Dropdown" :panel tag-dropdown/panel} {:id :tree-select :level :minor :label "Tree-select" :panel tree-select/panel} {:id :tabs :level :minor :label "Tabs" :panel tabs/panel} {:id :typeahead :level :minor :label "Typeahead" :panel typeahead/panel} + {:id :generic-dropdown :level :minor :label "Generic Dropdown" :panel dropdown/panel} {:id :tables :level :major :label "Tables"} {:id :simple-v-table :level :minor :label "Simple V-table" :panel simple-v-table/panel} diff --git a/src/re_demo/dropdown.cljs b/src/re_demo/dropdown.cljs new file mode 100644 index 00000000..1483ea5e --- /dev/null +++ b/src/re_demo/dropdown.cljs @@ -0,0 +1,51 @@ +(ns re-demo.dropdown + (:require-macros + [re-com.core :refer []]) + (:require + [re-com.core :refer [at h-box v-box single-dropdown label hyperlink-href p p-span]] + [re-com.dropdown :refer [dropdown-parts-desc dropdown-args-desc dropdown]] + [re-demo.utils :refer [panel-title title2 parts-table args-table status-text]] + [reagent.core :as reagent])) + +(def model (reagent/atom false)) + +(defn panel* + [] + (fn [] + [v-box :src (at) :size "auto" :gap "10px" + :children + [[panel-title "[dropdown ... ]" + "src/re_com/dropdown.cljs" + "src/re_demo/dropdown.cljs"] + [h-box :src (at) :gap "100px" + :children + [[v-box :src (at) :gap "10px" :width "450px" + :children + [[title2 "Notes"] + [status-text "Alpha" {:color "red"}] + [p-span "A generic dropdown component. You pass in your own components for " + [:code ":anchor"] " and " [:code ":body"] "."] + [p-span [:code ":dropdown"] " provides: "] + [:ul + [:li "state management (" [:span {:style {:color "red"}} "alpha!"] ") for opening and closing"] + [:li "dynamically positioned container elements"]] + [args-table dropdown-args-desc]]] + [v-box :src (at) :width "700px" :gap "10px" + :children + [[title2 "Demo"] + [dropdown + {:anchor (fn [{:keys [state]}] + [:div "I am " (:openable state) " ;)"]) + #_#_:parts {:backdrop {:style {:background-color "blue"}}} + :body [:div "Hello World!"] + :model model + :min-width "300px" + :max-height "300px" + :min-height "200px" + :on-change #(reset! model %)}]]]]] + [parts-table "dropdown" dropdown-parts-desc]]])) + +;; core holds a reference to panel, so need one level of indirection to get figwheel updates +(defn panel + [] + [panel*])