325 lines
20 KiB
Clojure
325 lines
20 KiB
Clojure
(ns re-com.dropdown
|
|
(:require-macros [re-com.core :refer [handler-fn]])
|
|
(:require [re-com.util :refer [deref-or-value position-for-id item-for-id]]
|
|
[re-com.box :refer [align-style flex-child-style]]
|
|
[re-com.validate :refer [vector-of-maps? css-style? html-attr? number-or-string?] :refer-macros [validate-args-macro]]
|
|
[clojure.string :as string]
|
|
[reagent.core :as reagent]))
|
|
|
|
;; Inspiration: http://alxlit.name/bootstrap-chosen
|
|
;; Alternative: http://silviomoreto.github.io/bootstrap-select
|
|
|
|
(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
|
|
from id (usually +1 or -1 to go to next/previous). Also accepts :start and :end"
|
|
[choices id-fn id offset]
|
|
(let [current-index (position-for-id id choices :id-fn id-fn)
|
|
new-index (cond
|
|
(= offset :start) 0
|
|
(= offset :end) (dec (count choices))
|
|
(nil? current-index) 0
|
|
:else (mod (+ current-index offset) (count choices)))]
|
|
(when (and new-index (pos? (count choices)))
|
|
(id-fn (nth choices new-index)))))
|
|
|
|
|
|
(defn- choices-with-group-headings
|
|
"If necessary, inserts group headings entries into the choices"
|
|
[opts group-fn]
|
|
(let [groups (partition-by group-fn opts)
|
|
group-headers (->> groups
|
|
(map first)
|
|
(map group-fn)
|
|
(map #(hash-map :id (gensym) :group %)))]
|
|
[group-headers groups]))
|
|
|
|
|
|
(defn- filter-choices
|
|
"Filter a list of choices based on a filter string using plain string searches (case insensitive). Less powerful
|
|
than regex's but no confusion with reserved characters"
|
|
[choices group-fn label-fn filter-text]
|
|
(let [lower-filter-text (string/lower-case filter-text)
|
|
filter-fn (fn [opt]
|
|
(let [group (if (nil? (group-fn opt)) "" (group-fn opt))
|
|
label (str (label-fn opt))]
|
|
(or
|
|
(>= (.indexOf (string/lower-case group) lower-filter-text) 0)
|
|
(>= (.indexOf (string/lower-case label) lower-filter-text) 0))))]
|
|
(filter filter-fn choices)))
|
|
|
|
|
|
(defn- filter-choices-regex
|
|
"Filter a list of choices based on a filter string using regex's (case insensitive). More powerful but can cause
|
|
confusion for users entering reserved characters such as [ ] * + . ( ) etc."
|
|
[choices group-fn label-fn filter-text]
|
|
(let [re (try
|
|
(js/RegExp. filter-text "i")
|
|
(catch js/Object e nil))
|
|
filter-fn (partial (fn [re opt]
|
|
(when-not (nil? re)
|
|
(or (.test re (group-fn opt)) (.test re (label-fn opt)))))
|
|
re)]
|
|
(filter filter-fn choices)))
|
|
|
|
|
|
(defn filter-choices-by-keyword
|
|
"Filter a list of choices extra data within the choices vector"
|
|
[choices keyword value]
|
|
(let [filter-fn (fn [opt] (>= (.indexOf (keyword opt) value) 0))]
|
|
(filter filter-fn choices)))
|
|
|
|
|
|
(defn show-selected-item
|
|
[node]
|
|
(let [item-offset-top (.-offsetTop node)
|
|
item-offset-bottom (+ item-offset-top (.-clientHeight node))
|
|
parent (.-parentNode node)
|
|
parent-height (.-clientHeight parent)
|
|
parent-visible-top (.-scrollTop parent)
|
|
parent-visible-bottom (+ parent-visible-top parent-height)
|
|
new-scroll-top (cond
|
|
(> item-offset-bottom parent-visible-bottom) (max (- item-offset-bottom parent-height) 0)
|
|
(< item-offset-top parent-visible-top) item-offset-top)]
|
|
(when new-scroll-top (set! (.-scrollTop parent) new-scroll-top))))
|
|
|
|
|
|
(defn- make-group-heading
|
|
"Render a group heading"
|
|
[m]
|
|
^{:key (:id m)} [:li.group-result
|
|
(:group m)])
|
|
|
|
|
|
(defn- choice-item
|
|
"Render a choice item and set up appropriate mouse events"
|
|
[id label on-click internal-model]
|
|
(let [mouse-over? (reagent/atom false)]
|
|
(reagent/create-class
|
|
{:component-did-mount
|
|
(fn [this]
|
|
(let [node (reagent/dom-node this)
|
|
selected (= @internal-model id)]
|
|
(when selected (show-selected-item node))))
|
|
|
|
:component-did-update
|
|
(fn [this]
|
|
(let [node (reagent/dom-node this)
|
|
selected (= @internal-model id)]
|
|
(when selected (show-selected-item node))))
|
|
|
|
:display-name "choice-item"
|
|
|
|
:reagent-render
|
|
(fn
|
|
[id label on-click internal-model]
|
|
(let [selected (= @internal-model id)
|
|
class (if selected
|
|
"highlighted"
|
|
(when @mouse-over? "mouseover"))]
|
|
[:li
|
|
{:class (str "active-result group-option " class)
|
|
:on-mouse-over (handler-fn (reset! mouse-over? true))
|
|
:on-mouse-out (handler-fn (reset! mouse-over? false))
|
|
:on-mouse-down (handler-fn (on-click id))}
|
|
label]))})))
|
|
|
|
|
|
(defn make-choice-item
|
|
[id-fn render-fn callback internal-model opt]
|
|
(let [id (id-fn opt)
|
|
markup (render-fn opt)]
|
|
^{:key (str id)} [choice-item id markup callback internal-model]))
|
|
|
|
|
|
(defn- filter-text-box-base
|
|
"Base function (before lifecycle metadata) to render a filter text box"
|
|
[]
|
|
(fn [filter-box? filter-text key-handler drop-showing?]
|
|
[:div.chosen-search
|
|
[:input
|
|
{:type "text"
|
|
:auto-complete "off"
|
|
:style (when-not filter-box? {:position "absolute" ;; When no filter box required, use it but hide it off screen
|
|
:width "0px" ;; The rest of these styles make the textbox invisible
|
|
:padding "0px"
|
|
:border "none"})
|
|
:value @filter-text
|
|
:on-change (handler-fn (reset! filter-text (-> event .-target .-value)))
|
|
:on-key-down (handler-fn (when-not (key-handler event)
|
|
(.preventDefault event))) ;; When key-handler returns false, preventDefault
|
|
:on-blur (handler-fn (reset! drop-showing? false))}]]))
|
|
|
|
|
|
(def ^:private filter-text-box
|
|
"Render a filter text box"
|
|
(with-meta filter-text-box-base
|
|
{:component-did-mount #(let [node (.-firstChild (reagent/dom-node %))]
|
|
(.focus node))
|
|
:component-did-update #(let [node (.-firstChild (reagent/dom-node %))]
|
|
(.focus node))}))
|
|
|
|
(defn- dropdown-top
|
|
"Render the top part of the dropdown, with the clickable area and the up/down arrow"
|
|
[]
|
|
(let [ignore-click (atom false)]
|
|
(fn
|
|
[internal-model choices id-fn label-fn tab-index placeholder dropdown-click key-handler filter-box? drop-showing? title?]
|
|
(let [_ (reagent/set-state (reagent/current-component) {:filter-box? filter-box?})
|
|
text (if @internal-model
|
|
(label-fn (item-for-id @internal-model choices :id-fn id-fn))
|
|
placeholder)]
|
|
[:a.chosen-single.chosen-default
|
|
{:href "javascript:" ;; Required to make this anchor appear in the tab order
|
|
:tab-index (when tab-index tab-index)
|
|
:on-click (handler-fn
|
|
(if @ignore-click
|
|
(reset! ignore-click false)
|
|
(dropdown-click)))
|
|
:on-mouse-down (handler-fn
|
|
(when @drop-showing?
|
|
(reset! ignore-click true))) ;; TODO: Hmmm, have a look at calling preventDefault (and stopProp?) and removing the ignore-click stuff
|
|
:on-key-down (handler-fn
|
|
(key-handler event)
|
|
(when (= (.-which event) 13) ;; Pressing enter on an anchor also triggers click event, which we don't want
|
|
(reset! ignore-click true))) ;; TODO: Hmmm, have a look at calling preventDefault (and stopProp?) and removing the ignore-click stuff
|
|
}
|
|
[:span (when title?
|
|
{:title text})
|
|
text]
|
|
[:div [:b]]])))) ;; This odd bit of markup produces the visual arrow on the right
|
|
|
|
|
|
;;--------------------------------------------------------------------------------------------------
|
|
;; Component: single-dropdown
|
|
;;--------------------------------------------------------------------------------------------------
|
|
|
|
(def single-dropdown-args-desc
|
|
[{:name :choices :required true :type "vector of choices | atom" :validate-fn vector-of-maps? :description [:span "Each is expected to have an id, label and, optionally, a group, provided by " [:code ":id-fn"] ", " [:code ":label-fn"] " & " [:code ":group-fn"]]}
|
|
{:name :model :required true :type "the id of a choice | atom" :description [:span "the id of the selected choice. If nil, " [:code ":placeholder"] " text is shown"]}
|
|
{:name :on-change :required true :type "id -> nil" :validate-fn fn? :description [:span "called when a new choice is selected. Passed the id of new choice"] }
|
|
{:name :id-fn :required false :default :id :type "choice -> anything" :validate-fn ifn? :description [:span "given an element of " [:code ":choices"] ", returns its unique identifier (aka id)"]}
|
|
{:name :label-fn :required false :default :label :type "choice -> string" :validate-fn ifn? :description [:span "given an element of " [:code ":choices"] ", returns its displayable label."]}
|
|
{:name :group-fn :required false :default :group :type "choice -> anything" :validate-fn ifn? :description [:span "given an element of " [:code ":choices"] ", returns its group identifier"]}
|
|
{:name :render-fn :required false :type "choice -> string | hiccup" :validate-fn ifn? :description [:span "given an element of " [:code ":choices"] ", returns the markup that will be rendered for that choice. Defaults to the label if no custom markup is required."]}
|
|
{:name :disabled? :required false :default false :type "boolean | atom" :description "if true, no user selection is allowed"}
|
|
{:name :filter-box? :required false :default false :type "boolean" :description "if true, a filter text field is placed at the top of the dropdown"}
|
|
{:name :regex-filter? :required false :default false :type "boolean | atom" :description "if true, the filter text field will support JavaScript regular expressions. If false, just plain text"}
|
|
{:name :placeholder :required false :type "string" :validate-fn string? :description "background text when no selection"}
|
|
{:name :title? :required false :default false :type "boolean" :description "if true, allows the title for the selected dropdown to be displayed via a mouse over. Handy when dropdown width is small and text is truncated"}
|
|
{:name :width :required false :default "100%" :type "string" :validate-fn string? :description "the CSS width. e.g.: \"500px\" or \"20em\""}
|
|
{:name :max-height :required false :default "240px" :type "string" :validate-fn string? :description "the maximum height of the dropdown part"}
|
|
{:name :tab-index :required false :type "integer | string" :validate-fn number-or-string? :description "component's tabindex. A value of -1 removes from order"}
|
|
{:name :class :required false :type "string" :validate-fn string? :description "CSS class names, space separated"}
|
|
{:name :style :required false :type "CSS style map" :validate-fn css-style? :description "CSS styles to add or override"}
|
|
{:name :attr :required false :type "HTML attr map" :validate-fn html-attr? :description [:span "HTML attributes, like " [:code ":on-mouse-move"] [:br] "No " [:code ":class"] " or " [:code ":style"] "allowed"]}])
|
|
|
|
(defn single-dropdown
|
|
"Render a single dropdown component which emulates the bootstrap-choosen style. Sample choices object:
|
|
[{:id \"AU\" :label \"Australia\" :group \"Group 1\"}
|
|
{:id \"US\" :label \"United States\" :group \"Group 1\"}
|
|
{:id \"GB\" :label \"United Kingdom\" :group \"Group 1\"}
|
|
{:id \"AF\" :label \"Afghanistan\" :group \"Group 2\"}]"
|
|
[& {:keys [model] :as args}]
|
|
{:pre [(validate-args-macro single-dropdown-args-desc args "single-dropdown")]}
|
|
(let [external-model (reagent/atom (deref-or-value model)) ;; Holds the last known external value of model, to detect external model changes
|
|
internal-model (reagent/atom @external-model) ;; Create a new atom from the model to be used internally
|
|
drop-showing? (reagent/atom false)
|
|
filter-text (reagent/atom "")]
|
|
(fn [& {:keys [choices model on-change disabled? filter-box? regex-filter? placeholder width max-height tab-index id-fn label-fn group-fn render-fn class style attr title?]
|
|
:or {id-fn :id label-fn :label group-fn :group render-fn label-fn}
|
|
:as args}]
|
|
{:pre [(validate-args-macro single-dropdown-args-desc args "single-dropdown")]}
|
|
(let [choices (deref-or-value choices)
|
|
disabled? (deref-or-value disabled?)
|
|
regex-filter? (deref-or-value regex-filter?)
|
|
latest-ext-model (reagent/atom (deref-or-value model))
|
|
_ (when (not= @external-model @latest-ext-model) ;; Has model changed externally?
|
|
(reset! external-model @latest-ext-model)
|
|
(reset! internal-model @latest-ext-model))
|
|
changeable? (and on-change (not disabled?))
|
|
callback #(do
|
|
(reset! internal-model %)
|
|
(when (and changeable? (not= @internal-model @latest-ext-model))
|
|
(on-change @internal-model))
|
|
(swap! drop-showing? not) ;; toggle to allow opening dropdown on Enter key
|
|
(reset! filter-text ""))
|
|
cancel #(do
|
|
(reset! drop-showing? false)
|
|
(reset! filter-text "")
|
|
(reset! internal-model @external-model))
|
|
dropdown-click #(when-not disabled?
|
|
(swap! drop-showing? not))
|
|
filtered-choices (if regex-filter?
|
|
(filter-choices-regex choices group-fn label-fn @filter-text)
|
|
(filter-choices choices group-fn label-fn @filter-text))
|
|
press-enter (fn []
|
|
(if disabled?
|
|
(cancel)
|
|
(callback @internal-model))
|
|
true)
|
|
press-escape (fn []
|
|
(cancel)
|
|
true)
|
|
press-tab (fn []
|
|
(if disabled?
|
|
(cancel)
|
|
(do ;; Was (callback @internal-model) but needed a customised version
|
|
(when changeable? (on-change @internal-model))
|
|
(reset! drop-showing? false)
|
|
(reset! filter-text "")))
|
|
(reset! drop-showing? false)
|
|
true)
|
|
press-up (fn []
|
|
(if @drop-showing? ;; Up arrow
|
|
(reset! internal-model (move-to-new-choice filtered-choices id-fn @internal-model -1))
|
|
(reset! drop-showing? true))
|
|
true)
|
|
press-down (fn []
|
|
(if @drop-showing? ;; Down arrow
|
|
(reset! internal-model (move-to-new-choice filtered-choices id-fn @internal-model 1))
|
|
(reset! drop-showing? true))
|
|
true)
|
|
press-home (fn []
|
|
(reset! internal-model (move-to-new-choice filtered-choices id-fn @internal-model :start))
|
|
true)
|
|
press-end (fn []
|
|
(reset! internal-model (move-to-new-choice filtered-choices id-fn @internal-model :end))
|
|
true)
|
|
key-handler #(if disabled?
|
|
false
|
|
(case (.-which %)
|
|
13 (press-enter)
|
|
27 (press-escape)
|
|
9 (press-tab)
|
|
38 (press-up)
|
|
40 (press-down)
|
|
36 (press-home)
|
|
35 (press-end)
|
|
filter-box?))] ;; Use this boolean to allow/prevent the key from being processed by the text box
|
|
[:div
|
|
(merge
|
|
{:class (str "rc-dropdown chosen-container chosen-container-single noselect " (when @drop-showing? "chosen-container-active chosen-with-drop ") class)
|
|
:style (merge (flex-child-style (if width "0 0 auto" "auto"))
|
|
(align-style :align-self :start)
|
|
{:width (when width width)}
|
|
style)}
|
|
attr) ;; Prevent user text selection
|
|
[dropdown-top internal-model choices id-fn label-fn tab-index placeholder dropdown-click key-handler filter-box? drop-showing? title?]
|
|
(when (and @drop-showing? (not disabled?))
|
|
[:div.chosen-drop
|
|
[filter-text-box filter-box? filter-text key-handler drop-showing?]
|
|
[:ul.chosen-results
|
|
(when max-height {:style {:max-height max-height}})
|
|
(if (-> filtered-choices count pos?)
|
|
(let [[group-names group-opt-lists] (choices-with-group-headings filtered-choices group-fn)
|
|
make-a-choice (partial make-choice-item id-fn render-fn callback internal-model)
|
|
make-choices #(map make-a-choice %1)
|
|
make-h-then-choices (fn [h opts]
|
|
(cons (make-group-heading h)
|
|
(make-choices opts)))
|
|
has-no-group-names? (nil? (:group (first group-names)))]
|
|
(if (and (= 1 (count group-opt-lists)) has-no-group-names?)
|
|
(make-choices (first group-opt-lists)) ;; one group means no headings
|
|
(apply concat (map make-h-then-choices group-names group-opt-lists))))
|
|
[:li.no-results (str "No results match \"" @filter-text "\"")])]])]))))
|