♻️ Add minor refactor to options dropdown options handling and validation (#6739)

* ♻️ Refactor options-dropdown* and related components

* 🐛 Fix props error

* 🐛 Fix test

* 📎 Update rumext

---------

Co-authored-by: Eva Marco <evamarcod@gmail.com>
This commit is contained in:
Andrey Antukh 2025-06-29 11:52:29 +02:00 committed by GitHub
parent 6bd3253e5e
commit 403d92838a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 336 additions and 289 deletions

View file

@ -20,8 +20,8 @@
:git/url "https://github.com/funcool/beicon.git"} :git/url "https://github.com/funcool/beicon.git"}
funcool/rumext funcool/rumext
{:git/tag "v2.22" {:git/tag "v2.24"
:git/sha "92879b6" :git/sha "17a0c94"
:git/url "https://github.com/funcool/rumext.git"} :git/url "https://github.com/funcool/rumext.git"}
instaparse/instaparse {:mvn/version "1.5.0"} instaparse/instaparse {:mvn/version "1.5.0"}
@ -42,7 +42,7 @@
:dev :dev
{:extra-paths ["dev"] {:extra-paths ["dev"]
:extra-deps :extra-deps
{thheller/shadow-cljs {:mvn/version "3.1.5"} {thheller/shadow-cljs {:mvn/version "3.1.7"}
com.bhauman/rebel-readline {:mvn/version "RELEASE"} com.bhauman/rebel-readline {:mvn/version "RELEASE"}
org.clojure/tools.namespace {:mvn/version "RELEASE"} org.clojure/tools.namespace {:mvn/version "RELEASE"}
criterium/criterium {:mvn/version "RELEASE"} criterium/criterium {:mvn/version "RELEASE"}

View file

@ -6,102 +6,85 @@
(ns app.main.ui.ds.controls.combobox (ns app.main.ui.ds.controls.combobox
(:require-macros (:require-macros
[app.common.data.macros :as dm]
[app.main.style :as stl]) [app.main.style :as stl])
(:require (:require
[app.common.data :as d] [app.common.data :as d]
[app.main.constants :refer [max-input-length]] [app.main.constants :refer [max-input-length]]
[app.main.ui.ds.controls.shared.options-dropdown :refer [options-dropdown*]] [app.main.ui.ds.controls.select :refer [get-option handle-focus-change]]
[app.main.ui.ds.foundations.assets.icon :refer [icon* icon-list] :as i] [app.main.ui.ds.controls.shared.options-dropdown :refer [options-dropdown* schema:option]]
[app.util.array :as array] [app.main.ui.ds.foundations.assets.icon :as i]
[app.util.dom :as dom] [app.util.dom :as dom]
[app.util.keyboard :as kbd] [app.util.keyboard :as kbd]
[app.util.object :as obj] [app.util.object :as obj]
[rumext.v2 :as mf])) [cuerdas.core :as str]
[rumext.v2 :as mf]
(def listbox-id-index (atom 0)) [rumext.v2.util :as mfu]))
(defn- get-option
[options id]
(array/find #(= id (obj/get % "id")) options))
(defn- handle-focus-change
[options focused* new-index options-nodes-refs]
(let [option (aget options new-index)
id (obj/get option "id")
nodes (mf/ref-val options-nodes-refs)
node (obj/get nodes id)]
(reset! focused* id)
(dom/scroll-into-view-if-needed! node)))
(def ^:private schema:combobox-option
[:and
[:map {:title "option"}
[:id :string]
[:icon {:optional true}
[:and :string [:fn #(contains? icon-list %)]]]
[:label {:optional true} :string]
[:aria-label {:optional true} :string]]
[:fn {:error/message "invalid data: missing required props"}
(fn [option]
(or (and (contains? option :icon)
(or (contains? option :label)
(contains? option :aria-label)))
(contains? option :label)))]])
(def ^:private schema:combobox (def ^:private schema:combobox
[:map [:map
[:id {:optional true} :string] [:id {:optional true} :string]
[:options [:vector schema:combobox-option]] [:options [:vector schema:option]]
[:class {:optional true} :string] [:class {:optional true} :string]
[:max-length {:optional true} :int] [:max-length {:optional true} :int]
[:placeholder {:optional true} :string] [:placeholder {:optional true} :string]
[:disabled {:optional true} :boolean] [:disabled {:optional true} :boolean]
[:default-selected {:optional true} :string] [:default-selected {:optional true} :string]
[:on-change {:optional true} fn?] [:on-change {:optional true} fn?]
[:empty-to-end {:optional true} :boolean] [:empty-to-end {:optional true} [:maybe :boolean]]
[:has-error {:optional true} :boolean]]) [:has-error {:optional true} :boolean]])
(mf/defc combobox* (mf/defc combobox*
{::mf/schema schema:combobox} {::mf/schema schema:combobox}
[{:keys [id options class placeholder disabled has-error default-selected max-length empty-to-end on-change] :rest props}] [{:keys [id options class placeholder disabled has-error default-selected max-length empty-to-end on-change] :rest props}]
(let [is-open* (mf/use-state false) (let [;; NOTE: we use mfu/bean here for transparently handle
;; options provide as clojure data structures or javascript
;; plain objects and lists.
options (if (array? options)
(mfu/bean options)
options)
empty-to-end (d/nilv empty-to-end false)
is-open* (mf/use-state false)
is-open (deref is-open*) is-open (deref is-open*)
selected-value* (mf/use-state default-selected) selected-id* (mf/use-state default-selected)
selected-value (deref selected-value*) selected-id (deref selected-id*)
filter-value* (mf/use-state "") filter-id* (mf/use-state "")
filter-value (deref filter-value*) filter-id (deref filter-id*)
focused-value* (mf/use-state nil) focused-id* (mf/use-state nil)
focused-value (deref focused-value*) focused-id (deref focused-id*)
combobox-ref (mf/use-ref nil) combobox-ref (mf/use-ref nil)
input-ref (mf/use-ref nil) input-ref (mf/use-ref nil)
options-nodes-refs (mf/use-ref nil) nodes-ref (mf/use-ref nil)
options-ref (mf/use-ref nil) options-ref (mf/use-ref nil)
listbox-id-ref (mf/use-ref (dm/str "listbox-" (swap! listbox-id-index inc))) listbox-id (mf/use-id)
listbox-id (mf/ref-val listbox-id-ref)
dropdown-options dropdown-options
(mf/use-memo (mf/with-memo [options filter-id]
(mf/deps options filter-value)
(fn []
(->> options (->> options
(array/filter (fn [option] (filterv (fn [option]
(let [lower-option (.toLowerCase (obj/get option "id")) (let [option (str/lower (get option :id))
lower-filter (.toLowerCase filter-value)] filter (str/lower filter-id)]
(.includes lower-option lower-filter))))))) (str/includes? option filter))))
(not-empty)))
set-option-ref set-option-ref
(mf/use-fn (mf/use-fn
(fn [node id] (fn [node]
(let [refs (or (mf/ref-val options-nodes-refs) #js {}) (let [state (mf/ref-val nodes-ref)
refs (if node state (d/nilv state #js {})
(obj/set! refs id node) id (dom/get-data node "id")
(obj/unset! refs id))] state (obj/set! state id node)]
(mf/set-ref-val! options-nodes-refs refs)))) (mf/set-ref-val! nodes-ref state)
(fn []
(let [state (mf/ref-val nodes-ref)
state (d/nilv state #js {})
id (dom/get-data node "id")
state (obj/unset! state id)]
(mf/set-ref-val! nodes-ref state))))))
on-option-click on-option-click
(mf/use-fn (mf/use-fn
@ -110,34 +93,36 @@
(dom/stop-propagation event) (dom/stop-propagation event)
(let [node (dom/get-current-target event) (let [node (dom/get-current-target event)
id (dom/get-data node "id")] id (dom/get-data node "id")]
(reset! selected-value* id) (reset! selected-id* id)
(reset! is-open* false) (reset! is-open* false)
(reset! focused-value* nil) (reset! focused-id* nil)
(when (fn? on-change) (when (fn? on-change)
(on-change id))))) (on-change id)))))
on-component-click on-click
(mf/use-fn (mf/use-fn
(mf/deps disabled) (mf/deps disabled)
(fn [event] (fn [event]
(dom/stop-propagation event) (dom/stop-propagation event)
(when-not disabled (when-not disabled
(when-not (deref is-open*) (when-not (deref is-open*)
(reset! filter-value* "")) (reset! filter-id* ""))
(swap! is-open* not)))) (swap! is-open* not))))
on-component-blur
on-blur
(mf/use-fn (mf/use-fn
(mf/deps on-change) (mf/deps on-change)
(fn [event] (fn [event]
(dom/stop-propagation event) (dom/stop-propagation event)
(let [target (.-relatedTarget event) (let [target (dom/get-related-target event)
outside? (not (.contains (mf/ref-val combobox-ref) target))] self-node (mf/ref-val combobox-ref)]
(when outside? (when-not (dom/is-child? self-node target)
(reset! is-open* false) (reset! is-open* false)
(reset! focused-value* nil) (reset! focused-id* nil)
(when (fn? on-change) (when (fn? on-change)
(on-change (dom/get-input-value (mf/ref-val input-ref)))))))) (when-let [input-node (mf/ref-val input-ref)]
(on-change (dom/get-input-value input-node))))))))
on-input-click on-input-click
(mf/use-fn (mf/use-fn
@ -146,7 +131,7 @@
(dom/stop-propagation event) (dom/stop-propagation event)
(when-not disabled (when-not disabled
(when-not (deref is-open*) (when-not (deref is-open*)
(reset! filter-value* "")) (reset! filter-id* ""))
(reset! is-open* true)))) (reset! is-open* true))))
on-input-focus on-input-focus
@ -158,47 +143,47 @@
on-input-key-down on-input-key-down
(mf/use-fn (mf/use-fn
(mf/deps is-open focused-value disabled dropdown-options) (mf/deps is-open focused-id disabled)
(fn [event] (fn [event]
(dom/stop-propagation event) (dom/stop-propagation event)
(when-not disabled (when-not disabled
(let [len (alength dropdown-options) (let [options (mf/ref-val options-ref)
index (array/find-index #(= (deref focused-value*) (obj/get % "id")) dropdown-options)] len (count options)
index (d/index-of-pred options #(= focused-id (get % :id)))
(when (< len 0) index (d/nilv index -1)
(reset! index len)) nodes (mf/ref-val nodes-ref)]
(if is-open (if is-open
(cond (cond
(kbd/home? event) (kbd/home? event)
(handle-focus-change dropdown-options focused-value* 0 options-nodes-refs) (handle-focus-change options focused-id* 0 nodes)
(kbd/up-arrow? event) (kbd/up-arrow? event)
(let [new-index (if (= index -1) (let [new-index (if (= index -1)
(dec len) (dec len)
(mod (- index 1) len))] (mod (- index 1) len))]
(handle-focus-change dropdown-options focused-value* new-index options-nodes-refs)) (handle-focus-change options focused-id* new-index nodes))
(kbd/down-arrow? event) (kbd/down-arrow? event)
(let [new-index (if (= index -1) (let [new-index (if (= index -1)
0 0
(mod (+ index 1) len))] (mod (+ index 1) len))]
(handle-focus-change dropdown-options focused-value* new-index options-nodes-refs)) (handle-focus-change options focused-id* new-index nodes))
(kbd/enter? event) (kbd/enter? event)
(do (do
(reset! selected-value* focused-value) (reset! selected-id* focused-id)
(reset! is-open* false) (reset! is-open* false)
(reset! focused-value* nil) (reset! focused-id* nil)
(dom/blur! (mf/ref-val input-ref)) (dom/blur! (mf/ref-val input-ref))
(when (and (fn? on-change) (when (and (fn? on-change)
(some? focused-value)) (some? focused-id))
(on-change focused-value))) (on-change focused-id)))
(kbd/esc? event) (kbd/esc? event)
(do (reset! is-open* false) (do (reset! is-open* false)
(reset! focused-value* nil) (reset! focused-id* nil)
(dom/blur! (mf/ref-val input-ref)))) (dom/blur! (mf/ref-val input-ref))))
(cond (cond
@ -215,20 +200,24 @@
(let [value (-> event (let [value (-> event
dom/get-target dom/get-target
dom/get-value)] dom/get-value)]
(reset! selected-value* value) (reset! selected-id* value)
(reset! filter-value* value) (reset! filter-id* value)
(reset! focused-value* nil)))) (reset! focused-id* nil))))
selected-option (get-option options selected-value) selected-option
icon (obj/get selected-option "icon")] (mf/with-memo [options selected-id]
(get-option options selected-id))
(mf/with-effect [options] icon
(mf/set-ref-val! options-ref options)) (get selected-option :icon)]
(mf/with-effect [dropdown-options]
(mf/set-ref-val! options-ref dropdown-options))
(mf/use-effect (mf/use-effect
(mf/deps default-selected) (mf/deps default-selected)
(fn [] (fn []
(reset! selected-value* default-selected))) (reset! selected-id* default-selected)))
[:div {:ref combobox-ref [:div {:ref combobox-ref
:class (stl/css-case :class (stl/css-case
@ -236,14 +225,14 @@
:has-error has-error :has-error has-error
:disabled disabled)} :disabled disabled)}
[:div {:class (dm/str class " " (stl/css :combobox)) [:div {:class [class (stl/css :combobox)]
:on-blur on-component-blur :on-blur on-blur
:on-click on-component-click} :on-click on-click}
[:span {:class (stl/css-case :header true [:span {:class (stl/css-case :header true
:header-icon (some? icon))} :header-icon (some? icon))}
(when icon (when icon
[:> icon* {:icon-id icon [:> i/icon* {:icon-id icon
:size "s" :size "s"
:aria-hidden true}]) :aria-hidden true}])
[:input {:id id [:input {:id id
@ -255,11 +244,11 @@
:aria-autocomplete "both" :aria-autocomplete "both"
:aria-expanded is-open :aria-expanded is-open
:aria-controls listbox-id :aria-controls listbox-id
:aria-activedescendant focused-value :aria-activedescendant focused-id
:data-testid "combobox-input" :data-testid "combobox-input"
:max-length (d/nilv max-length max-input-length) :max-length (d/nilv max-length max-input-length)
:disabled disabled :disabled disabled
:value (d/nilv selected-value "") :value (d/nilv selected-id "")
:placeholder placeholder :placeholder placeholder
:on-change on-input-change :on-change on-input-change
:on-click on-input-click :on-click on-input-click
@ -267,24 +256,25 @@
:on-key-down on-input-key-down}]] :on-key-down on-input-key-down}]]
(when (d/not-empty? options) (when (d/not-empty? options)
[:> :button {:type "button" [:button {:type "button"
:tab-index "-1" :tab-index "-1"
:aria-expanded is-open :aria-expanded is-open
:aria-controls listbox-id :aria-controls listbox-id
:class (stl/css :button-toggle-list) :class (stl/css :button-toggle-list)
:on-click on-component-click} :on-click on-click}
[:> icon* {:icon-id i/arrow [:> i/icon* {:icon-id i/arrow
:class (stl/css :arrow) :class (stl/css :arrow)
:size "s" :size "s"
:aria-hidden true :aria-hidden true
:data-testid "combobox-open-button"}]])] :data-testid "combobox-open-button"}]])]
(when (and is-open (seq dropdown-options)) (when (and ^boolean is-open
^boolean dropdown-options)
[:> options-dropdown* {:on-click on-option-click [:> options-dropdown* {:on-click on-option-click
:options dropdown-options :options dropdown-options
:selected selected-value :selected selected-id
:focused focused-value :focused focused-id
:set-ref set-option-ref :ref set-option-ref
:id listbox-id :id listbox-id
:empty-to-end empty-to-end :empty-to-end empty-to-end
:data-testid "combobox-options"}])])) :data-testid "combobox-options"}])]))

View file

@ -6,50 +6,34 @@
(ns app.main.ui.ds.controls.select (ns app.main.ui.ds.controls.select
(:require-macros (:require-macros
[app.common.data.macros :as dm]
[app.main.style :as stl]) [app.main.style :as stl])
(:require (:require
[app.main.ui.ds.controls.shared.options-dropdown :refer [options-dropdown*]] [app.common.data :as d]
[app.main.ui.ds.foundations.assets.icon :refer [icon* icon-list] :as i] [app.main.ui.ds.controls.shared.options-dropdown :refer [options-dropdown* schema:option]]
[app.util.array :as array] [app.main.ui.ds.foundations.assets.icon :as i]
[app.util.dom :as dom] [app.util.dom :as dom]
[app.util.keyboard :as kbd] [app.util.keyboard :as kbd]
[app.util.object :as obj] [app.util.object :as obj]
[clojure.string :as str] [clojure.string :as str]
[rumext.v2 :as mf])) [rumext.v2 :as mf]
[rumext.v2.util :as mfu]))
(def listbox-id-index (atom 0)) (defn get-option
(def ^:private schema:select-option
[:and
[:map {:title "option"}
[:id :string]
[:icon {:optional true}
[:and :string [:fn #(contains? icon-list %)]]]
[:label {:optional true} :string]
[:aria-label {:optional true} :string]]
[:fn {:error/message "invalid data: missing required props"}
(fn [option]
(or (and (contains? option :icon)
(or (contains? option :label)
(contains? option :aria-label)))
(contains? option :label)))]])
(defn- get-option
[options id] [options id]
(or (array/find #(= id (obj/get % "id")) options) (or (d/seek #(= id (get % :id)) options)
(aget options 0))) (nth options 0)))
(defn- get-selected-option-id (defn- get-selected-option-id
[options default] [options default]
(let [option (get-option options default)] (let [option (get-option options default)]
(obj/get option "id"))) (get option :id)))
(defn- handle-focus-change
[options focused* new-index options-nodes-refs] ;; Also used in combobox
(let [option (aget options new-index) (defn handle-focus-change
id (obj/get option "id") [options focused* new-index nodes]
nodes (mf/ref-val options-nodes-refs) (let [option (get options new-index)
id (get option :id)
node (obj/get nodes id)] node (obj/get nodes id)]
(reset! focused* id) (reset! focused* id)
(dom/scroll-into-view-if-needed! node))) (dom/scroll-into-view-if-needed! node)))
@ -63,45 +47,59 @@
(def ^:private schema:select (def ^:private schema:select
[:map [:map
[:options [:vector {:min 1} schema:select-option]] [:options [:vector {:min 1} schema:option]]
[:class {:optional true} :string] [:class {:optional true} :string]
[:disabled {:optional true} :boolean] [:disabled {:optional true} :boolean]
[:default-selected {:optional true} :string] [:default-selected {:optional true} :string]
[:empty-to-end {:optional true} :boolean] [:empty-to-end {:optional true} [:maybe :boolean]]
[:on-change {:optional true} fn?]]) [:on-change {:optional true} fn?]])
(mf/defc select* (mf/defc select*
{::mf/schema schema:select} {::mf/schema schema:select}
[{:keys [options class disabled default-selected empty-to-end on-change] :rest props}] [{:keys [options class disabled default-selected empty-to-end on-change] :rest props}]
(let [is-open* (mf/use-state false) (let [;; NOTE: we use mfu/bean here for transparently handle
;; options provide as clojure data structures or javascript
;; plain objects and lists.
options (if (array? options)
(mfu/bean options)
options)
empty-to-end (d/nilv empty-to-end false)
is-open* (mf/use-state false)
is-open (deref is-open*) is-open (deref is-open*)
selected-value* (mf/use-state #(get-selected-option-id options default-selected)) selected-id* (mf/use-state #(get-selected-option-id options default-selected))
selected-value (deref selected-value*) selected-id (deref selected-id*)
focused-value* (mf/use-state nil) focused-id* (mf/use-state selected-id)
focused-value (deref focused-value*) focused-id (deref focused-id*)
has-focus* (mf/use-state false) has-focus* (mf/use-state false)
has-focus (deref has-focus*) has-focus (deref has-focus*)
listbox-id-ref (mf/use-ref (dm/str "select-listbox-" (swap! listbox-id-index inc))) listbox-id (mf/use-id)
options-nodes-refs (mf/use-ref nil)
nodes-ref (mf/use-ref nil)
options-ref (mf/use-ref nil) options-ref (mf/use-ref nil)
select-ref (mf/use-ref nil) select-ref (mf/use-ref nil)
listbox-id (mf/ref-val listbox-id-ref)
empty-selected-value? (str/blank? selected-value) empty-selected-id?
(str/blank? selected-id)
set-option-ref set-option-ref
(mf/use-fn (mf/use-fn
(mf/deps options-nodes-refs) (fn [node]
(fn [node id] (let [state (mf/ref-val nodes-ref)
(let [refs (or (mf/ref-val options-nodes-refs) #js {}) state (d/nilv state #js {})
refs (if node id (dom/get-data node "id")
(obj/set! refs id node) state (obj/set! state id node)]
(obj/unset! refs id))] (mf/set-ref-val! nodes-ref state)
(mf/set-ref-val! options-nodes-refs refs)))) (fn []
(let [state (mf/ref-val nodes-ref)
state (d/nilv state #js {})
id (dom/get-data node "id")
state (obj/unset! state id)]
(mf/set-ref-val! nodes-ref state))))))
on-option-click on-option-click
(mf/use-fn (mf/use-fn
@ -109,13 +107,13 @@
(fn [event] (fn [event]
(let [node (dom/get-current-target event) (let [node (dom/get-current-target event)
id (dom/get-data node "id")] id (dom/get-data node "id")]
(reset! selected-value* id) (reset! selected-id* id)
(reset! focused-value* nil) (reset! focused-id* nil)
(reset! is-open* false) (reset! is-open* false)
(when (fn? on-change) (when (fn? on-change)
(on-change id))))) (on-change id)))))
on-component-click on-click
(mf/use-fn (mf/use-fn
(mf/deps disabled) (mf/deps disabled)
(fn [event] (fn [event]
@ -124,95 +122,108 @@
(when-not disabled (when-not disabled
(swap! is-open* not)))) (swap! is-open* not))))
on-component-blur on-blur
(mf/use-fn (mf/use-fn
(fn [event] (fn [event]
(let [target (.-relatedTarget event) (let [target (dom/get-related-target event)
outside? (not (.contains (mf/ref-val select-ref) target))] select-node (mf/ref-val select-ref)]
(when outside? (when-not (dom/is-child? select-node target)
(reset! focused-value* nil) (reset! focused-id* nil)
(reset! is-open* false) (reset! is-open* false)
(reset! has-focus* false))))) (reset! has-focus* false)))))
on-component-focus on-focus
(mf/use-fn (mf/use-fn
(fn [_] #(reset! has-focus* true))
(reset! has-focus* true)))
on-button-key-down on-button-key-down
(mf/use-fn (mf/use-fn
(mf/deps focused-value disabled) (mf/deps focused-id disabled)
(fn [event] (fn [event]
(dom/stop-propagation event) (dom/stop-propagation event)
(when-not disabled (when-not disabled
(let [options (mf/ref-val options-ref) (let [options (mf/ref-val options-ref)
len (alength options) len (count options)
index (array/find-index #(= (deref focused-value*) (obj/get % "id")) options)] index (d/index-of-pred options #(= focused-id (get % :id)))
nodes (mf/ref-val nodes-ref)]
(cond (cond
(kbd/home? event) (kbd/home? event)
(handle-focus-change options focused-value* 0 options-nodes-refs) (handle-focus-change options focused-id* 0 nodes)
(kbd/up-arrow? event) (kbd/up-arrow? event)
(handle-focus-change options focused-value* (mod (- index 1) len) options-nodes-refs) (handle-focus-change options focused-id* (mod (- index 1) len) nodes)
(kbd/down-arrow? event) (kbd/down-arrow? event)
(handle-focus-change options focused-value* (mod (+ index 1) len) options-nodes-refs) (handle-focus-change options focused-id* (mod (+ index 1) len) nodes)
(or (kbd/space? event) (kbd/enter? event)) (or (kbd/space? event)
(kbd/enter? event))
(when (deref is-open*) (when (deref is-open*)
(dom/prevent-default event) (dom/prevent-default event)
(handle-selection focused-value* selected-value* is-open*)) (handle-selection focused-id* selected-id* is-open*))
(kbd/esc? event) (kbd/esc? event)
(do (reset! is-open* false) (do (reset! is-open* false)
(reset! focused-value* nil))))))) (reset! focused-id* nil)))))))
class (dm/str class " " (stl/css-case :select true select-class
:focused has-focus)) (stl/css-case :select true
:focused has-focus)
props (mf/spread-props props {:class class props
(mf/spread-props props {:class [class select-class]
:role "combobox" :role "combobox"
:aria-controls listbox-id :aria-controls listbox-id
:aria-haspopup "listbox" :aria-haspopup "listbox"
:aria-activedescendant focused-value :aria-activedescendant focused-id
:aria-expanded is-open :aria-expanded is-open
:on-key-down on-button-key-down :on-key-down on-button-key-down
:disabled disabled :disabled disabled
:on-click on-component-click}) :on-click on-click})
selected-option (get-option options selected-value) selected-option
label (obj/get selected-option "label") (mf/with-memo [options selected-id]
icon (obj/get selected-option "icon")] (get-option options selected-id))
label
(get selected-option :label)
icon
(get selected-option :icon)
has-icon?
(some? icon)]
(mf/with-effect [options] (mf/with-effect [options]
(mf/set-ref-val! options-ref options)) (mf/set-ref-val! options-ref options))
[:div {:class (stl/css :select-wrapper) [:div {:class (stl/css :select-wrapper)
:on-click on-component-click :on-click on-click
:on-focus on-component-focus :on-focus on-focus
:ref select-ref :ref select-ref
:on-blur on-component-blur} :on-blur on-blur}
[:> :button props [:> :button props
[:span {:class (stl/css-case :select-header true [:span {:class (stl/css-case :select-header true
:header-icon (some? icon))} :header-icon has-icon?)}
(when icon (when ^boolean has-icon?
[:> icon* {:icon-id icon [:> i/icon* {:icon-id icon
:size "s" :size "s"
:aria-hidden true}]) :aria-hidden true}])
[:span {:class (stl/css-case :header-label true [:span {:class (stl/css-case :header-label true
:header-label-dimmed empty-selected-value?)} :header-label-dimmed empty-selected-id?)}
(if empty-selected-value? "--" label)]] (if ^boolean empty-selected-id? "--" label)]]
[:> icon* {:icon-id i/arrow
[:> i/icon* {:icon-id i/arrow
:class (stl/css :arrow) :class (stl/css :arrow)
:size "m" :size "m"
:aria-hidden true}]] :aria-hidden true}]]
(when is-open (when ^boolean is-open
[:> options-dropdown* {:on-click on-option-click [:> options-dropdown* {:on-click on-option-click
:id listbox-id :id listbox-id
:options options :options options
:selected selected-value :selected selected-id
:focused focused-value :focused focused-id
:empty-to-end empty-to-end :empty-to-end empty-to-end
:set-ref set-option-ref}])])) :ref set-option-ref}])]))

View file

@ -28,7 +28,7 @@ export default {
}, },
{ {
label: "Menu", label: "Menu",
id: "opeion-menu", id: "option-menu",
}, },
], ],
defaultSelected: "option-code", defaultSelected: "option-code",

View file

@ -8,24 +8,56 @@
(:require-macros (:require-macros
[app.main.style :as stl]) [app.main.style :as stl])
(:require (:require
[app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] [app.main.ui.ds.foundations.assets.icon :as i]
[app.util.array :as array]
[app.util.object :as obj]
[cuerdas.core :as str] [cuerdas.core :as str]
[rumext.v2 :as mf])) [rumext.v2 :as mf]))
(def ^:private schema:icon-list
[:and :string
[:fn {:error/message "invalid data: invalid icon"} #(contains? i/icon-list %)]])
(def schema:option
[:and
[:map {:title "option"}
[:id :string]
[:icon {:optional true} schema:icon-list]
[:label {:optional true} :string]
[:aria-label {:optional true} :string]]
[:fn {:error/message "invalid data: missing required props"}
(fn [option]
(or (and (contains? option :icon)
(or (contains? option :label)
(contains? option :aria-label)))
(contains? option :label)))]])
(def ^:private schema:options-dropdown
[:map
[:ref {:optional true} fn?]
[:on-click fn?]
[:options [:vector schema:option]]
[:selected :any]
[:focused :any]
[:empty-to-end {:optional true} :boolean]])
(def ^:private
xf:filter-blank-id
(filter #(str/blank? (get % :id))))
(def ^:private
xf:filter-non-blank-id
(remove #(str/blank? (get % :id))))
(mf/defc option* (mf/defc option*
{::mf/private true} {::mf/private true}
[{:keys [id label icon aria-label on-click selected set-ref focused dimmed] :rest props}] [{:keys [id ref label icon aria-label on-click selected focused dimmed] :rest props}]
(let [class (stl/css-case :option true
[:> :li {:value id
:class (stl/css-case :option true
:option-with-icon (some? icon) :option-with-icon (some? icon)
:option-selected selected :option-selected selected
:option-current focused) :option-current focused)]
[:li {:value id
:class class
:aria-selected selected :aria-selected selected
:ref (fn [node] :ref ref
(set-ref node id))
:role "option" :role "option"
:id id :id id
:on-click on-click :on-click on-click
@ -33,7 +65,7 @@
:data-testid "dropdown-option"} :data-testid "dropdown-option"}
(when (some? icon) (when (some? icon)
[:> icon* [:> i/icon*
{:icon-id icon {:icon-id icon
:size "s" :size "s"
:class (stl/css :option-icon) :class (stl/css :option-icon)
@ -41,41 +73,49 @@
:aria-label (when (not label) aria-label)}]) :aria-label (when (not label) aria-label)}])
[:span {:class (stl/css-case :option-text true [:span {:class (stl/css-case :option-text true
:option-text-dimmed dimmed)} label] :option-text-dimmed dimmed)}
label]
(when selected (when selected
[:> icon* [:> i/icon*
{:icon-id i/tick {:icon-id i/tick
:size "s" :size "s"
:class (stl/css :option-check) :class (stl/css :option-check)
:aria-hidden (when label true)}])]) :aria-hidden (when label true)}])]))
(mf/defc options-dropdown* (mf/defc options-dropdown*
{::mf/props :obj} {::mf/schema schema:options-dropdown}
[{:keys [set-ref on-click options selected focused empty-to-end] :rest props}] [{:keys [ref on-click options selected focused empty-to-end] :rest props}]
(let [props (mf/spread-props props (let [props
(mf/spread-props props
{:class (stl/css :option-list) {:class (stl/css :option-list)
:tab-index "-1" :tab-index "-1"
:role "listbox"}) :role "listbox"})
options-blank (when empty-to-end options-blank
(array/filter #(str/blank? (obj/get % "id")) options)) (mf/with-memo [empty-to-end options]
options (if empty-to-end (when ^boolean empty-to-end
(array/filter #((complement str/blank?) (obj/get % "id")) options) (into [] xf:filter-blank-id options)))
options)]
[:> "ul" props options
(for [option ^js options] (mf/with-memo [empty-to-end options]
(let [id (obj/get option "id") (if ^boolean empty-to-end
label (obj/get option "label") (into [] xf:filter-non-blank-id options)
aria-label (obj/get option "aria-label") options))]
icon (obj/get option "icon")]
[:> :ul props
(for [option options]
(let [id (get option :id)
label (get option :label)
aria-label (get option :aria-label)
icon (get option :icon)]
[:> option* {:selected (= id selected) [:> option* {:selected (= id selected)
:key id :key id
:id id :id id
:label label :label label
:icon icon :icon icon
:aria-label aria-label :aria-label aria-label
:set-ref set-ref :ref ref
:focused (= id focused) :focused (= id focused)
:dimmed false :dimmed false
:on-click on-click}])) :on-click on-click}]))
@ -85,18 +125,18 @@
(when (seq options) (when (seq options)
[:hr {:class (stl/css :option-separator)}]) [:hr {:class (stl/css :option-separator)}])
(for [option ^js options-blank] (for [option options-blank]
(let [id (obj/get option "id") (let [id (get option :id)
label (obj/get option "label") label (get option :label)
aria-label (obj/get option "aria-label") aria-label (get option :aria-label)
icon (obj/get option "icon")] icon (get option :icon)]
[:> option* {:selected (= id selected) [:> option* {:selected (= id selected)
:key id :key id
:id id :id id
:label label :label label
:icon icon :icon icon
:aria-label aria-label :aria-label aria-label
:set-ref set-ref :ref ref
:focused (= id focused) :focused (= id focused)
:dimmed true :dimmed true
:on-click on-click}]))])])) :on-click on-click}]))])]))

View file

@ -220,6 +220,7 @@
(when on-dispose (on-dispose)))))))) (when on-dispose (on-dispose))))))))
;; https://reactjs.org/docs/hooks-faq.html#how-to-get-the-previous-props-or-state ;; https://reactjs.org/docs/hooks-faq.html#how-to-get-the-previous-props-or-state
;; FIXME: replace with rumext
(defn use-previous (defn use-previous
"Returns the value from previous render cycle." "Returns the value from previous render cycle."
[value] [value]
@ -238,6 +239,7 @@
(reset! ptr value)) (reset! ptr value))
ptr)) ptr))
;; FIXME: replace with rumext
(defn use-update-ref (defn use-update-ref
[value] [value]
(let [ref (mf/use-ref value)] (let [ref (mf/use-ref value)]
@ -245,6 +247,7 @@
(mf/set-ref-val! ref value)) (mf/set-ref-val! ref value))
ref)) ref))
;; FIXME: replace with rumext
(defn use-ref-callback (defn use-ref-callback
"Returns a stable callback pointer what calls the interned "Returns a stable callback pointer what calls the interned
callback. The interned callback will be automatically updated on callback. The interned callback will be automatically updated on
@ -260,6 +263,7 @@
(when ^boolean obj (when ^boolean obj
(apply (.-f obj) args))))))) (apply (.-f obj) args)))))))
;; FIXME: replace with rumext
(defn use-ref-value (defn use-ref-value
"Returns a ref that will be automatically updated when the value is changed" "Returns a ref that will be automatically updated when the value is changed"
[v] [v]
@ -268,6 +272,7 @@
(mf/set-ref-val! ref v)) (mf/set-ref-val! ref v))
ref)) ref))
;; FIXME: replace with rumext
(defn use-equal-memo (defn use-equal-memo
[val] [val]
(let [ref (mf/use-ref nil)] (let [ref (mf/use-ref nil)]
@ -285,6 +290,7 @@
(mf/with-memo [focus objects] (mf/with-memo [focus objects]
(cpf/focus-objects objects focus)))) (cpf/focus-objects objects focus))))
;; FIXME: replace with rumext
(defn use-debounce (defn use-debounce
[ms value] [ms value]
(let [[state update-state-fn] (mf/useState value) (let [[state update-state-fn] (mf/useState value)

View file

@ -365,7 +365,7 @@
[:> combobox* {:id (str "variant-prop-" variant-id "-" pos) [:> combobox* {:id (str "variant-prop-" variant-id "-" pos)
:placeholder (if mixed-value? (tr "settings.multiple") "--") :placeholder (if mixed-value? (tr "settings.multiple") "--")
:default-selected (if mixed-value? "" (:value prop)) :default-selected (if mixed-value? "" (:value prop))
:options (clj->js (get-options (:name prop))) :options (get-options (:name prop))
:empty-to-end true :empty-to-end true
:max-length ctv/property-max-length :max-length ctv/property-max-length
:on-change (partial update-property-value pos)}])]])] :on-change (partial update-property-value pos)}])]])]
@ -438,7 +438,7 @@
[:span {:class (stl/css :variant-property-name)} [:span {:class (stl/css :variant-property-name)}
(:name prop)] (:name prop)]
[:> select* {:default-selected (:value prop) [:> select* {:default-selected (:value prop)
:options (clj->js (get-options (:name prop))) :options (get-options (:name prop))
:empty-to-end true :empty-to-end true
:on-change (partial switch-component pos)}]]])] :on-change (partial switch-component pos)}]]])]