/**
* Flexible UI element featuring both a list of items and an input field with suggestion support.
*
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Ui/ItemList
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Ui/ItemList
*/
-define(['Core', 'Dictionary', 'Language', 'Dom/Traverse', 'EventKey', 'WoltLabSuite/Core/Ui/Suggestion', 'Ui/SimpleDropdown'], function (Core, Dictionary, Language, DomTraverse, EventKey, UiSuggestion, UiSimpleDropdown) {
+var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
+ if (k2 === undefined) k2 = k;
+ Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } });
+}) : (function(o, m, k, k2) {
+ if (k2 === undefined) k2 = k;
+ o[k2] = m[k];
+}));
+var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
+}) : function(o, v) {
+ o["default"] = v;
+});
+var __importStar = (this && this.__importStar) || function (mod) {
+ if (mod && mod.__esModule) return mod;
+ var result = {};
+ if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
+ __setModuleDefault(result, mod);
+ return result;
+};
+var __importDefault = (this && this.__importDefault) || function (mod) {
+ return (mod && mod.__esModule) ? mod : { "default": mod };
+};
+define(["require", "exports", "../Core", "../Dom/Traverse", "../Language", "./Suggestion", "./Dropdown/Simple", "../Dom/Util"], function (require, exports, Core, DomTraverse, Language, Suggestion_1, Simple_1, Util_1) {
"use strict";
- var _activeId = '';
- var _data = new Dictionary();
- var _didInit = false;
- var _callbackKeyDown = null;
- var _callbackKeyPress = null;
- var _callbackKeyUp = null;
- var _callbackPaste = null;
- var _callbackRemoveItem = null;
- var _callbackBlur = null;
+ Object.defineProperty(exports, "__esModule", { value: true });
+ exports.setValues = exports.getValues = exports.init = void 0;
+ Core = __importStar(Core);
+ DomTraverse = __importStar(DomTraverse);
+ Language = __importStar(Language);
+ Suggestion_1 = __importDefault(Suggestion_1);
+ Simple_1 = __importDefault(Simple_1);
+ Util_1 = __importDefault(Util_1);
+ let _activeId = '';
+ const _data = new Map();
/**
- * @exports WoltLabSuite/Core/Ui/ItemList
+ * Creates the DOM structure for target element. If `element` is a `<textarea>`
+ * it will be automatically replaced with an `<input>` element.
*/
- return {
- /**
- * Initializes an item list.
- *
- * The `values` argument must be empty or contain a list of strings or object, e.g.
- * `['foo', 'bar']` or `[{ objectId: 1337, value: 'baz'}, {...}]`
- *
- * @param {string} elementId input element id
- * @param {Array} values list of existing values
- * @param {Object} options option list
- */
- init: function (elementId, values, options) {
- var element = elById(elementId);
- if (element === null) {
- throw new Error("Expected a valid element id, '" + elementId + "' is invalid.");
- }
- // remove data from previous instance
- if (_data.has(elementId)) {
- var tmp = _data.get(elementId);
- for (var key in tmp) {
- if (tmp.hasOwnProperty(key)) {
- var el = tmp[key];
- if (el instanceof Element && el.parentNode) {
- elRemove(el);
- }
- }
- }
- UiSimpleDropdown.destroy(elementId);
- _data.delete(elementId);
- }
- options = Core.extend({
- // search parameters for suggestions
- ajax: {
- actionName: 'getSearchResultList',
- className: '',
- data: {}
- },
- // list of excluded string values, e.g. `['ignore', 'these strings', 'when', 'searching']`
- excludedSearchValues: [],
- // maximum number of items this list may contain, `-1` for infinite
- maxItems: -1,
- // maximum length of an item value, `-1` for infinite
- maxLength: -1,
- // disallow custom values, only values offered by the suggestion dropdown are accepted
- restricted: false,
- // initial value will be interpreted as comma separated value and submitted as such
- isCSV: false,
- // will be invoked whenever the items change, receives the element id first and list of values second
- callbackChange: null,
- // callback once the form is about to be submitted
- callbackSubmit: null,
- // Callback for the custom shadow synchronization.
- callbackSyncShadow: null,
- // Callback to set values during the setup.
- callbackSetupValues: null,
- // value may contain the placeholder `{$objectId}`
- submitFieldName: ''
- }, options);
- var form = DomTraverse.parentByTag(element, 'FORM');
- if (form !== null) {
- if (options.isCSV === false) {
- if (!options.submitFieldName.length && typeof options.callbackSubmit !== 'function') {
- throw new Error("Expected a valid function for option 'callbackSubmit', a non-empty value for option 'submitFieldName' or enabling the option 'submitFieldCSV'.");
- }
- form.addEventListener('submit', (function () {
- if (this._acceptsNewItems(elementId)) {
- var value = _data.get(elementId).element.value.trim();
- if (value.length) {
- this._addItem(elementId, { objectId: 0, value: value });
- }
- }
- var values = this.getValues(elementId);
- if (options.submitFieldName.length) {
- var input;
- for (var i = 0, length = values.length; i < length; i++) {
- input = elCreate('input');
- input.type = 'hidden';
- input.name = options.submitFieldName.replace('{$objectId}', values[i].objectId);
- input.value = values[i].value;
- form.appendChild(input);
- }
- }
- else {
- options.callbackSubmit(form, values);
- }
- }).bind(this));
- }
- else {
- form.addEventListener('submit', function () {
- if (this._acceptsNewItems(elementId)) {
- var value = _data.get(elementId).element.value.trim();
- if (value.length) {
- this._addItem(elementId, { objectId: 0, value: value });
- }
- }
- }.bind(this));
+ function createUI(element, options) {
+ const parentElement = element.parentElement;
+ const list = document.createElement('ol');
+ list.className = 'inputItemList' + (element.disabled ? ' disabled' : '');
+ list.dataset.elementId = element.id;
+ list.addEventListener('click', event => {
+ if (event.target === list) {
+ element.focus();
+ }
+ });
+ const listItem = document.createElement('li');
+ listItem.className = 'input';
+ list.appendChild(listItem);
+ element.addEventListener('keydown', keyDown);
+ element.addEventListener('keypress', keyPress);
+ element.addEventListener('keyup', keyUp);
+ element.addEventListener('paste', paste);
+ const hasFocus = (element === document.activeElement);
+ if (hasFocus) {
+ element.blur();
+ }
+ element.addEventListener('blur', blur);
+ parentElement.insertBefore(list, element);
+ listItem.appendChild(element);
+ if (hasFocus) {
+ window.setTimeout(() => {
+ element.focus();
+ }, 1);
+ }
+ if (options.maxLength !== -1) {
+ element.maxLength = options.maxLength;
+ }
+ const limitReached = document.createElement('span');
+ limitReached.className = 'inputItemListLimitReached';
+ limitReached.textContent = Language.get('wcf.global.form.input.maxItems');
+ Util_1.default.hide(limitReached);
+ listItem.appendChild(limitReached);
+ let shadow = null;
+ const values = [];
+ if (options.isCSV) {
+ shadow = document.createElement('input');
+ shadow.className = 'itemListInputShadow';
+ shadow.type = 'hidden';
+ shadow.name = element.name;
+ element.removeAttribute('name');
+ list.parentNode.insertBefore(shadow, list);
+ element.value.split(',').forEach(value => {
+ value = value.trim();
+ if (value) {
+ values.push(value);
}
- }
- this._setup();
- var data = this._createUI(element, options);
- //noinspection JSUnresolvedVariable
- var suggestion = new UiSuggestion(elementId, {
- ajax: options.ajax,
- callbackSelect: this._addItem.bind(this),
- excludedSearchValues: options.excludedSearchValues
});
- _data.set(elementId, {
- dropdownMenu: null,
- element: data.element,
- limitReached: data.limitReached,
- list: data.list,
- listItem: data.element.parentNode,
- options: options,
- shadow: data.shadow,
- suggestion: suggestion
- });
- if (options.callbackSetupValues) {
- values = options.callbackSetupValues();
- }
- else {
- values = (data.values.length) ? data.values : values;
+ if (element.nodeName === 'TEXTAREA') {
+ const inputElement = document.createElement('input');
+ inputElement.type = 'text';
+ parentElement.insertBefore(inputElement, element);
+ inputElement.id = element.id;
+ element.remove();
+ element = inputElement;
}
- if (Array.isArray(values)) {
- var value;
- for (var i = 0, length = values.length; i < length; i++) {
- value = values[i];
- if (typeof value === 'string') {
- value = { objectId: 0, value: value };
+ }
+ return {
+ element: element,
+ limitReached: limitReached,
+ list: list,
+ shadow: shadow,
+ values: values,
+ };
+ }
+ /**
+ * Returns true if the input accepts new items.
+ */
+ function acceptsNewItems(elementId) {
+ const data = _data.get(elementId);
+ if (data.options.maxItems === -1) {
+ return true;
+ }
+ return (data.list.childElementCount - 1 < data.options.maxItems);
+ }
+ /**
+ * Enforces the maximum number of items.
+ */
+ function handleLimit(elementId) {
+ const data = _data.get(elementId);
+ if (acceptsNewItems(elementId)) {
+ Util_1.default.show(data.element);
+ Util_1.default.hide(data.limitReached);
+ }
+ else {
+ Util_1.default.hide(data.element);
+ Util_1.default.show(data.limitReached);
+ }
+ }
+ /**
+ * Sets the active item list id and handles keyboard access to remove an existing item.
+ */
+ function keyDown(event) {
+ const input = event.currentTarget;
+ _activeId = input.id;
+ const lastItem = input.parentElement.previousElementSibling;
+ if (event.key === 'Backspace') {
+ if (input.value.length === 0) {
+ if (lastItem !== null) {
+ if (lastItem.classList.contains('active')) {
+ removeItem(lastItem);
+ }
+ else {
+ lastItem.classList.add('active');
}
- this._addItem(elementId, value);
}
}
- },
- /**
- * Returns the list of current values.
- *
- * @param {string} elementId input element id
- * @return {Array} list of objects containing object id and value
- */
- getValues: function (elementId) {
- if (!_data.has(elementId)) {
- throw new Error("Element id '" + elementId + "' is unknown.");
- }
- var data = _data.get(elementId);
- var values = [];
- elBySelAll('.item > span', data.list, function (span) {
- values.push({
- objectId: ~~elData(span, 'object-id'),
- value: span.textContent.trim(),
- type: elData(span, 'type')
- });
- });
- return values;
- },
- /**
- * Sets the list of current values.
- *
- * @param {string} elementId input element id
- * @param {Array} values list of objects containing object id and value
- */
- setValues: function (elementId, values) {
- if (!_data.has(elementId)) {
- throw new Error("Element id '" + elementId + "' is unknown.");
+ }
+ else if (event.key === 'Escape') {
+ if (lastItem !== null && lastItem.classList.contains('active')) {
+ lastItem.classList.remove('active');
}
- var data = _data.get(elementId);
- // remove all existing items first
- var i, length;
- var items = DomTraverse.childrenByClass(data.list, 'item');
- for (i = 0, length = items.length; i < length; i++) {
- this._removeItem(null, items[i], true);
+ }
+ }
+ /**
+ * Handles the `[ENTER]` and `[,]` key to add an item to the list unless it is restricted.
+ */
+ function keyPress(event) {
+ if (event.key === 'Enter' || event.key === ',') {
+ event.preventDefault();
+ const input = event.currentTarget;
+ if (_data.get(input.id).options.restricted) {
+ // restricted item lists only allow results from the dropdown to be picked
+ return;
}
- // add new items
- for (i = 0, length = values.length; i < length; i++) {
- this._addItem(elementId, values[i]);
+ const value = input.value.trim();
+ if (value.length) {
+ addItem(input.id, { objectId: 0, value: value });
}
- },
- /**
- * Binds static event listeners.
- */
- _setup: function () {
- if (_didInit) {
- return;
+ }
+ }
+ /**
+ * Splits comma-separated values being pasted into the input field.
+ */
+ function paste(event) {
+ event.preventDefault();
+ const text = event.clipboardData.getData('text/plain');
+ const element = event.currentTarget;
+ const elementId = element.id;
+ const maxLength = +element.maxLength;
+ text.split(/,/).forEach(item => {
+ item = item.trim();
+ if (maxLength && item.length > maxLength) {
+ // truncating items provides a better UX than throwing an error or silently discarding it
+ item = item.substr(0, maxLength);
+ }
+ if (item.length > 0 && acceptsNewItems(elementId)) {
+ addItem(elementId, { objectId: 0, value: item });
+ }
+ });
+ }
+ /**
+ * Handles the keyup event to unmark an item for deletion.
+ */
+ function keyUp(event) {
+ const input = event.currentTarget;
+ if (input.value.length > 0) {
+ const lastItem = input.parentElement.previousElementSibling;
+ if (lastItem !== null) {
+ lastItem.classList.remove('active');
}
- _didInit = true;
- _callbackKeyDown = this._keyDown.bind(this);
- _callbackKeyPress = this._keyPress.bind(this);
- _callbackKeyUp = this._keyUp.bind(this);
- _callbackPaste = this._paste.bind(this);
- _callbackRemoveItem = this._removeItem.bind(this);
- _callbackBlur = this._blur.bind(this);
- },
- /**
- * Creates the DOM structure for target element. If `element` is a `<textarea>`
- * it will be automatically replaced with an `<input>` element.
- *
- * @param {Element} element input element
- * @param {Object} options option list
- */
- _createUI: function (element, options) {
- var list = elCreate('ol');
- list.className = 'inputItemList' + (element.disabled ? ' disabled' : '');
- elData(list, 'element-id', element.id);
- list.addEventListener(WCF_CLICK_EVENT, function (event) {
- if (event.target === list) {
- //noinspection JSUnresolvedFunction
- element.focus();
- }
- });
- var listItem = elCreate('li');
- listItem.className = 'input';
- list.appendChild(listItem);
- element.addEventListener('keydown', _callbackKeyDown);
- element.addEventListener('keypress', _callbackKeyPress);
- element.addEventListener('keyup', _callbackKeyUp);
- element.addEventListener('paste', _callbackPaste);
- var hasFocus = element === document.activeElement;
- if (hasFocus) {
- //noinspection JSUnresolvedFunction
- element.blur();
+ }
+ }
+ /**
+ * Adds an item to the list.
+ */
+ function addItem(elementId, value) {
+ const data = _data.get(elementId);
+ const listItem = document.createElement('li');
+ listItem.className = 'item';
+ const content = document.createElement('span');
+ content.className = 'content';
+ content.dataset.objectId = value.objectId.toString();
+ if (value.type) {
+ content.dataset.type = value.type;
+ }
+ content.textContent = value.value;
+ listItem.appendChild(content);
+ if (!data.element.disabled) {
+ const button = document.createElement('a');
+ button.className = 'icon icon16 fa-times';
+ button.addEventListener('click', removeItem);
+ listItem.appendChild(button);
+ }
+ data.list.insertBefore(listItem, data.listItem);
+ data.suggestion.addExcludedValue(value.value);
+ data.element.value = '';
+ if (!data.element.disabled) {
+ handleLimit(elementId);
+ }
+ let values = syncShadow(data);
+ if (typeof data.options.callbackChange === 'function') {
+ if (values === null) {
+ values = getValues(elementId);
}
- element.addEventListener('blur', _callbackBlur);
- element.parentNode.insertBefore(list, element);
- listItem.appendChild(element);
- if (hasFocus) {
- window.setTimeout(function () {
- //noinspection JSUnresolvedFunction
- element.focus();
- }, 1);
+ data.options.callbackChange(elementId, values);
+ }
+ }
+ /**
+ * Removes an item from the list.
+ */
+ function removeItem(item, noFocus) {
+ if (item instanceof Event) {
+ const target = item.currentTarget;
+ item = target.parentElement;
+ }
+ const parent = item.parentElement;
+ const elementId = parent.dataset.elementId || '';
+ const data = _data.get(elementId);
+ if (item.children[0].textContent) {
+ data.suggestion.removeExcludedValue(item.children[0].textContent);
+ }
+ item.remove();
+ if (!noFocus) {
+ data.element.focus();
+ }
+ handleLimit(elementId);
+ let values = syncShadow(data);
+ if (typeof data.options.callbackChange === 'function') {
+ if (values === null) {
+ values = getValues(elementId);
}
- if (options.maxLength !== -1) {
- elAttr(element, 'maxLength', options.maxLength);
+ data.options.callbackChange(elementId, values);
+ }
+ }
+ /**
+ * Synchronizes the shadow input field with the current list item values.
+ */
+ function syncShadow(data) {
+ if (!data.options.isCSV) {
+ return null;
+ }
+ if (typeof data.options.callbackSyncShadow === 'function') {
+ return data.options.callbackSyncShadow(data);
+ }
+ const values = getValues(data.element.id);
+ data.shadow.value = getValues(data.element.id)
+ .map(value => value.value)
+ .join(',');
+ return values;
+ }
+ /**
+ * Handles the blur event.
+ */
+ function blur(event) {
+ const input = event.currentTarget;
+ const data = _data.get(input.id);
+ if (data.options.restricted) {
+ // restricted item lists only allow results from the dropdown to be picked
+ return;
+ }
+ const value = input.value.trim();
+ if (value.length) {
+ if (!data.suggestion || !data.suggestion.isActive()) {
+ addItem(input.id, { objectId: 0, value: value });
}
- var limitReached = elCreate('span');
- limitReached.className = 'inputItemListLimitReached';
- limitReached.textContent = Language.get('wcf.global.form.input.maxItems');
- elHide(limitReached);
- listItem.appendChild(limitReached);
- var shadow = null, values = [];
- if (options.isCSV) {
- shadow = elCreate('input');
- shadow.className = 'itemListInputShadow';
- shadow.type = 'hidden';
- //noinspection JSUnresolvedVariable
- shadow.name = element.name;
- element.removeAttribute('name');
- list.parentNode.insertBefore(shadow, list);
- //noinspection JSUnresolvedVariable
- var value, tmp = element.value.split(',');
- for (var i = 0, length = tmp.length; i < length; i++) {
- value = tmp[i].trim();
- if (value.length) {
- values.push(value);
- }
+ }
+ }
+ /**
+ * Initializes an item list.
+ *
+ * The `values` argument must be empty or contain a list of strings or object, e.g.
+ * `['foo', 'bar']` or `[{ objectId: 1337, value: 'baz'}, {...}]`
+ */
+ function init(elementId, values, options) {
+ const element = document.getElementById(elementId);
+ if (element === null) {
+ throw new Error("Expected a valid element id, '" + elementId + "' is invalid.");
+ }
+ // remove data from previous instance
+ if (_data.has(elementId)) {
+ const tmp = _data.get(elementId);
+ Object.keys(tmp).forEach(key => {
+ const el = tmp[key];
+ if (el instanceof Element && el.parentNode) {
+ el.remove();
}
- if (element.nodeName === 'TEXTAREA') {
- var inputElement = elCreate('input');
- inputElement.type = 'text';
- element.parentNode.insertBefore(inputElement, element);
- inputElement.id = element.id;
- elRemove(element);
- element = inputElement;
+ });
+ Simple_1.default.destroy(elementId);
+ _data.delete(elementId);
+ }
+ options = Core.extend({
+ // search parameters for suggestions
+ ajax: {
+ actionName: 'getSearchResultList',
+ className: '',
+ data: {},
+ },
+ // list of excluded string values, e.g. `['ignore', 'these strings', 'when', 'searching']`
+ excludedSearchValues: [],
+ // maximum number of items this list may contain, `-1` for infinite
+ maxItems: -1,
+ // maximum length of an item value, `-1` for infinite
+ maxLength: -1,
+ // disallow custom values, only values offered by the suggestion dropdown are accepted
+ restricted: false,
+ // initial value will be interpreted as comma separated value and submitted as such
+ isCSV: false,
+ // will be invoked whenever the items change, receives the element id first and list of values second
+ callbackChange: null,
+ // callback once the form is about to be submitted
+ callbackSubmit: null,
+ // Callback for the custom shadow synchronization.
+ callbackSyncShadow: null,
+ // Callback to set values during the setup.
+ callbackSetupValues: null,
+ // value may contain the placeholder `{$objectId}`
+ submitFieldName: '',
+ }, options);
+ const form = DomTraverse.parentByTag(element, 'FORM');
+ if (form !== null) {
+ if (!options.isCSV) {
+ if (!options.submitFieldName.length && typeof options.callbackSubmit !== 'function') {
+ throw new Error("Expected a valid function for option 'callbackSubmit', a non-empty value for option 'submitFieldName' or enabling the option 'submitFieldCSV'.");
}
- }
- return {
- element: element,
- limitReached: limitReached,
- list: list,
- shadow: shadow,
- values: values
- };
- },
- /**
- * Returns true if the input accepts new items.
- *
- * @param {string} elementId input element id
- * @return {boolean} true if at least one more item can be added
- * @protected
- */
- _acceptsNewItems: function (elementId) {
- var data = _data.get(elementId);
- if (data.options.maxItems === -1) {
- return true;
- }
- return (data.list.childElementCount - 1 < data.options.maxItems);
- },
- /**
- * Enforces the maximum number of items.
- *
- * @param {string} elementId input element id
- */
- _handleLimit: function (elementId) {
- var data = _data.get(elementId);
- if (this._acceptsNewItems(elementId)) {
- elShow(data.element);
- elHide(data.limitReached);
- }
- else {
- elHide(data.element);
- elShow(data.limitReached);
- }
- },
- /**
- * Sets the active item list id and handles keyboard access to remove an existing item.
- *
- * @param {object} event event object
- */
- _keyDown: function (event) {
- var input = event.currentTarget;
- var lastItem = input.parentNode.previousElementSibling;
- _activeId = input.id;
- if (event.keyCode === 8) {
- // 8 = [BACKSPACE]
- if (input.value.length === 0) {
- if (lastItem !== null) {
- if (lastItem.classList.contains('active')) {
- this._removeItem(null, lastItem);
- }
- else {
- lastItem.classList.add('active');
+ form.addEventListener('submit', () => {
+ if (acceptsNewItems(elementId)) {
+ const value = _data.get(elementId).element.value.trim();
+ if (value.length) {
+ addItem(elementId, { objectId: 0, value: value });
}
}
- }
- }
- else if (event.keyCode === 27) {
- // 27 = [ESC]
- if (lastItem !== null && lastItem.classList.contains('active')) {
- lastItem.classList.remove('active');
- }
- }
- },
- /**
- * Handles the `[ENTER]` and `[,]` key to add an item to the list unless it is restricted.
- *
- * @param {Event} event event object
- */
- _keyPress: function (event) {
- if (EventKey.Enter(event) || EventKey.Comma(event)) {
- event.preventDefault();
- if (_data.get(event.currentTarget.id).options.restricted) {
- // restricted item lists only allow results from the dropdown to be picked
- return;
- }
- var value = event.currentTarget.value.trim();
- if (value.length) {
- this._addItem(event.currentTarget.id, { objectId: 0, value: value });
- }
- }
- },
- /**
- * Splits comma-separated values being pasted into the input field.
- *
- * @param {Event} event
- * @protected
- */
- _paste: function (event) {
- var text = '';
- if (typeof window.clipboardData === 'object') {
- // IE11
- text = window.clipboardData.getData('Text');
+ const values = getValues(elementId);
+ if (options.submitFieldName.length) {
+ values.forEach(value => {
+ const input = document.createElement('input');
+ input.type = 'hidden';
+ input.name = options.submitFieldName.replace('{$objectId}', value.objectId.toString());
+ input.value = value.value;
+ form.appendChild(input);
+ });
+ }
+ else {
+ options.callbackSubmit(form, values);
+ }
+ });
}
else {
- text = event.clipboardData.getData('text/plain');
- }
- var element = event.currentTarget;
- var elementId = element.id;
- var maxLength = ~~elAttr(element, 'maxLength');
- text.split(/,/).forEach((function (item) {
- item = item.trim();
- if (maxLength && item.length > maxLength) {
- // truncating items provides a better UX than throwing an error or silently discarding it
- item = item.substr(0, maxLength);
- }
- if (item.length > 0 && this._acceptsNewItems(elementId)) {
- this._addItem(elementId, { objectId: 0, value: item });
- }
- }).bind(this));
- event.preventDefault();
- },
- /**
- * Handles the keyup event to unmark an item for deletion.
- *
- * @param {object} event event object
- */
- _keyUp: function (event) {
- var input = event.currentTarget;
- if (input.value.length > 0) {
- var lastItem = input.parentNode.previousElementSibling;
- if (lastItem !== null) {
- lastItem.classList.remove('active');
- }
- }
- },
- /**
- * Adds an item to the list.
- *
- * @param {string} elementId input element id
- * @param {object} value item value
- */
- _addItem: function (elementId, value) {
- var data = _data.get(elementId);
- var listItem = elCreate('li');
- listItem.className = 'item';
- var content = elCreate('span');
- content.className = 'content';
- elData(content, 'object-id', value.objectId);
- if (value.type)
- elData(content, 'type', value.type);
- content.textContent = value.value;
- listItem.appendChild(content);
- if (!data.element.disabled) {
- var button = elCreate('a');
- button.className = 'icon icon16 fa-times';
- button.addEventListener(WCF_CLICK_EVENT, _callbackRemoveItem);
- listItem.appendChild(button);
- }
- data.list.insertBefore(listItem, data.listItem);
- data.suggestion.addExcludedValue(value.value);
- data.element.value = '';
- if (!data.element.disabled) {
- this._handleLimit(elementId);
- }
- var values = this._syncShadow(data);
- if (typeof data.options.callbackChange === 'function') {
- if (values === null)
- values = this.getValues(elementId);
- data.options.callbackChange(elementId, values);
- }
- },
- /**
- * Removes an item from the list.
- *
- * @param {?object} event event object
- * @param {Element?} item list item
- * @param {boolean?} noFocus input element will not be focused if true
- */
- _removeItem: function (event, item, noFocus) {
- item = (event === null) ? item : event.currentTarget.parentNode;
- var parent = item.parentNode;
- //noinspection JSCheckFunctionSignatures
- var elementId = elData(parent, 'element-id');
- var data = _data.get(elementId);
- data.suggestion.removeExcludedValue(item.children[0].textContent);
- parent.removeChild(item);
- if (!noFocus)
- data.element.focus();
- this._handleLimit(elementId);
- var values = this._syncShadow(data);
- if (typeof data.options.callbackChange === 'function') {
- if (values === null)
- values = this.getValues(elementId);
- data.options.callbackChange(elementId, values);
- }
- },
- /**
- * Synchronizes the shadow input field with the current list item values.
- *
- * @param {object} data element data
- */
- _syncShadow: function (data) {
- if (!data.options.isCSV)
- return null;
- if (typeof data.options.callbackSyncShadow === 'function') {
- return data.options.callbackSyncShadow(data);
- }
- var value = '', values = this.getValues(data.element.id);
- for (var i = 0, length = values.length; i < length; i++) {
- value += (value.length ? ',' : '') + values[i].value;
- }
- data.shadow.value = value;
- return values;
- },
- /**
- * Handles the blur event.
- *
- * @param {object} event event object
- */
- _blur: function (event) {
- var input = event.currentTarget;
- var data = _data.get(input.id);
- if (data.options.restricted) {
- // restricted item lists only allow results from the dropdown to be picked
- return;
+ form.addEventListener('submit', () => {
+ if (acceptsNewItems(elementId)) {
+ const value = _data.get(elementId).element.value.trim();
+ if (value.length) {
+ addItem(elementId, { objectId: 0, value: value });
+ }
+ }
+ });
}
- var value = input.value.trim();
- if (value.length) {
- if (!data.suggestion || !data.suggestion.isActive()) {
- this._addItem(input.id, { objectId: 0, value: value });
+ }
+ const data = createUI(element, options);
+ const suggestion = new Suggestion_1.default(elementId, {
+ ajax: options.ajax,
+ callbackSelect: addItem,
+ excludedSearchValues: options.excludedSearchValues,
+ });
+ _data.set(elementId, {
+ dropdownMenu: null,
+ element: data.element,
+ limitReached: data.limitReached,
+ list: data.list,
+ listItem: data.element.parentElement,
+ options: options,
+ shadow: data.shadow,
+ suggestion: suggestion,
+ });
+ if (options.callbackSetupValues) {
+ values = options.callbackSetupValues();
+ }
+ else {
+ values = (data.values.length) ? data.values : values;
+ }
+ if (Array.isArray(values)) {
+ values.forEach(value => {
+ if (typeof value === 'string') {
+ value = { objectId: 0, value: value };
}
- }
+ addItem(elementId, value);
+ });
+ }
+ }
+ exports.init = init;
+ /**
+ * Returns the list of current values.
+ */
+ function getValues(elementId) {
+ const data = _data.get(elementId);
+ if (!data) {
+ throw new Error("Element id '" + elementId + "' is unknown.");
+ }
+ const values = [];
+ data.list.querySelectorAll('.item > span').forEach((span) => {
+ values.push({
+ objectId: +(span.dataset.objectId || ''),
+ value: span.textContent.trim(),
+ type: span.dataset.type,
+ });
+ });
+ return values;
+ }
+ exports.getValues = getValues;
+ /**
+ * Sets the list of current values.
+ */
+ function setValues(elementId, values) {
+ const data = _data.get(elementId);
+ if (!data) {
+ throw new Error("Element id '" + elementId + "' is unknown.");
}
- };
+ // remove all existing items first
+ DomTraverse.childrenByClass(data.list, 'item').forEach((item) => {
+ removeItem(item, true);
+ });
+ // add new items
+ values.forEach(value => {
+ addItem(elementId, value);
+ });
+ }
+ exports.setValues = setValues;
});
+++ /dev/null
-/**
- * Flexible UI element featuring both a list of items and an input field with suggestion support.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Ui/ItemList
- */
-define(['Core', 'Dictionary', 'Language', 'Dom/Traverse', 'EventKey', 'WoltLabSuite/Core/Ui/Suggestion', 'Ui/SimpleDropdown'], function (Core, Dictionary, Language, DomTraverse, EventKey, UiSuggestion, UiSimpleDropdown) {
- "use strict";
- var _activeId = '';
- var _data = new Dictionary();
- var _didInit = false;
- var _callbackKeyDown = null;
- var _callbackKeyPress = null;
- var _callbackKeyUp = null;
- var _callbackPaste = null;
- var _callbackRemoveItem = null;
- var _callbackBlur = null;
- /**
- * @exports WoltLabSuite/Core/Ui/ItemList
- */
- return {
- /**
- * Initializes an item list.
- *
- * The `values` argument must be empty or contain a list of strings or object, e.g.
- * `['foo', 'bar']` or `[{ objectId: 1337, value: 'baz'}, {...}]`
- *
- * @param {string} elementId input element id
- * @param {Array} values list of existing values
- * @param {Object} options option list
- */
- init: function (elementId, values, options) {
- var element = elById(elementId);
- if (element === null) {
- throw new Error("Expected a valid element id, '" + elementId + "' is invalid.");
- }
- // remove data from previous instance
- if (_data.has(elementId)) {
- var tmp = _data.get(elementId);
- for (var key in tmp) {
- if (tmp.hasOwnProperty(key)) {
- var el = tmp[key];
- if (el instanceof Element && el.parentNode) {
- elRemove(el);
- }
- }
- }
- UiSimpleDropdown.destroy(elementId);
- _data.delete(elementId);
- }
- options = Core.extend({
- // search parameters for suggestions
- ajax: {
- actionName: 'getSearchResultList',
- className: '',
- data: {}
- },
- // list of excluded string values, e.g. `['ignore', 'these strings', 'when', 'searching']`
- excludedSearchValues: [],
- // maximum number of items this list may contain, `-1` for infinite
- maxItems: -1,
- // maximum length of an item value, `-1` for infinite
- maxLength: -1,
- // disallow custom values, only values offered by the suggestion dropdown are accepted
- restricted: false,
- // initial value will be interpreted as comma separated value and submitted as such
- isCSV: false,
- // will be invoked whenever the items change, receives the element id first and list of values second
- callbackChange: null,
- // callback once the form is about to be submitted
- callbackSubmit: null,
- // Callback for the custom shadow synchronization.
- callbackSyncShadow: null,
- // Callback to set values during the setup.
- callbackSetupValues: null,
- // value may contain the placeholder `{$objectId}`
- submitFieldName: ''
- }, options);
- var form = DomTraverse.parentByTag(element, 'FORM');
- if (form !== null) {
- if (options.isCSV === false) {
- if (!options.submitFieldName.length && typeof options.callbackSubmit !== 'function') {
- throw new Error("Expected a valid function for option 'callbackSubmit', a non-empty value for option 'submitFieldName' or enabling the option 'submitFieldCSV'.");
- }
- form.addEventListener('submit', (function () {
- if (this._acceptsNewItems(elementId)) {
- var value = _data.get(elementId).element.value.trim();
- if (value.length) {
- this._addItem(elementId, { objectId: 0, value: value });
- }
- }
- var values = this.getValues(elementId);
- if (options.submitFieldName.length) {
- var input;
- for (var i = 0, length = values.length; i < length; i++) {
- input = elCreate('input');
- input.type = 'hidden';
- input.name = options.submitFieldName.replace('{$objectId}', values[i].objectId);
- input.value = values[i].value;
- form.appendChild(input);
- }
- }
- else {
- options.callbackSubmit(form, values);
- }
- }).bind(this));
- }
- else {
- form.addEventListener('submit', function () {
- if (this._acceptsNewItems(elementId)) {
- var value = _data.get(elementId).element.value.trim();
- if (value.length) {
- this._addItem(elementId, { objectId: 0, value: value });
- }
- }
- }.bind(this));
- }
- }
- this._setup();
- var data = this._createUI(element, options);
- //noinspection JSUnresolvedVariable
- var suggestion = new UiSuggestion(elementId, {
- ajax: options.ajax,
- callbackSelect: this._addItem.bind(this),
- excludedSearchValues: options.excludedSearchValues
- });
- _data.set(elementId, {
- dropdownMenu: null,
- element: data.element,
- limitReached: data.limitReached,
- list: data.list,
- listItem: data.element.parentNode,
- options: options,
- shadow: data.shadow,
- suggestion: suggestion
- });
- if (options.callbackSetupValues) {
- values = options.callbackSetupValues();
- }
- else {
- values = (data.values.length) ? data.values : values;
- }
- if (Array.isArray(values)) {
- var value;
- for (var i = 0, length = values.length; i < length; i++) {
- value = values[i];
- if (typeof value === 'string') {
- value = { objectId: 0, value: value };
- }
- this._addItem(elementId, value);
- }
- }
- },
- /**
- * Returns the list of current values.
- *
- * @param {string} elementId input element id
- * @return {Array} list of objects containing object id and value
- */
- getValues: function (elementId) {
- if (!_data.has(elementId)) {
- throw new Error("Element id '" + elementId + "' is unknown.");
- }
- var data = _data.get(elementId);
- var values = [];
- elBySelAll('.item > span', data.list, function (span) {
- values.push({
- objectId: ~~elData(span, 'object-id'),
- value: span.textContent.trim(),
- type: elData(span, 'type')
- });
- });
- return values;
- },
- /**
- * Sets the list of current values.
- *
- * @param {string} elementId input element id
- * @param {Array} values list of objects containing object id and value
- */
- setValues: function (elementId, values) {
- if (!_data.has(elementId)) {
- throw new Error("Element id '" + elementId + "' is unknown.");
- }
- var data = _data.get(elementId);
- // remove all existing items first
- var i, length;
- var items = DomTraverse.childrenByClass(data.list, 'item');
- for (i = 0, length = items.length; i < length; i++) {
- this._removeItem(null, items[i], true);
- }
- // add new items
- for (i = 0, length = values.length; i < length; i++) {
- this._addItem(elementId, values[i]);
- }
- },
- /**
- * Binds static event listeners.
- */
- _setup: function () {
- if (_didInit) {
- return;
- }
- _didInit = true;
- _callbackKeyDown = this._keyDown.bind(this);
- _callbackKeyPress = this._keyPress.bind(this);
- _callbackKeyUp = this._keyUp.bind(this);
- _callbackPaste = this._paste.bind(this);
- _callbackRemoveItem = this._removeItem.bind(this);
- _callbackBlur = this._blur.bind(this);
- },
- /**
- * Creates the DOM structure for target element. If `element` is a `<textarea>`
- * it will be automatically replaced with an `<input>` element.
- *
- * @param {Element} element input element
- * @param {Object} options option list
- */
- _createUI: function (element, options) {
- var list = elCreate('ol');
- list.className = 'inputItemList' + (element.disabled ? ' disabled' : '');
- elData(list, 'element-id', element.id);
- list.addEventListener(WCF_CLICK_EVENT, function (event) {
- if (event.target === list) {
- //noinspection JSUnresolvedFunction
- element.focus();
- }
- });
- var listItem = elCreate('li');
- listItem.className = 'input';
- list.appendChild(listItem);
- element.addEventListener('keydown', _callbackKeyDown);
- element.addEventListener('keypress', _callbackKeyPress);
- element.addEventListener('keyup', _callbackKeyUp);
- element.addEventListener('paste', _callbackPaste);
- var hasFocus = element === document.activeElement;
- if (hasFocus) {
- //noinspection JSUnresolvedFunction
- element.blur();
- }
- element.addEventListener('blur', _callbackBlur);
- element.parentNode.insertBefore(list, element);
- listItem.appendChild(element);
- if (hasFocus) {
- window.setTimeout(function () {
- //noinspection JSUnresolvedFunction
- element.focus();
- }, 1);
- }
- if (options.maxLength !== -1) {
- elAttr(element, 'maxLength', options.maxLength);
- }
- var limitReached = elCreate('span');
- limitReached.className = 'inputItemListLimitReached';
- limitReached.textContent = Language.get('wcf.global.form.input.maxItems');
- elHide(limitReached);
- listItem.appendChild(limitReached);
- var shadow = null, values = [];
- if (options.isCSV) {
- shadow = elCreate('input');
- shadow.className = 'itemListInputShadow';
- shadow.type = 'hidden';
- //noinspection JSUnresolvedVariable
- shadow.name = element.name;
- element.removeAttribute('name');
- list.parentNode.insertBefore(shadow, list);
- //noinspection JSUnresolvedVariable
- var value, tmp = element.value.split(',');
- for (var i = 0, length = tmp.length; i < length; i++) {
- value = tmp[i].trim();
- if (value.length) {
- values.push(value);
- }
- }
- if (element.nodeName === 'TEXTAREA') {
- var inputElement = elCreate('input');
- inputElement.type = 'text';
- element.parentNode.insertBefore(inputElement, element);
- inputElement.id = element.id;
- elRemove(element);
- element = inputElement;
- }
- }
- return {
- element: element,
- limitReached: limitReached,
- list: list,
- shadow: shadow,
- values: values
- };
- },
- /**
- * Returns true if the input accepts new items.
- *
- * @param {string} elementId input element id
- * @return {boolean} true if at least one more item can be added
- * @protected
- */
- _acceptsNewItems: function (elementId) {
- var data = _data.get(elementId);
- if (data.options.maxItems === -1) {
- return true;
- }
- return (data.list.childElementCount - 1 < data.options.maxItems);
- },
- /**
- * Enforces the maximum number of items.
- *
- * @param {string} elementId input element id
- */
- _handleLimit: function (elementId) {
- var data = _data.get(elementId);
- if (this._acceptsNewItems(elementId)) {
- elShow(data.element);
- elHide(data.limitReached);
- }
- else {
- elHide(data.element);
- elShow(data.limitReached);
- }
- },
- /**
- * Sets the active item list id and handles keyboard access to remove an existing item.
- *
- * @param {object} event event object
- */
- _keyDown: function (event) {
- var input = event.currentTarget;
- var lastItem = input.parentNode.previousElementSibling;
- _activeId = input.id;
- if (event.keyCode === 8) {
- // 8 = [BACKSPACE]
- if (input.value.length === 0) {
- if (lastItem !== null) {
- if (lastItem.classList.contains('active')) {
- this._removeItem(null, lastItem);
- }
- else {
- lastItem.classList.add('active');
- }
- }
- }
- }
- else if (event.keyCode === 27) {
- // 27 = [ESC]
- if (lastItem !== null && lastItem.classList.contains('active')) {
- lastItem.classList.remove('active');
- }
- }
- },
- /**
- * Handles the `[ENTER]` and `[,]` key to add an item to the list unless it is restricted.
- *
- * @param {Event} event event object
- */
- _keyPress: function (event) {
- if (EventKey.Enter(event) || EventKey.Comma(event)) {
- event.preventDefault();
- if (_data.get(event.currentTarget.id).options.restricted) {
- // restricted item lists only allow results from the dropdown to be picked
- return;
- }
- var value = event.currentTarget.value.trim();
- if (value.length) {
- this._addItem(event.currentTarget.id, { objectId: 0, value: value });
- }
- }
- },
- /**
- * Splits comma-separated values being pasted into the input field.
- *
- * @param {Event} event
- * @protected
- */
- _paste: function (event) {
- var text = '';
- if (typeof window.clipboardData === 'object') {
- // IE11
- text = window.clipboardData.getData('Text');
- }
- else {
- text = event.clipboardData.getData('text/plain');
- }
- var element = event.currentTarget;
- var elementId = element.id;
- var maxLength = ~~elAttr(element, 'maxLength');
- text.split(/,/).forEach((function (item) {
- item = item.trim();
- if (maxLength && item.length > maxLength) {
- // truncating items provides a better UX than throwing an error or silently discarding it
- item = item.substr(0, maxLength);
- }
- if (item.length > 0 && this._acceptsNewItems(elementId)) {
- this._addItem(elementId, { objectId: 0, value: item });
- }
- }).bind(this));
- event.preventDefault();
- },
- /**
- * Handles the keyup event to unmark an item for deletion.
- *
- * @param {object} event event object
- */
- _keyUp: function (event) {
- var input = event.currentTarget;
- if (input.value.length > 0) {
- var lastItem = input.parentNode.previousElementSibling;
- if (lastItem !== null) {
- lastItem.classList.remove('active');
- }
- }
- },
- /**
- * Adds an item to the list.
- *
- * @param {string} elementId input element id
- * @param {object} value item value
- */
- _addItem: function (elementId, value) {
- var data = _data.get(elementId);
- var listItem = elCreate('li');
- listItem.className = 'item';
- var content = elCreate('span');
- content.className = 'content';
- elData(content, 'object-id', value.objectId);
- if (value.type)
- elData(content, 'type', value.type);
- content.textContent = value.value;
- listItem.appendChild(content);
- if (!data.element.disabled) {
- var button = elCreate('a');
- button.className = 'icon icon16 fa-times';
- button.addEventListener(WCF_CLICK_EVENT, _callbackRemoveItem);
- listItem.appendChild(button);
- }
- data.list.insertBefore(listItem, data.listItem);
- data.suggestion.addExcludedValue(value.value);
- data.element.value = '';
- if (!data.element.disabled) {
- this._handleLimit(elementId);
- }
- var values = this._syncShadow(data);
- if (typeof data.options.callbackChange === 'function') {
- if (values === null)
- values = this.getValues(elementId);
- data.options.callbackChange(elementId, values);
- }
- },
- /**
- * Removes an item from the list.
- *
- * @param {?object} event event object
- * @param {Element?} item list item
- * @param {boolean?} noFocus input element will not be focused if true
- */
- _removeItem: function (event, item, noFocus) {
- item = (event === null) ? item : event.currentTarget.parentNode;
- var parent = item.parentNode;
- //noinspection JSCheckFunctionSignatures
- var elementId = elData(parent, 'element-id');
- var data = _data.get(elementId);
- data.suggestion.removeExcludedValue(item.children[0].textContent);
- parent.removeChild(item);
- if (!noFocus)
- data.element.focus();
- this._handleLimit(elementId);
- var values = this._syncShadow(data);
- if (typeof data.options.callbackChange === 'function') {
- if (values === null)
- values = this.getValues(elementId);
- data.options.callbackChange(elementId, values);
- }
- },
- /**
- * Synchronizes the shadow input field with the current list item values.
- *
- * @param {object} data element data
- */
- _syncShadow: function (data) {
- if (!data.options.isCSV)
- return null;
- if (typeof data.options.callbackSyncShadow === 'function') {
- return data.options.callbackSyncShadow(data);
- }
- var value = '', values = this.getValues(data.element.id);
- for (var i = 0, length = values.length; i < length; i++) {
- value += (value.length ? ',' : '') + values[i].value;
- }
- data.shadow.value = value;
- return values;
- },
- /**
- * Handles the blur event.
- *
- * @param {object} event event object
- */
- _blur: function (event) {
- var input = event.currentTarget;
- var data = _data.get(input.id);
- if (data.options.restricted) {
- // restricted item lists only allow results from the dropdown to be picked
- return;
- }
- var value = input.value.trim();
- if (value.length) {
- if (!data.suggestion || !data.suggestion.isActive()) {
- this._addItem(input.id, { objectId: 0, value: value });
- }
- }
- }
- };
-});
--- /dev/null
+/**
+ * Flexible UI element featuring both a list of items and an input field with suggestion support.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Ui/ItemList
+ */
+
+import * as Core from '../Core';
+import * as DomTraverse from '../Dom/Traverse';
+import * as Language from '../Language';
+import UiSuggestion from './Suggestion';
+import UiDropdownSimple from './Dropdown/Simple';
+import { DatabaseObjectActionPayload } from '../Ajax/Data';
+import DomUtil from '../Dom/Util';
+
+let _activeId = '';
+const _data = new Map<string, ElementData>();
+
+/**
+ * Creates the DOM structure for target element. If `element` is a `<textarea>`
+ * it will be automatically replaced with an `<input>` element.
+ */
+function createUI(element: ItemListInputElement, options: ItemListOptions): UiData {
+ const parentElement = element.parentElement!;
+
+ const list = document.createElement('ol');
+ list.className = 'inputItemList' + (element.disabled ? ' disabled' : '');
+ list.dataset.elementId = element.id;
+ list.addEventListener('click', event => {
+ if (event.target === list) {
+ element.focus();
+ }
+ });
+
+ const listItem = document.createElement('li');
+ listItem.className = 'input';
+ list.appendChild(listItem);
+ element.addEventListener('keydown', keyDown);
+ element.addEventListener('keypress', keyPress);
+ element.addEventListener('keyup', keyUp);
+ element.addEventListener('paste', paste);
+
+ const hasFocus = (element === document.activeElement);
+ if (hasFocus) {
+ element.blur();
+ }
+ element.addEventListener('blur', blur);
+ parentElement.insertBefore(list, element);
+ listItem.appendChild(element);
+
+ if (hasFocus) {
+ window.setTimeout(() => {
+ element.focus();
+ }, 1);
+ }
+
+ if (options.maxLength !== -1) {
+ element.maxLength = options.maxLength;
+ }
+
+ const limitReached = document.createElement('span');
+ limitReached.className = 'inputItemListLimitReached';
+ limitReached.textContent = Language.get('wcf.global.form.input.maxItems');
+ DomUtil.hide(limitReached);
+ listItem.appendChild(limitReached);
+
+ let shadow: HTMLInputElement | null = null;
+ const values: string[] = [];
+ if (options.isCSV) {
+ shadow = document.createElement('input');
+ shadow.className = 'itemListInputShadow';
+ shadow.type = 'hidden';
+ shadow.name = element.name;
+ element.removeAttribute('name');
+ list.parentNode!.insertBefore(shadow, list);
+
+ element.value.split(',').forEach(value => {
+ value = value.trim();
+ if (value) {
+ values.push(value);
+ }
+ });
+
+ if (element.nodeName === 'TEXTAREA') {
+ const inputElement = document.createElement('input');
+ inputElement.type = 'text';
+ parentElement.insertBefore(inputElement, element);
+ inputElement.id = element.id;
+
+ element.remove();
+ element = inputElement;
+ }
+ }
+
+ return {
+ element: element,
+ limitReached: limitReached,
+ list: list,
+ shadow: shadow,
+ values: values,
+ };
+}
+
+/**
+ * Returns true if the input accepts new items.
+ */
+function acceptsNewItems(elementId: string): boolean {
+ const data = _data.get(elementId)!;
+ if (data.options.maxItems === -1) {
+ return true;
+ }
+
+ return (data.list.childElementCount - 1 < data.options.maxItems);
+}
+
+/**
+ * Enforces the maximum number of items.
+ */
+function handleLimit(elementId: string): void {
+ const data = _data.get(elementId)!;
+ if (acceptsNewItems(elementId)) {
+ DomUtil.show(data.element);
+ DomUtil.hide(data.limitReached);
+ } else {
+ DomUtil.hide(data.element);
+ DomUtil.show(data.limitReached);
+ }
+}
+
+/**
+ * Sets the active item list id and handles keyboard access to remove an existing item.
+ */
+function keyDown(event: KeyboardEvent): void {
+ const input = event.currentTarget as HTMLInputElement;
+ _activeId = input.id;
+
+ const lastItem = input.parentElement!.previousElementSibling as HTMLElement | null;
+ if (event.key === 'Backspace') {
+ if (input.value.length === 0) {
+ if (lastItem !== null) {
+ if (lastItem.classList.contains('active')) {
+ removeItem(lastItem);
+ } else {
+ lastItem.classList.add('active');
+ }
+ }
+ }
+ } else if (event.key === 'Escape') {
+ if (lastItem !== null && lastItem.classList.contains('active')) {
+ lastItem.classList.remove('active');
+ }
+ }
+}
+
+/**
+ * Handles the `[ENTER]` and `[,]` key to add an item to the list unless it is restricted.
+ */
+function keyPress(event: KeyboardEvent): void {
+ if (event.key === 'Enter' || event.key === ',') {
+ event.preventDefault();
+
+ const input = event.currentTarget as HTMLInputElement;
+ if (_data.get(input.id)!.options.restricted) {
+ // restricted item lists only allow results from the dropdown to be picked
+ return;
+ }
+ const value = input.value.trim();
+ if (value.length) {
+ addItem(input.id, {objectId: 0, value: value});
+ }
+ }
+}
+
+/**
+ * Splits comma-separated values being pasted into the input field.
+ */
+function paste(event: ClipboardEvent): void {
+ event.preventDefault();
+
+ const text = event.clipboardData!.getData('text/plain');
+
+ const element = event.currentTarget as HTMLInputElement;
+ const elementId = element.id;
+ const maxLength = +element.maxLength;
+ text.split(/,/).forEach(item => {
+ item = item.trim();
+ if (maxLength && item.length > maxLength) {
+ // truncating items provides a better UX than throwing an error or silently discarding it
+ item = item.substr(0, maxLength);
+ }
+
+ if (item.length > 0 && acceptsNewItems(elementId)) {
+ addItem(elementId, {objectId: 0, value: item});
+ }
+ });
+}
+
+/**
+ * Handles the keyup event to unmark an item for deletion.
+ */
+function keyUp(event: KeyboardEvent): void {
+ const input = event.currentTarget as HTMLInputElement;
+ if (input.value.length > 0) {
+ const lastItem = input.parentElement!.previousElementSibling;
+ if (lastItem !== null) {
+ lastItem.classList.remove('active');
+ }
+ }
+}
+
+/**
+ * Adds an item to the list.
+ */
+function addItem(elementId: string, value: ItemData): void {
+ const data = _data.get(elementId)!;
+ const listItem = document.createElement('li');
+ listItem.className = 'item';
+
+ const content = document.createElement('span');
+ content.className = 'content';
+ content.dataset.objectId = value.objectId.toString();
+ if (value.type) {
+ content.dataset.type = value.type;
+ }
+ content.textContent = value.value;
+ listItem.appendChild(content);
+
+ if (!data.element.disabled) {
+ const button = document.createElement('a');
+ button.className = 'icon icon16 fa-times';
+ button.addEventListener('click', removeItem);
+ listItem.appendChild(button);
+ }
+
+ data.list.insertBefore(listItem, data.listItem);
+ data.suggestion.addExcludedValue(value.value);
+ data.element.value = '';
+ if (!data.element.disabled) {
+ handleLimit(elementId);
+ }
+
+ let values = syncShadow(data);
+ if (typeof data.options.callbackChange === 'function') {
+ if (values === null) {
+ values = getValues(elementId);
+ }
+
+ data.options.callbackChange(elementId, values);
+ }
+}
+
+/**
+ * Removes an item from the list.
+ */
+function removeItem(item: Event | HTMLElement, noFocus?: boolean): void {
+ if (item instanceof Event) {
+ const target = item.currentTarget as HTMLElement;
+ item = target.parentElement!;
+ }
+
+ const parent = item.parentElement!;
+ const elementId = parent.dataset.elementId || '';
+ const data = _data.get(elementId)!;
+ if (item.children[0].textContent) {
+ data.suggestion.removeExcludedValue(item.children[0].textContent);
+ }
+
+ item.remove();
+
+ if (!noFocus) {
+ data.element.focus();
+ }
+
+ handleLimit(elementId);
+
+ let values = syncShadow(data);
+ if (typeof data.options.callbackChange === 'function') {
+ if (values === null) {
+ values = getValues(elementId);
+ }
+
+ data.options.callbackChange(elementId, values);
+ }
+}
+
+/**
+ * Synchronizes the shadow input field with the current list item values.
+ */
+function syncShadow(data: ElementData): ItemData[] | null {
+ if (!data.options.isCSV) {
+ return null;
+ }
+
+ if (typeof data.options.callbackSyncShadow === 'function') {
+ return data.options.callbackSyncShadow(data);
+ }
+
+ const values = getValues(data.element.id);
+
+ data.shadow!.value = getValues(data.element.id)
+ .map(value => value.value)
+ .join(',');
+
+ return values;
+}
+
+/**
+ * Handles the blur event.
+ */
+function blur(event: FocusEvent): void {
+ const input = event.currentTarget as HTMLInputElement;
+ const data = _data.get(input.id)!;
+
+ if (data.options.restricted) {
+ // restricted item lists only allow results from the dropdown to be picked
+ return;
+ }
+
+ const value = input.value.trim();
+ if (value.length) {
+ if (!data.suggestion || !data.suggestion.isActive()) {
+ addItem(input.id, {objectId: 0, value: value});
+ }
+ }
+}
+
+
+/**
+ * Initializes an item list.
+ *
+ * The `values` argument must be empty or contain a list of strings or object, e.g.
+ * `['foo', 'bar']` or `[{ objectId: 1337, value: 'baz'}, {...}]`
+ */
+export function init(elementId: string, values: ItemDataOrPlainValue[], options: ItemListOptions): void {
+ const element = document.getElementById(elementId) as ItemListInputElement;
+ if (element === null) {
+ throw new Error("Expected a valid element id, '" + elementId + "' is invalid.");
+ }
+
+ // remove data from previous instance
+ if (_data.has(elementId)) {
+ const tmp = _data.get(elementId)!;
+ Object.keys(tmp).forEach(key => {
+ const el = tmp[key];
+ if (el instanceof Element && el.parentNode) {
+ el.remove();
+ }
+ });
+
+ UiDropdownSimple.destroy(elementId);
+ _data.delete(elementId);
+ }
+
+ options = Core.extend({
+ // search parameters for suggestions
+ ajax: {
+ actionName: 'getSearchResultList',
+ className: '',
+ data: {},
+ },
+ // list of excluded string values, e.g. `['ignore', 'these strings', 'when', 'searching']`
+ excludedSearchValues: [],
+ // maximum number of items this list may contain, `-1` for infinite
+ maxItems: -1,
+ // maximum length of an item value, `-1` for infinite
+ maxLength: -1,
+ // disallow custom values, only values offered by the suggestion dropdown are accepted
+ restricted: false,
+ // initial value will be interpreted as comma separated value and submitted as such
+ isCSV: false,
+ // will be invoked whenever the items change, receives the element id first and list of values second
+ callbackChange: null,
+ // callback once the form is about to be submitted
+ callbackSubmit: null,
+ // Callback for the custom shadow synchronization.
+ callbackSyncShadow: null,
+ // Callback to set values during the setup.
+ callbackSetupValues: null,
+ // value may contain the placeholder `{$objectId}`
+ submitFieldName: '',
+ }, options) as ItemListOptions;
+
+ const form = DomTraverse.parentByTag(element, 'FORM') as HTMLFormElement;
+ if (form !== null) {
+ if (!options.isCSV) {
+ if (!options.submitFieldName.length && typeof options.callbackSubmit !== 'function') {
+ throw new Error("Expected a valid function for option 'callbackSubmit', a non-empty value for option 'submitFieldName' or enabling the option 'submitFieldCSV'.");
+ }
+
+ form.addEventListener('submit', () => {
+ if (acceptsNewItems(elementId)) {
+ const value = _data.get(elementId)!.element.value.trim();
+ if (value.length) {
+ addItem(elementId, {objectId: 0, value: value});
+ }
+ }
+
+ const values = getValues(elementId);
+ if (options.submitFieldName.length) {
+ values.forEach(value => {
+ const input = document.createElement('input');
+ input.type = 'hidden';
+ input.name = options.submitFieldName.replace('{$objectId}', value.objectId.toString());
+ input.value = value.value;
+ form.appendChild(input);
+ });
+ } else {
+ options.callbackSubmit!(form, values);
+ }
+ });
+ } else {
+ form.addEventListener('submit', () => {
+ if (acceptsNewItems(elementId)) {
+ const value = _data.get(elementId)!.element.value.trim();
+ if (value.length) {
+ addItem(elementId, {objectId: 0, value: value});
+ }
+ }
+ });
+ }
+ }
+
+ const data = createUI(element, options);
+
+ const suggestion = new UiSuggestion(elementId, {
+ ajax: options.ajax,
+ callbackSelect: addItem,
+ excludedSearchValues: options.excludedSearchValues,
+ });
+
+ _data.set(elementId, {
+ dropdownMenu: null,
+ element: data.element,
+ limitReached: data.limitReached,
+ list: data.list,
+ listItem: data.element.parentElement!,
+ options: options,
+ shadow: data.shadow,
+ suggestion: suggestion,
+ });
+
+ if (options.callbackSetupValues) {
+ values = options.callbackSetupValues();
+ } else {
+ values = (data.values.length) ? data.values : values;
+ }
+
+ if (Array.isArray(values)) {
+ values.forEach(value => {
+ if (typeof value === 'string') {
+ value = {objectId: 0, value: value};
+ }
+
+ addItem(elementId, value);
+ });
+ }
+}
+
+/**
+ * Returns the list of current values.
+ */
+export function getValues(elementId: string): ItemData[] {
+ const data = _data.get(elementId);
+ if (!data) {
+ throw new Error("Element id '" + elementId + "' is unknown.");
+ }
+
+ const values: ItemData[] = [];
+ data.list.querySelectorAll('.item > span').forEach((span: HTMLSpanElement) => {
+ values.push({
+ objectId: +(span.dataset.objectId || ''),
+ value: span.textContent!.trim(),
+ type: span.dataset.type,
+ });
+ });
+
+ return values;
+}
+
+/**
+ * Sets the list of current values.
+ */
+export function setValues(elementId: string, values: ItemData[]): void {
+ const data = _data.get(elementId);
+ if (!data) {
+ throw new Error("Element id '" + elementId + "' is unknown.");
+ }
+
+ // remove all existing items first
+ DomTraverse.childrenByClass(data.list, 'item').forEach((item: HTMLElement) => {
+ removeItem(item, true);
+ });
+
+ // add new items
+ values.forEach(value => {
+ addItem(elementId, value);
+ });
+}
+
+type ItemListInputElement = HTMLInputElement | HTMLTextAreaElement;
+
+interface ItemData {
+ objectId: number;
+ value: string;
+ type?: string;
+}
+
+type PlainValue = string;
+
+type ItemDataOrPlainValue = ItemData | PlainValue;
+
+interface ItemListOptions {
+ // search parameters for suggestions
+ ajax: DatabaseObjectActionPayload;
+
+ // list of excluded string values, e.g. `['ignore', 'these strings', 'when', 'searching']`
+ excludedSearchValues: string[];
+
+ // maximum number of items this list may contain, `-1` for infinite
+ maxItems: number;
+
+ // maximum length of an item value, `-1` for infinite
+ maxLength: number;
+
+ // disallow custom values, only values offered by the suggestion dropdown are accepted
+ restricted: boolean;
+
+ // initial value will be interpreted as comma separated value and submitted as such
+ isCSV: boolean;
+
+ // will be invoked whenever the items change, receives the element id first and list of values second
+ callbackChange?: (elementId: string, values: ItemData[]) => void;
+
+ // callback once the form is about to be submitted
+ callbackSubmit?: (form: HTMLFormElement, values: ItemData[]) => void;
+
+ // Callback for the custom shadow synchronization.
+ callbackSyncShadow?: (data: ElementData) => ItemData[];
+
+ // Callback to set values during the setup.
+ callbackSetupValues?: () => ItemDataOrPlainValue[];
+
+ // value may contain the placeholder `{$objectId}`
+ submitFieldName: string;
+}
+
+interface ElementData {
+ dropdownMenu: HTMLElement | null;
+ element: ItemListInputElement;
+ limitReached: HTMLSpanElement;
+ list: HTMLElement;
+ listItem: HTMLElement;
+ options: ItemListOptions,
+ shadow: HTMLInputElement | null;
+ suggestion: UiSuggestion;
+}
+
+interface UiData {
+ element: ItemListInputElement;
+ limitReached: HTMLSpanElement;
+ list: HTMLOListElement;
+ shadow: HTMLInputElement | null;
+ values: string[];
+}