UNPKG

@taufik-nurrohman/tag-picker

Version:

Better tags input interaction with JavaScript.

1,267 lines (1,216 loc) 44.2 kB
import {/* focusTo, */getCharBeforeCaret, insertAtSelection, selectTo, selectToNone} from '@taufik-nurrohman/selection'; import {delay} from '@taufik-nurrohman/tick'; import {forEachArray, forEachMap, forEachObject, getPrototype, getReference, getValueInMap, hasKeyInMap, letValueInMap, setObjectAttributes, setObjectMethods, setReference, setValueInMap, toValueFirstFromMap, toValueLastFromMap} from '@taufik-nurrohman/f'; import {fromStates, fromValue} from '@taufik-nurrohman/from'; import {getAria, getElement, getElementIndex, getID, getNext, getParent, getParentForm, getPrev, getRole, getState, getText, getValue, isDisabled, isReadOnly, isRequired, letAria, letAttribute, letClass, letElement, letStyle, setAria, setAttribute, setChildLast, setClass, setDatum, setElement, setID, setNext, setPrev, setStyle, setText, setValue} from '@taufik-nurrohman/document'; import {hasValue} from '@taufik-nurrohman/has'; import {hook} from '@taufik-nurrohman/hook'; import {isArray, isFloat, isFunction, isInstance, isInteger, isObject, isSet, isString} from '@taufik-nurrohman/is'; import {offEvent, offEventDefault, onEvent} from '@taufik-nurrohman/event'; import {toCount, toMapCount, toValue} from '@taufik-nurrohman/to'; import {toPattern} from '@taufik-nurrohman/pattern'; const EVENT_DOWN = 'down'; const EVENT_UP = 'up'; const EVENT_BLUR = 'blur'; const EVENT_COPY = 'copy'; const EVENT_CUT = 'cut'; const EVENT_FOCUS = 'focus'; const EVENT_INPUT = 'input'; const EVENT_INPUT_START = 'before' + EVENT_INPUT; const EVENT_INVALID = 'invalid'; const EVENT_KEY = 'key'; const EVENT_KEY_DOWN = EVENT_KEY + EVENT_DOWN; const EVENT_KEY_UP = EVENT_KEY + EVENT_UP; const EVENT_MOUSE = 'mouse'; const EVENT_MOUSE_DOWN = EVENT_MOUSE + EVENT_DOWN; const EVENT_PASTE = 'paste'; const EVENT_RESET = 'reset'; const EVENT_SUBMIT = 'submit'; const EVENT_TOUCH = 'touch'; const EVENT_TOUCH_START = EVENT_TOUCH + 'start'; const KEY_LEFT = 'Left'; const KEY_RIGHT = 'Right'; const KEY_A = 'a'; const KEY_ARROW = 'Arrow'; const KEY_ARROW_LEFT = KEY_ARROW + KEY_LEFT; const KEY_ARROW_RIGHT = KEY_ARROW + KEY_RIGHT; const KEY_BEGIN = 'Home'; const KEY_DELETE_LEFT = 'Backspace'; const KEY_DELETE_RIGHT = 'Delete'; const KEY_END = 'End'; const KEY_ENTER = 'Enter'; const KEY_ESCAPE = 'Escape'; const KEY_TAB = 'Tab'; const TOKEN_CONTENTEDITABLE = 'contenteditable'; const TOKEN_DISABLED = 'disabled'; const TOKEN_FALSE = 'false'; const TOKEN_INVALID = EVENT_INVALID; const TOKEN_PRESSED = 'pressed'; const TOKEN_READONLY = 'readonly'; const TOKEN_READ_ONLY = 'readOnly'; const TOKEN_REQUIRED = 'required'; const TOKEN_TABINDEX = 'tabindex'; const TOKEN_TAB_INDEX = 'tabIndex'; const TOKEN_TRUE = 'true'; const TOKEN_VALUE = 'value'; const TOKEN_VALUES = TOKEN_VALUE + 's'; const TOKEN_VISIBILITY = 'visibility'; const [letError, letErrorAbort] = delay(function (picker) { letAria(picker.mask, TOKEN_INVALID); }); const setError = function (picker) { let {mask, state} = picker, {time} = state, {error} = time; if (isInteger(error) && error > 0) { setAria(mask, TOKEN_INVALID, true); } }; const [toggleHint] = delay(function (picker) { let {_mask} = picker, {input} = _mask; toggleHintByValue(picker, getText(input, 0)); }); const toggleHintByValue = function (picker, value) { let {_mask} = picker, {hint} = _mask; value ? setStyle(hint, TOKEN_VISIBILITY, 'hidden') : letStyle(hint, TOKEN_VISIBILITY); }; const name = 'TagPicker'; let _keyIsCtrl, _keyIsShift, _keyOverTag; function createTags($, tags) { const map = isInstance(tags, Map) ? tags : new Map; if (isArray(tags)) { forEachArray(tags, tag => { if (isArray(tag)) { tag[0] = tag[0] ?? ""; tag[1] = tag[1] ?? {}; setValueInMap(toValue(tag[1][TOKEN_VALUE] ?? tag[0]), tag, map); } else { setValueInMap(toValue(tag), [tag, {}], map); } }); } else if (isObject(tags, 0)) { forEachObject(tags, (v, k) => { if (isArray(v)) { tags[k][0] = v[0] ?? ""; tags[k][1] = v[1] ?? {}; setValueInMap(toValue(v[1][TOKEN_VALUE] ?? k), v, map); } else { setValueInMap(toValue(k), [v, {}], map); } }); } let {_tags} = $, r = []; // Reset the tag(s) data, but do not fire the `let.tags` hook _tags.let(null, 0); forEachMap(map, (v, k) => { if (isArray(v) && v[1]) { r.push(v[1][TOKEN_VALUE] ?? k); } // Set the tag data, but do not fire the `set.tag` hook _tags.set(toValue(isArray(v) && v[1] ? (v[1][TOKEN_VALUE] ?? k) : k), v, 0); }); return r; } function focusTo(node) { return node.focus(), node; } function getTagValue(tag, parseValue) { return getValue(tag, parseValue); } // Do not allow user(s) to edit the tag text function onBeforeInputTag(e) { offEventDefault(e); } // Better mobile support function onBeforeInputTextInput(e) { let $ = this, {data, inputType} = e, picker = getReference($), {_active, _fix} = picker; if (!_active || _fix) { return offEventDefault(e); } let {_tags, state} = picker, {escape} = state, exit, key, tagLast, v; key = isString(data) && 1 === toCount(data) ? data : 0; if ( (KEY_ENTER === key && (hasValue('\n', escape) || hasValue(13, escape))) || (KEY_TAB === key && (hasValue('\t', escape) || hasValue(9, escape))) || (0 !== key && hasValue(key, escape)) ) { exit = true; setValueInMap(toValue(v = getText($)), v, _tags); focusTo(picker).text = ""; } else if ('deleteContentBackward' === inputType && !getText($, 0)) { if (tagLast = toValueLastFromMap(_tags)) { exit = true; letValueInMap(getTagValue(tagLast[2]), _tags); } } exit && offEventDefault(e); } function onBlurTag() { let $ = this, picker = getReference($), {_tags} = picker; if (!_keyIsCtrl && !_keyIsShift) { forEachMap(_tags, v => letAria(v[2], TOKEN_PRESSED)); } } function onBlurTextInput() { let $ = this, picker = getReference($), {mask, state} = picker, {time} = state, {error} = time; letError(isInteger(error) && error > 0 ? error : 0, picker); onEvent(EVENT_MOUSE_DOWN, mask, onPointerDownMask); onEvent(EVENT_TOUCH_START, mask, onPointerDownMask); } function onCopyTag(e) { offEventDefault(e); let $ = this, picker = getReference($), {_tags, state} = picker, {join} = state, selected = []; setAria($, TOKEN_PRESSED, true); forEachMap(_tags, v => { if (getAria(v[2], TOKEN_PRESSED)) { selected.push(getTagValue(v[2])); } }); e.clipboardData.setData('text/plain', selected.join(join)); if (EVENT_CUT !== e.type && toCount(selected) < 2) { letAria($, TOKEN_PRESSED); } } function onCutTag(e) { offEventDefault(e); let $ = this, picker = getReference($), {_tags} = picker; onCopyTag.call($, e); forEachMap(_tags, v => { if (getAria(v[2], TOKEN_PRESSED)) { letValueInMap(getTagValue(v[2]), _tags); } }); focusTo(picker.fire('change', [picker[TOKEN_VALUE]])); } function onCutTextInput() { toggleHint(1, getReference(this)); } function onFocusSelf() { focusTo(getReference(this)); } // Select the tag text on focus to hide the text cursor function onFocusTag() { selectTo(this); } function onFocusTextInput() { let $ = this, picker = getReference($), {mask, state} = picker, {pattern} = state, value = getText($); if (value && isString(pattern) && !toPattern(pattern).test(value)) { letErrorAbort(), setError(picker); } selectTo($); offEvent(EVENT_MOUSE_DOWN, mask, onPointerDownMask); offEvent(EVENT_TOUCH_START, mask, onPointerDownMask); } // Better mobile support function onInputTextInput(e) { let $ = this, picker = getReference($), {_active, _fix} = picker; if (!_active || _fix) { return offEventDefault(e); } let {state} = picker, {pattern} = state, {inputType} = e, v = getText($, 0); if ('deleteContent' === inputType.slice(0, 13) && !v) { toggleHintByValue(picker, 0); } else if ('insertText' === inputType) { toggleHintByValue(picker, 1); } if (isString(pattern) && !toPattern(pattern).test(v)) { letErrorAbort(), setError(picker); } else { letError(0, picker); } } function onInvalidSelf(e) { e && offEventDefault(e); let $ = this; onBlurTextInput.call($), setError(getReference($)); } function onKeyDownTag(e) { let $ = _keyOverTag = this, key = e.key, keyIsCtrl = _keyIsCtrl = e.ctrlKey, keyIsShift = _keyIsShift = e.shiftKey, picker = getReference($), {_active, _fix} = picker; if (!_active || _fix) { return offEventDefault(e); } let {_mask, _tags} = picker, {text} = _mask, exit, tagFirst, tagLast, tagNext, tagPrev; if (keyIsShift) { exit = true; setAria($, TOKEN_PRESSED, true); if (KEY_ARROW_LEFT === key) { if (tagPrev = getPrev($)) { if (getAria(tagPrev, TOKEN_PRESSED)) { letAria($, TOKEN_PRESSED); } else { setAria(tagPrev, TOKEN_PRESSED, true); } focusTo(tagPrev); } } else if (KEY_ARROW_RIGHT === key) { if ((tagNext = getNext($)) && tagNext !== text) { if (getAria(tagNext, TOKEN_PRESSED)) { letAria($, TOKEN_PRESSED); } else { setAria(tagNext, TOKEN_PRESSED, true); } focusTo(tagNext); } } else if (KEY_TAB === key) { selectToNone(); } } else if (keyIsCtrl) { if (KEY_A === key) { exit = true; forEachMap(_tags, v => (setAria(v[2], TOKEN_PRESSED, true), focusTo(v[2]), selectTo(v[2]))); } else if (KEY_ARROW_LEFT === key) { exit = true; if (tagPrev = getPrev($)) { focusTo(tagPrev); } } else if (KEY_ARROW_RIGHT === key) { exit = true; if ((tagNext = getNext($)) && tagNext !== text) { focusTo(tagNext); } } else if (KEY_BEGIN === key) { exit = true; tagFirst = toValueFirstFromMap(_tags); tagFirst && focusTo(tagFirst[2]); } else if (KEY_END === key) { exit = true; tagLast = toValueLastFromMap(_tags); tagLast && focusTo(tagLast[2]); } else if (KEY_ENTER === key || ' ' === key) { exit = true; getAria($, TOKEN_PRESSED) ? letAria($, TOKEN_PRESSED) : setAria($, TOKEN_PRESSED, true); } else { setAria($, TOKEN_PRESSED, true); } } else { if (KEY_ARROW_LEFT === key) { exit = true; if (tagPrev = getPrev($)) { focusTo(tagPrev); } } else if (KEY_ARROW_RIGHT === key) { exit = true; focusTo((tagNext = getNext($)) && tagNext !== text ? tagNext : picker); } else if (KEY_BEGIN === key) { exit = true; tagFirst = toValueFirstFromMap(_tags); tagFirst && focusTo(tagFirst[2]); } else if (KEY_END === key) { exit = true; tagLast = toValueLastFromMap(_tags); tagLast && focusTo(tagLast[2]); } else if (KEY_DELETE_LEFT === key) { exit = true; tagPrev = getPrev($); letValueInMap(getTagValue($), _tags); forEachMap(_tags, v => { if (getAria(v[2], TOKEN_PRESSED)) { tagPrev = getPrev(v[2]); letValueInMap(getTagValue(v[2]), _tags); } }); focusTo(tagPrev || picker), picker.fire('change', [picker[TOKEN_VALUE]]); } else if (KEY_DELETE_RIGHT === key) { exit = true; tagNext = getNext($); letValueInMap(getTagValue($), _tags); forEachMap(_tags, v => { if (getAria(v[2], TOKEN_PRESSED)) { tagNext = getNext(v[2]); letValueInMap(getTagValue(v[2]), _tags); } }); focusTo(tagNext && tagNext !== text ? tagNext : picker), picker.fire('change', [picker[TOKEN_VALUE]]); } else if (KEY_ENTER === key || ' ' === key) { exit = true; getAria($, TOKEN_PRESSED) ? letAria($, TOKEN_PRESSED) : setAria($, TOKEN_PRESSED, true); } else if (KEY_ESCAPE === key || KEY_TAB === key) { exit = true; selectToNone(), focusTo(picker); // Any type-able key } else if (1 === toCount(key)) { forEachMap(_tags, v => { if (getAria(v[2], TOKEN_PRESSED)) { letValueInMap(getTagValue(v[2]), _tags); } }); selectToNone(), focusTo(picker).fire('change', [picker[TOKEN_VALUE]]); } } exit && offEventDefault(e); } function onKeyDownTextInput(e) { let $ = this, key = e.key, keyCode = e.keyCode, keyIsCtrl = _keyIsCtrl = e.ctrlKey, keyIsShift = _keyIsShift = e.shiftKey, picker = getReference($), {_active, _fix} = picker; if (!_active || _fix) { return; } let {_tags, self, state} = picker, {escape} = state, exit, form, submit, v; if ( (KEY_ENTER === key && (hasValue('\n', escape) || hasValue(13, escape))) || (KEY_TAB === key && (hasValue('\t', escape) || hasValue(9, escape))) || (hasValue(key, escape) || hasValue(keyCode, escape)) ) { setValueInMap(toValue(v = getText($)), v, _tags); return (focusTo(picker).text = ""), offEventDefault(e); } toggleHint(1, picker); let caretIsToTheFirst = "" === getCharBeforeCaret($), tagFirst, tagLast, textIsVoid = !getText($, 0); if (keyIsShift) { if (KEY_ENTER === key) { exit = true; } else if (KEY_TAB === key) { selectToNone(); } else if (KEY_ARROW_LEFT === key) { if (caretIsToTheFirst || textIsVoid) { exit = true; selectToNone(); tagLast = toValueLastFromMap(_tags); tagLast && focusTo(tagLast[2]) && setAria(tagLast[2], TOKEN_PRESSED, true); } } } else if (keyIsCtrl) { if (KEY_A === key && textIsVoid && _tags.count()) { exit = true; forEachMap(_tags, v => (setAria(v[2], TOKEN_PRESSED, true), focusTo(v[2]), selectTo(v[2]))); } else if (KEY_ARROW_LEFT === key) { exit = true; tagLast = toValueLastFromMap(_tags); tagLast && focusTo(tagLast[2]); } else if (KEY_BEGIN === key) { exit = true; tagFirst = toValueFirstFromMap(_tags); tagFirst && focusTo(tagFirst[2]); } else if (KEY_ENTER === key) { exit = true; } } else { if (KEY_BEGIN === key) { exit = true; tagFirst = toValueFirstFromMap(_tags); tagFirst && focusTo(tagFirst[2]); } else if (KEY_ENTER === key) { exit = true; if ((form = getParentForm(self)) && isFunction(form.requestSubmit)) { // <https://developer.mozilla.org/en-US/docs/Glossary/Submit_button> submit = getElement('button:not([type]),button[type=submit],input[type=image],input[type=submit]', form); submit ? form.requestSubmit(submit) : form.requestSubmit(); } } else if (KEY_TAB === key) { selectToNone(); } else if (caretIsToTheFirst || textIsVoid) { if (KEY_ARROW_LEFT === key) { exit = true; selectToNone(); tagLast = toValueLastFromMap(_tags); tagLast && focusTo(tagLast[2]); } else if (KEY_DELETE_LEFT === key) { if (textIsVoid) { exit = true; tagLast = toValueLastFromMap(_tags); tagLast && letValueInMap(getTagValue(tagLast[2]), _tags); } } } } exit && offEventDefault(e); } function onKeyUpTag(e) { _keyOverTag = 0; let $ = this, key = e.key, picker = getReference($), {_tags} = picker, selected = 0; forEachMap(_tags, v => { if (getAria(v[2], TOKEN_PRESSED)) { ++selected; } }); _keyIsCtrl = e.ctrlKey; _keyIsShift = e.shiftKey; if (selected < 2 && !_keyIsCtrl && !_keyIsShift && KEY_ENTER !== key && ' ' !== key) { letAria($, TOKEN_PRESSED); } } function onKeyUpTextInput(e) { _keyIsCtrl = e.ctrlKey; _keyIsShift = e.shiftKey; } function onPasteTag(e) { offEventDefault(e); let $ = this, picker = getReference($), {_tags, state} = picker, {join} = state; forEachArray(e.clipboardData.getData('text/plain').split(join), v => { if (!hasKeyInMap(v = toValue(v.trim()), _tags)) { setValueInMap(v, v, _tags); } }); forEachMap(_tags, v => letAria(v[2], TOKEN_PRESSED)); focusTo(picker.fire('change', [picker[TOKEN_VALUE]])); } function onPasteTextInput(e) { offEventDefault(e); let $ = this, picker = getReference($), {_tags, self, state} = picker, {join} = state, v; toggleHint(1, picker), insertAtSelection($, v = e.clipboardData.getData('text/plain')); if (v !== getText($)) {} else { forEachArray((getText($) + "").split(join), v => { if (!hasKeyInMap(v = toValue(v.trim()), _tags)) { setValueInMap(v, v, _tags); } else { onInvalidSelf.call(self); picker.fire('has.tag', [toValue(v)]); } }); forEachMap(_tags, v => letAria(v[2], TOKEN_PRESSED)); picker.fire('change', [picker[TOKEN_VALUE]]).text = ""; } } function onPointerDownMask(e) { offEventDefault(e); let $ = this, picker = getReference($), {target} = e; // Is it focused on a tag mask? if (target && 'option' === getRole(target)) { return; // Yes it is! } // Is it focused on a node in the tag mask? while (target && $ !== target) { target = getParent(target); if (target && 'option' === getRole(target)) { return; // Yes it is! } } // It focuses on something else in the root mask. The default is to execute `picker.focus()` focusTo(picker); } function onPointerDownTag(e) { offEventDefault(e); let $ = this, picker = getReference($), {_tags} = picker; focusTo($), selectTo($); if (!_keyIsCtrl) { forEachMap(_tags, v => letAria(v[2], TOKEN_PRESSED)); } if (_keyIsCtrl) { setAria($, TOKEN_PRESSED, true); } else if (_keyIsShift && _keyOverTag) { let tagEndIndex = getElementIndex($), tagStartIndex = getElementIndex(_keyOverTag), tagCurrent = _keyOverTag, tagNext, tagPrev; setAria($, TOKEN_PRESSED, true); setAria(_keyOverTag, TOKEN_PRESSED, true); // Select to the right if (tagEndIndex > tagStartIndex) { while (tagNext = getNext(tagCurrent)) { if ($ === tagNext) { break; } setAria(tagCurrent = tagNext, TOKEN_PRESSED, true); } // Select to the left } else if (tagEndIndex < tagStartIndex) { while (tagPrev = getPrev(tagCurrent)) { if ($ === tagPrev) { break; } setAria(tagCurrent = tagPrev, TOKEN_PRESSED, true); } } } } function onPointerDownTagX(e) { offEventDefault(e); let $ = this, tag = getParent($), picker = getReference(tag), {_active, _fix} = picker; if (!_active || _fix) { return focusTo(picker); } let {_tags} = picker; letValueInMap(getTagValue(tag), _tags); focusTo(picker); } function onResetForm() { getReference(this).reset(); } function onSubmitForm(e) { let $ = this, picker = getReference($), {_tags, max, min, self} = picker, count = _tags.count(), exit; if (count > max) { exit = true; focusTo(picker.fire('max.tags', [count, max])); } else if (count < min) { exit = true; focusTo(picker.fire('min.tags', [count, min])); } exit && (onInvalidSelf.call(self), offEventDefault(e)); } function TagPicker(self, state) { const $ = this; if (!self) { return $; } // Return new instance if `TagPicker` was called without the `new` operator if (!isInstance($, TagPicker)) { return new TagPicker(self, state); } setReference(self, hook($, TagPicker._)); let newState = fromStates({}, TagPicker.state, isString(state) ? { join: state } : (state || {})); // Special case for `state.escape`: replace instead of join the value(s) if (isObject(state) && state.escape) { newState.escape = state.escape; } return $.attach(self, newState); } function TagPickerTags(of, tags) { const $ = this; // Return new instance if `TagPickerTags` was called without the `new` operator if (!isInstance($, TagPickerTags)) { return new TagPickerTags(of, tags); } $.of = of; $[TOKEN_VALUES] = new Map; if (tags) { createTags(of, tags); } return $; } TagPicker.from = function (self, state) { return new TagPicker(self, state); }; TagPicker.of = getReference; TagPicker.state = { 'escape': [','], 'join': ', ', 'max': Infinity, 'min': 0, 'n': 'tag-picker', 'pattern': null, 'time': { 'error': 1000 }, 'with': [] }; TagPicker.version = '4.2.6'; setObjectAttributes(TagPicker, { name: { value: name } }, 1); setObjectAttributes(TagPicker, { active: { get: function () { return this._active; }, set: function (value) { selectToNone(); let $ = this, {_mask, _tags, mask, self} = $, {input} = _mask, v = !!value; self[TOKEN_DISABLED] = !($._active = v); if (v) { letAria(input, TOKEN_DISABLED); letAria(mask, TOKEN_DISABLED); setAttribute(input, TOKEN_CONTENTEDITABLE, ""); forEachMap(_tags, v => { setAttribute(v[2], TOKEN_CONTENTEDITABLE, ""); setAttribute(v[2], TOKEN_TABINDEX, -1); }); } else { letAttribute(input, TOKEN_CONTENTEDITABLE); setAria(input, TOKEN_DISABLED, true); setAria(mask, TOKEN_DISABLED, true); forEachMap(_tags, v => { letAttribute(v[2], TOKEN_CONTENTEDITABLE); letAttribute(v[2], TOKEN_TABINDEX); }); } return $; } }, fix: { get: function () { return this._fix; }, set: function (value) { selectToNone(); let $ = this, {_mask, _tags, mask, self} = $, {input} = _mask, v = !!value; self[TOKEN_READ_ONLY] = $._fix = v; if (v) { letAttribute(input, TOKEN_CONTENTEDITABLE); setAria(input, TOKEN_READONLY, true); setAria(mask, TOKEN_READONLY, true); setAttribute(input, TOKEN_TABINDEX, 0); forEachMap(_tags, v => { letAttribute(v[2], TOKEN_CONTENTEDITABLE); letAttribute(v[2], TOKEN_TABINDEX); }); } else { letAria(input, TOKEN_READONLY); letAria(mask, TOKEN_READONLY); letAttribute(input, TOKEN_TABINDEX); setAttribute(input, TOKEN_CONTENTEDITABLE, ""); forEachMap(_tags, v => { setAttribute(v[2], TOKEN_CONTENTEDITABLE, ""); setAttribute(v[2], TOKEN_TABINDEX, -1); }); } return $; } }, max: { get: function () { let {max} = this.state; return Infinity === max || (isInteger(max) && max >= 0) ? max : Infinity; }, set: function (value) { let $ = this; return ($.state.max = isInteger(value) && value >= 0 ? value : Infinity), $; } }, min: { get: function () { let {min} = this.state; return isInteger(min) && min >= 0 ? min : 0; }, set: function (value) { let $ = this; return ($.state.min = isInteger(value) && value >= 0 ? value : 0), $; } }, tags: { get: function () { return this._tags; }, set: function (tags) { selectToNone(); let $ = this, tagsValues = []; createTags($, tags); forEachMap($._tags, v => tagsValues.push(getTagValue(v[2], 1))); return $.fire('set.tags', [tagsValues]); } }, text: { get: function () { return getText(this._mask.input); }, set: function (value) { let $ = this, {_active, _fix} = $; if (!_active || _fix) { return $; } let {_mask} = $, {input} = _mask, v; return setText(input, v = fromValue(value)), toggleHintByValue($, v), $; } }, value: { get: function () { let value = getValue(this.self); return "" !== value ? value : null; }, set: function (value) { let $ = this, {_active} = $; if (!_active) { return $; } let {_tags, state} = $, {join} = state; $[TOKEN_VALUE] && forEachArray($[TOKEN_VALUE].split(join), v => letValueInMap(v, _tags)); value && forEachArray(value.split(join), v => setValueInMap(v, v, _tags)); return $.fire('change', [$[TOKEN_VALUE]]); } }, vital: { get: function () { return this._vital; }, set: function (value) { selectToNone(); let $ = this, {_mask, mask, min, self} = $, {input} = _mask, v = !!value; self[TOKEN_REQUIRED] = $._vital = v; if (v) { if (0 === min) { $.min = 1; } setAria(input, TOKEN_REQUIRED, true); setAria(mask, TOKEN_REQUIRED, true); } else { $.min = 0; letAria(input, TOKEN_REQUIRED); letAria(mask, TOKEN_REQUIRED); } return $; } } }); TagPicker._ = setObjectMethods(TagPicker, { attach: function (self, state) { let $ = this; self = self || $.self; if (state && isString(state)) { state = { join: state }; } state = fromStates({}, $.state, state || {}); $._tags = new TagPickerTags($); $.self = self; $.state = state; let {max, min, n} = state, isDisabledSelf = isDisabled(self), isReadOnlySelf = isReadOnly(self), isRequiredSelf = isRequired(self), theInputID = self.id, theInputName = self.name, theInputPlaceholder = self.placeholder, theInputValue = getValue(self); $._active = !isDisabledSelf; $._fix = isReadOnlySelf; $._vital = isRequiredSelf; if (isRequiredSelf && min < 1) { state.min = min = 1; // Force minimum tag(s) to insert to be `1` } const form = getParentForm(self); const mask = setElement('div', { 'aria': { 'disabled': isDisabledSelf ? TOKEN_TRUE : false, 'multiselectable': TOKEN_TRUE, 'readonly': isReadOnlySelf ? TOKEN_TRUE : false, 'required': isRequiredSelf ? TOKEN_TRUE : false }, 'class': n, 'role': 'listbox' }); $.mask = mask; const maskFlex = setElement('span', { 'class': n + '__flex', 'role': 'none' }); const text = setElement('span', { 'class': n + '__text', 'role': 'none' }); const textInput = setElement('span', { 'aria': { 'disabled': isDisabledSelf ? TOKEN_TRUE : false, 'multiline': TOKEN_FALSE, 'placeholder': theInputPlaceholder, 'readonly': isReadOnlySelf ? TOKEN_TRUE : false, 'required': isRequiredSelf ? TOKEN_TRUE : false }, 'autocapitalize': 'off', 'contenteditable': isDisabledSelf || isReadOnlySelf ? false : "", 'role': 'textbox', 'spellcheck': TOKEN_FALSE, 'tabindex': isReadOnlySelf ? 0 : false }); const textInputHint = setElement('span', theInputPlaceholder + "", { 'aria': { 'hidden': TOKEN_TRUE } }); setChildLast(mask, maskFlex); setChildLast(maskFlex, text); setChildLast(text, textInput); setChildLast(text, textInputHint); setAria(self, 'hidden', true); setClass(self, n + '__self'); setReference(textInput, $); setNext(self, mask); setChildLast(mask, self); if (form) { onEvent(EVENT_RESET, form, onResetForm); onEvent(EVENT_SUBMIT, form, onSubmitForm); setID(form); setReference(form, $); } onEvent(EVENT_BLUR, textInput, onBlurTextInput); onEvent(EVENT_CUT, textInput, onCutTextInput); onEvent(EVENT_FOCUS, self, onFocusSelf); onEvent(EVENT_FOCUS, textInput, onFocusTextInput); onEvent(EVENT_INPUT, textInput, onInputTextInput); onEvent(EVENT_INPUT_START, textInput, onBeforeInputTextInput); onEvent(EVENT_INVALID, self, onInvalidSelf); onEvent(EVENT_KEY_DOWN, textInput, onKeyDownTextInput); onEvent(EVENT_KEY_UP, textInput, onKeyUpTextInput); onEvent(EVENT_MOUSE_DOWN, mask, onPointerDownMask); onEvent(EVENT_PASTE, textInput, onPasteTextInput); onEvent(EVENT_TOUCH_START, mask, onPointerDownMask); self[TOKEN_TAB_INDEX] = -1; setReference(mask, $); $._mask = { flex: maskFlex, hint: textInputHint, input: textInput, of: self, self: mask, text: text }; // Re-assign some state value(s) using the setter to either normalize or reject the initial value $.max = max = Infinity === max || (isInteger(max) && max >= 0) ? max : Infinity; $.min = min = isInteger(min) && min >= 0 ? min : 0; let {_active} = $, {join} = state, tagsValues; // Force the `this._active` value to `true` to set the initial value $._active = true; // Attach the current tag(s) tagsValues = createTags($, (theInputValue ? theInputValue.split(join) : [])); $['_' + TOKEN_VALUE] = tagsValues.join(join); // After the initial value has been set, restore the previous `this._active` value $._active = _active; // Force `id` attribute(s) setAria(textInput, 'controls', getID(setID(maskFlex))); setID(mask); setID(self); setID(textInput); setID(textInputHint); theInputID && setDatum(mask, 'id', theInputID); theInputName && setDatum(mask, 'name', theInputName); // Attach extension(s) if (isSet(state) && isArray(state.with)) { forEachArray(state.with, (v, k) => { if (isString(v)) { v = TagPicker[v]; } // `const Extension = function (self, state = {}) {}` if (isFunction(v)) { v.call($, self, state); // `const Extension = {attach: function (self, state = {}) {}, detach: function (self, state = {}) {}}` } else if (isObject(v) && isFunction(v.attach)) { v.attach.call($, self, state); } }); } return $; }, blur: function () { selectToNone(); let $ = this, {_mask, _tags} = $, {input} = _mask; forEachMap(_tags, v => v[2].blur()); return input.blur(), $; }, detach: function () { let $ = this, {_mask, mask, self, state} = $, {input} = _mask; const form = getParentForm(self); $._active = false; $._tags = new TagPickerTags($); $['_' + TOKEN_VALUE] = null; if (form) { offEvent(EVENT_RESET, form, onResetForm); offEvent(EVENT_SUBMIT, form, onSubmitForm); } offEvent(EVENT_BLUR, input, onBlurTextInput); offEvent(EVENT_CUT, input, onCutTextInput); offEvent(EVENT_FOCUS, input, onFocusTextInput); offEvent(EVENT_FOCUS, self, onFocusSelf); offEvent(EVENT_INPUT, input, onInputTextInput); offEvent(EVENT_INPUT_START, input, onBeforeInputTextInput); offEvent(EVENT_INVALID, self, onInvalidSelf); offEvent(EVENT_KEY_DOWN, input, onKeyDownTextInput); offEvent(EVENT_KEY_UP, input, onKeyUpTextInput); offEvent(EVENT_MOUSE_DOWN, mask, onPointerDownMask); offEvent(EVENT_PASTE, input, onPasteTextInput); offEvent(EVENT_TOUCH_START, mask, onPointerDownMask); // Detach extension(s) if (isArray(state.with)) { forEachArray(state.with, (v, k) => { if (isString(v)) { v = TagPicker[v]; } if (isObject(v) && isFunction(v.detach)) { v.detach.call($, self, state); } }); } self[TOKEN_TAB_INDEX] = null; letAria(self, 'hidden'); letClass(self, state.n + '__self'); setNext(mask, self); letElement(mask); $._mask = { of: self }; $.mask = null; return $; }, focus: function (mode) { let $ = this, {_active} = $; if (!_active) { return $; } let {_mask} = $, {input} = _mask; return focusTo(input), selectTo(input, mode), $; }, reset: function (focus, mode) { let $ = this, {_active} = $; if (!_active) { return $; } $[TOKEN_VALUE] = $['_' + TOKEN_VALUE]; return focus ? $.focus(mode) : $; } }); setObjectAttributes(TagPickerTags, { name: { value: name + 'Tags' } }, 1); TagPickerTags._ = setObjectMethods(TagPickerTags, { at: function (key) { return getValueInMap(toValue(key), this[TOKEN_VALUES]); }, count: function () { return toMapCount(this[TOKEN_VALUES]); }, // To be used by the `letValueInMap()` function delete: function (key, _fireHook = 1) { let $ = this, {of, values} = $, {_active} = of; if (!_active) { return false; } let {min, self, state} = of, {join, n} = state, count, r, tagsValues = []; if ((count = $.count()) <= min) { _fireHook && onInvalidSelf.call(self); return (_fireHook && of.fire('min.tags', [count, min])), false; } if (!isSet(key)) { forEachMap(values, (v, k) => $.let(k, 0)); return (_fireHook && of.fire('let.tags', [[]]).fire('change', [null])), 0 === $.count(); } if (!(r = getValueInMap(key = toValue(key), values))) { onInvalidSelf.call(self); return (_fireHook && of.fire('not.tag', [key])), false; } let tag = r[2], tagX = getElement('.' + n + '__x', tag); offEvent(EVENT_BLUR, tag, onBlurTag); offEvent(EVENT_COPY, tag, onCopyTag); offEvent(EVENT_CUT, tag, onCutTag); offEvent(EVENT_FOCUS, tag, onFocusTag); offEvent(EVENT_INPUT_START, tag, onBeforeInputTag); offEvent(EVENT_KEY_DOWN, tag, onKeyDownTag); offEvent(EVENT_KEY_UP, tag, onKeyUpTag); offEvent(EVENT_MOUSE_DOWN, tag, onPointerDownTag); offEvent(EVENT_MOUSE_DOWN, tagX, onPointerDownTagX); offEvent(EVENT_PASTE, tag, onPasteTag); offEvent(EVENT_TOUCH_START, tag, onPointerDownTag); offEvent(EVENT_TOUCH_START, tagX, onPointerDownTagX); letElement(tagX), letElement(tag); r = letValueInMap(key, values); forEachMap(values, (v, k) => tagsValues.push(fromValue(k))); setValue(self, tagsValues = tagsValues.join(join)); return (_fireHook && of.fire('let.tag', [key]).fire('change', ["" !== tagsValues ? tagsValues : null])), r; }, get: function (key) { let $ = this, {values} = $, value = getValueInMap(toValue(key), values); return value ? getElementIndex(value[2]) : -1; }, has: function (key) { return hasKeyInMap(toValue(key), this[TOKEN_VALUES]); }, let: function (key, _fireHook = 1) { return this.delete(key, _fireHook); }, set: function (key, value, _fireHook = 1) { let $ = this, {of, values} = $, {_active} = of; if (!_active) { return false; } let {_fix, _mask, max, self, state} = of, {text} = _mask, {join, n, pattern} = state, count, r, tag, tagText, tagX, tagsValues = []; if ((count = $.count()) >= max) { _fireHook && onInvalidSelf.call(self); return (_fireHook && of.fire('max.tags', [count, max])), false; } // `picker.tags.set('asdf')` if (!isSet(value)) { value = [key, { active: true, mark: false }]; // `picker.tags.set('asdf', 'asdf')` } else if (isFloat(value) || isInteger(value) || isString(value)) { value = [value, { active: true, mark: false }]; // `picker.tags.set('asdf', [ … ])` } else {} // All tag(s) act as selected option(s) that complement the mask root. The mask root functions as a `listbox` // with active `aria-multiselectable` attribute. For visually impaired user(s), this element should be described // as a multiple selection control with all option(s) selected. There is no point in having a way to disable a // tag to exclude it from the selection. The best strategy is to simply remove the tag. That’s why the `active` // option is always `true` and every tag has an active `aria-selected` attribute. value[1].active = value[1].active ?? true; // This `mark` option is used to determine the state of the `aria-pressed` attribute. value[1].mark = value[1].mark ?? false; let {mark, value: v} = value[1]; if (null === key || "" === (v = fromValue(v || key).trim()) || (isString(pattern) && !toPattern(pattern).test(v))) { onInvalidSelf.call(self); return (_fireHook && of.fire('not.tag', [key])), false; } if (isFunction(pattern)) { if (isArray(r = pattern.call(of, v))) { key = v = r[1] ? (r[1][TOKEN_VALUE] ?? r[0]) : r[0]; value = r; } else if (isString(r)) { key = v = r; value[0] = r; } } if ($.has(key = toValue(key))) { onInvalidSelf.call(self); return (_fireHook && of.fire('has.tag', [key])), false; } tag = value[2] || setElement('data', { 'aria': { 'pressed': mark ? TOKEN_TRUE : false, 'selected': TOKEN_TRUE }, 'class': n + '__tag', // Make the tag “content editable”, so that the “Cut” option is available in the context menu, but do not // allow user(s) to edit the tag text. We just want to make sure that the “Cut” option is available. 'contenteditable': TOKEN_TRUE, // <https://html.spec.whatwg.org/multipage/interaction.html#attr-inputmode-keyword-none> 'inputmode': 'none', 'role': 'option', 'spellcheck': TOKEN_FALSE, 'tabindex': -1, 'title': getState(value[1], 'title') ?? false, 'value': v, // <https://www.w3.org/TR/virtual-keyboard#dom-elementcontenteditable-virtualkeyboardpolicy> 'virtualkeyboardpolicy': 'manual' }); // Disable focus on “read-only” tag picker if (_fix) { letAttribute(tag, TOKEN_CONTENTEDITABLE); letAttribute(tag, TOKEN_TABINDEX); } tagText = value[2] ? getElement('.' + n + '__v', value[2]) : setElement('span', fromValue(value[0]), { 'class': n + '__v', 'role': 'none' }); n += '__x'; tagX = value[2] ? getElement('.' + n, value[2]) : setElement('span', { 'aria': { 'hidden': TOKEN_TRUE }, 'class': n, 'tabindex': -1 }); // Force `id` attribute(s) setID(tagText); setID(tagX); setAria(tagX, 'controls', getID(setID(tag))); if (!value[2]) { onEvent(EVENT_BLUR, tag, onBlurTag); onEvent(EVENT_COPY, tag, onCopyTag); onEvent(EVENT_CUT, tag, onCutTag); onEvent(EVENT_FOCUS, tag, onFocusTag); onEvent(EVENT_INPUT_START, tag, onBeforeInputTag); onEvent(EVENT_KEY_DOWN, tag, onKeyDownTag); onEvent(EVENT_KEY_UP, tag, onKeyUpTag); onEvent(EVENT_MOUSE_DOWN, tag, onPointerDownTag); onEvent(EVENT_MOUSE_DOWN, tagX, onPointerDownTagX); onEvent(EVENT_PASTE, tag, onPasteTag); onEvent(EVENT_TOUCH_START, tag, onPointerDownTag); onEvent(EVENT_TOUCH_START, tagX, onPointerDownTagX); } setChildLast(tag, tagText); setChildLast(tag, tagX); setPrev(text, tag); setReference(tag, of); value[2] = tag; _fireHook && of.fire('is.tag', [key]); setValueInMap(key, value, values); forEachMap(values, (v, k) => tagsValues.push(fromValue(k))); setValue(self, tagsValues = tagsValues.join(join)); return (_fireHook && of.fire('set.tag', [key]).fire('change', ["" !== tagsValues ? tagsValues : null])), true; } }); // In order for an object to be iterable, it must have a `Symbol.iterator` key getPrototype(TagPickerTags)[Symbol.iterator] = function () { return this[TOKEN_VALUES][Symbol.iterator](); }; TagPicker.Tags = TagPickerTags; export default TagPicker;