Created
October 1, 2015 23:31
-
-
Save cullenjohnson/f636b0018673ef7e12c8 to your computer and use it in GitHub Desktop.
Backbone <select> tag replacement inspired by the "Burn your select tags" talk by Alice Bartlett at EpicFEL 2014: https://www.youtube.com/watch?v=CUkMCQR4TpY
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* FilterSelectView | |
* Creator: Cullen | |
*/ | |
FilterSelectView = Backbone.View.extend({ | |
/** ----------------------------------- | |
* DESCRIPTION | |
* ------------------------------------ | |
* | |
* Description: Displays a selectable list with a search field at the top. | |
* Recommended for a better UX than plain ol' <select> tags. | |
* Inspired by the "Burn your select tags" talk by Alice Bartlett at EpicFEL 2014: https://www.youtube.com/watch?v=CUkMCQR4TpY | |
* | |
* It should look sorta like this: | |
* | |
* LABEL | |
* |-----------------------| | |
* | [Search Bar] | | |
* |-----------------------| | |
* | Item 1 | | |
* | Item 2 | | |
* | Item 3 | | |
* | Item 4 | | |
* | ... | | |
* |-----------------------| | |
* | |
* Required: | |
* source - A Backbone Collection containing all the options to be listed. | |
* id_field - (default: 'id') The name of the Backbone property used as the ID attribute for each option. | |
* value_field - (default: 'name') The name of the Backbone property used as the displayed name for each option. | |
* fetch_function - (default: source.fetch) A reference to the function used to fetch all the data in the source. | |
* | |
* Options | |
* label - The LABEL above the search bar (see the diagram above). | |
* rowCount - (default: 5) the number of items to display at once, effectively defining the height of the widget. | |
* | |
* Recommended SCSS rules: | |
div.filter-select-wrapper { | |
display: -ms-inline-flexbox; | |
display: -webkit-inline-flex; | |
display: inline-flex; | |
-webkit-flex-direction: column; | |
-ms-flex-direction: column; | |
flex-direction: column; | |
-webkit-flex-wrap: nowrap; | |
-ms-flex-wrap: nowrap; | |
flex-wrap: nowrap; | |
-webkit-justify-content: flex-start; | |
-ms-flex-pack: start; | |
justify-content: flex-start; | |
-webkit-align-content: stretch; | |
-ms-flex-line-pack: stretch; | |
align-content: stretch; | |
-webkit-align-items: stretch; | |
-ms-flex-align: stretch; | |
align-items: stretch; | |
input[type=search], select { | |
margin-bottom: 1px; | |
} | |
} | |
*/ | |
/** ----------------------------------- | |
* DATA HANDLING/INITIALIZATION | |
** ----------------------------------- */ | |
initialize: function (options) { | |
_.bindAll(this, 'inputChange', 'dataLoaded', 'clearFocus'); | |
var fetchOptions = { | |
success: this.dataLoaded, | |
// TODO: replace alert() with error notification function that doesn't suck. | |
error: function(collection, error) { alert("An error occurred when trying to get data for the " + label + " field: " + error); } | |
}; | |
this.searchTerm = 'Search...'; | |
this.options = options; | |
this.source = options.source; | |
this.options.id_field = options.id_field || 'id'; | |
this.options.value_field = options.value_field || 'name'; | |
this.options.fetch_function = options.fetch_function || this.source.fetch; | |
this.renderSkeleton(); | |
this.options.fetch_function.call(this.options.source, fetchOptions); | |
}, | |
// Returns the Backbone Model the user selected. | |
val: function() { | |
var id = parseInt(this.$('#' + this.getInputID()).val()), | |
value = this.source.get(id); | |
return value || null; | |
}, | |
dataLoaded: function() { | |
this.dataReady = true; | |
this.lastSearchResult = this.source.models; | |
this.renderData(); | |
this.listenTo(this, 'searchChange', _.throttle(this.renderData, 10)); | |
}, | |
/** ----------------------------------- | |
* BUILD/RENDER FUNCTIONS | |
** ----------------------------------- */ | |
renderSkeleton: function() { | |
var html = ''; | |
html += '<div class="filter-select-wrapper">'; | |
html += '<label for="filterSelectSearch_' + this.cid + '">'; | |
html += this.options.label || 'Please Select:'; | |
html += '</label>'; | |
html += '<input id="filterSelectSearch_' + this.cid + '" type="search" incremental="incremental" value="' + (this.searchTerm || 'Search...') + '" title="Search the list">'; | |
html += '<select id="' + this.getInputID() + '" size="' + (this.options.rowCount || 5) +'" disabled="disabled">'; | |
html += '<option value="null">Loading...</option>'; | |
html += '</select>'; | |
html += '</div>'; | |
this.$el.html(html); | |
}, | |
render: function () { | |
this.renderSkeleton(); | |
if(this.dataReady) { | |
this.renderData(); | |
} | |
if (this.focusSelector) { | |
this.$(this.focusSelector).focus(); | |
} | |
}, | |
renderData: function() { | |
var html = ''; | |
html += this.getListHTML(); | |
this.$('select').removeAttr('disabled'); | |
this.$('select').html(html); | |
}, | |
/** ----------------------------------- | |
* EVENT HANDLERS | |
** ----------------------------------- */ | |
events: { | |
'change input[type="search"]': 'inputChange', | |
'keyup input[type="search"]': 'inputChange', | |
'search input[type="search"]': 'inputChange', | |
'change select': 'selectionChange', | |
'keyup select': 'selectKeyHandler', | |
'focusin input[type="search"]': 'searchFocus', | |
'focusin select': 'selectFocus' | |
}, | |
inputChange: function(e) { | |
if ( e.type == 'keyup' && | |
(e.originalEvent.keyCode === KeyCodes.DOWN || e.originalEvent.keyCode === KeyCodes.ENTER) | |
) { | |
this.$('select').focus(); | |
this.selectFocus(e); | |
if (!this.$('select option:first-child').is(':selected')) { | |
this.$('select')[0].selectedIndex = 0; | |
this.selectionChange(e); | |
} | |
} else { | |
this.searchTerm = _.escape(this.$('input[type="search"]').val()); | |
this.trigger('searchChange'); | |
} | |
}, | |
selectionChange: function() { | |
if (this.val() && this.val().has(this.options.id_field)) { | |
this.selectedId = this.val().get(this.options.id_field); | |
} else { | |
this.selectedId = null; | |
} | |
this.trigger('change'); | |
}, | |
selectKeyHandler: function(e) { | |
if (e.originalEvent.keyCode === KeyCodes.UP && this.$('select option:first-child').is(':selected')) { | |
this.$('input[type="search"]').focus(); | |
} else if (e.originalEvent.keyCode === KeyCodes.ENTER) { | |
this.$('select').blur(); | |
} else if (e.originalEvent.keyCode === KeyCodes.ESC) { | |
this.$('option:selected').removeAttr('selected'); | |
this.$('select').trigger('change'); | |
} | |
}, | |
clearFocus: function() { | |
var focused = $(':focus'); | |
if (focused.length === 0 || !this.$el.contains(focused)) { | |
this.focusSelector = null; | |
} | |
if (this.$('input[type="search"]').val() === '') { | |
this.$('input[type="search"]').val('Search...'); | |
} | |
}, | |
searchFocus: function(e) { | |
e.stopPropagation(); | |
this.focusSelector = 'input[type="search"]'; | |
if (this.$(this.focusSelector).val() === 'Search...') { | |
this.$(this.focusSelector).val(''); | |
} | |
this.$(this.focusSelector).select(); | |
this.$(this.focusSelector).one('focusout', this.clearFocus); | |
}, | |
selectFocus: function(e) { | |
e.stopPropagation(); | |
this.focusSelector = 'select'; | |
this.$(this.focusSelector).one('focusout', this.clearFocus); | |
}, | |
/** ----------------------------------- | |
* HELPERS | |
** ----------------------------------- */ | |
searchAndSortList: function() { | |
// first, filter the items to the ones that contain the search term in their name. | |
var results = this.source.models, | |
comparator; | |
if (this.searchTerm.length > 0 && this.searchTerm !== 'Search...') { | |
results = _.filter(results, function(model) { | |
var name = model.escape(this.options.value_field).toUpperCase(); | |
return name.indexOf(this.searchTerm.toUpperCase()) >= 0; | |
}, this); | |
if (results.length === 0) { | |
this.searchError = true; | |
results = this.lastSearchResult; | |
} else { | |
this.searchError = false; | |
comparator = getSearchTermRelevanceComparator( | |
this.searchTerm, | |
{ | |
caseSensitive: false, | |
propertyName: this.options.value_field, | |
isBackboneObject: true | |
} | |
); | |
results = results.sort(comparator); | |
this.lastSearchResult = _.clone(results); | |
} | |
} | |
return results; | |
}, | |
// returns a list of options | |
getListHTML: function() { | |
var html = '', | |
items = this.searchAndSortList(); | |
_.each(items, function(item) { | |
var selected = item.get(this.options.id_field) === this.selectedId; | |
html += '<option value="' + item.get(this.options.id_field) + '"' + (selected ? ' selected = "selected"' : '') +'>'; | |
html += item.escape(this.options.value_field); | |
html += '</option>'; | |
}, this); | |
return html; | |
}, | |
getInputID: function() { | |
return 'filterSelect_' + this.cid; | |
} | |
}); | |
KeyCodes = { | |
ESC: 27, | |
ENTER : 13, | |
TAB: 9, | |
UP: 38, | |
DOWN: 40 | |
}; | |
/** getSearchTermRelevanceComparator | |
* Return an underscore sort comparator that can sort a list of strings based on the relevancy of the search term. | |
* Can be used to sort strings, objects with string properties, or Backbone objects with string properties. | |
* | |
* Search Priority: (Example search term: "FOO") | |
* 1. Exact match of search term ("FOO" > "super FOObar") | |
* 2. Nearest match of search term at the start of a word. ("super FOObar" > "super duper FOObar" > "kungFOO") | |
* 3. Match closest to the start of the word. ("kungFOO" > "blablablaFOO") | |
* | |
* NOTE: When the search term contains a space, the function prioritizes the closest match, ignoring its word location. | |
* (Example: When the search term is "FOO BAR", "superFOO BAR" > "bla bla bla FOO BAR") | |
* | |
* Options: | |
* * searchTerm: the string to search for. | |
* * options | |
* caseSensitive: set to true if the search should be case sensitive. (False by default) | |
* propertyName: if the items being compared are objects, set this parameter to the name of the property to search | |
* for the term. (Leave null if the items being compared are strings.) | |
* isBackBoneObject: set to true if the items being compared are Backbone objects. (Leave null if the items being compared are strings.) | |
*/ | |
getSearchTermRelevanceComparator = function(searchTerm, options) { | |
return function(a, b) { | |
var aWordList, bWordList, i, | |
compareMatchPosition, | |
aMatchingWordIndex = 0, aMatchPosition = null, | |
bMatchingWordIndex = 0, bMatchPosition = null, | |
caseSensitive = options.caseSensitive || false, | |
propertyName = options.propertyName || null, | |
isBackboneObject = options.isBackboneObject || false; | |
if (propertyName) { | |
if (isBackboneObject) { | |
a = a.get(propertyName); | |
b = b.get(propertyName); | |
} else { | |
a = a[propertyName]; | |
b = b[propertyName]; | |
} | |
} | |
if (!caseSensitive) { | |
a = a.toUpperCase(); | |
b = b.toUpperCase(); | |
searchTerm = searchTerm.toUpperCase(); | |
} | |
// first of all, if a term does not contain the searchTerm, it is at the bottom. | |
if (a.indexOf(searchTerm) < 0 && b.indexOf(searchTerm) < 0) { | |
return 0; | |
} if (a.indexOf(searchTerm) < 0) { | |
return 1; | |
} else if (b.indexOf(searchTerm) < 0) { | |
return -1; | |
} | |
// top priority: exact match | |
if (a === searchTerm) { | |
return -1; | |
} | |
if (b === searchTerm) { | |
return 1; | |
} | |
// next priority: match at start of the word | |
// if the search term contains spaces, prioritize the match closer to the start of the entire string. | |
if (/\s/.test(searchTerm)) { | |
aMatchPosition = a.indexOf(searchTerm); | |
bMatchPosition = b.indexOf(searchTerm); | |
} else { | |
// split compared terms into collections of words. | |
aWordList = a.split(/\s+/g); | |
bWordList = b.split(/\s+/g); | |
// find the match that is closest to the start of the word for each collection of words. | |
for (i = 0; i < aWordList.length; ++i) { | |
compareMatchPosition = aWordList[i].indexOf(searchTerm); | |
if (compareMatchPosition >= 0 && (_.isNull(aMatchPosition) || compareMatchPosition < aMatchPosition )) { | |
aMatchingWordIndex = i; | |
aMatchPosition = compareMatchPosition | |
} | |
} | |
for (i = 0; i < bWordList.length; ++i) { | |
compareMatchPosition = bWordList[i].indexOf(searchTerm); | |
if (compareMatchPosition >= 0 && (_.isNull(bMatchPosition) || compareMatchPosition < bMatchPosition )) { | |
bMatchingWordIndex = i; | |
bMatchPosition = compareMatchPosition | |
} | |
} | |
} | |
// If the match is closer to the start of any word in the two terms, return that term. | |
if (aMatchPosition < bMatchPosition) { | |
return -1; | |
} else if (bMatchPosition < aMatchPosition) { | |
return 1; | |
} else { | |
// If the match is at the same position in both words, return the term with the earliest matching word. | |
if (aMatchingWordIndex < bMatchingWordIndex) { | |
return -1; | |
} else if (bMatchingWordIndex < aMatchingWordIndex) { | |
return 1; | |
} else { | |
return 0; | |
} | |
} | |
}; | |
}; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment