Skip to content

Commit 596f342

Browse files
committed
new searchable combobox
1 parent a65faf7 commit 596f342

File tree

2 files changed

+394
-1
lines changed

2 files changed

+394
-1
lines changed

‎project.clj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
(defproject org.clojars.intception/om-widgets "0.3.43"
1+
(defproject org.clojars.intception/om-widgets "0.3.44"
22
:description "Widgets for OM/React"
33
:url "https://github.com/orgs/intception/"
44
:license {:name "Eclipse Public License"
Lines changed: 393 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,393 @@
1+
(ns om-widgets.combobox-searchable
2+
(:require-macros [cljs.core.async.macros :refer [go go-loop]])
3+
(:require [om.core :as om :include-macros true]
4+
[om-widgets.popover :refer [popover-container]]
5+
[cljs.core.async :refer [put! chan <! alts! timeout close!]]
6+
[sablono.core :refer-macros [html]]
7+
[dommy.core :refer-macros [sel sel1]]
8+
[om-widgets.core :as w]
9+
[pallet.thread-expr :as th]))
10+
11+
(defn re-quote [s]
12+
(let [special (set ".?*+^$[]\\(){}|")
13+
escfn #(if (special %) (str \\ %) %)]
14+
(apply str (map escfn s))))
15+
16+
(defn- entry
17+
[target owner]
18+
(reify
19+
om/IRenderState
20+
(render-state
21+
[this {:keys [value name channel divider-class disabled? active?]}]
22+
(html
23+
(cond
24+
(= value :custom-html)
25+
(html name)
26+
27+
(= value :divider)
28+
[:li {:class (or divider-class "divider")}
29+
(if (vector? name)
30+
(html name)
31+
(when (string? name)
32+
name))]
33+
34+
:else
35+
[:li {:class (when active? "active")}
36+
[:a {:disabled disabled?
37+
:onClick #(put! channel {:type :set
38+
:value value})}
39+
(if (vector? name)
40+
(html name)
41+
(str name))]])))))
42+
43+
(defn- body
44+
[target owner]
45+
(reify
46+
om/IDidMount
47+
(did-mount [this]
48+
(when (om/get-state owner :searchable?)
49+
(go
50+
(<! (timeout 50))
51+
(->> (str "#combo-search-input")
52+
(sel1 (om/get-node owner))
53+
(.focus)))))
54+
55+
om/IRenderState
56+
(render-state [_ {:keys [options loading? loading-view typing-timeout] :as state}]
57+
(html
58+
[:div
59+
(when (:searchable? state)
60+
[:div {:class "popover-header"}
61+
[:div {:class "form-group"}
62+
(w/textinput owner :search-value {:input-class "form-control input-sm"
63+
:flush-on-enter true
64+
:typing-timeout (or typing-timeout 50)
65+
:onChange #(put! (:channel state) {:type :search
66+
:value %})
67+
:id "combo-search-input"})
68+
[:div {:class "form-control-feedback"}
69+
[:span {:class "icn-magnifier"}]]]])
70+
71+
(cond
72+
(and loading?
73+
(fn? loading-view))
74+
(loading-view)
75+
76+
(empty? options)
77+
[:ul
78+
[:li [:div {:class "padding-block"}
79+
[:span {:class "body-lighter body-sm-i text-center"}
80+
"No results found"]]]]
81+
82+
:else
83+
(->> options
84+
(map (fn [[value name]]
85+
(om/build entry target {:state {:path (:path state)
86+
:divider-class (:divider-class state)
87+
:value value
88+
:active? (= value (:selected state))
89+
:disabled? true
90+
:name name
91+
:channel (:channel state)}})))
92+
(concat [:ul {:class ["dropdown-menu" (:list-class state)]
93+
:role "menu"}])
94+
vec))]))))
95+
96+
97+
(defn- handle-keydown
98+
[owner event options]
99+
(let [ESC 27
100+
UP 38
101+
DOWN 40
102+
ENTER 13
103+
k (.-keyCode event)
104+
channel (om/get-state owner :channel)
105+
index (om/get-state owner :selected-index)]
106+
(when (contains? #{ESC UP DOWN ENTER} k)
107+
(condp = k
108+
UP (let [new-index (if (pos? index)
109+
(dec index)
110+
0)]
111+
(om/set-state! owner :selected (first (nth options new-index)))
112+
(om/set-state! owner :selected-index new-index)
113+
(.preventDefault event))
114+
DOWN (let [size (- (count options) 1)
115+
new-index (if (>= index size)
116+
size
117+
(inc index))]
118+
(om/set-state! owner :selected (first (nth options new-index)))
119+
(om/set-state! owner :selected-index new-index)
120+
(.preventDefault event))
121+
ENTER (let [selected (om/get-state owner :selected)]
122+
(when selected (put! channel {:type :set
123+
:value selected})))
124+
ESC (.stopPropagation event)))))
125+
126+
127+
(defn open-button
128+
[target owner]
129+
(reify
130+
om/IRenderState
131+
(render-state [_ {:keys [as-link? channel opened onClick icon disabled path default label placeholder options label-key]}]
132+
(html
133+
[:button (-> {:type "button"
134+
:class ["btn" (if as-link?
135+
"btn-link"
136+
"btn-default dropdown-toggle")]
137+
:onClick #(do (put! channel {:type :open
138+
:open (not opened)})
139+
(when (fn? onClick)
140+
(onClick (not opened))))}
141+
(merge (when disabled
142+
{:disabled true})))
143+
144+
(when icon
145+
[:span {:class icon}])
146+
147+
[:span
148+
(cond
149+
label label
150+
151+
(and target (not (nil? (get-in target path))) label-key)
152+
(get-in target (conj path label-key))
153+
154+
(and target (or (not (nil? (get-in target path))) default))
155+
(get (into {} options)
156+
(if (not (nil? (get-in target path)))
157+
(get-in target path)
158+
default))
159+
160+
:else
161+
placeholder)]
162+
163+
(when-not as-link?
164+
[:span {:class "caret"}])]))))
165+
166+
167+
168+
(defn- combobox-component
169+
[target owner {:keys [class-name divider-class list-class popover-class]}]
170+
(reify
171+
om/IInitState
172+
(init-state [_]
173+
{:opened false
174+
:search-value nil
175+
:selected nil
176+
:selected-index -1
177+
:channel (chan)})
178+
179+
om/IWillMount
180+
(will-mount [_]
181+
(om/set-state! owner :filtered-options (om/get-state owner :options))
182+
(let [path (om/get-state owner :path)
183+
on-change (om/get-state owner :onChange)
184+
channel (om/get-state owner :channel)]
185+
(go-loop []
186+
(let [msg (<! channel)]
187+
188+
(condp = (:type msg)
189+
:set
190+
(do
191+
(when (om/get-props owner)
192+
(om/update! (om/get-props owner) path (:value msg)))
193+
(when on-change
194+
(on-change (:value msg)))
195+
196+
(om/update-state! owner #(merge % {:search-value nil
197+
:selected nil
198+
:opened false})))
199+
200+
:open (om/set-state! owner :opened (:open msg))
201+
202+
:search
203+
(om/set-state! owner :search-value (:value msg)))
204+
(recur)))))
205+
206+
om/IRenderState
207+
(render-state
208+
[this {:keys [id path opened disabled label placeholder as-link? icon onBlur onClick default channel] :as state}]
209+
(let [options (->> (:options state)
210+
(filter (fn [[v n s]]
211+
(if (and (:search-value state)
212+
(not (clojure.string/blank? (:search-value state))))
213+
(re-find (re-pattern (str "(?i)" (re-quote (:search-value state))))
214+
(or s
215+
(pr-str n)))
216+
true))))]
217+
(html
218+
[:div (merge {:class (str "btn-group " (when opened "open ") (or class-name "btn-group-xs"))
219+
:onKeyDown (fn [event] (handle-keydown owner event options))}
220+
(when id
221+
{:id id}))
222+
223+
(om/build open-button target {:state {:channel channel
224+
:path path
225+
:disabled disabled
226+
:label label
227+
:options options
228+
:placeholder placeholder
229+
:as-link? as-link?
230+
:icon icon
231+
:onClick onClick
232+
:default default}})
233+
234+
(when opened
235+
(om/build popover-container
236+
nil
237+
{:state {:content-fn #(om/build body target {:state (merge state
238+
{:options options}
239+
{:divider-class divider-class
240+
:list-class list-class})})
241+
:prefered-side :bottom}
242+
:opts {:align 0
243+
:mouse-down #(do
244+
(om/update-state! owner (fn [s] (merge s {:opened false
245+
:search-value nil
246+
:selected nil
247+
:selected-index -1})))
248+
(when onBlur
249+
(onBlur)))
250+
:popover-class (str "combobox"
251+
(when popover-class
252+
(str " " popover-class)))}}))])))))
253+
254+
255+
;; ---------------------------------------------------------------------
256+
;; Public
257+
258+
(defn combobox
259+
[target path {:keys [id label options onChange onBlur onClick disabled
260+
placeholder class-name as-link? icon opened searchable?
261+
divider-class popover-class list-class default loading? loading-view]}]
262+
(om/build combobox-component target
263+
{:state (merge {:id id
264+
:path (if (sequential? path) path [path])
265+
:placeholder placeholder
266+
:as-link? as-link?
267+
:icon icon
268+
:loading? loading?
269+
:loading-view loading-view
270+
:searchable? searchable?
271+
:disabled disabled
272+
:onChange onChange
273+
:onClick onClick
274+
:onBlur onBlur
275+
:default default
276+
:label label
277+
:options options}
278+
(when (some? opened)
279+
{:opened opened}))
280+
:opts {:class-name class-name
281+
:divider-class divider-class
282+
:popover-class popover-class
283+
:list-class list-class}}))
284+
285+
286+
(defn async-combobox
287+
[target owner {:keys [class-name divider-class list-class]}]
288+
(reify
289+
om/IInitState
290+
(init-state [_]
291+
{:opened false
292+
:search-value nil
293+
:selected nil
294+
:selected-index -1
295+
:local-channel (chan)})
296+
297+
om/IWillMount
298+
(will-mount [_]
299+
(let [path (om/get-state owner :path)
300+
on-change (om/get-state owner :onChange)]
301+
(go-loop []
302+
(let [msg (<! (om/get-state owner :local-channel))]
303+
(condp = (:type msg)
304+
:set
305+
(do
306+
(when (om/get-props owner)
307+
(om/update! (om/get-props owner) path (:value msg)))
308+
309+
(when on-change
310+
(on-change (:value msg)))
311+
312+
(om/update-state! owner #(merge % {:search-value nil
313+
:selected nil
314+
:opened false})))
315+
316+
:open (om/set-state! owner :opened (:open msg))
317+
318+
:search
319+
;; TODO textinput is triggering a onChange event even when the value is not changed (arrow keys, command key, etc)
320+
(when (not= (:value msg) (om/get-state owner :search-value))
321+
(om/set-state! owner :search-value (:value msg))
322+
(when-let [channel (om/get-state owner :channel)]
323+
(put! channel {:event-type :search-updated
324+
:value (:value msg)}))))
325+
(recur)))))
326+
327+
om/IDidUpdate
328+
(did-update [this prev-props prev-state]
329+
(when-not (= (om/get-state owner :opened)
330+
(:opened prev-state))
331+
(when (false? (om/get-state owner :opened))
332+
(when (om/get-state owner :channel)
333+
(put! (om/get-state owner :channel) {:event-type :closed})))))
334+
335+
336+
om/IRenderState
337+
(render-state
338+
[this {:keys [id path opened disabled label placeholder as-link? icon onBlur onClick default options loading-more? loading? local-channel label-key] :as state}]
339+
(html
340+
[:div (merge {:class (str "btn-group " (when opened "open ") (or class-name "btn-group-xs"))
341+
:onKeyDown (fn [event] (handle-keydown owner event options))}
342+
(when id
343+
{:id id}))
344+
345+
(om/build open-button target {:state {:channel local-channel
346+
:path path
347+
:disabled disabled
348+
:label label
349+
:label-key label-key
350+
:placeholder placeholder
351+
:as-link? as-link?
352+
:options options
353+
:icon icon
354+
:onClick onClick
355+
:default default}})
356+
357+
(when opened
358+
(om/build popover-container
359+
nil
360+
{:state {:content-fn (fn []
361+
(let [options (->> options
362+
(th/if->> loading-more?
363+
(#(conj % [:custom-html
364+
[:div.loading-dots]]))
365+
(#(concat % [[:divider ""]
366+
[:custom-html
367+
[:li [:a {:class "btn btn-link btn-sm"
368+
:onClick (fn []
369+
(when (om/get-state owner :channel)
370+
(put! (om/get-state owner :channel) {:event-type :load-more})))}
371+
"Load More"]]]]))))]
372+
(om/build body target {:state {:options options
373+
:path (:path state)
374+
:channel local-channel
375+
:loading? loading?
376+
:typing-timeout 300
377+
:class-name (:class-name state)
378+
:onChange (:onChange state)
379+
:onClick (:onClick state)
380+
:loading-view (:loading-view state)
381+
:searchable? true
382+
:divider-class divider-class
383+
:list-class list-class}})))
384+
:prefered-side :bottom}
385+
:opts {:align 0
386+
:mouse-down #(do
387+
(om/update-state! owner (fn [s] (merge s {:opened false
388+
:search-value nil
389+
:selected nil
390+
:selected-index -1})))
391+
(when onBlur
392+
(onBlur)))
393+
:popover-class "combobox"}}))]))))

0 commit comments

Comments
 (0)