Skip to content

Instantly share code, notes, and snippets.

@katspaugh
Created January 13, 2011 20:58
Show Gist options
  • Save katspaugh/778591 to your computer and use it in GitHub Desktop.
Save katspaugh/778591 to your computer and use it in GitHub Desktop.
Displays a suggestions box.
(function (globals, exports, $, doc) {
/*
** Takes one @options argument with properties:
** - 'list' -- a hashtable or array of possible suggestions,
** - 'propName' -- a property with suggestions in the hashtable,
** - 'input' -- an input element or input selector,
** - 'callback' -- what to do on option select,
** - 'focusFirst' -- whether to set focus on the first option,
** - 'meaningfulLength' -- how many characters can provide meaning.
*/
function SuggestInput() {
this.constructor = SuggestInput;
return this.init.apply(this, arguments);
}
SuggestInput.prototype = {
Timeouts: {
blur: 300,
resize: 10
},
ClassNames: {
select : 'suggest-select',
focus : 'suggest-focus'
},
init: function (options) {
var inputElement = $(options.input);
if (!inputElement.length) {
return null;
}
this.settings = {
meaningfulLength: options.meaningfulLength || 3,
focusFirst: options.focusFirst || false
};
this.elements = {
container: $(doc.body),
input: inputElement
};
this.elements.select = this.createSelect();
this.list = options.list;
this.selectCallback = options.callback;
if (null != options.propName) {
this.listIsHash = true;
this.propName = options.propName;
}
this.value = this.elements.input.val() || '';
this.bindInput();
this.bindSelect();
this.bindDocument();
return this;
},
bindDocument: function () {
var suggest = this;
$(doc).bind('keydown', function (e) {
if (suggest.visible) {
var code = e.keyCode;
switch (code) {
/* arrow up */
case 38: {
e.preventDefault();
suggest.moveFocus(-1);
break;
}
/* arrow down */
case 40: {
e.preventDefault();
suggest.moveFocus(1);
break;
}
/* esc */
case 27: {
e.preventDefault();
suggest.hideSelect();
break;
}
};
}
});
var resizeTimeout;
$(window).bind('resize', function () {
if (suggest.visible) {
resizeTimeout && clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(
function () {
suggest.setSelectPosition();
},
suggest.Timeouts.resize
);
}
});
},
bindInput: function () {
var suggest = this,
blurDelay;
suggest.elements.input.attr('autocomplete', 'off');
var ENTER = 13;
suggest.elements.input.bind({
'keydown': function (e) {
if (ENTER == e.keyCode && suggest.visible) {
e.preventDefault();
}
},
'keyup': function (e) {
if (ENTER == e.keyCode && suggest.visible) {
var val = '';
if (suggest.visible) {
val = suggest.getFocusedValue();
} else {
val = $(e.target).val();
}
suggest.onSelect(val);
} else {
suggest.setValue();
}
},
'change': function () {
suggest.setValue();
},
'blur': function () {
if (suggest.visible) {
blurDelay && clearTimeout(blurDelay);
blurDelay = setTimeout(function () {
suggest.hideSelect();
}, suggest.Timeouts.blur);
}
}
});
},
setSelectPosition: function () {
var input = this.elements.input,
offset = input.offset(),
margin = input.outerHeight();
return this.elements.select.css({
top: offset.top + margin,
left: offset.left
});
},
showSelect: function () {
this.setSelectPosition().show().scrollTop(0);
if (this.settings.focusFirst) {
this.focusFirst();
}
this.visible = true;
},
focusFirst: function () {
this.elements.focusedOption = $('div:first', this.elements.select);
this.toggleHighlight(true);
},
hideSelect: function () {
if (this.visible) {
this.elements.select.hide();
this.visible = false;
}
},
getFocusedValue: function () {
var retVal = this.value;
if (this.elements.focusedOption) {
var value = this.elements.focusedOption.data('value');
if (null != value) {
retVal = value;
}
}
return retVal;
},
setValue: function () {
var val = this.elements.input.val();
if (val) {
if (val.toLowerCase() !== this.value.toLowerCase()) {
this.value = val;
this.suggest();
}
} else {
this.hideSelect();
}
},
bindSelect: function () {
var suggest = this;
suggest.elements.select.bind({
'click': function (e) {
e.preventDefault();
var el = $(e.target),
val = el.data('value');
if (null != val) {
suggest.elements.focusedOption = el;
suggest.onSelect(val);
}
},
'mouseover': function (e) {
if (e.target !== this) {
if (suggest.elements.focusedOption) {
suggest.toggleHighlight(false);
}
suggest.elements.focusedOption = $(e.target);
suggest.toggleHighlight(true);
}
}
});
},
setInput: function (val) {
if (null != val) {
this.value = val;
this.elements.input.val(val);
}
},
moveFocus: function (d) {
if (this.elements.focusedOption) {
this.toggleHighlight(false);
var next = this.elements.focusedOption[d > 0 ? 'next': 'prev']();
this.elements.focusedOption = (next.length ? next : null);
}
if (!this.elements.focusedOption) {
this.elements.focusedOption = $(
'div:' + (d > 0 ? 'first' : 'last'),
this.elements.select
);
}
this.toggleHighlight(true);
this.adjustScroll();
},
toggleHighlight: function (toggle) {
this.elements.focusedOption.toggleClass(
this.ClassNames.focus,
toggle
);
},
adjustScroll: function () {
var el = this.elements.focusedOption,
select = this.elements.select,
elHeight = el.outerHeight(),
offset = el.position()['top'] + elHeight,
height = select.outerHeight(),
sTop = select.scrollTop();
if (offset < 0) {
select.scrollTop(0);
}
else if (offset < elHeight) {
select.scrollTop(sTop - height + elHeight);
}
else if (offset > height) {
select.scrollTop(sTop + offset - elHeight);
}
},
onSelect: function (val) {
this.hideSelect();
this.setInput(val);
if (this.selectCallback) {
this.selectCallback(val);
}
},
suggest: function () {
var list = this.getFilteredList();
if (list.length) {
var options = this.createOptionElements(list);
this.elements.select
.detach()
.empty()
.append(options)
.appendTo(this.elements.container);
this.showSelect();
} else {
this.hideSelect();
}
},
getFilteredList: function () {
var filtered = [],
item, i;
if (!this.value) {
return filtered;
}
if (this.listIsHash) {
for (i in this.list) {
if (this.list.hasOwnProperty(i) && null != this.list[i]) {
item = this.list[i][this.propName];
if (this.checkItem(item)) {
filtered.push(item);
}
}
}
} else {
for (i = 0; i < this.list.length; i++) {
item = this.list[i];
if (this.checkItem(item)) {
filtered.push(item);
}
}
}
return filtered.sort();
},
checkItem: function (item) {
var match = false;
if (item) {
var value = this.value.toLowerCase(),
words = item.toLowerCase();
match = (0 === words.indexOf(value));
if (!match && value.length >= this.settings.meaningfulLength) {
match = new RegExp('\\s' + value, 'g').test(words);
}
}
return match;
},
createSelect: function () {
var select = $('<div />');
select.css({
display : 'none',
position : 'absolute',
zIndex : 1000,
minWidth : this.elements.input.outerWidth()
}).attr(
'class', this.ClassNames.select
).appendTo(this.elements.container);
return select;
},
createOptionElements: function (list) {
var options = [], item, i;
for (i = 0; i < list.length; i++) {
item = list[i];
options[i] = $('<div />')
.data('value', item)
.text(item)
.get(0);
}
return $(options);
}
};
if (!exports.SuggestInput) {
exports['SuggestInput'] = SuggestInput;
}
}(this, this, jQuery, document));
@katspaugh
Copy link
Author

TODO: use reducing of subsets for consecutive completions

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment