tagelect
Version:
Tag input field with autocomplete and validations.
509 lines (362 loc) • 16.6 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports["default"] = void 0;
require("core-js/stable");
require("regenerator-runtime/runtime");
var _axios = require("axios");
var _lodash = _interopRequireDefault(require("lodash.merge"));
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; }
function _toConsumableArray(arr) { return _arrayWithoutHoles(arr) || _iterableToArray(arr) || _unsupportedIterableToArray(arr) || _nonIterableSpread(); }
function _nonIterableSpread() { throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); }
function _unsupportedIterableToArray(o, minLen) { if (!o) return; if (typeof o === "string") return _arrayLikeToArray(o, minLen); var n = Object.prototype.toString.call(o).slice(8, -1); if (n === "Object" && o.constructor) n = o.constructor.name; if (n === "Map" || n === "Set") return Array.from(o); if (n === "Arguments" || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)) return _arrayLikeToArray(o, minLen); }
function _iterableToArray(iter) { if (typeof Symbol !== "undefined" && iter[Symbol.iterator] != null || iter["@@iterator"] != null) return Array.from(iter); }
function _arrayWithoutHoles(arr) { if (Array.isArray(arr)) return _arrayLikeToArray(arr); }
function _arrayLikeToArray(arr, len) { if (len == null || len > arr.length) len = arr.length; for (var i = 0, arr2 = new Array(len); i < len; i++) { arr2[i] = arr[i]; } return arr2; }
function Tagelect(element, options) {
var _this5 = this;
this.element = element;
this.defaultOptions = {
// max amount of tags that can be added
// (it is possible it initially render more by setting input value)
maxTags: undefined,
maxTagsError: "can't contain more than %TAGS% tags",
validationRegex: undefined,
// the regex that is going to be used while validating a tag
validationRegexError: 'permitted characters used',
// the message shown if regexp validation fails
noDuplicates: false,
// are duplicates allowed
noDuplicatesError: 'duplicates are not allowed',
// the message shown if validations fail because of duplicates
placeholder: 'Enter a tag...',
// The placeholder for the tag entry
removeButton: true,
// Show remove button
tagDelimiter: ',',
// The delimiter used when setting form input value
suggestionsCount: 5,
// Fetch and display 5 suggestions
suggestionHeaders: function suggestionHeaders() {
return {};
},
// Function returning headers for suggestion GET request
suggestionsSource: null,
// CSS classes
classNames: {
wrapper: 'tagelect',
container: 'tagelect-content',
tagInput: 'tagelect-input',
tag: 'tagelect-tag',
removeButton: 'tagelect-tag-remove-btn',
tagText: 'tagelect-tag-text',
dropdown: 'tagelect-dropdown',
dropdownItem: 'tagelect-dropdown-item',
dropdownItemSelected: 'tagelect-dropdown-item--selected',
error: 'tagelect-error'
}
};
this.options = (0, _lodash["default"])(this.defaultOptions, options);
this.tags = [];
this.suggestions = []; // If input has a value, try to initialize tags from it
// by reading the value, splitting on delimiter and trimming the tag text
if (this.element.value) {
// Set the initial tags from hidden input value
this.tags = this.element.value.split(this.options.tagDelimiter).map(function (e) {
return e.trim();
});
} // Hide element
this.element.style.display = 'none'; // Serialize the current tags to a string (using the delimiter) and set it as input value
function setInputValue() {
this.element.value = this.tags.join(this.options.tagDelimiter);
}
this.setInputValue = setInputValue.bind(this);
function tagelectContainerElement() {
return this.element.parentElement.querySelector('[data-tagelect-container]');
}
this.tagelectContainerElement = tagelectContainerElement.bind(this);
function tabCompleteAllowed(suggestion) {
var container = this.tagelectContainerElement();
var tagInput = container.querySelector('[data-tagelect-tag-input]');
if (!suggestion || tagInput.innerText.length === 0) {
return false;
}
return suggestion.startsWith(tagInput.innerText);
}
this.tabCompleteAllowed = tabCompleteAllowed.bind(this);
function setFirstSuggestion(suggestion) {
var container = this.tagelectContainerElement();
var tagInput = container.querySelector('[data-tagelect-tag-input]');
if (suggestion) {
tagInput.dataset.suggestion = suggestion.replace(tagInput.innerText, '');
} else {
tagInput.removeAttribute('data-suggestion');
}
}
this.setFirstSuggestion = setFirstSuggestion.bind(this);
function removeValidationErrors() {
this.containerElement.parentElement.querySelectorAll('[data-tagelect-error]').forEach(function (e) {
e.remove();
});
}
this.removeValidationErrors = removeValidationErrors.bind(this);
function optionClasses(name) {
return this.options.classNames[name].split(' ');
}
this.optionClasses = optionClasses.bind(this);
function createTagelectElement(htmlTag, name) {
var _tagelectElement$clas;
var tagelectElement = document.createElement(htmlTag);
var elementClasses = this.optionClasses(name);
(_tagelectElement$clas = tagelectElement.classList).add.apply(_tagelectElement$clas, _toConsumableArray(elementClasses));
tagelectElement.dataset["tagelect".concat(name[0].toUpperCase()).concat(name.slice(1))] = true;
return tagelectElement;
}
this.createTagelectElement = createTagelectElement.bind(this);
function buildErrorSpan(errorMessage) {
var errorElement = this.createTagelectElement('span', 'error');
errorElement.innerText = errorMessage;
return errorElement;
}
this.buildErrorSpan = buildErrorSpan.bind(this);
function appendError(errorMessage) {
var errorElement = this.buildErrorSpan(errorMessage);
this.containerElement.insertAdjacentElement('afterend', errorElement);
}
this.appendError = appendError.bind(this);
function validateStuff(nextTag) {
// Remove previous errors
this.removeValidationErrors();
var maxTags = parseInt(this.options.maxTags, 10); // Error if max tags limit reached
if (this.options.maxTags && this.tags.length === maxTags) {
// The maxTagsError may contain templating variable %TAGS%
this.appendError(this.options.maxTagsError.replace('%TAGS%', maxTags));
return false;
} // Error if tag does not match validation regex
if (this.options.validationRegex && !nextTag.match(this.options.validationRegex)) {
this.appendError(this.options.validationRegexError);
return false;
}
if (this.options.noDuplicates && this.tags.includes(nextTag)) {
this.appendError(this.options.noDuplicatesError);
return false;
}
return true;
}
this.validateStuff = validateStuff.bind(this);
function renderRemoveButton() {
var _this = this;
var removeBtnElement = this.createTagelectElement('div', 'removeButton');
removeBtnElement.innerText = '×'; // × icon
removeBtnElement.addEventListener('click', function (e) {
e.preventDefault();
var tag = e.target.closest('[data-tagelect-tag]');
var removedTagText = tag.querySelector('[data-tagelect-tag-text]').innerText; // Removing tag from state
_this.tags = _this.tags.filter(function (tg) {
return tg !== removedTagText;
}); // Clear the tag input
_this.setInputValue(); // Remove the tag from DOM
tag.remove();
});
return removeBtnElement;
}
this.renderRemoveButton = renderRemoveButton.bind(this); // Tag rendering
function renderTag(text) {
var container = this.tagelectContainerElement(); // Build a tag
var tagElement = this.createTagelectElement('div', 'tag');
var tagTextElement = this.createTagelectElement('div', 'tagText');
tagTextElement.innerHTML = text;
tagElement.insertAdjacentElement('beforeend', tagTextElement);
if (this.options.removeButton) {
var removeButtonElement = this.renderRemoveButton();
tagElement.insertAdjacentElement('beforeend', removeButtonElement);
}
var tagInput = container.querySelector('[data-tagelect-tag-input]'); // Prepend tag to the tags
tagInput.insertAdjacentElement('beforebegin', tagElement);
}
this.renderTag = renderTag.bind(this);
function renderTags() {
var _this2 = this;
var container = this.tagelectContainerElement();
var tagElements = container.querySelectorAll('[data-tagelect-tag]'); // Remove previously rendered tags
tagElements.forEach(function (tagElem) {
return tagElem.remove();
});
if (this.tags.length === 0) {
return;
}
this.tags.forEach(function (tagText) {
_this2.renderTag(tagText);
});
}
this.renderTags = renderTags.bind(this);
function focusOnTagInput() {
var tagInput = this.containerElement.querySelector('[data-tagelect-tag-input]');
var clickEvent = new Event('click');
tagInput.dispatchEvent(clickEvent);
}
this.focusOnTagInput = focusOnTagInput.bind(this);
function clearTagInput() {
var tagInput = this.containerElement.querySelector('[data-tagelect-tag-input]');
tagInput.innerHTML = '';
}
this.clearTagInput = clearTagInput.bind(this);
function toggleDropdown(show) {
var _this3 = this;
var prevDropdown = document.querySelector('[data-tagelect-dropdown]'); // Remove previous dropdown
if (prevDropdown) {
prevDropdown.remove();
}
if (!show) {
return;
} // Else re-build a new dropdown
var dropdownElement = this.createTagelectElement('ul', 'dropdown');
this.suggestions.forEach(function (suggestion, idx) {
var itemElement = _this3.createTagelectElement('li', 'dropdownItem');
if (idx === 0) {
var _itemElement$classLis;
(_itemElement$classLis = itemElement.classList).add.apply(_itemElement$classLis, _toConsumableArray(_this3.options.classNames.dropdownItemSelected.split(' ')));
}
itemElement.innerText = suggestion;
itemElement.addEventListener('click', function (e) {
if (!_this3.validateStuff(e.target.innerText)) {
return;
}
_this3.tags.push(e.target.innerText);
_this3.renderTags();
_this3.setInputValue();
_this3.clearTagInput();
_this3.focusOnTagInput();
_this3.toggleDropdown(false);
});
dropdownElement.insertAdjacentElement('beforeend', itemElement);
});
this.containerElement.insertAdjacentElement('afterend', dropdownElement);
}
this.toggleDropdown = toggleDropdown.bind(this);
function fetchSuggestions(searchPhrase) {
var _this4 = this;
var count = this.options.suggestionsCount;
if (!this.options.suggestionsSource) {
return;
}
var url = "".concat(this.options.suggestionsSource, "?name=").concat(searchPhrase, "&count=").concat(count);
var headers = this.options.suggestionHeaders();
(0, _axios.get)(url, {
headers: headers
}).then(function (response) {
_this4.suggestions = response.data === null ? [] : response.data;
var firstSuggestion = _this4.suggestions[0] || null; // If typed "a" and the source returned "Alabama" the data-suggestion should
// not be set, because the resulting Tab-completion would look like:
// "aAlabama"
if (!_this4.tabCompleteAllowed(firstSuggestion)) {
firstSuggestion = null;
}
_this4.setFirstSuggestion(firstSuggestion); // If no suggestions found - don't show dropdown
_this4.toggleDropdown(_this4.suggestions.length > 0);
})["catch"](function () {
_this4.setFirstSuggestion(null);
_this4.toggleDropdown(false);
});
}
this.fetchSuggestions = fetchSuggestions.bind(this); // Render initial tagelect elements
this.wrapperElement = this.createTagelectElement('div', 'wrapper');
this.wrapperElement.setAttribute('tabindex', '-1'); // Track if mouse is in container (used for during blur event handling)
this.wrapperElement.addEventListener('mouseover', function () {
_this5.mouseOverContainer = true;
});
this.wrapperElement.addEventListener('mouseout', function () {
_this5.mouseOverContainer = false;
});
this.containerElement = this.createTagelectElement('div', 'container'); // Render the input field for entering tags
var tagInputElement = this.createTagelectElement('span', 'tagInput');
tagInputElement.setAttribute('contenteditable', true);
tagInputElement.dataset.placeholder = this.options.placeholder;
tagInputElement.style.minWidth = "".concat(this.options.placeholder.length * 6, "px");
['click', 'focus'].forEach(function (eventType) {
tagInputElement.addEventListener(eventType, function (evt) {
if (evt.target.innerText.length > 0) {
_this5.fetchSuggestions(evt.target.innerText);
}
});
});
tagInputElement.addEventListener('blur', function () {
_this5.suggestions = [];
var dropdown = document.querySelector('[data-tagelect-dropdown]'); // Do nothing unless dropdown is open
if (!dropdown) {
return;
} // If mouse is outside of the whole container
if (!_this5.mouseOverContainer) {
_this5.setFirstSuggestion(null);
_this5.toggleDropdown(false);
}
}); // Process the keydown events for tag input
tagInputElement.addEventListener('keydown', function (e) {
// Add the suggestion as tag -> if Tab is pressed while there is a valid suggestion
if (e.key === 'Tab' && _this5.tabCompleteAllowed(_this5.suggestions[0])) {
e.preventDefault();
var tagText = _this5.suggestions[0];
if (!_this5.validateStuff(tagText)) {
return;
}
_this5.tags = _this5.tags.concat(tagText);
_this5.renderTags();
_this5.setInputValue();
_this5.clearTagInput();
_this5.suggestions = [];
_this5.toggleDropdown(false);
_this5.focusOnTagInput();
} // Add tag -> if Enter pressed in non-empty input
if ((e.key === 'Enter' || e.key === ',') && e.target.innerText.length > 0) {
e.preventDefault();
if (!_this5.validateStuff(e.target.innerText)) {
return;
}
_this5.tags = _this5.tags.concat(e.target.innerText);
_this5.renderTags();
_this5.setInputValue();
_this5.clearTagInput();
_this5.suggestions = [];
_this5.toggleDropdown(false);
_this5.focusOnTagInput();
return;
} // Close dropdown -> if no tags and tag input is cleared
if (e.key === 'Backspace' && e.target.innerText.length === 1 && _this5.tags.length === 0) {
_this5.removeValidationErrors();
_this5.toggleDropdown(false);
return;
} // Remove last tag -> if Backspace pressed in empty tag input
if (e.key === 'Backspace' && e.target.innerText.length === 0 && _this5.tags.length > 0) {
_this5.removeValidationErrors();
_this5.tags.pop();
_this5.setInputValue();
_this5.renderTags();
return;
} // Do nothing -> if Enter pressed in empty tag input
if (e.key === 'Enter' && e.target.innerText.length === 0) {
e.preventDefault();
}
var alphanumeric = e.key.match(/^\w$/); // Update tag input and fetch suggestions -> if alphanum. entered or char deleted
//
// NOTE: The event hasn't changed the innerText yet, so we have to check for length > 1
if (alphanumeric || e.key === 'Backspace' && e.target.innerText.length > 1) {
var tagInput = _this5.containerElement.querySelector('[data-tagelect-tag-input]');
var newText = e.key === 'Backspace' ? tagInput.innerText.slice(0, -1) : tagInput.innerText + e.key;
if (!_this5.validateStuff(newText)) {
return;
}
_this5.fetchSuggestions(newText);
}
if (e.key === 'Backspace' && e.target.innerText.length === 1) {
_this5.removeValidationErrors();
_this5.toggleDropdown(false);
}
}); // Add elements to DOM
this.containerElement.insertAdjacentElement('beforeend', tagInputElement);
this.wrapperElement.insertAdjacentElement('beforeend', this.containerElement);
this.element.insertAdjacentElement('beforebegin', this.wrapperElement); // Render the initial tags
this.renderTags();
}
var _default = Tagelect;
exports["default"] = _default;