UNPKG

tokenfield

Version:

Input field with tagging/token/chip capabilities written in raw JavaScript

1,664 lines (1,430 loc) 45.5 kB
/** * Input field with tagging/token/chip capabilities written in raw JavaScript * tokenfield 1.5.2 <https://github.com/KaneCohen/tokenfield> * Copyright 2022 Kane Cohen <https://github.com/KaneCohen> * Available under BSD-3-Clause license */ import EventEmitter from 'events'; import ajax from './ajax.js'; let _tokenfields = {}; const reRegExpChar = /[\\^$.*+?()[\]{}|]/g; const reHasRegExpChar = RegExp(reRegExpChar.source); const _factory = document.createElement('div'); const _templates = { containerTokenfield: `<div class="tokenfield tokenfield-mode-tokens"> <input class="tokenfield-copy-helper" style="display:none;position:fixed;top:-1000px;right:1000px;" tabindex="-1" type="text" /> <div class="tokenfield-set"> <ul></ul> </div> <input class="tokenfield-input" /> <div class="tokenfield-suggest"> <ul class="tokenfield-suggest-list"></ul> </div> </div>`, containerList: `<div class="tokenfield tokenfield-mode-list"> <input class="tokenfield-input" /> <div class="tokenfield-suggest"> <ul class="tokenfield-suggest-list"></ul> </div> <div class="tokenfield-set"> <ul></ul> </div> </div>`, suggestItem: `<li class="tokenfield-suggest-item"></li>`, setItem: `<li class="tokenfield-set-item"> <span class="item-label"></span> <a href="#" class="item-remove" tabindex="-1">×</a> <input class="item-input" type="hidden" /> </li>` }; function guid() { return (((1 + Math.random()) * 0x10000) | 0).toString(16) + (((1 + Math.random()) * 0x10000) | 0).toString(16); } function includes(arr, item) { return arr.indexOf(item) >= 0; } function getPath(node) { let nodes = [node]; while (node.parentNode) { node = node.parentNode; nodes.push(node); } return nodes; } function findElement(input) { if (input.nodeName) { return input; } else if (typeof input === 'string') { return document.querySelector(input); } return null; } function build(html, all) { if (html.nodeName) return html; html = html.replace(/(\t|\n$)/g, ''); _factory.innerHTML = ''; _factory.innerHTML = html; if (all === true) { return _factory.childNodes; } else { return _factory.childNodes[0]; } } function toString(value) { if (typeof value == 'string') { return value; } if (value === null) { return ''; } let result = (value + ''); return (result === '0' && (1 / value) === -Infinity) ? '-0' : result; } function keyToChar(e) { if (e.key || e.keyIdentifier) { return e.key || String.fromCharCode(parseInt(e.keyIdentifier.substr(2), 16)); } return null; } function escapeRegex(string) { string = toString(string); return (string && reHasRegExpChar.test(string)) ? string.replace(reRegExpChar, '\\$&') : string; } function makeDefaultsAndOptions() { const _defaults = { focusedItem: null, cache: {}, timer: null, xhr: null, suggested: false, suggestedItems: [], setItems: [], events: {}, delimiters: {} }; const _options = { el: null, form: true, // Listens to reset event on the specifiedform. If set to true listens to // immediate parent form. Also accepts selectors or elements. mode: 'tokenfield', // Display mode: tokenfield or list. addItemOnBlur: false, // Add token if input field loses focus. addItemsOnPaste: false, // Add tokens using `delimiters` option below to tokenize given string. keepItemsOrder: true, // Items and New Items values will be set in input with a specific position // in the list so that you can retreive correct position on the backend. setItems: [], // List of set items. items: [], // List of available items to work with. // Example: [{id: 143, value: 'Hello World'}, {id: 144, value: 'Foo Bar'}]. newItems: true, // Allow input (on delimiter key) of new items. multiple: true, // Accept multiple items. maxItems: 0, // Set maximum allowed number of items. minLength: 0, // Minimum length of the string to be converted into token. keys: { // Various action keys. 17: 'ctrl', 16: 'shift', 91: 'meta', 8: 'delete', // Backspace 27: 'esc', 37: 'left', 38: 'up', 39: 'right', 40: 'down', 46: 'delete', 65: 'select', // A 67: 'copy', // C 88: 'cut', // X 9: 'delimiter', // Tab 13: 'delimiter', // Enter 108: 'delimiter' // Numpad Enter }, matchRegex: '{value}', // Match regex where {value} would be replaced by ecaped user input. matchFlags: 'i', // Flags used in regex matching. matchStart: false, // Set match regex to test from the beginning of the string. matchEnd: false, // Set match regex to test from the end of the string. delimiters: [], // Array of strings which act as delimiters during tokenization. copyProperty: 'name', // Property of the token used for copy event. copyDelimiter: ', ', // Delimiter used to populate clipboard with selected tokens. remote: { type: 'GET', // Ajax request type. url: null, // Full server url. queryParam: 'q', // What param to use when asking server for data. delay: 300, // Dealy between last keydown event and ajax request for data. timestampParam: 't', params: {}, headers: {} }, placeholder: null, // Hardcoded placeholder text. If not set, will use placeholder from the element itself. inputType: 'text', // HTML attribute for the input element which lets mobile browsers use various input modes. // Accepts text, email, url, and others. minChars: 2, // Number of characters before we start to look for similar items. maxSuggest: 10, // Max items in the suggest box. maxSuggestWindow: 10, // Limit height of the suggest box after given number of items. filterSetItems: true, // Filters already set items from the suggestions list. filterMatchCase: false, // Sets case-sensitivity for checking whether new item is already set in the list. singleInput: false, // Pushes all token values into a single. Accepts: true, 'selector', or an element. // When set to true - would use tokenfield target element as an input to fill. singleInputValue: 'id', // Which property of the item to use when using fillInput. singleInputDelimiter: ', ', itemLabel: 'name', // Property to use in order to render item label. itemName: 'items', // If set, for each tag/token there will be added // input field with array property name: // name="items[]". newItemName: 'items_new', // Suffix that will be added to the new tag in // case it was not available from the server: // name="items_new[]". itemValue: 'id', // Value that will be taken out of the results and inserted into itemAttr. newItemValue: 'name', // Value that will be taken out of the results and inserted into itemAttr. itemData: 'name', // Which property to search for. validateNewItem: null // Run a function to test if new item is valid and can be added. }; return {_defaults, _options}; } class Tokenfield extends EventEmitter { constructor(options = {}) { super(); let { _defaults, _options } = makeDefaultsAndOptions(); this.id = guid(); this.key = `key_${this.id}`; this._vars = Object.assign({}, _defaults); this._options = Object.assign({}, _options, options); this._options.keys = Object.assign({}, _options.keys, options.keys); this._options.remote = Object.assign({}, _options.remote, options.remote); this._templates = Object.assign({}, _templates, options.templates); this._vars.setItems = this._prepareData(this.remapData(this._options.setItems || [])); this._focused = false; this._input = null; this._form = false; this._html = {}; let o = this._options; // Make a hash map to simplify filtering later. o.delimiters.forEach((delimiter) => { this._vars.delimiters[delimiter] = true; }); let el = findElement(o.el); if (el) { this.el = el; } else { throw new Error(`Selector: DOM Element ${o.el} not found.`); } if (o.singleInput) { let el = findElement(o.singleInput); if (el) { this._input = el; } else { this._input = this.el; } } this.el.tokenfield = this; if (o.placeholder === null) { o.placeholder = o.el.placeholder || ''; } if (o.form) { let form = false; if (o.form.nodeName) { form = o.form; } else if (o.form === true) { let node = this.el; while (node.parentNode) { if (node.nodeName === 'FORM') { form = node; break; } node = node.parentNode; } } else if (typeof form == 'string') { form = document.querySelector(form); if (! form) { throw new Error(`Selector: DOM Element ${o.form} not found.`); } } this._form = form; } else { throw new Error(`Cannot create tokenfield without DOM Element.`); } _tokenfields[this.id] = this; this._render(); } _render() { let o = this._options; let html = this._html; if (o.mode === 'tokenfield') { html.container = build(this._templates.containerTokenfield); } else { html.container = build(this._templates.containerList); } html.suggest = html.container.querySelector('.tokenfield-suggest'); html.suggestList = html.container.querySelector('.tokenfield-suggest-list'); html.items = html.container.querySelector('.tokenfield-set > ul'); html.input = html.container.querySelector('.tokenfield-input'); html.input.setAttribute('type', o.inputType); if (o.mode === 'tokenfield') { html.input.placeholder = this._vars.setItems.length ? '' : o.placeholder; } else { html.input.placeholder = o.placeholder; } html.copyHelper = html.container.querySelector('.tokenfield-copy-helper'); o.el.style.display = 'none'; html.suggest.style.display = 'none'; this._renderSizer(); // Set tokenfield in DOM. html.container.tokenfield = this; o.el.parentElement.insertBefore(html.container, o.el); html.container.insertBefore(o.el, html.container.firstChild); this._setEvents(); this._renderItems(); if (o.mode === 'tokenfield') { this._minimizeInput()._resizeInput(); } return this; } _renderSizer() { let html = this._html; let b = this._getBounds(); let style = window.getComputedStyle(html.container); let compensate = parseInt(style.paddingLeft, 10) + parseInt(style.paddingRight, 10); let styles = { width: 'auto', height: 'auto', overflow: 'hidden', whiteSpace: 'pre', maxWidth: b.width - compensate + 'px', position: 'fixed', top: -10000 + 'px', left: 10000 + 'px', fontSize: style.fontSize, paddingLeft: style.paddingLeft, paddingRight: style.paddingRight }; html.sizer = document.createElement('div'); html.sizer.id = 'tokenfield-sizer-' + this.id; for (let key in styles) { html.sizer.style[key] = styles[key]; } html.container.appendChild(html.sizer); } _minimizeInput() { if (this._options.mode === 'tokenfield') { this._html.input.style.width = '20px'; } return this; } _refreshInput(empty = true) { let v = this._vars; let html = this._html; if (empty) html.input.value = ''; if (this._options.mode === 'tokenfield') { this._resizeInput(); let placeholder = v.setItems.length ? '' : this._options.placeholder; html.input.setAttribute('placeholder', placeholder); } else if (this._options.mode === 'list') { html.input.setAttribute('placeholder', this._options.placeholder); } return this; } _resizeInput(val = '') { let html = this._html; let b = this._getBounds(); let style = window.getComputedStyle(html.container); let compensate = parseInt(style.paddingRight, 10) + parseInt(style.borderRightWidth, 10); let fullCompensate = compensate + parseInt(style.paddingLeft, 10) + parseInt(style.borderLeftWidth, 10); html.sizer.innerHTML = val; html.sizer.style.maxWidth = b.width - compensate + 'px'; if (b.width === 0) { html.input.style.width = '100%'; return; } else { html.input.style.width = '20px'; } let sb = html.sizer.getBoundingClientRect(); let ib = html.input.getBoundingClientRect(); let rw = b.width - (ib.left - b.left) - compensate; if (sb.width > rw) { html.input.style.width = (b.width - fullCompensate - 1) + 'px'; } else { html.input.style.width = (rw - 1) + 'px'; } } _fetchData(val) { let v = this._vars; let o = this._options; let r = o.remote; let reqData = Object.assign({}, o.params); for (let key in r.params) { reqData[key] = r.params[key]; } if (r.limit) { reqData[r.limit] = o.remote.limit; } reqData[r.queryParam] = val; reqData[r.timestampParam] = Math.round((new Date()).getTime() / 1000); v.xhr = ajax(reqData, o.remote, () => { if (v.xhr && v.xhr.readyState == 4) { if (v.xhr.status == 200) { let response = JSON.parse(v.xhr.responseText); v.cache[val] = response; let data = this._prepareData(this.remapData(response)); let items = this._filterData(val, data); v.suggestedItems = o.filterSetItems ? this._filterSetItems(items) : items; this.showSuggestions(); } else if (v.xhr.status > 0) { throw new Error('Error while loading remote data.'); } this._abortXhr(); } }); } // Overwriteable method where you can change given data to appropriate format. remapData(data) { return data; } _prepareData(data) { return data.map(item => { return Object.assign({}, item, { [this.key]: guid() }); }); } _filterData(val, data) { let o = this._options; let regex = o.matchRegex.replace('{value}', escapeRegex(val)); if (o.matchStart) { regex = '^' + regex; } else if (o.matchEnd) { regex = regex + '$'; } let pattern = new RegExp(regex, o.matchFlags); return data.filter(item => pattern.test(item[o.itemData])); } _abortXhr() { let v = this._vars; if (v.xhr !== null) { v.xhr.abort(); v.xhr = null; } } _filterSetItems(items) { const key = this._options.itemValue; let v = this._vars; if (! v.setItems.length) return items; let setKeys = v.setItems.map(item => item[key]); return items.filter(item => { if (setKeys.indexOf(item[key]) === -1) { return true; } return false; }); } _setEvents() { let v = this._vars; let html = this._html; v.events.onClick = this._onClick.bind(this); v.events.onMouseDown = this._onMouseDown.bind(this); v.events.onMouseOver = this._onMouseOver.bind(this); v.events.onFocus = this._onFocus.bind(this); v.events.onResize = this._onResize.bind(this); v.events.onReset = this._onReset.bind(this); v.events.onKeyDown = this._onKeyDown.bind(this); v.events.onFocusOut = this._onFocusOut.bind(this); html.container.addEventListener('click', v.events.onClick); // Attach document event only once. if (Object.keys(_tokenfields).length === 1) { document.addEventListener('mousedown', v.events.onMouseDown); window.addEventListener('resize', v.events.onResize); } if (this._form && this._form.nodeName) { this._form.addEventListener('reset', v.events.onReset); } html.suggestList.addEventListener('mouseover', v.events.onMouseOver); html.input.addEventListener('focus', v.events.onFocus); } _onMouseOver(e) { let target = e.target; if (target.classList.contains('tokenfield-suggest-item')) { let selected = [].slice.call(this._html.suggestList.querySelectorAll('.selected')); selected.forEach(item => { if (item !== target) item.classList.remove('selected'); }); target.classList.add('selected'); this._selectItem(target.key, false); this._refreshItemsSelection(); } } _onReset() { this.setItems(this._options.setItems); } _onFocus() { let v = this._vars; let html = this._html; let o = this._options; html.input.removeEventListener('keydown', v.events.onKeyDown); html.input.addEventListener('keydown', v.events.onKeyDown); html.input.addEventListener('focusout', v.events.onFocusOut); if (o.addItemsOnPaste) { v.events.onPaste = this._onPaste.bind(this); html.input.addEventListener('paste', v.events.onPaste); } this._focused = true; this._html.container.classList.add('focused'); if (o.mode === 'tokenfield') { this._resizeInput(); } if (html.input.value.trim().length >= o.minChars) { this.showSuggestions(); } } _onFocusOut(e) { let v = this._vars; let o = this._options; let html = this._html; html.input.removeEventListener('keydown', v.events.onKeyDown); html.input.removeEventListener('focusout', v.events.onFocusOut); if (typeof v.events.onPaste !== 'undefined') { html.input.removeEventListener('paste', v.events.onPaste); } if (e.relatedTarget && e.relatedTarget === html.copyHelper) { return; } let canAddItem = ((o.multiple && ! o.maxItems) || (! o.multiple && ! v.setItems.length) || (o.multiple && o.maxItems && v.setItems.length < o.maxItems)); if (this._focused && o.addItemOnBlur && canAddItem && this._newItem(html.input.value) ) { this._renderItems()._refreshInput(); } else { this._defocusItems()._renderItems(); } this._focused = false; this._html.container.classList.remove('focused'); } _onMouseDown(e) { let tokenfield = null; for (let key in _tokenfields) { if (_tokenfields[key]._html.container.contains(e.target)) { tokenfield = _tokenfields[key]; break; } } if (tokenfield) { for (let key in _tokenfields) { if (key !== tokenfield.id) { _tokenfields[key].hideSuggestions(); _tokenfields[key].blur(); } } // Prevent input blur. if (e.target !== tokenfield._html.input) { e.preventDefault(); } } else { for (let key in _tokenfields) { _tokenfields[key].hideSuggestions(); _tokenfields[key].blur(); } } } _onResize() { for (let key in _tokenfields) { if (_tokenfields[key]._options.mode === 'tokenfield') { _tokenfields[key]._resizeInput(_tokenfields[key]._html.input.value); } } } _onPaste(e) { let v = this._vars; let o = this._options; let val = e.clipboardData.getData('text'); let tokens = [val]; // Break input using delimiters option. if (o.delimiters.length) { let search = o.delimiters.join('|'); let splitRegex = new RegExp(`(${search})`, 'ig'); tokens = val.split(splitRegex); } let items = tokens .map((token) => token.trim()) .filter((token) => { return token.length > 0 && token.length >= o.minLength && typeof v.delimiters[token] === 'undefined'; }) .map((token) => { return this._newItem(token); }); if (items.length) { setTimeout(() => { this._renderItems() ._refreshInput() ._deselectItems() .hideSuggestions(); }, 1); e.preventDefault(); } } _onKeyDown(e) { let v = this._vars; let o = this._options; let html = this._html; if (o.maxItems && v.setItems.length >= o.maxItems) { e.preventDefault(); } if (o.mode === 'tokenfield') { setTimeout(() => { this._resizeInput(html.input.value); }, 1); } let key = keyToChar(e); if (typeof o.keys[e.keyCode] !== 'undefined' || includes(o.delimiters, key)) { if (this._keyAction(e)) return true; } else { this._defocusItems()._refreshItems(); } clearTimeout(v.timer); this._abortXhr(); if (! o.maxItems || v.setItems.length < o.maxItems) { setTimeout(() => { this._keyInput(e); }, 1); } } _keyAction(e) { const key = this.key; let item = null; let v = this._vars; let o = this._options; let html = this._html; let keyName = o.keys[e.keyCode]; let val = html.input.value.trim(); let keyChar = keyToChar(e); if (includes(o.delimiters, keyChar) && typeof keyName === 'undefined') { keyName = 'delimiter'; } let selected = this._getSelectedItems(); if (selected.length) { item = selected[0]; } switch (keyName) { case 'esc': this._deselectItems()._defocusItems()._renderItems().hideSuggestions(); break; case 'up': if (this._vars.suggested) { this._selectPrevItem()._refreshItemsSelection(); e.preventDefault(); } this._defocusItems()._renderItems(); break; case 'down': if (this._vars.suggested) { this._selectNextItem()._refreshItemsSelection(); e.preventDefault(); } this._defocusItems()._renderItems(); break; case 'left': if (this.getFocusedItems().length || (! html.input.selectionStart && ! html.input.selectionEnd) ) { this._focusPrevItem(e.shiftKey); e.preventDefault(); } break; case 'right': if (this.getFocusedItems().length || html.input.selectionStart === val.length ) { this._focusNextItem(e.shiftKey); e.preventDefault(); } break; case 'delimiter': this._abortXhr(); this._defocusItems(); if (! o.multiple && v.setItems.length >= 1) { return false; } val = this.onInput(val, e); if (item) { this._addItem(item); } else if (val.length) { item = this._newItem(val); } if (item) { this ._minimizeInput() ._renderItems() .focus() ._refreshInput() ._refreshSuggestions() ._deselectItems(); } e.preventDefault(); break; case 'select': if (! val.length && (e.ctrlKey || e.metaKey)) { this._vars.setItems.forEach((item) => { item.focused = true; }); this._refreshItems(); } else { return false; } break; case 'cut': { let focusedItems = this.getFocusedItems(); if (focusedItems.length && (e.ctrlKey || e.metaKey)) { this._copy()._delete(e); } else { return false; } break; } case 'copy': { let focusedItems = this.getFocusedItems(); if (focusedItems.length && (e.ctrlKey || e.metaKey)) { this._copy(); } else { return false; } break; } case 'delete': { this._abortXhr(); let focusedItems = this.getFocusedItems(); if ((! html.input.selectionEnd && e.keyCode === 8) || (html.input.selectionStart === val.length && e.keyCode === 46) || focusedItems.length ) { this._delete(e); } else { v.timer = setTimeout(() => { this._keyInput(e); }, o.delay); } break; } default: break; } return true; } _copy() { let o = this._options; let html = this._html; let copyString = this.getFocusedItems() .map(item => item[o.copyProperty]) .join(o.copyDelimiter); html.copyHelper.style.display = 'block'; html.copyHelper.value = copyString; html.copyHelper.focus(); html.copyHelper.select(); document.execCommand('copy'); html.copyHelper.style.display = 'none'; html.copyHelper.value = ''; html.input.focus(); return this; } _delete(e) { let v = this._vars; let o = this._options; const key = this.key; let html = this._html; let focusedItems = this.getFocusedItems(); if (o.mode === 'tokenfield' && v.setItems.length) { if (focusedItems.length) { focusedItems.forEach((item) => { this._removeItem(item[key]); }); this._refreshSuggestions()._keyInput(e); } else if (! html.input.selectionStart) { this._focusItem(v.setItems[v.setItems.length - 1][key]); } } else if (focusedItems.length) { focusedItems.forEach((item) => { this._removeItem(item[key]); }); this._refreshSuggestions()._keyInput(e); } this._minimizeInput()._renderItems()._refreshInput(false); return this; } _keyInput(e) { let v = this._vars; let o = this._options; let html = this._html; this._defocusItems()._refreshItems(); const val = this.onInput(html.input.value.trim(), e); if (e.type === 'keydown') { this.emit('input', this, val, e); } if (val.length < o.minChars) { this.hideSuggestions(); return false; } if (! o.multiple && v.setItems.length >= 1) { return false; } // Check if we have cache with this val. if (typeof v.cache[val] === 'undefined') { // Get new data. if (o.remote.url) { v.timer = setTimeout(() => { this._fetchData(val); }, o.delay); } else if (! o.remote.url && o.items.length) { let data = this._prepareData(o.items); let items = this._filterData(val, data); v.suggestedItems = o.filterSetItems ? this._filterSetItems(items) : items; this.showSuggestions(); } } else { // Work with cached data. let data = this._prepareData(this.remapData(v.cache[val])); let items = this._filterData(val, data); v.suggestedItems = o.filterSetItems ? this._filterSetItems(items) : items; this.showSuggestions(); } return this; } _onClick(e) { let target = e.target; if (target.classList.contains('item-remove')) { e.preventDefault(); this._removeItem(target.key) ._defocusItems() ._minimizeInput() ._renderItems() ._refreshInput(false) ._keyInput(e); } else if (target.classList.contains('tokenfield-suggest-item')) { let item = this._getSuggestedItem(target.key); this._addItem(item) ._minimizeInput() ._renderItems() ._refreshInput() ._refreshSuggestions(); } else { let setItem = getPath(target).filter((node) => { return node.classList && node.classList.contains('tokenfield-set-item'); })[0]; if (setItem) { this._focusItem(setItem.key, e.shiftKey, e.ctrlKey || e.metaKey, true); this._refreshItems(); } else { this._keyInput(e); } } this.focus(); } _selectPrevItem() { const key = this.key; const o = this._options; let items = this._vars.suggestedItems; let index = this._getSelectedItemIndex(); if (! items.length) { return this; } if (index !== null) { if (index === 0) { if (o.newItems) { this._deselectItems(); } else { this._selectItem(items[items.length - 1][key]); } } else { this._selectItem(items[index - 1][key]); } } else { this._selectItem(items[items.length - 1][key]); } return this; } _selectNextItem() { const key = this.key; const o = this._options; let items = this._vars.suggestedItems; let index = this._getSelectedItemIndex(); if (! items.length) { return this; } if (index !== null) { if (index === items.length - 1) { if (o.newItems) { this._deselectItems(); } else { this._selectItem(items[0][key]); } } else { this._selectItem(items[index + 1][key]); } } else { this._selectItem(items[0][key]); } return this; } _focusPrevItem(multiple = false) { const key = this.key; let items = this._vars.setItems; let index = this._getFocusedItemIndex(); if (! items.length) { return this; } if (index !== null) { if (index === 0 && ! multiple) { this._defocusItems(); } else if (index === 0 && multiple) { let lastFocused = this._getFocusedItemIndex(true); this._defocusItem(items[lastFocused][key]); } else { this._focusItem(items[index - 1][key], multiple, false, true); } } else { this._focusItem(items[items.length - 1][key], false, false, true); } this._refreshItems(); return this; } _focusNextItem(multiple = false) { const key = this.key; let items = this._vars.setItems; let index = this._getFocusedItemIndex(true); if (! items.length) { return this; } if (index !== null) { if (index === items.length - 1 && ! multiple) { this._defocusItems(); } else if (index === items.length - 1 && multiple) { this._focusItem(items[0][key], multiple); } else { this._focusItem(items[index + 1][key], multiple); } } else { this._focusItem(items[0][key], false); } this._refreshItems(); return this; } _getSelectedItems() { const key = this.key; let setIds = this._vars.setItems.map(item => item[key]); return this._vars.suggestedItems.filter(v => { return v.selected && setIds.indexOf(v[key]) < 0; }); } _selectItem(key, scroll = false) { this._vars.suggestedItems.forEach(v => { v.selected = v[this.key] === key; if (v.selected && scroll) { let height = parseInt(this._html.suggest.style.maxHeight, 10); if (height) { let listBounds = this._html.suggestList.getBoundingClientRect(); let elBounds = v.el.getBoundingClientRect(); let top = elBounds.top - listBounds.top; let bottom = top + elBounds.height; if (bottom >= height + this._html.suggest.scrollTop) { this._html.suggest.scrollTop = bottom - height; } else if (top < this._html.suggest.scrollTop) { this._html.suggest.scrollTop = top; } } } }); } _deselectItem(key) { this._vars.suggestedItems.every(v => { if (v[this.key] === key) { v.selected = false; return false; } return true; }); return this; } _deselectItems() { this._vars.suggestedItems.forEach(v => { v.selected = false; }); return this; } _refreshItemsSelection() { this._vars.suggestedItems.forEach(v => { if (v.selected && v.el) { v.el.classList.add('selected'); } else if (v.el) { v.el.classList.remove('selected'); } }); } _getSelectedItemIndex() { let index = null; this._vars.suggestedItems.every((v, k) => { if (v.selected) { index = k; return false; } return true; }); return index; } _getFocusedItemIndex(last = false) { let index = null; this._vars.setItems.every((v, k) => { if (v.focused) { index = k; if (! last) { return false; } } return true; }); return index; } _getItem(val, prop = null) { if (prop === null) prop = this.key; let items = this._filterItems(this._vars.setItems, val, prop); return items.length ? items[0] : null; } _getSuggestedItem(val, prop = null) { if (prop === null) prop = this.key; let items = this._filterItems(this._vars.suggestedItems, val, prop); return items.length ? items[0] : null; } _getAvailableItem(val, prop = null) { if (prop === null) prop = this.key; let items = this._filterItems(this._options.items, val, prop); return items.length ? items[0] : null; } _filterItems(items, val, prop) { const matchCase = this._options.filterMatchCase; return items.filter(v => { if (typeof v[prop] === 'string' && typeof val === 'string') { if (matchCase) return v[prop] === val; return v[prop].toLowerCase() == val.toLowerCase(); } return v[prop] == val; }); } _removeItem(key) { this._vars.setItems.every((item, k) => { if (item[this.key] === key) { this.emit('removeToken', this, item); this._vars.setItems.splice(k, 1); this.emit('removedToken', this, item); this.emit('change', this); return false; } return true; }); return this; } _addItem(item) { item.focused = false; let o = this._options; // Check if item already exists in a given list. if ((item.isNew && ! this._getItem(item[o.itemData], o.itemData)) || ! this._getItem(item[o.itemValue], o.itemValue) ) { this.emit('addToken', this, item); if (! this._options.maxItems || (this._options.maxItems && this._vars.setItems.length < this._options.maxItems) ) { item.selected = false; let clonedItem = Object.assign({}, item); this._vars.setItems.push(clonedItem); this.emit('addedToken', this, clonedItem); this.emit('change', this); } } return this; } getFocusedItem() { let items = this._vars.setItems.filter(item => { return item.focused; })[0]; if (items.length) return items[0]; return null; } getFocusedItems() { return this._vars.setItems.filter(item => { return item.focused; }); } _focusItem(key, shift = false, ctrl = false, add = false) { if (shift) { let first = null; let last = null; let target = null; let length = this._vars.setItems.length; this._vars.setItems.forEach((item, k) => { if (item[this.key] === key) { target = k; } if (first === null && item.focused) { first = k; } if (item.focused) { last = k; } }); if ((target === 0 || target === length - 1) && first === null && last === null) { return; } else if (first === null && last === null) { this._vars.setItems[target].focused = true; } else if (target === 0 && last === length - 1 && ! add) { this._vars.setItems[first].focused = false; } else { first = Math.min(target, first); last = Math.max(target, last); this._vars.setItems.forEach((item, k) => { item.focused = target === k || (k >= first && k <= last); }); } } else { this._vars.setItems.forEach(item => { if (ctrl) { item.focused = item[this.key] === key ? ! item.focused : item.focused; } else { item.focused = item[this.key] === key; } }); } return this; } _defocusItem(key) { return this._vars.setItems.filter(item => { if (item[this.key] === key) { item.focused = false; } }); } _defocusItems() { this._vars.setItems.forEach(item => { item.focused = false; }); return this; } _newItem(value) { let o = this._options; if (typeof value === 'string' && (!value.length || value.length < o.minLength)) return null; let item = this._getItem(value, o.itemData) || this._getSuggestedItem(value, o.itemData) || this._getAvailableItem(value, o.itemData); if (! item && o.newItems) { // If validation is set and returns false - item should not be added. if (typeof o.validateNewItem === 'function' && !o.validateNewItem(value)) { return null; } item = { isNew: true, [this.key]: guid(), [o.itemData]: value }; this.emit('newToken', this, item); } if (item) { this._addItem(item); return item; } return null; } // Wrapper for build function in case some of the functions are overwritten. _buildEl(html) { return build(html); } _getBounds() { return this._html.container.getBoundingClientRect(); } _renderItems() { let v = this._vars; let o = this._options; let html = this._html; html.items.innerHTML = ''; v.setItems.forEach((item, k) => { let itemEl = this._renderItem(item, k); html.items.appendChild(itemEl); item.el = itemEl; if (item.focused) { item.el.classList.add('focused'); } else { item.el.classList.remove('focused'); } }); if (v.setItems.length > 1 && o.mode === 'tokenfield') { html.input.setAttribute('placeholder', ''); } else if (o.mode === 'list') { html.input.setAttribute('placeholder', o.placeholder); } if (this._input) { this._input.value = v.setItems.map(item => item[o.singleInputValue]) .join(o.singleInputDelimiter); } return this; } _refreshItems() { let v = this._vars; v.setItems.forEach(item => { if (item.el) { if (item.focused) { item.el.classList.add('focused'); } else { item.el.classList.remove('focused'); } } }); } _renderItem(item, k) { let o = this._options; let itemHtml = this.renderSetItemHtml(item); let label = itemHtml.querySelector('.item-label'); let input = itemHtml.querySelector('.item-input'); let remove = itemHtml.querySelector('.item-remove'); let position = o.keepItemsOrder ? `[${k}]` : '[]'; itemHtml.key = item[this.key]; remove.key = item[this.key]; input.setAttribute('name', (item.isNew ? o.newItemName : o.itemName) + position); input.value = item[(item.isNew ? o.newItemValue : o.itemValue)] || null; label.textContent = this.renderSetItemLabel(item); if (item.focused) { itemHtml.classList.add('focused'); } return itemHtml; } onInput(value, e) { return value; } renderSetItemHtml() { return this._buildEl(this._templates.setItem); } renderSetItemLabel(item) { return item[this._options.itemLabel]; } renderSuggestions(items) { let v = this._vars; let o = this._options; let html = this._html; let index = this._getSelectedItemIndex(); if (! items.length) { return this; } if (o.maxSuggestWindow === 0) { html.suggest.style.maxHeight = null; } if (! v.suggestedItems.length) { return this; } if (! o.newItems && index === null) { items[0].selected = true; } let maxHeight = 0; items.every((item, k) => { if (k >= o.maxSuggest) return false; let child = html.suggestList.childNodes[k]; let el = item.el = this.renderSuggestedItem(item); if (child) { if (child.itemValue === item[o.itemValue]) { child.key = item[this.key]; item.el = child; } else { html.suggestList.replaceChild(el, child); } } else if (!child) { html.suggestList.appendChild(el); } if (o.maxSuggestWindow > 0 && k < o.maxSuggestWindow) { maxHeight += html.suggestList.childNodes[k].getBoundingClientRect().height; } if (o.maxSuggestWindow > 0 && k === o.maxSuggestWindow) { html.suggest.style.maxHeight = maxHeight + 'px'; } return true; }); let overflow = html.suggestList.childElementCount - items.length; if (overflow > 0) { for (let i = overflow- 1; i >= 0; i--) { html.suggestList.removeChild(html.suggestList.childNodes[items.length + i]); } } return this; } renderSuggestedItem(item) { let o = this._options; let el = this._buildEl(this._templates.suggestItem); el.key = item[this.key]; el.itemValue = item[o.itemValue]; el.innerHTML = this.renderSuggestedItemContent(item); el.setAttribute('title', item[o.itemData]); if (item.selected) { el.classList.add('selected'); } if (! o.filterSetItems) { let setItem = this._getItem(item[o.itemValue], o.itemValue) || this._getItem(item[o.itemData], o.itemData); if (setItem) { el.classList.add('set'); } } return el; } renderSuggestedItemContent(item) { return item[this._options.itemData]; } _refreshSuggestions() { let v = this._vars; let o = this._options; if (this._html.input.value.length < o.minChars) { this.hideSuggestions(); return this; } let data = this._prepareData(o.items); let items = this._filterData(this._html.input.value, data); v.suggestedItems = o.filterSetItems ? this._filterSetItems(items) : items; if (v.suggestedItems.length) { if (! o.maxItems || (o.maxItems && v.setItems.length < o.maxItems)) { this.renderSuggestions(v.suggestedItems); return this; } } this.hideSuggestions(); return this; } showSuggestions() { let v = this._vars; let o = this._options; if (v.suggestedItems.length) { this.emit('showSuggestions', this); if (!o.maxItems || (o.maxItems && v.setItems.length < o.maxItems)) { this._html.suggest.style.display = 'block'; v.suggested = true; this.renderSuggestions(v.suggestedItems); } this.emit('shownSuggestions', this); } else { this.hideSuggestions(); } return this; } hideSuggestions() { this.emit('hideSuggestions', this); this._vars.suggested = false; this._html.suggest.style.display = 'none'; this._html.suggestList.innerHTML = ''; this.emit('hiddenSuggestions', this); return this; } setSuggestedItems(items) { if (!Array.isArray(items)) { throw new Error('Argument must be an array of objects.'); } this._options.items = items; this._refreshSuggestions(); } getItems() { return this._vars.setItems.map(item => { let v = Object.assign({}, item); delete v[this.key]; delete v.focused; delete v.selected; delete v.el; return v; }); } setItems(items = []) { this._vars.setItems = []; this.addItems(items); return this; } addItems(items = []) { const key = this._options.itemValue; if (! Array.isArray(items)) { items = [items]; } this._prepareData(items).forEach((item) => { if (item.isNew || typeof item[key] !== 'undefined') { this._addItem(item); } }); this._minimizeInput() ._renderItems() ._refreshInput() .hideSuggestions(); return this; } sortItems() { let items = []; [...this._html.items.childNodes].forEach(el => { let item = this._getItem(el.key); if (item) { items.push(item); } }); this.setItems(items); } removeItem(value) { const o = this._options; if (typeof value === 'object' && (value[o.itemValue] || value[o.newItemValue]) ) { value = value[o.itemValue] || value[o.newItemValue]; } let item = this._getItem(value, o.itemValue) || this._getItem(value, o.newItemValue); if (! item) { return this; } this._removeItem(item[this.key])._renderItems(); return this; } emptyItems() { this._vars.setItems = []; this._renderItems() ._refreshInput() .hideSuggestions(); this.emit('change', this); return this; } getSuggestedItems() { return this._vars.suggestedItems.map(item => { return Object.assign({}, item); }); } focus() { this._html.container.classList.add('focused'); if (! this._focused) this._html.input.focus(); return this; } blur() { this._html.container.classList.remove('focused'); if (this._focused) this._html.input.blur(); return this; } remove() { let html = this._html; html.container.parentElement.insertBefore(this.el, html.container); html.container.remove(); this.el.style.display = 'block'; if (Object.keys(_tokenfields).length === 1) { document.removeEventListener('mousedown', this._vars.events.onMouseDown); window.removeEventListener('resize', this._vars.events.onResize); } if (this._form && this._form.nodeName) { this._form.removeEventListener('reset', this._vars.events.onReset); } delete _tokenfields[this.id]; delete this.el.tokenfield; } } export default Tokenfield;