Skip to content

Instantly share code, notes, and snippets.

@psaliente
Last active February 8, 2023 08:34
Show Gist options
  • Save psaliente/e0913668ee171966712516504315b951 to your computer and use it in GitHub Desktop.
Save psaliente/e0913668ee171966712516504315b951 to your computer and use it in GitHub Desktop.
custom knockout js component inspired by jbt-tagbox (https://github.com/jbt/tagbox)
(function ($) {
$.fn.hasScrollBar = function () {
return this.get(0) ? this.get(0).scrollHeight > this.innerHeight() : false;
};
} (jQuery));
function KOTagComponent(params) {
var self = this;
self.version = "1.1.3";
self.fuzzyMatch = function (item, term, relevances) {
function escapeRegex(term) {
return term.replace(/[\[\]\{\}\(\)\^\$\.\*\+\|]/g, function (a) {
return '\\' + a;
});
}
var UPPER = 0, LOWER = 1, NUMBER = 2, COMMON_DELIMS = 3, OTHER = 4,
relevanceMatrix = [
[0, 240, 120, 240, 220],
[20, 0, 20, 120, 120],
[140, 140, 0, 140, 140],
[120, 120, 120, 0, 120],
[120, 120, 120, 160, 0]
],
stripAccents;
function charType(c) {
if (/[a-z]/.test(c)) { return LOWER; }
if (/[A-Z]/.test(c)) { return UPPER; }
if (/[0-9]/.test(c)) { return NUMBER; }
if (/[\/\-_\.]/.test(c)) { return COMMON_DELIMS; }
return OTHER;
}
function compareCharacters(theChar, before, after) {
var theType = charType(theChar),
beforeType = charType(before),
afterType = charType(after);
return relevanceMatrix[theType][beforeType] +
0.4 * relevanceMatrix[theType][afterType];
}
stripAccents = (function (accented, unaccented) {
var matchRegex = new RegExp('[' + accented + ']', 'g'), translationTable = {}, i,
lookup = function (chr) {
return translationTable[chr] || chr;
};
for (i = 0; i < accented.length; i += 1) {
translationTable[accented.charAt(i)] = unaccented.charAt(i);
}
return function (str) {
return str.replace(matchRegex, lookup);
};
} ('àáâãäçèéêëìíîïñòóôõöùúûüýÿÀÁÂÃÄÇÈÉÊËÌÍÎÏÑÒÓÔÕÖÙÚÛÜÝ',
'aaaaaceeeeiiiinooooouuuuyyAAAAACEEEEIIIINOOOOOUUUUY'));
function bestRank(item, term, startingFrom) {
if (term.length === 0) { return startingFrom * 100 / item.length; }
if (item.length === 0) { return -1; }
if (!item.slice(startingFrom).match(new RegExp(('^.*' + escapeRegex(term.split('').join('~~K~~')) + '.*$').split('~~K~~').join('.*'), 'i'))) {
return -1;
}
var firstSearchChar = term.charAt(0), bestRankSoFar = -1, highlights, i, j, subsequentRank, characterScore, subsequentHighlights;
for (i = startingFrom; i < item.length; i += 1) {
if (item.charAt(i).toLowerCase() !== firstSearchChar.toLowerCase()) { continue; }
subsequentRank = bestRank(item.substr(i), term.slice(1), 1);
if (subsequentRank === -1) { continue; }
characterScore = 400 / Math.max(1, i);
if (item.substr(i).toLowerCase().indexOf(term.toLowerCase()) === 0) { characterScore += 3 * term.length * term.length; }
characterScore += compareCharacters(
item.charAt(i),
i === 0 ? '/' : item.charAt(i - 1),
i === item.length - 1 ? '/' : item.charAt(i + 1)
);
characterScore += subsequentRank;
if (characterScore > bestRankSoFar) {
bestRankSoFar = characterScore;
highlights = [i];
subsequentHighlights = subsequentRank.highlights || [];
for (j = 0; j < subsequentHighlights.length; j += 1) {
highlights.push(subsequentHighlights[j] + i);
}
}
}
return {
__score: bestRankSoFar,
valueOf: function () { return this.__score; },
highlights: highlights
};
}
function fuzzyScoreStr(item, term) {
return bestRank(stripAccents(item), stripAccents(term), 0);
}
function fuzzyScore(item, term, relevances) {
if (typeof item == 'string') { return fuzzyScoreStr(item, term); }
var result = {
__score: 0,
valueOf: function () { return this.__score; },
highlights: {}
}, i, thatScore;
for (i in relevances) {
if (!relevances.hasOwnProperty(i) || !item.hasOwnProperty(i)) { continue; }
thatScore = fuzzyScoreStr(item[i], term);
result.__score += relevances[i] * thatScore;
result.highlights[i] = thatScore > 0 ? thatScore.highlights : [];
}
return result;
}
return fuzzyScore(item, term, relevances);
};
self.searchKey = ko.observable();
self.activeIndex = ko.observable(0);
self.propertyKey = params.propertyKey || '';
self.propertyText = params.propertyText || '';
self.inputHasFocus = ko.observable(false);
self.dropdownHasFocus = ko.observable(false);
self.items = params.items;
self.searchIn = ko.observableArray(params.searchIn || []);
self.selectedItems = params.value;
self.onSelect = params.onSelect;
function scoresObject() {
var si = self.searchIn(), scores = {}, i;
for (i = 0; i < si.length; i += 1) {
scores[si[i]] = 10;
}
return scores;
}
self.suggestions = ko.computed(function () {
var alreadyExist = function (val) { var len = self.selectedItems().length; while (len--) { if (self.selectedItems()[len][self.propertyKey] === val) return true; } }, i, tmpsuggestions = [], item, term, score;
term = self.searchKey() || '';
if (self.items()) {
for (i = 0; i < self.items().length; i += 1) {
item = self.items()[i];
score = self.fuzzyMatch(item, term, scoresObject());
if (!alreadyExist(item[self.propertyKey]) && (!term || score > 0)) {
tmpsuggestions.push(item);
}
}
}
if (Lazy) {
tmpsuggestions = Lazy(tmpsuggestions).sortBy(function (item) { return item.Name; }).toArray();
}
return tmpsuggestions;
});
self.deleteItem = function (item) {
self.selectedItems.remove(item);
};
self.selectItem = function (item, event) {
if (self.onSelect) {
self.onSelect(item, event);
}
self.selectedItems.push(item);
self.searchKey("");
self.dropdownHasFocus(false);
};
self.showdropdown = function (index) { self.dropdownHasFocus(true); self.activeIndex(index); };
self.hidedropdown = function (data, event) { self.dropdownHasFocus(false); };
self.handleKeyEvent = function (value, event) {
var position = self.activeIndex(),
$suggestions = $(event.target).parent().siblings('.kotag-suggestions'),
scrollheight = $suggestions.get(0).scrollHeight,
itemheight = parseInt(scrollheight / self.suggestions().length, 10);
if (event.keyCode == 13) {
self.selectItem(self.suggestions()[position]);
} else if (event.keyCode == 38) {
position--;
} else if (event.keyCode == 40) {
position++;
}
position = position < 0 ? 0 : (position > (self.suggestions().length - 1) ? (self.suggestions().length - 1) : position);
if ($suggestions.hasScrollBar()) {
$suggestions.scrollTop(position * itemheight);
}
self.activeIndex(position);
return true;
};
}
ko.components.register('component-kotag', {
viewModel: KOTagComponent,
template: '<div class="component-kotag">\
<div class="kotag-fake-input">\
<div data-bind="foreach: $component.selectedItems">\
<div class="kotag-token"><span data-bind="text: $data.Name"></span>&nbsp;<a href="#" data-bind="click: $component.deleteItem">&times;</a></div>\
</div>\
<input type="text" data-bind="textInput: $component.searchKey, event: { keydown: $component.handleKeyEvent }, hasFocus: $component.inputHasFocus" class="kotag-input"/>\
</div>\
<div data-bind="visible: $component.searchKey() || $component.inputHasFocus() || $component.dropdownHasFocus(), event: { mouseleave: $component.hidedropdown }" class="kotag-suggestions">\
<!-- ko foreach: $component.suggestions -->\
<div class="kotag-suggestion" data-bind="click: $component.selectItem, css: { \'kotag-suggestion-active\': $component.activeIndex() === $index() }, event: { mouseover: $component.showdropdown.bind($data, $index()) }">\
<span data-bind="text: $data[$component.propertyText]"></span></div>\
<!-- /ko -->\
<div class="tagbox-item empty" data-bind="visible: $component.suggestions().length === 0">Nothing found</div>\
</div>\
</div>'
});
@psaliente
Copy link
Author

psaliente commented Jun 15, 2016

usage:
js
document.createElement('component-kotag');

html

<component-kotag params="value: $root.filters.ArrFlights, label: 'ARR FLT', header: 'Arrival Flights', css: 'blackText', items: $root.ArrFlts"></component-kotag>

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