|
| 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