UNPKG

combojs

Version:

A combobox for intelligent, responsive search and filtering.

715 lines (572 loc) 23.3 kB
(($, window) -> class Combo # minium search term length to initate filtering # minLength: 1 # max height in px on scrollbar window maxHeight: 300 # number of list items in a page (PAGEUP / PAGEDOWN) pageSize: 10 # whether the list should expand on input focus expandOnFocus: false # whether the active item should be selected on key TAB selectOnTab: true # sets the attribute tabindex tabIndex: null # selected item will default to first item in source instead of the empty string # on blur, if value is blank, selected item reverts to last selected item # throws if source is empty forceNonEmpty: false # onblur, when value does not match any item from list the # selected item reverts to last selected which may be null # the empty string is treated as a null value # throw if setValue is called with a non-matching value forceSelectionFromList: false # enable whether list can be closed keepListOpen: false # empty list text displayed as faux item emptyListText: '(ingen valgmuligheder)' # on list open by button click, does not reapply filters i.e. show entire list instead forceAllOnButtonClick: true # apply to filters, none, inText, firstInText, firstInWord, wholeWord matchBy: 'inText' # only shows enabled items in list onlyShowEnabled: false # enable spellcheck attribute spellcheck: false # value that - if set - will be used as submit value for the selected item valueField: 'id' # text that will be shown in textbox when item is selected titleField: null # text that will be shown in picker, and in textbox if not title is present displayField: 'text' # additional numerical searchable text, null means disabled litraField: null # value that decides if the item is enabled enabledField: () -> yes # e.g. given { modifier: '!', field: 'isReallySpecial' }, combo will only show records with the isReallySpecial field set to true when query is prefixed with ! modifiers: [] # e.g. given { alias: 'lag', field: 'layers' }, combo will be able to search the fields layers specifically using 'lag:' and a value specifications: [] placeholder: "" # a new element will be shown in the top of the source list # the new element is a replica of the the element in the inputfield, unless it matches an allready existing element showUnmatchedRawValue: false # show classname if not false or null classNameOnEmpty: false # use to specify which items are rendered with which labels # ({ rawValue: string, item: T}) => { text: string, className: string} | null label: null # --------- source: [] # secondary source to show in the bottom of the combo-list secondarySource: [] # private disabled: false activeLi: null isExpanded: false inputLabel: null key: DOWN: 40 END: 35 ENTER: 13 ESCAPE: 27 HOME: 36 INSERT: 45 LEFT: 37 PAGE_DOWN: 34 PAGE_UP: 33 RIGHT: 39 SPACE: 32 TAB: 9 UP: 38 BACKSPACE: 8 DELETE: 46 NUMPAD_ENTER: 108 constructor: (wrapper, options = {}) -> @[key] = value for own key, value of options when value? if @forceSelectionFromList and @showUnmatchedRawValue throw new Error('invalid configuration, forceSelectionFromList and showUnmatchedRawValue cannot both be true') @el = $(wrapper) .addClass("combo-container") .on 'click', '.combo-list li', @onListClick @input = $( "<input type='text' class='combo-input' autocomplete='off' disabled='disabled' spellcheck='#{@spellcheck}' placeholder='#{@placeholder}' #{if @tabIndex? then "tabindex='#{@tabIndex}'" else ""} />") .bind keydown: @onKeyDown keyup: @onKeyUp mouseup: @onMouseUp # circumvent http://bugs.jquery.com/ticket/6705se focus: _.throttle @onFocus, 0 blur: @onBlur .appendTo(@el) @button = $( '<button class="combo-button" tabindex="-1" disabled="disabled" />') .bind click: @onButtonClick mousedown: @suppressNextBlur .appendTo(@el) @list = $( '<ul class="combo-list"/>') .css(maxHeight: @maxHeight) .bind(mousedown: @onListMouseDown) .appendTo(@el) .hide() @link(@source, @secondarySource) if !_.isEmpty(@source) or !_.isEmpty(@secondarySource) if not @disabled then @enable() @updateClassNames() @ link: (source, secondarySource = []) -> @source = source @secondarySource = secondarySource @ensureSelection() @lastQuery = @input.val() @input.trigger 'linked' @ itemValue: (item) => if item.__isRawValueItem then null else evaluate @valueField, item itemLitra: (item) => if item.__isRawValueItem then null else evaluate @litraField, item itemEnabled: (item) => if item.__isRawValueItem then true else evaluate @enabledField, item itemDisplay: (item) => if item.__isRawValueItem then item.__rawValue else evaluate @displayField, item itemTitle: (item) => if item.__isRawValueItem then item.__rawValue else @stripMarkup evaluate(@titleField, item) ? @itemDisplay(item) itemModifier: (modifier) -> (item) => if item.__isRawValueItem then null else evaluate modifier.field, item itemSpecification: (specification) -> (item) => if item.__isRawValueItem then null else evaluate specification.field, item setValue: (value) => @updateInputLabel() if @input.val() is value then return for item in @source when @itemValue(item) is value @selectItem item, forced: yes return for item in @secondarySource when @itemValue(item) is value @selectItem item, forced: yes return @input.val value @updateClassNames() selectItem: (item, options = {}) => return if not @itemEnabled(item) and not options.forced if !item.__isRawValueItem and @input.val() is @itemTitle(item) # avoids circular updates in react # (model => setValue => trigger.itemSelect => model.change => setValue =>) @internalCollapse() return @input.val @itemTitle(item) @updateClassNames() @lastQuery = @input.val() @updateLastSelection() @internalCollapse() _.delay (=> @input.trigger 'itemSelect', item), 10 getSelectedValue: => item = @getSelectedItemAndIndex()?.item if item? then @itemValue(item) else null getSelectedItem: => @getSelectedItemAndIndex()?.item getSelectedIndex: => @getSelectedItemAndIndex()?.index getSelectedItemAndIndex: => for item, index in @source when @itemTitle(item) is @input.val() return {item, index} for item, index in @secondarySource when @itemTitle(item) is @input.val() return {item, index: index + @source.length} hasSelection: -> @getSelectedItemAndIndex()? getValue: => @getSelectedValue() ? @getRawValue() getRawValue: => @input.val() isEmpty: => @input.val() is null or @input.val().trim() is '' selectLi: (li) => comboId = $(li).data('combo-id') if comboId is 'emptylist-item' @internalCollapse() return if @source[comboId]? @selectItem @source[comboId] else if @secondarySource[comboId - @source.length]? @selectItem @secondarySource[comboId - @source.length] else if @showUnmatchedRawValue @selectItem { __isRawValueItem: true, __rawValue: @stripMarkup @getRawValue() } else @selectItem null @refocus() onListClick: (event) => @selectLi event.currentTarget onListMouseDown: (event) => # if it's a list item (or subelement of list item), prevent the # close-on-blur in case of a "slow" click on the list (long mousedown) @suppressNextBlur() # if it's the scrollbar, send focus back to input after scrolling # this can't be done in mouseup as chrome won't send mouseup for # clicks on the scrollbar - only the list if not $(event.target).closest('ul.combo-list li').length @refocus() onButtonClick: (event) => return if @disabled # if it's open and is not empty, close it if @isExpanded and $('li', @list).length @internalCollapse() else @searchAndExpand forceAll: @forceAllOnButtonClick @focus() onKeyDown: (event) => return if @disabled if @isExpanded switch event.keyCode when @key.HOME @moveHome() and event.preventDefault() when @key.END @moveEnd() and event.preventDefault() when @key.PAGE_UP @movePreviousPage() event.preventDefault() when @key.PAGE_DOWN @moveNextPage() event.preventDefault() when @key.UP @movePrevious() event.preventDefault() when @key.DOWN @moveNext() event.preventDefault() when @key.ENTER, @key.NUMPAD_ENTER @selectLi @activeLi if @activeLi event.preventDefault() event.stopPropagation() when @key.TAB if @selectOnTab @selectLi @activeLi if @activeLi event.preventDefault() event.stopPropagation() when @key.ESCAPE @internalCollapse() else switch event.keyCode when @key.PAGE_UP, @key.PAGE_DOWN, @key.UP, @key.DOWN @searchAndExpand() event.preventDefault() when @key.ENTER @input.trigger 'enterpress' event.preventDefault(); when @key.ESCAPE @input.select() onKeyUp: (event) => return if @disabled @updateClassNames() @updateInputLabel() @updateLastSelection() return if @lastQuery is @input.val() switch event.keyCode when @key.BACKSPACE, @key.DELETE, @key.ENTER # do not open the list when deleting chars return unless @isExpanded @searchAndExpand() onMouseUp: => return if @disabled @updateLastSelection() onFocus: (event, data) => clearTimeout @closing return if @disabled if not data?.forcedFocus @input.trigger 'enter' @searchAndExpand() if @expandOnFocus _.defer => @input.select() onBlur: (event) => if @disabled or @blurIsSuppressed @blurIsSuppressed = false return @ensureSelection() @internalCollapse() @input.trigger 'leave' ensureSelection: -> return if @hasSelection() if @isEmpty() and @forceNonEmpty if @lastSelection? return @selectItem @lastSelection.item if @source.length return @selectItem @source[0] if @secondarySource.length return @selectItem @secondarySource[0] throw new Error("consistency error: forceNonEmpty require forced item selection but no items can be selected! (either list is empty or all items are disabled)") if not @isEmpty() and @forceSelectionFromList if @lastSelection? return @selectItem @lastSelection.item @input.val '' @updateClassNames() @lastSelection = null updateLastSelection: => return if not @forceSelectionFromList currentSelection = @getSelectedItemAndIndex() @lastSelection = currentSelection if currentSelection? suppressNextBlur: => @blurIsSuppressed = true if @input.is(':focus') refocus: -> # every time a focus is forced from within the combo effects that should # only follow a 'natural' focus must be suppressed # every focus is delayed a tiny bit to accomodate for timing issues where the current # focus might not yet have changed by last mouse/key event _.delay (=> if not @input.is(':focus') @input.trigger 'focus', { forcedFocus: true }), 1 focus: => _.delay (=> if not @input.is(':focus') @input.trigger 'focus'), 1 activateSelectedItem: => index = @getSelectedIndex() return if not index? for li in @list.children() when $(li).data('combo-id') == index @activate $(li) $(li).addClass('selected') stripMarkup: (text) -> text?.replace(/<br[^>]*>/g, " | ").replace(/<.*?>/g, '').replace(/[\n\r]/g, '') searchAndExpand: (options = {}) => @lastQuery = @input.val() if @hasSelection() or options.forceAll @renderFullList() @activateSelectedItem() @expand(options) return @renderFilteredList() @activate $('li:first', @list) @expand() buildFilters: (queryString) -> filters = [] queryString = queryString.replace(/([.*+?^${}()|[\]\/\\])/g, "\\$1") queryString = queryString.replace(/([<>])/g, "") firstChar = queryString.substr(0, 1) for modifier in @modifiers if firstChar == modifier.modifier filters.push getter: @itemModifier(modifier) predicate: (value) -> value queryString = queryString.substr(1) for specification in @specifications specFinder = new RegExp(specification.alias + ":\\s*(\\w+)") specsInQuery = specFinder.exec(queryString) if specsInQuery filters.push getter: @itemSpecification(specification) predicate: (value) -> value is specsInQuery[1] queryString = queryString.replace(specFinder, "") queryStringSplit = queryString.split(" ") if @litraField?? and queryString.match(/^[#,]\w[\w\\\.,]*(\s|$)/) first = queryStringSplit.shift() filters.push getter: @itemLitra regex: new RegExp("^()(" + first.substr(1).replace(/,/g, "\\.") + ")", "i") predicate: (value) -> @regex.test value for currentWord in queryStringSplit when currentWord isnt "" currentWord = currentWord.replace(/\\\*/g, "[\\wæøåÆØÅ]*") currentWord = currentWord.replace(/\\\+/g, " ") dontSearchInsideTags = "(?![^><\\[\\]]*(>|\\]))" switch @matchBy when "none" break when "inText" filters.push getter: @itemDisplay regex: new RegExp("()(" + currentWord + ")()" + dontSearchInsideTags, "i") predicate: (value) -> @regex.test value when "firstInText" filters.push getter: @itemDisplay regex: new RegExp("^()(" + currentWord + ")()" + dontSearchInsideTags, "i") predicate: (value) -> @regex.test value when "firstInWord" filters.push getter: @itemDisplay regex: new RegExp("(^|[^\\wæøåÆØÅ\\[\\]])(" + currentWord + ")()" + dontSearchInsideTags, "i") predicate: (value) -> @regex.test value when "wholeWord" filters.push getter: @itemDisplay regex: new RegExp("(^|[^\\wæøåÆØÅ\\[\\]])(" + currentWord + ")($|[^\\wæøåÆØÅ\\[\\]])", "i") predicate: (value) -> @regex.test value else throw new Error "matchBy not set to a valid value" filters renderFilteredList: => filters = if @input.val() is '' then [] else @buildFilters @input.val() @renderList @source, @secondarySource, filters renderFullList: => @renderList @source, @secondarySource renderList: (items, secondaryItems = [], filters = []) => # for performance use native html manipulation # be aware never to attach events or data to list elements! htmls = []; if @showUnmatchedRawValue rawValue = @stripMarkup @getRawValue() if rawValue isnt "" and !@hasSelection() if @label?(null, @getRawValue())? htmls.push("<li class='unmatched-raw-value #{"has-label"}'>#{rawValue} #{@createLabel()}</li>") else htmls.push("<li class='unmatched-raw-value'>#{rawValue}</li>") htmls.push(@renderItems(items, filters)...) htmls.push(@renderItems(secondaryItems, filters, 'secondary-source', items.length)...) if htmls.length @list.html htmls.join('') # append classname "first" to the first secondary-source, it is style related @list.find('.secondary-source').first().addClass('first') else @list.html "<li class='disabled' data-combo-id='emptylist-item'>#{@emptyListText}</li>" renderItems: (items, filters, className = '', itemOffset = 0) => for item, index in items continue if @onlyShowEnabled and not @itemEnabled(item) continue if not _.all filters, (filter) -> filter.predicate filter.getter(item) @renderItem item, index + itemOffset, filters, className renderItem: (item, index, filters, className) => if @litraField? and (litra = @itemLitra(item))? text = "[#{litra}] #{@highlightValue(item, filters)}" else text = @highlightValue(item, filters) classes = [ className, if @onlyShowEnabled or @itemEnabled(item) then 'enabled' else 'disabled', if @label?(item, @getRawValue())? then "has-label" else "" ] if @createLabel(item) != "" "<li data-combo-id=\"#{index}\" class=\"#{classes.join(' ')}\">#{text} #{@createLabel(item)}</li>" else "<li data-combo-id=\"#{index}\" class=\"#{classes.join(' ')}\">#{text}</li>" highlightValue: (item, filters) => value = @itemDisplay(item) return null if not value? for filter in filters when filter.getter == @itemDisplay and filter.regex? value = value.replace(filter.regex, "<b>$2</b>") value moveNext: -> if @activeLi @activate @activeLi.next() unless @lastItemIsActive() else @moveHome() movePrevious: -> if @activeLi @activate @activeLi.prev() unless @firstItemIsActive() else @moveEnd() moveNextPage: -> @moveHome() unless @activeLi rest = @activeLi.nextAll() if rest.length >= @pageSize @activate rest.eq @pageSize-1 else @moveEnd() movePreviousPage: -> @activate $('li:last', @list) unless @activeLi rest = @activeLi.prevAll() if rest.length >= @pageSize @activate rest.eq @pageSize-1 else @moveHome() moveHome: -> @activate $('li:first', @list) moveEnd: -> @activate $('li:last', @list) activate: (item) -> @activeLi?.removeClass 'active' if item? and item.length item.addClass 'active' @activeLi = item @scrollIntoView() else @activeLi = null @activeLi firstItemIsActive: => @activeLi?[0] is $("li:first", @list)[0] lastItemIsActive: => @activeLi?[0] is $('li:last', @list)[0] scrollIntoView: => return unless @isExpanded and @activeLi #for performance just reset scroll to top if the first item is active if @firstItemIsActive() @list.scrollTop 0 if @list[0].scrollTop > 0 return # for performance only get list height once hasScroll = @list.prop('scrollHeight') > @maxHeight if hasScroll listTopBorder = parseFloat($.css(@list[0], 'borderTopWidth')) || 0 listTopPadding = parseFloat($.css(@list[0], 'paddingTop')) || 0 itemOffset = @activeLi.offset().top - @list.offset().top - listTopBorder - listTopPadding listHeight = @maxHeight currentScroll = @list.scrollTop() itemHeight = @activeLi.outerHeight() scroll = if itemOffset < 0 currentScroll + itemOffset else if itemOffset + itemHeight > listHeight currentScroll + itemOffset - listHeight + itemHeight @list.scrollTop scroll expand: (options = {}) => return if @disabled return if @isExpanded @el.addClass 'expanded' @isExpanded = true @list.show(options.callback) @scrollIntoView() @updateInputLabel() internalCollapse: => if @keepListOpen @searchAndExpand() else @collapse() collapse: (options = {}) => @el.removeClass 'expanded' @isExpanded = false @list.hide(options.callback) @updateInputLabel() disable: => @disabled = true @input.attr disabled: true @button.attr disabled: true enable: => @disabled = false @input.attr disabled: false @button.attr disabled: false evaluate = (fieldGetter, item) -> if not fieldGetter? null else if _.isFunction(fieldGetter) fieldGetter(item) else if _.isFunction(item[fieldGetter]) item[fieldGetter]() else item[fieldGetter] updateClassNames: () -> if @classNameOnEmpty @input.toggleClass @classNameOnEmpty, not @getRawValue() createLabel: (item) -> label = @label?(item, @getRawValue()) if label? then "<span class='#{label.className}'>#{label.text}</span>" else "" updateInputLabel: () -> label = @createLabel(@getSelectedItem()) if @inputLabel? and (@isExpanded or label == "") @inputLabel.remove() @inputLabel = null @el.removeClass('has-label') else if @inputLabel == null and !@isExpanded and label != "" @inputLabel = $(label).insertAfter(@input) @el.addClass('has-label') #==================================================== # PLUGIN DEFINITION #==================================================== # Define the plugin # https://gist.github.com/rjz/3610858 setters = ["link", "renderFullList"] $.fn.extend combo: (option, args...) -> value = @ @each -> $this = $(@) plugin = $this.data('combo') if !plugin $this.data('combo', (data = new Combo(@, option))) else if typeof option == 'string' if not option of plugin then throw new Error("Unknown combo method #{option}") _value = plugin[option].apply(plugin, args) value = _value if !(option in setters) value #==================================================== )(window.jQuery || require('jquery')(window), window)