Skip to content

Instantly share code, notes, and snippets.

@cullenjohnson
Created October 1, 2015 23:31
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save cullenjohnson/f636b0018673ef7e12c8 to your computer and use it in GitHub Desktop.
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
/**
* 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