|
@@ -1,19 +1,25 @@
|
|
|
class App.SearchableSelect extends Spine.Controller
|
|
|
|
|
|
events:
|
|
|
- 'input .js-input': 'onInput'
|
|
|
- 'blur .js-input': 'onBlur'
|
|
|
- 'focus .js-input': 'onFocus'
|
|
|
- 'click .js-option': 'selectItem'
|
|
|
- 'mouseenter .js-option': 'highlightItem'
|
|
|
- 'shown.bs.dropdown': 'onDropdownShown'
|
|
|
- 'hidden.bs.dropdown': 'onDropdownHidden'
|
|
|
+ 'input .js-input': 'onInput'
|
|
|
+ 'blur .js-input': 'onBlur'
|
|
|
+ 'focus .js-input': 'onFocus'
|
|
|
+ 'click .js-option': 'selectItem'
|
|
|
+ 'click .js-enter': 'navigateIn'
|
|
|
+ 'click .js-back': 'navigateOut'
|
|
|
+ 'mouseenter .js-option': 'highlightItem'
|
|
|
+ 'mouseenter .js-enter': 'highlightItem'
|
|
|
+ 'mouseenter .js-back': 'highlightItem'
|
|
|
+ 'shown.bs.dropdown': 'onDropdownShown'
|
|
|
+ 'hidden.bs.dropdown': 'onDropdownHidden'
|
|
|
|
|
|
elements:
|
|
|
- '.js-option': 'option_items'
|
|
|
+ '.js-dropdown': 'dropdown'
|
|
|
+ '.js-option, .js-enter': 'optionItems'
|
|
|
'.js-input': 'input'
|
|
|
'.js-shadow': 'shadowInput'
|
|
|
'.js-optionsList': 'optionsList'
|
|
|
+ '.js-optionsSubmenu': 'optionsSubmenu'
|
|
|
'.js-autocomplete-invisible': 'invisiblePart'
|
|
|
'.js-autocomplete-visible': 'visiblePart'
|
|
|
|
|
@@ -27,32 +33,95 @@ class App.SearchableSelect extends Spine.Controller
|
|
|
@render()
|
|
|
|
|
|
render: ->
|
|
|
- firstSelected = _.find @options.attribute.options, (option) -> option.selected
|
|
|
+ firstSelected = _.find @attribute.options, (option) -> option.selected
|
|
|
|
|
|
if firstSelected
|
|
|
- @options.attribute.valueName = firstSelected.name
|
|
|
- @options.attribute.value = firstSelected.value
|
|
|
- else if @options.attribute.unknown && @options.attribute.value
|
|
|
- @options.attribute.valueName = @options.attribute.value
|
|
|
-
|
|
|
- @options.attribute.renderedOptions = App.view('generic/searchable_select_options')
|
|
|
- options: @options.attribute.options
|
|
|
-
|
|
|
- @html App.view('generic/searchable_select')( @options.attribute )
|
|
|
-
|
|
|
- @input.on 'keydown', @navigate
|
|
|
+ @attribute.valueName = firstSelected.name
|
|
|
+ @attribute.value = firstSelected.value
|
|
|
+ else if @attribute.unknown && @attribute.value
|
|
|
+ @attribute.valueName = @attribute.value
|
|
|
+ else if @hasSubmenu @attribute.options
|
|
|
+ @attribute.valueName = @getName @attribute.value, @attribute.options
|
|
|
+
|
|
|
+ @html App.view('generic/searchable_select')
|
|
|
+ attribute: @attribute
|
|
|
+ options: @renderAllOptions '', @attribute.options, 0
|
|
|
+ submenus: @renderSubmenus @attribute.options
|
|
|
+
|
|
|
+ # initial data
|
|
|
+ @currentMenu = @findMenuContainingValue @attribute.value
|
|
|
+ @level = @getIndex @currentMenu
|
|
|
+
|
|
|
+ renderSubmenus: (options) ->
|
|
|
+ html = ''
|
|
|
+ if options
|
|
|
+ for option in options
|
|
|
+ if option.children
|
|
|
+ html += App.view('generic/searchable_select_submenu')
|
|
|
+ options: @renderOptions(option.children)
|
|
|
+ parentValue: option.value
|
|
|
+ title: option.name
|
|
|
+
|
|
|
+ if @hasSubmenu(option.children)
|
|
|
+ html += @renderSubmenus option.children
|
|
|
+ html
|
|
|
+
|
|
|
+ hasSubmenu: (options) ->
|
|
|
+ return false if !options
|
|
|
+ for option in options
|
|
|
+ return true if option.children
|
|
|
+ return false
|
|
|
+
|
|
|
+ getName: (value, options) ->
|
|
|
+ for option in options
|
|
|
+ if option.value is value
|
|
|
+ return option.name
|
|
|
+ if option.children
|
|
|
+ name = @getName value, option.children
|
|
|
+ return name if name isnt undefined
|
|
|
+ undefined
|
|
|
+
|
|
|
+ renderOptions: (options) ->
|
|
|
+ html = ''
|
|
|
+ for option in options
|
|
|
+ html += App.view('generic/searchable_select_option')
|
|
|
+ option: option
|
|
|
+ class: if option.children then 'js-enter' else 'js-option'
|
|
|
+ html
|
|
|
+
|
|
|
+ renderAllOptions: (parentName, options, level) ->
|
|
|
+ html = ''
|
|
|
+ if options
|
|
|
+ for option in options
|
|
|
+ className = if option.children then 'js-enter' else 'js-option'
|
|
|
+ if level && level > 0
|
|
|
+ className += ' is-hidden is-child'
|
|
|
+
|
|
|
+ html += App.view('generic/searchable_select_option')
|
|
|
+ option: option
|
|
|
+ class: className
|
|
|
+ detail: parentName
|
|
|
+
|
|
|
+ if option.children
|
|
|
+ html += @renderAllOptions "#{parentName} — #{option.name}", option.children, level+1
|
|
|
+ html
|
|
|
|
|
|
onDropdownShown: =>
|
|
|
@input.on 'click', @stopPropagation
|
|
|
@highlightFirst()
|
|
|
+ $(document).on 'keydown.searchable_select', @navigate
|
|
|
+ if @level > 0
|
|
|
+ @showSubmenu(@currentMenu)
|
|
|
@isOpen = true
|
|
|
|
|
|
onDropdownHidden: =>
|
|
|
@input.off 'click', @stopPropagation
|
|
|
- @option_items.removeClass '.is-active'
|
|
|
+ @unhighlightCurrentItem()
|
|
|
+ $(document).off 'keydown.searchable_select'
|
|
|
@isOpen = false
|
|
|
|
|
|
toggle: =>
|
|
|
+ @currentItem = null
|
|
|
@$('[data-toggle="dropdown"]').dropdown('toggle')
|
|
|
|
|
|
stopPropagation: (event) ->
|
|
@@ -62,8 +131,8 @@ class App.SearchableSelect extends Spine.Controller
|
|
|
switch event.keyCode
|
|
|
when 40 then @nudge event, 1 # down
|
|
|
when 38 then @nudge event, -1 # up
|
|
|
- when 39 then @fillWithAutocompleteSuggestion event # right
|
|
|
- when 37 then @fillWithAutocompleteSuggestion event # left
|
|
|
+ when 39 then @autocompleteOrNavigateIn event # right
|
|
|
+ when 37 then @autocompleteOrNavigateOut event # left
|
|
|
when 13 then @onEnter event
|
|
|
when 27 then @onEscape()
|
|
|
when 9 then @onTab event
|
|
@@ -71,12 +140,20 @@ class App.SearchableSelect extends Spine.Controller
|
|
|
onEscape: ->
|
|
|
@toggle() if @isOpen
|
|
|
|
|
|
+ getCurrentOptions: ->
|
|
|
+ @currentMenu.find('.js-option, .js-enter, .js-back')
|
|
|
+
|
|
|
+ getOptionIndex: (menu, value) ->
|
|
|
+ menu.find('.js-option, .js-enter').filter("[data-value=\"#{value}\"]").index()
|
|
|
+
|
|
|
nudge: (event, direction) ->
|
|
|
return @toggle() if not @isOpen
|
|
|
|
|
|
+ options = @getCurrentOptions()
|
|
|
+
|
|
|
event.preventDefault()
|
|
|
- visibleOptions = @option_items.not('.is-hidden')
|
|
|
- highlightedItem = @option_items.filter('.is-active')
|
|
|
+ visibleOptions = options.not('.is-hidden')
|
|
|
+ highlightedItem = options.filter('.is-active')
|
|
|
currentPosition = visibleOptions.index(highlightedItem)
|
|
|
|
|
|
currentPosition += direction
|
|
@@ -84,10 +161,24 @@ class App.SearchableSelect extends Spine.Controller
|
|
|
return if currentPosition < 0
|
|
|
return if currentPosition > visibleOptions.size() - 1
|
|
|
|
|
|
- @option_items.removeClass('is-active')
|
|
|
- visibleOptions.eq(currentPosition).addClass('is-active')
|
|
|
+ @unhighlightCurrentItem()
|
|
|
+ @currentItem = visibleOptions.eq(currentPosition)
|
|
|
+ @currentItem.addClass('is-active')
|
|
|
@clearAutocomplete()
|
|
|
|
|
|
+ autocompleteOrNavigateIn: (event) ->
|
|
|
+ if @currentItem.hasClass('js-enter')
|
|
|
+ @navigateIn(event)
|
|
|
+ else
|
|
|
+ @fillWithAutocompleteSuggestion(event)
|
|
|
+
|
|
|
+ autocompleteOrNavigateOut: (event) ->
|
|
|
+ # if we're in a depth then navigateOut
|
|
|
+ if @level != 0
|
|
|
+ @navigateOut(event)
|
|
|
+ else
|
|
|
+ @fillWithAutocompleteSuggestion(event)
|
|
|
+
|
|
|
fillWithAutocompleteSuggestion: (event) ->
|
|
|
if !@suggestion
|
|
|
return
|
|
@@ -129,11 +220,96 @@ class App.SearchableSelect extends Spine.Controller
|
|
|
@shadowInput.val event.currentTarget.getAttribute('data-value')
|
|
|
@shadowInput.trigger('change')
|
|
|
|
|
|
+ navigateIn: (event) ->
|
|
|
+ event.stopPropagation()
|
|
|
+ @navigateDepth(1)
|
|
|
+
|
|
|
+ navigateOut: (event) ->
|
|
|
+ event.stopPropagation()
|
|
|
+ @navigateDepth(-1)
|
|
|
+
|
|
|
+ navigateDepth: (dir) ->
|
|
|
+ return if @animating
|
|
|
+ if dir > 0
|
|
|
+ target = @currentItem.attr('data-value')
|
|
|
+ target_menu = @optionsSubmenu.filter("[data-parent-value=\"#{target}\"]")
|
|
|
+ else
|
|
|
+ target_menu = @findMenuContainingValue @currentMenu.attr('data-parent-value')
|
|
|
+
|
|
|
+ @animateToSubmenu(target_menu, dir)
|
|
|
+
|
|
|
+ @level+=dir
|
|
|
+
|
|
|
+ animateToSubmenu: (target_menu, direction) ->
|
|
|
+ @animating = true
|
|
|
+ target_menu.prop('hidden', false)
|
|
|
+ @dropdown.height(Math.max(target_menu.height(), @currentMenu.height()))
|
|
|
+ oldCurrentItem = @currentItem
|
|
|
+
|
|
|
+ @currentMenu.data('current_item_index', @currentItem.index())
|
|
|
+ # default: 1 (first item after the back button)
|
|
|
+ target_item_index = target_menu.data('current_item_index') || 1
|
|
|
+ # if the direction is out then we know the target item -> its the parent item
|
|
|
+ if direction is -1
|
|
|
+ value = @currentMenu.attr('data-parent-value')
|
|
|
+ target_item_index = @getOptionIndex(target_menu, value)
|
|
|
+
|
|
|
+ @currentItem = target_menu.children().eq(target_item_index)
|
|
|
+ @currentItem.addClass('is-active')
|
|
|
+
|
|
|
+ target_menu.velocity
|
|
|
+ properties:
|
|
|
+ translateX: [0, direction*100+'%']
|
|
|
+ options:
|
|
|
+ duration: 240
|
|
|
+
|
|
|
+ @currentMenu.velocity
|
|
|
+ properties:
|
|
|
+ translateX: [direction*-100+'%', 0]
|
|
|
+ options:
|
|
|
+ duration: 240
|
|
|
+ complete: =>
|
|
|
+ oldCurrentItem.removeClass('is-active')
|
|
|
+ $.Velocity.hook(@currentMenu, 'translateX', '')
|
|
|
+ @currentMenu.prop('hidden', true)
|
|
|
+ @dropdown.height(target_menu.height())
|
|
|
+ @currentMenu = target_menu
|
|
|
+ @animating = false
|
|
|
+
|
|
|
+ showSubmenu: (menu) ->
|
|
|
+ @currentMenu.prop('hidden', true)
|
|
|
+ menu.prop('hidden', false)
|
|
|
+ @dropdown.height(menu.height())
|
|
|
+
|
|
|
+ findMenuContainingValue: (value) ->
|
|
|
+ return @optionsList if !value
|
|
|
+
|
|
|
+ # in case of numbers
|
|
|
+ if !value.split && value.toString
|
|
|
+ value = value.toString()
|
|
|
+ path = value.split('::')
|
|
|
+ if path.length == 1
|
|
|
+ return @optionsList
|
|
|
+ else
|
|
|
+ path.pop()
|
|
|
+ return @optionsSubmenu.filter("[data-parent-value=\"#{path.join('::')}\"]")
|
|
|
+
|
|
|
+ getIndex: (menu) ->
|
|
|
+ parentValue = menu.attr('data-parent-value')
|
|
|
+ return 0 if !parentValue
|
|
|
+ return parentValue.split('::').length
|
|
|
+
|
|
|
onTab: (event) ->
|
|
|
return if not @isOpen
|
|
|
event.preventDefault()
|
|
|
|
|
|
onEnter: (event) ->
|
|
|
+ if @currentItem
|
|
|
+ if @currentItem.hasClass('js-enter')
|
|
|
+ return @navigateIn(event)
|
|
|
+ else if @currentItem.hasClass('js-back')
|
|
|
+ return @navigateOut(event)
|
|
|
+
|
|
|
@clearAutocomplete()
|
|
|
|
|
|
if not @isOpen
|
|
@@ -144,13 +320,14 @@ class App.SearchableSelect extends Spine.Controller
|
|
|
|
|
|
event.preventDefault()
|
|
|
|
|
|
- selected = @option_items.filter('.is-active')
|
|
|
- if selected.length || !@options.attribute.unknown
|
|
|
- valueName = selected.text().trim()
|
|
|
- value = selected.attr('data-value')
|
|
|
+ if @currentItem || !@attribute.unknown
|
|
|
+ valueName = @currentItem.text().trim()
|
|
|
+ value = @currentItem.attr('data-value')
|
|
|
@input.val valueName
|
|
|
@shadowInput.val value
|
|
|
|
|
|
+ @currentItem = null
|
|
|
+
|
|
|
@input.trigger('change')
|
|
|
@shadowInput.trigger('change')
|
|
|
@toggle()
|
|
@@ -169,32 +346,46 @@ class App.SearchableSelect extends Spine.Controller
|
|
|
@query = @input.val()
|
|
|
@filterByQuery @query
|
|
|
|
|
|
- if @options.attribute.unknown
|
|
|
+ if @attribute.unknown
|
|
|
@shadowInput.val @query
|
|
|
|
|
|
filterByQuery: (query) ->
|
|
|
query = escapeRegExp(query)
|
|
|
regex = new RegExp(query.split(' ').join('.*'), 'i')
|
|
|
|
|
|
- @option_items
|
|
|
+ @optionsList.addClass 'is-filtered'
|
|
|
+
|
|
|
+ @optionItems
|
|
|
.addClass 'is-hidden'
|
|
|
.filter ->
|
|
|
@textContent.match(regex)
|
|
|
.removeClass 'is-hidden'
|
|
|
|
|
|
- if @options.attribute.unknown && @option_items.length == @option_items.filter('.is-hidden').length
|
|
|
- @option_items.removeClass 'is-hidden'
|
|
|
- @option_items.removeClass 'is-active'
|
|
|
+ if !query
|
|
|
+ @optionItems.filter('.is-child').addClass 'is-hidden'
|
|
|
+
|
|
|
+ # if all are hidden
|
|
|
+ if @attribute.unknown && @optionItems.length == @optionItems.filter('.is-hidden').length
|
|
|
+ @optionItems.not('.is-child').removeClass 'is-hidden'
|
|
|
+ @unhighlightCurrentItem()
|
|
|
+ @optionsList.removeClass 'is-filtered'
|
|
|
else
|
|
|
@highlightFirst(true)
|
|
|
|
|
|
highlightFirst: (autocomplete) ->
|
|
|
- first = @option_items.removeClass('is-active').not('.is-hidden').first()
|
|
|
- first.addClass 'is-active'
|
|
|
+ @unhighlightCurrentItem()
|
|
|
+ @currentItem = @getCurrentOptions().not('.is-hidden').first()
|
|
|
+ @currentItem.addClass 'is-active'
|
|
|
|
|
|
if autocomplete
|
|
|
- @autocomplete first.attr('data-value'), first.text().trim()
|
|
|
+ @autocomplete @currentItem.attr('data-value'), @currentItem.text().trim()
|
|
|
|
|
|
highlightItem: (event) =>
|
|
|
- @option_items.removeClass('is-active')
|
|
|
- $(event.currentTarget).addClass('is-active')
|
|
|
+ @unhighlightCurrentItem()
|
|
|
+ @currentItem = $(event.currentTarget)
|
|
|
+ @currentItem.addClass('is-active')
|
|
|
+
|
|
|
+ unhighlightCurrentItem: ->
|
|
|
+ return if !@currentItem
|
|
|
+ @currentItem.removeClass('is-active')
|
|
|
+ @currentItem = null
|