var DEFAULT_DELIMITER = ', ';
var keyIdentifier = {
',': "U+002C"
};
var keyCode = {
',': 188
};
var defaultTokenTemplate = document.createElement('template');
defaultTokenTemplate.innerHTML = '\n<label class="token">\n <input type="text" value="" autocorrect="off">\n <span class="uk-badge">\n <span class="value"></span>\n <i class="uk-icon-remove" data-remove=".token"></i>\n </span>\n</label>';
var defaultTokenInputTemplate = document.createElement('template');
defaultTokenInputTemplate.innerHTML = '\n<div class="token-input">\n <input type="text" class="new-token" autocorrect="off"\n</div>';
function TokenInput(input) {
// Allow calling using new TokenInput(element), or TokenInput(element).
if (!(this instanceof TokenInput)) {
return new TokenInput(input);
}
// FULL_DELIMITER will be used for re-joining tokens into string.
// DELIMITER will be used for splitting. We do this so that slightly
// mal-formed values (ie "foo,bar") still tokenises into ["foo", "bar"],
// even if the delimiter token in ", ".
var FULL_DELIMITER = input.dataset.tokenDelimiter || DEFAULT_DELIMITER;
var DELIMITER = FULL_DELIMITER.replace(/ /g, '');
// Strip whitespace (and token delimiter) from the token.
var strip = function strip(value) {
return value.replace(new RegExp('(^ *)|(' + DELIMITER + '? *$)', 'g'), '');
};
// We need the token template.
var tokenTemplate = document.querySelector('template#token') || defaultTokenTemplate;
// Create a new Token instance.
// This assumes that the token template contains a single <input> element,
// and a single <element class="value"></element> that will contain the
// label text.
function Token(value) {
tokenTemplate.content.querySelector('input').value = value;
tokenTemplate.content.querySelector('.value').innerHTML = value;
return document.importNode(tokenTemplate.content, true);
}
var tokenInputTemplate = document.querySelector('template#token-input') || defaultTokenInputTemplate;
// We need the container element for the new widget. This needs to be inserted directly
// after the input element we are "replacing".
input.insertAdjacentHTML('afterend', tokenInputTemplate.innerHTML);
var widget = input.nextElementSibling;
var newTokenInput = widget.querySelector('input.new-token');
// Given an input string, split it into tokens, and rebuild the tokens array+widget.
// Eventually, this should get smarter, and only add/replace stuff to what is already
// there.
function rebuildTokens(value) {
Array.apply(null, widget.querySelectorAll('.token')).forEach(function (element) {
return element.remove();
});
input.value.split(DELIMITER).map(strip).filter(Boolean).forEach(function (token) {
return widget.insertBefore(new Token(token), newTokenInput);
});
}
rebuildTokens(input.value);
// The opposite of rebuildTokens(): get the value that represents the full list of tokens.
function aggregateTokens() {
input.value = Array.apply(null, widget.querySelectorAll('.token > input')).map(function (token) {
return token.value;
}).filter(Boolean).join(FULL_DELIMITER);
}
function updateWidth(event) {
if (event.target.closest('.token') && event.target.nextElementSibling) {
event.target.style.width = event.target.nextElementSibling.getBoundingClientRect().width + 'px';
}
}
function updateLabel(event) {
if (event.target.closest('.token')) {
event.target.closest('.token').querySelector('.value').innerHTML = event.target.value;
}
}
// Event handlers.
function click(event) {
if (event.target.dataset.remove) {
event.stopPropagation();
event.stopImmediatePropagation();
event.preventDefault();
event.target.closest(event.target.dataset.remove).remove();
aggregateTokens();
} else if (event.target === widget) {
newTokenInput.focus();
}
}
function tokenValueUpdated(event) {
if (event.target.closest('.token')) {
updateLabel(event);
} else {
// New token, at the end.
widget.insertBefore(new Token(event.target.value), event.target);
event.target.value = '';
}
aggregateTokens();
}
function keyUp(event) {
// If this was the delimiter, then we want to create a new token.
// Otherwise, update the label and the width of the input element.
// We need to update the label to get the width from that!
if (event.key == DELIMITER || event.keyIdentifier == keyIdentifier[DELIMITER] || event.keyCode == keyCode[DELIMITER]) {
var tokens = event.target.value.split(DELIMITER).map(strip);
var element = event.target.closest('.token') || event.target;
if (tokens[0]) {
widget.insertBefore(new Token(tokens[0]), element);
}
event.target.value = tokens[1] || '';
aggregateTokens();
} else if (event.target.closest('.token')) {
updateLabel(event);
updateWidth(event);
}
}
function keyDown(event) {
if (event.key == 'Backspace' || event.keyIdentifier == "U+0008" || event.keyCode == 8) {
// Because this is keydown, event.target.value will be the value before the keypress,
// therefore if it's empty, the user has pressed backspace when the field is already empty.
if (event.target.value == '') {
var previousToken = (event.target.closest('.token') || event.target).previousElementSibling;
if (previousToken) {
// Because we are going to activate the previous sub-element, we don't want the backspace
// event to fire in that input instead, se we stop propagation.
event.stopPropagation();
event.preventDefault();
previousToken.click();
}
event.target.blur();
aggregateTokens();
}
}
}
function focusOut(event) {
if (event.target.closest('.token') && !event.target.value) {
event.target.closest('.token').remove();
aggregateTokens();
}
}
widget.addEventListener('click', click);
widget.addEventListener('change', tokenValueUpdated);
widget.addEventListener('keyup', keyUp);
widget.addEventListener('keydown', keyDown);
widget.addEventListener('focusout', focusOut);
widget.addEventListener('focusin', updateWidth);
input.addEventListener('change', rebuildTokens);
input.type = 'hidden';
};
Array.apply(null, document.querySelectorAll('[type=token]')).forEach(TokenInput);
<h1>Token Widget</h1>
<div class="uk-form uk-form-horizontal">
<div class="uk-form-row">
<label for="token-number" class="uk-form-label">numbers</label>
<div class="uk-form-controls">
<input type="token" value="one, two, three" tabindex="0" data-token-delimiter=", ">
</div>
</div>
<div class="uk-form-row">
<label for="token-number" class="uk-form-label">letters</label>
<div class="uk-form-controls">
<input type="token" value="a,b,c" tabindex="1" data-token-delimiter=",">
</div>
</div>
</div>
.token-input {
border: 1px solid #ddd;
padding-left: 6px;
}
.token-input input[type="text"] {
border: none;
}
.token-input input[type="text"]:focus {
background: transparent;
}
.token {
position: relative;
}
.token input[type="text"] {
position: absolute;
opacity: 0;
transition: none;
font-size: 10px;
}
.token input:focus {
opacity: 1;
position: relative;
}
.token input + span {
position: relative;
white-space: nowrap;
}
.token input:focus + span {
opacity: 0;
position: absolute;
}
External resources loaded into this fiddle: