@botandrose/input-tag
Version:
A declarative, zero-dependency, framework-agnostic custom element for tag input with autocomplete
1,089 lines (884 loc) • 30.8 kB
JavaScript
// taggle@1.15.0 downloaded from https://raw.githubusercontent.com/okcoker/taggle.js/refs/heads/master/src/taggle.js
/* !
* @author Sean Coker <hello@sean.is>
* @version 1.15.0
* @url http://sean.is/poppin/tags
* @license MIT
* @description Taggle is a dependency-less tagging library
*/
// @todo remove bower from next major version
(function(root, factory) {
'use strict';
var libName = 'Taggle';
/* global define, module */
if (typeof define === 'function' && define.amd) {
define([], function() {
var module = factory();
root[libName] = module;
return module;
});
}
else if (typeof module === 'object' && module.exports) {
module.exports = root[libName] = factory();
}
else {
root[libName] = factory();
}
}(window, function() {
'use strict';
/////////////////////
// Default options //
/////////////////////
var noop = function() {};
var retTrue = function() {
return true;
};
var BACKSPACE = 8;
var DELETE = 46;
var COMMA = 188;
var TAB = 9;
var ENTER = 13;
var ARROW_LEFT = 37;
var ARROW_RIGHT = 39;
var DEFAULTS = {
/**
* Class names to be added on each tag entered
* @type {String}
*/
additionalTagClasses: '',
/**
* Allow duplicate tags to be entered in the field?
* @type {Boolean}
*/
allowDuplicates: false,
/**
* Allow the saving of a tag on blur, rather than it being
* removed.
*
* @type {Boolean}
*/
saveOnBlur: false,
/**
* Clear the input value when blurring.
*
* @type {Boolean}
*/
clearOnBlur: true,
/**
* Class name that will be added onto duplicate existant tag
* @type {String}
* @todo
* @deprecated can be handled by onBeforeTagAdd
*/
duplicateTagClass: '',
/**
* Class added to the container div when focused
* @type {String}
*/
containerFocusClass: 'active',
/**
* Should the input be focused when the container is clicked?
* @type {Bool}
*/
focusInputOnContainerClick: true,
/**
* Name added to the hidden inputs within each tag
* @type {String}
*/
hiddenInputName: 'taggles[]',
/**
* Tags that should be preloaded in the div on load
* @type {Array}
*/
tags: [],
/**
* The default delimeter character to split tags on
* @type {String}
* @todo Change this to just "delimiter: ','"
*/
delimeter: ',',
delimiter: '',
/**
* Add an ID to each of the tags.
* @type {Boolean}
* @todo
* @deprecated make this the default in next version
*/
attachTagId: false,
/**
* Tags that the user will be restricted to
* @type {Array}
*/
allowedTags: [],
/**
* Tags that the user will not be able to add
* @type {Array}
*/
disallowedTags: [],
/**
* Spaces will be removed from the tags by default
* @type {Boolean}
*/
trimTags: true,
/**
* Limit the number of tags that can be added
* @type {Number}
*/
maxTags: null,
/**
* If within a form, you can specify the tab index flow
* @type {Number}
* @todo make 0 in next update
*/
tabIndex: 1,
/**
* Placeholder string to be placed in an empty taggle field
* @type {String}
*/
placeholder: 'Enter tags...',
/**
* Keycodes that will add a tag
* @type {Array}
*/
submitKeys: [COMMA, TAB, ENTER],
/**
* Preserve case of tags being added ie
* "tag" is different than "Tag"
* @type {Boolean}
*/
preserveCase: false,
// @todo bind callback hooks to instance
/**
* Function hook called with the to-be-added input DOM element.
*
* @param {HTMLElement} input The input element to be added
*/
inputFormatter: noop,
/**
* Function hook called with the to-be-added tag DOM element.
* Use this function to edit the list item before it is appended
* to the DOM
* @param {HTMLElement} li The list item to be added
*/
tagFormatter: noop,
/**
* Function hook called before a tag is added. Return false
* to prevent tag from being added
* @param {String} tag The tag to be added
*/
onBeforeTagAdd: noop,
/**
* Function hook called when a tag is added
* @param {Event} event Event triggered when tag was added
* @param {String} tag The tag added
*/
onTagAdd: noop,
/**
* Function hook called before a tag is removed. Return false
* to prevent tag from being removed
* @param {String} tag The tag to be removed
*/
onBeforeTagRemove: retTrue,
/**
* Function hook called when a tag is removed
* @param {Event} event Event triggered when tag was removed
* @param {String} tag The tag removed
*/
onTagRemove: noop
};
//////////////////////
// Helper functions //
//////////////////////
function _extend() {
var master = arguments[0];
for (var i = 1, l = arguments.length; i < l; i++) {
var object = arguments[i];
for (var key in object) {
if (object.hasOwnProperty(key)) {
master[key] = object[key];
}
}
}
return master;
}
function _isArray(arr) {
if (Array.isArray) {
return Array.isArray(arr);
}
return Object.prototype.toString.call(arr) === '[object Array]';
}
function _on(element, eventName, handler) {
if (element.addEventListener) {
element.addEventListener(eventName, handler, false);
}
else if (element.attachEvent) {
element.attachEvent('on' + eventName, handler);
}
else {
element['on' + eventName] = handler;
}
}
function _off(element, eventName, handler) {
if (element.removeEventListener) {
element.removeEventListener(eventName, handler, false);
}
else if (element.detachEvent) {
element.detachEvent('on' + eventName, handler);
}
else {
element['on' + eventName] = null;
}
}
function _trim(str) {
return str.replace(/^\s+|\s+$/g, '');
}
function _setText(el, text) {
if (window.attachEvent && !window.addEventListener) { // <= IE8
el.innerText = text;
}
else {
el.textContent = text;
}
}
function _clamp(val, min, max) {
return Math.min(Math.max(val, min), max);
}
/**
* Constructor
* @param {Mixed} el ID of an element or the actual element
* @param {Object} options
*/
var Taggle = function(el, options) {
// @todo also check that option type is correct #106
// @todo uncomment this in next major version
// for (var key in (options || {})) {
// if (!DEFAULTS.hasOwnProperty(key)) {
// throw new Error('"' + key + '" is not a valid option.');
// }
// }
this.settings = _extend({}, DEFAULTS, options);
this.measurements = {
container: {
rect: null,
style: null,
padding: null
}
};
this.container = el;
this.tag = {
values: [],
elements: []
};
this.inputContainer = options.inputContainer;
this.input = document.createElement('input');
this.sizer = document.createElement('div');
this.pasting = false;
this.placeholder = null;
this.data = null;
if (this.settings.placeholder) {
this.placeholder = document.createElement('span');
}
if (typeof el === 'string') {
this.container = document.getElementById(el);
}
this._id = 0;
this._backspacePressed = false;
this._inputPosition = 0;
this._setMeasurements();
this._setupTextarea();
this._attachEvents();
};
/**
* Gets all the layout measurements up front
*/
Taggle.prototype._setMeasurements = function() {
this.measurements.container.rect = this.container.getBoundingClientRect();
this.measurements.container.style = window.getComputedStyle(this.container);
var style = this.measurements.container.style;
var lpad = parseInt(style['padding-left'] || style.paddingLeft, 10);
var rpad = parseInt(style['padding-right'] || style.paddingRight, 10);
var lborder = parseInt(style['border-left-width'] || style.borderLeftWidth, 10);
var rborder = parseInt(style['border-right-width'] || style.borderRightWidth, 10);
this.measurements.container.padding = lpad + rpad + lborder + rborder;
};
/**
* Setup the div container for tags to be entered
*/
Taggle.prototype._setupTextarea = function() {
var fontSize;
this.input.type = 'text';
// Make sure no left/right padding messes with the input sizing
this.input.style.paddingLeft = 0;
this.input.style.paddingRight = 0;
this.input.className = 'taggle_input';
this.input.tabIndex = this.settings.tabIndex;
this.sizer.className = 'taggle_sizer';
[...this.container.children].forEach(tagOption => {
this.tag.values.push(tagOption.value);
this.tag.elements.push(tagOption);
this._inputPosition = _clamp(this._inputPosition + 1, 0, this.tag.values.length);
})
if (this.settings.tags.length) {
for (var i = 0, len = this.settings.tags.length; i < len; i++) {
var taggle = this._createTag(this.settings.tags[i], this.tag.values.length);
this.container.appendChild(taggle);
}
}
if (this.placeholder) {
this._hidePlaceholder();
this.placeholder.classList.add('taggle_placeholder');
this.container.appendChild(this.placeholder);
_setText(this.placeholder, this.settings.placeholder);
if (!this.tag.values.length) {
this._showPlaceholder();
}
}
var formattedInput = this.settings.inputFormatter(this.input);
if (formattedInput) {
this.input = formattedInput;
}
const div = document.createElement('div');
div.appendChild(this.input);
div.appendChild(this.sizer);
this.inputContainer.appendChild(div);
fontSize = window.getComputedStyle(this.input).fontSize;
this.sizer.style.fontSize = fontSize;
};
/**
* Attaches neccessary events
*/
Taggle.prototype._attachEvents = function() {
var self = this;
if (this._eventsAttached) {
return false;
}
this._eventsAttached = true;
function containerClick() {
self.input.focus();
}
if (this.settings.focusInputOnContainerClick) {
this._handleContainerClick = containerClick.bind(this);
_on(this.container, 'click', this._handleContainerClick);
}
this._handleFocus = this._setFocusStateForContainer.bind(this);
this._handleBlur = this._blurEvent.bind(this);
this._handleKeydown = this._keydownEvents.bind(this);
this._handleKeyup = this._keyupEvents.bind(this);
_on(this.input, 'focus', this._handleFocus);
_on(this.input, 'blur', this._handleBlur);
_on(this.input, 'keydown', this._handleKeydown);
_on(this.input, 'keyup', this._handleKeyup);
return true;
};
Taggle.prototype._detachEvents = function() {
if (!this._eventsAttached) {
return false;
}
var self = this;
this._eventsAttached = false;
_off(this.container, 'click', this._handleContainerClick);
_off(this.input, 'focus', this._handleFocus);
_off(this.input, 'blur', this._handleBlur);
_off(this.input, 'keydown', this._handleKeydown);
_off(this.input, 'keyup', this._handleKeyup);
return true;
};
/**
* Resizes the hidden input where user types to fill in the
* width of the div
*/
Taggle.prototype._fixInputWidth = function() {
this._setMeasurements();
this._setInputWidth();
};
/**
* Returns whether or not the specified tag text can be added
* @param {Event} e event causing the potentially added tag
* @param {String} text tag value
* @return {Boolean}
*/
Taggle.prototype._canAdd = function(e, text) {
if (!text) {
return false;
}
var limit = this.settings.maxTags;
if (limit !== null && limit <= this.getTagValues().length) {
return false;
}
if (this.settings.onBeforeTagAdd(e, text) === false) {
return false;
}
if (!this.settings.allowDuplicates && this._hasDupes(text)) {
return false;
}
var sensitive = this.settings.preserveCase;
var allowed = this.settings.allowedTags;
if (allowed.length && !this._tagIsInArray(text, allowed, sensitive)) {
return false;
}
var disallowed = this.settings.disallowedTags;
if (disallowed.length && this._tagIsInArray(text, disallowed, sensitive)) {
return false;
}
return true;
};
/**
* Returns whether a string is in an array based on case sensitivity
*
* @param {String} text string to search for
* @param {Array} arr array of strings to search through
* @param {Boolean} caseSensitive
* @return {Boolean}
*/
Taggle.prototype._tagIsInArray = function(text, arr, caseSensitive) {
if (caseSensitive) {
return arr.indexOf(text) !== -1;
}
var lowercased = [].slice.apply(arr).map(function(str) {
return str.toLowerCase();
});
return lowercased.indexOf(text) !== -1;
};
/**
* Appends tag with its corresponding input to the list
* @param {Event} e
* @param {String} text
* @param {Number} index
*/
Taggle.prototype._add = function(e, text, index) {
var self = this;
var values = text || '';
var delimiter = this.settings.delimiter || this.settings.delimeter;
if (typeof text !== 'string') {
values = this.input.value;
if (this.settings.trimTags) {
if (values[0] === delimiter) {
values = values.replace(delimiter, '');
}
values = _trim(values);
}
}
values.split(delimiter).map(val => {
if (this.settings.trimTags) {
val = _trim(val);
}
return this._formatTag(val);
}).forEach(val => {
if (!this._canAdd(e, val)) {
return;
}
var currentTagLength = this.tag.values.length;
var tagIndex = _clamp(index || currentTagLength, 0, currentTagLength);
var tagOption = this._createTag(val, tagIndex);
var tagOptions = this.container.children;
this.container.append(tagOption);
val = this.tag.values[tagIndex];
this.settings.onTagAdd(e, val);
this.input.value = '';
this._fixInputWidth();
this._setFocusStateForContainer();
});
};
/**
* Removes last tag if it has already been probed
* @param {Event} e
*/
Taggle.prototype._checkPrevOrNextTag = function(e) {
e = e || window.event;
var taggles = this.container.querySelectorAll('tag-option');
var prevTagIndex = _clamp(this._inputPosition - 1, 0, taggles.length - 1);
var nextTagIndex = _clamp(this._inputPosition, 0, taggles.length - 1);
var index = prevTagIndex;
if (e.keyCode === DELETE) {
index = nextTagIndex;
}
var targetTaggle = taggles[index];
var hotClass = 'taggle_hot';
var isDeleteOrBackspace = [BACKSPACE, DELETE].indexOf(e.keyCode) !== -1;
// prevent holding backspace from deleting all tags
if (this.input.value === '' && isDeleteOrBackspace && !this._backspacePressed) {
if (targetTaggle.classList.contains(hotClass)) {
this._backspacePressed = true;
this._remove(targetTaggle, e);
this._fixInputWidth();
this._setFocusStateForContainer();
}
else {
targetTaggle.classList.add(hotClass);
}
}
else if (targetTaggle.classList.contains(hotClass)) {
targetTaggle.classList.remove(hotClass);
}
};
/**
* Setter for the hidden input.
* @param {Number} width
*/
Taggle.prototype._setInputWidth = function() {
var width = this.sizer.getBoundingClientRect().width;
var max = this.measurements.container.rect.width - this.measurements.container.padding;
var size = parseInt(this.sizer.style.fontSize, 10);
// 1.5 just seems to be a good multiplier here
var newWidth = Math.round(_clamp(width + (size * 1.5), 10, max));
this.input.style.width = newWidth + 'px';
};
/**
* Checks global tags array if provided tag exists
* @param {String} text
* @return {Boolean}
*/
Taggle.prototype._hasDupes = function(text) {
var needle = this.tag.values.indexOf(text);
var tagglelist = this.container.querySelector('.taggle_list');
var dupes;
if (this.settings.duplicateTagClass) {
dupes = tagglelist.querySelectorAll('.' + this.settings.duplicateTagClass);
for (var i = 0, len = dupes.length; i < len; i++) {
dupes[i].classList.remove(this.settings.duplicateTagClass);
}
}
// if found
if (needle > -1) {
if (this.settings.duplicateTagClass) {
tagglelist.childNodes[needle].classList.add(this.settings.duplicateTagClass);
}
return true;
}
return false;
};
/**
* Checks whether or not the key pressed is acceptable
* @param {Number} key code
* @return {Boolean}
*/
Taggle.prototype._isConfirmKey = function(key) {
var confirmKey = false;
if (this.settings.submitKeys.indexOf(key) > -1) {
confirmKey = true;
}
return confirmKey;
};
// Event handlers
/**
* Handles focus state of div container.
*/
Taggle.prototype._setFocusStateForContainer = function() {
this._fixInputWidth();
if (!this.container.classList.contains(this.settings.containerFocusClass)) {
this.container.classList.add(this.settings.containerFocusClass);
}
this._hidePlaceholder();
};
/**
* Runs all the events that need to happen on a blur
* @param {Event} e
*/
Taggle.prototype._blurEvent = function(e) {
if (this.container.classList.contains(this.settings.containerFocusClass)) {
this.container.classList.remove(this.settings.containerFocusClass);
}
if (this.settings.saveOnBlur) {
e = e || window.event;
this._setInputWidth();
if (this.input.value !== '') {
this._confirmValidTagEvent(e);
return;
}
if (this.tag.values.length) {
this._checkPrevOrNextTag(e);
}
}
else if (this.settings.clearOnBlur) {
this.input.value = '';
this._setInputWidth();
}
if (!this.tag.values.length && !this.input.value) {
this._showPlaceholder();
}
};
/**
* Runs all the events that need to run on keydown
* @param {Event} e
*/
Taggle.prototype._keydownEvents = function(e) {
e = e || window.event;
var key = e.keyCode;
this.pasting = false;
this._setInputWidth();
if (key === 86 && e.metaKey) {
this.pasting = true;
}
if (this._isConfirmKey(key) && this.input.value !== '') {
this._confirmValidTagEvent(e);
return;
}
if (this.tag.values.length) {
this._checkPrevOrNextTag(e);
}
};
/**
* Runs all the events that need to run on keyup
* @param {Event} e
*/
Taggle.prototype._keyupEvents = function(e) {
e = e || window.event;
this._backspacePressed = false;
_setText(this.sizer, this.input.value);
// If we break to a new line because the text is too long
// and decide to delete everything, we should resize the input
// so it falls back inline
if (!this.input.value) {
this._setInputWidth();
}
if (this.pasting && this.input.value !== '') {
this._add(e);
this.pasting = false;
}
};
/**
* Confirms the inputted value to be converted to a tag
* @param {Event} e
*/
Taggle.prototype._confirmValidTagEvent = function(e) {
e = e || window.event;
// prevents from jumping out of textarea
if (e.preventDefault) {
e.preventDefault();
}
else {
e.returnValue = false;
}
this._add(e, null, this._inputPosition);
};
Taggle.prototype._createTag = function(text, index) {
var tagOption = document.createElement('tag-option');
text = this._formatTag(text);
_setText(tagOption, text);
tagOption.setAttribute('value', text);
var formatted = this.settings.tagFormatter(tagOption);
if (typeof formatted !== 'undefined') {
tagOption = formatted;
}
if (!(tagOption instanceof HTMLElement) || !(tagOption.localName === 'tag-option' || tagOption.tagName === 'TAG-OPTION')) {
throw new Error('tagFormatter must return an tag-option element');
}
if (this.settings.attachTagId) {
this._id += 1;
text = {
text: text,
id: this._id
};
}
this.tag.values.splice(index, 0, text);
this.tag.elements.splice(index, 0, tagOption);
this._inputPosition = _clamp(this._inputPosition + 1, 0, this.tag.values.length);
return tagOption;
};
Taggle.prototype._showPlaceholder = function() {
if (this.placeholder) {
this.placeholder.style.opacity = 1;
this.placeholder.setAttribute('aria-hidden', 'false');
}
};
Taggle.prototype._hidePlaceholder = function() {
if (this.placeholder) {
this.placeholder.style.opacity = 0;
this.placeholder.setAttribute('aria-hidden', 'true');
}
};
/**
* Removes tag from the tags collection
* @param {tagOption} tagOption List item to remove
* @param {Event} e
*/
Taggle.prototype._remove = function(tagOption, e) {
var self = this;
var text;
var elem;
var index;
if (tagOption.tagName.toLowerCase() !== 'tag-option') {
tagOption = tagOption.parentNode;
}
elem = (tagOption.tagName.toLowerCase() === 'a') ? tagOption.parentNode : tagOption;
index = this.tag.elements.indexOf(elem);
text = this.tag.values[index];
function done(error) {
if (error) {
return;
}
tagOption.remove()
// Going to assume the indicies match for now
self.tag.elements.splice(index, 1);
self.tag.values.splice(index, 1);
self.settings.onTagRemove(e, text);
if (index < self._inputPosition) {
self._inputPosition = _clamp(self._inputPosition - 1, 0, self.tag.values.length);
}
self._setFocusStateForContainer();
}
var ret = this.settings.onBeforeTagRemove(e, text, done);
if (!ret) {
return;
}
done();
};
/**
* Format the text for a tag
* @param {String} text Tag text
* @return {String}
*/
Taggle.prototype._formatTag = function(text) {
return this.settings.preserveCase ? text : text.toLowerCase();
};
Taggle.prototype._isIndexInRange = function(index) {
return index >= 0 && index <= this.tag.values.length - 1;
};
Taggle.prototype.getTags = function() {
return {
elements: this.getTagElements(),
values: this.getTagValues()
};
};
// @todo
// @deprecated use getTags().elements
Taggle.prototype.getTagElements = function() {
return [].slice.apply(this.tag.elements);
};
// @todo
// @deprecated use getTags().values
Taggle.prototype.getTagValues = function() {
return [].slice.apply(this.tag.values);
};
Taggle.prototype.getInput = function() {
return this.input;
};
Taggle.prototype.getContainer = function() {
return this.container;
};
Taggle.prototype.add = function(text, index) {
var isArr = _isArray(text);
if (isArr) {
var startingIndex = index;
for (var i = 0, len = text.length; i < len; i++) {
if (typeof text[i] === 'string') {
this._add(null, text[i], startingIndex);
if (!isNaN(startingIndex)) {
startingIndex += 1;
}
}
}
}
else {
this._add(null, text, index);
}
return this;
};
Taggle.prototype.edit = function(text, index) {
if (typeof text !== 'string') {
throw new Error('First edit argument must be of type string');
}
if (typeof index !== 'number') {
throw new Error('Second edit argument must be a number');
}
if (!this._isIndexInRange(index)) {
throw new Error('Edit index should be between 0 and ' + this.tag.values.length - 1);
}
var textValue = this.tag.values[index];
if (typeof textValue === 'string') {
this.tag.values[index] = text;
}
else {
this.tag.values[index].text = text;
}
_setText(this.tag.elements[index], text);
return this;
};
Taggle.prototype.move = function(currentIndex, destinationIndex) {
if (typeof currentIndex !== 'number' || typeof destinationIndex !== 'number') {
throw new Error('Both arguments must be numbers');
}
if (!this._isIndexInRange(currentIndex)) {
throw new Error('First index should be between 0 and ' + this.tag.values.length - 1);
}
if (!this._isIndexInRange(destinationIndex)) {
throw new Error('Second index should be between 0 and ' + this.tag.values.length - 1);
}
if (currentIndex === destinationIndex) {
return this;
}
var value = this.tag.values[currentIndex];
var element = this.tag.elements[currentIndex];
var lastElement = this.tag.elements[destinationIndex];
this.tag.values.splice(currentIndex, 1);
this.tag.elements.splice(currentIndex, 1);
this.tag.values.splice(destinationIndex, 0, value);
this.tag.elements.splice(destinationIndex, 0, element);
this.container.insertBefore(element, lastElement.nextSibling);
return this;
};
Taggle.prototype.remove = function(text, all) {
var len = this.tag.values.length - 1;
var found = false;
while (len > -1) {
var tagText = this.tag.values[len];
if (this.settings.attachTagId) {
tagText = tagText.text;
}
if (tagText === text) {
found = true;
this._remove(this.tag.elements[len]);
}
if (found && !all) {
break;
}
len--;
}
return this;
};
Taggle.prototype.removeAll = function() {
for (var i = this.tag.values.length - 1; i >= 0; i--) {
this._remove(this.tag.elements[i]);
}
this._showPlaceholder();
return this;
};
Taggle.prototype.setOptions = function(options) {
this.settings = _extend({}, this.settings, options || {});
return this;
};
Taggle.prototype.enable = function() {
var buttons = [].slice.call(this.container.querySelectorAll('button'));
var inputs = [].slice.call(this.container.querySelectorAll('input'));
buttons.concat(inputs).forEach(function(el) {
el.removeAttribute('disabled');
});
return this;
};
Taggle.prototype.disable = function() {
var buttons = [].slice.call(this.container.querySelectorAll('button'));
var inputs = [].slice.call(this.container.querySelectorAll('input'));
buttons.concat(inputs).forEach(function(el) {
el.setAttribute('disabled', '');
});
return this;
};
Taggle.prototype.setData = function(data) {
this.data = data;
return this;
};
Taggle.prototype.getData = function() {
return this.data;
};
Taggle.prototype.attachEvents = function() {
var self = this;
var attached = this._attachEvents();
return this;
};
Taggle.prototype.removeEvents = function() {
this._detachEvents();
return this;
};
return Taggle;
}));
export default Taggle;