diff --git a/node_modules/@yaireo/tagify/.eslintrc b/node_modules/@yaireo/tagify/.eslintrc
new file mode 100644
index 0000000..a251eb3
--- /dev/null
+++ b/node_modules/@yaireo/tagify/.eslintrc
@@ -0,0 +1,46 @@
+{
+ "rules": {
+ "no-mixed-spaces-and-tabs" : [2, "smart-tabs"],
+ "block-spacing" : [2, "always"],
+ "comma-style" : [2, "last"],
+ "no-debugger" : [1],
+ "no-alert" : [1],
+ "indent" : [1, 4, {"SwitchCase":1}],
+ "strict" : 0,
+ "no-undef" : 1
+ },
+ "parserOptions": {
+ "ecmaVersion" : 9,
+ "sourceType": "module",
+ "ecmaFeatures": {
+ "modules": true,
+ "sourceTyp": "module",
+ "experimentalObjectRestSpread": true
+ }
+ },
+ "globals" : {
+ "FB": "readonly",
+ "ga": "readonly",
+ "jQuery": "readonly",
+ "$":"readonly",
+ "_": "readonly",
+ "d3": "readonly",
+ "Router": "readonly",
+ "ttip": "readonly",
+ "Cookies": "readonly",
+ "fastdom": "readonly",
+ "describe": "readonly",
+ "beforeEach": "readonly",
+ "it": "readonly",
+ "expect": "readonly",
+ "assert": "readonly",
+ "done": "readonly",
+ "dataLayer": "readonly",
+ "validator": "readonly"
+ },
+ "env": {
+ "es6": true,
+ "browser": true,
+ "node": true
+ }
+}
\ No newline at end of file
diff --git a/node_modules/@yaireo/tagify/.gitattributes b/node_modules/@yaireo/tagify/.gitattributes
new file mode 100644
index 0000000..bdb0cab
--- /dev/null
+++ b/node_modules/@yaireo/tagify/.gitattributes
@@ -0,0 +1,17 @@
+# Auto detect text files and perform LF normalization
+* text=auto
+
+# Custom for Visual Studio
+*.cs diff=csharp
+
+# Standard to msysgit
+*.doc diff=astextplain
+*.DOC diff=astextplain
+*.docx diff=astextplain
+*.DOCX diff=astextplain
+*.dot diff=astextplain
+*.DOT diff=astextplain
+*.pdf diff=astextplain
+*.PDF diff=astextplain
+*.rtf diff=astextplain
+*.RTF diff=astextplain
diff --git a/node_modules/@yaireo/tagify/LICENSE b/node_modules/@yaireo/tagify/LICENSE
new file mode 100644
index 0000000..4110d3c
--- /dev/null
+++ b/node_modules/@yaireo/tagify/LICENSE
@@ -0,0 +1,19 @@
+Copyright 2019 Yair Even-Or
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
\ No newline at end of file
diff --git a/node_modules/@yaireo/tagify/README.md b/node_modules/@yaireo/tagify/README.md
new file mode 100644
index 0000000..570cb19
--- /dev/null
+++ b/node_modules/@yaireo/tagify/README.md
@@ -0,0 +1,888 @@
+
+
+
+ Tagify - tags input component
+
+
+
+ Transforms an input field or a textarea into a Tags component , in an easy, customizable way,
+ with great performance and small code footprint, exploded with features.
+
+ Vanilla ⚡ React ⚡ Vue ⚡ Angular
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+## Table of Contents
+
+
+* [Installation](#installation)
+* [What can Tagify do](#features)
+* [Building the project](#building-the-project)
+* [Adding tags dynamically](#adding-tags-dynamically)
+* [Output value](#output-value)
+* [Ajax whitelist](#ajax-whitelist)
+* [Edit tags](#edit-tags)
+* [Drag & Sort](#drag--sort)
+* [DOM Templates](#dom-templates)
+* [Suggestions selectbox](#suggestions-selectbox)
+* [Tags mixed with text](#mixed-content)
+* [Single-Value Only](#single-value)
+* [React wrapper](#react)
+* [Angular wrapper](#angular)
+* [Vue Example](https://codesandbox.io/s/tagify-tags-component-vue-example-l8ok4)
+* [jQuery version](#jquery-version)
+* [FAQ](#FAQ)
+* [CSS Variables](#css-variables)
+* [Methods](#methods)
+* [Events](#events)
+* [Hooks](#hooks)
+* [Settings](#settings)
+
+
+## Installation
+
+```sh
+npm i @yaireo/tagify --save
+```
+
+### Usage (in your bundle):
+
+[live demo using Parcel as bundler](https://codesandbox.io/s/simple-tagify-setup-6pfi2)
+
+```js
+import Tagify from '@yaireo/tagify'
+
+var tagify = new Tagify(...)
+```
+
+> Don't forget to **include `tagify.css`** file in your project.
+> CSS location: `@yaireo/tagify/dist/tagify.css`
+> SCSS location: `@yaireo/tagify/src/tagify.scss`
+> [See SCSS usecase & example](https://github.com/yairEO/tagify/pull/282)
+
+## Features
+* Can be applied on input & textarea elements
+* Supports [mix content](#mixed-content) (text and tags together)
+* Supports [single-value](#single-value) mode (like ``)
+* Supports whitelist/blacklist
+* Supports Templates for: component wrapper , tag items , suggestion list & suggestion items
+* Shows suggestions selectbox (flexiable settings & styling) at *full (component) width* or *next to* the typed texted (caret)
+* Allows setting suggestions' [aliases](#example-for-a-suggestion-item-alias) for easier fuzzy-searching
+* Auto-suggest input as-you-type with ability to auto-complete
+* Can paste in multiple values: `tag 1, tag 2, tag 3` or even newline-separated tags
+* Tags can be created by Regex delimiter or by pressing the "Enter" key / focusing of the input
+* Validate tags by Regex pattern
+* Tags may be [editable](#edit-tags) (double-click)
+* ARIA accessibility support(Component too generic for any meaningful ARIA)
+* Supports read-only mode to the whole componenet or per-tag
+* Each tag can have any properties desired (class, data-whatever, readonly...)
+* Automatically disallow duplicate tags (vis "settings" object)
+* Has built-in CSS loader, if needed (Ex. AJAX whitelist pulling)
+* Tags can be trimmed via `hellip` by giving `max-width` to the `tag` element in your `CSS`
+* Easily change direction to RTL (via the SCSS file)
+* Internet Explorer - A polyfill script should be used: `tagify.polyfills.min.js` (in `/dist`)
+* Many useful custom [events](#events)
+* Original input/textarea element values kept in sync with Tagify
+
+## Building the project
+Simply run `gulp` in your terminal, from the project's path ([Gulp](https://gulpjs.com) should be installed first).
+
+Source files are this path: `/src/`
+
+Output files, which are automatically generated using Gulp, are in: `/dist/`
+
+The rest of the files are most likely irrelevant.
+
+## Adding tags dynamically
+```javascript
+var tagify = new Tagify(...);
+
+tagify.addTags(["banana", "orange", "apple"])
+
+// or add tags with pre-defined propeties
+
+tagify.addTags([{value:"banana", color:"yellow"}, {value:"apple", color:"red"}, {value:"watermelon", color:"green"}])
+```
+
+## Output value
+There are two possible ways to get the value of the tags:
+
+1. Access the tagify's instance's `value` prop: `tagify.value` (Array of tags)
+2. Access the *original* input's value: `inputElm.value` (Stringified Array of tags)
+
+The most common way is to simply listen to the `change` event on the *original input*
+
+```javascript
+var inputElm = document.querySelector,
+ tagify = new Tagify (inputElm);
+
+inputElm.addEventListener('change', onChange)
+
+function onChange(e){
+ // outputs a String
+ console.log(e.target.value)
+}
+
+```
+
+### [Modify original input value format](https://jsbin.com/paxijaj/edit?html,js,output)
+
+Default format is a JSON string:
+`'[{"value":"cat"}, {"value":"dog"}]'`
+
+I **recommend** keeping this because some situations might have values such as addresses (tags contain commas):
+`'[{"value":"Apt. 2A, Jacksonville, FL 39404"}, {"value":"Forrest Ray, 191-103 Integer Rd., Corona New Mexico"}]'`
+
+Another example for complex tags state might be disabled tags, or ones with custom identifier *class*:
+*(tags can be clicked, so delevopers can choose to use this to disable/enable tags)*
+`'[{"value":"cat", "disabled":true}, {"value":"dog"}, {"value":"bird", "class":"color-green"}]'`
+
+To change the format, assuming your tags have no commas and are fairly simple:
+
+```js
+var tagify = new Tagify(inputElm, {
+ originalInputValueFormat: valuesArr => valuesArr.map(item => item.value).join(',')
+})
+```
+
+**Output:**
+`"cat,dog"`
+
+
+## Ajax whitelist
+Dynamically-loaded suggestions list (*whitelist*) from the server (as the user types) is a frequent need to many.
+
+Tagify comes with its own loading animation, which is a very lightweight CSS-only code, and the loading
+state is controlled by the method `tagify.loading` which accepts `true` or `false` as arguments.
+
+Below is a basic example using the `fetch` API. I advise to abort the last request on any input before starting a new request.
+
+
+ Example:
+
+```javascript
+var input = document.querySelector('input'),
+ tagify = new Tagify(input, {whitelist:[]}),
+ controller; // for aborting the call
+
+// listen to any keystrokes which modify tagify's input
+tagify.on('input', onInput)
+
+function onInput( e ){
+ var value = e.detail.value;
+ tagify.settings.whitelist.length = 0; // reset the whitelist
+
+ // https://developer.mozilla.org/en-US/docs/Web/API/AbortController/abort
+ controller && controller.abort();
+ controller = new AbortController();
+
+ // show loading animation and hide the suggestions dropdown
+ tagify.loading(true).dropdown.hide.call(tagify)
+
+ fetch('http://get_suggestions.com?value=' + value, {signal:controller.signal})
+ .then(RES => RES.json())
+ .then(function(whitelist){
+ // update inwhitelist Array in-place
+ tagify.settings.whitelist.splice(0, whitelist.length, ...whitelist)
+ tagify.loading(false).dropdown.show.call(tagify, value); // render the suggestions dropdown
+ })
+}
+```
+
+
+## Edit tags
+Tags which aren't `read-only` can be edited by double-clicking them (by default)
+or by changing the `editTags` *setting* to `1`, making tags editable by single-clicking them.
+
+The value is saved on `blur` or by pressing `enter` key. Pressing `Escape` will revert the change trigger `blur`.
+ctrl z will revert the change if an edited tag was marked as not valid (perhaps duplicate or blacklisted)
+
+To prevent *all* tags from being allowed to be editable, set the `editTags` setting to `false` (or `null`).
+To do the same but for specific tag(s), set those tags' data with `editable` property set to `false`:
+
+```html
+
+```
+
+## Drag & Sort
+
+To be able to sort tags by draging, a 3rd-party script is needed.
+
+I have made a very simple *drag & drop* (~`11kb` *unminified*) script which uses [HTML5 native API](https://developer.mozilla.org/en-US/docs/Web/API/HTML_Drag_and_Drop_API) and
+it is available to download via [NPM](https://www.npmjs.com/package/@yaireo/dragsort) or [Github](https://github.com/yairEO/dragsort)
+but any other *drag & drop* script may possibly work. I could not find in the whole internet a decent lightweight script.
+
+### [Integration example](https://codepen.io/vsync/pen/jOqYOVJ):
+
+```js
+var tagify = new Tagify(inputElement)
+
+// bind "DragSort" to Tagify's main element and tell
+// it that all the items with the below "selector" are "draggable"
+var dragsort = new DragSort(tagify.DOM.scope, {
+ selector: '.'+tagify.settings.classNames.tag,
+ callbacks: {
+ dragEnd: onDragEnd
+ }
+})
+
+// must update Tagify's value according to the re-ordered nodes in the DOM
+function onDragEnd(elm){
+ tagify.updateValueByDOMTags()
+}
+```
+
+
+## DOM Templates
+It's possible to control the templates for some of the HTML elements tagify is using by
+modifying the `settings.templates` Object with your own custom functions which **must return** an *HTML string*.
+
+Available templates are: `wrapper`, `tag`, `dropdown`, `dropdownItem` and the optional `dropdownItemNoMatch`
+which is a special template for rendering a suggestion item (in the dropdown list) only if there were no matches found for the typed input.
+
+[View templates](https://github.com/yairEO/tagify/blob/master/src/parts/templates.js)
+
+## Suggestions selectbox
+The suggestions selectbox is shown is a whitelist Array of Strings or Objects was passed in the settings when the Tagify instance was created.
+Suggestions list will only be rendered if there are at least two matching suggestions (case-insensitive).
+
+The selectbox dropdown will be appended to the document's `` element and will be rendered by default in a position below (bottom of) the Tagify element.
+Using the keyboard arrows up/down will highlight an option from the list, and hitting the Enter key to select.
+
+It is possible to tweak the selectbox dropdown via 2 settings:
+
+ - `enabled` - this is a numeral value which tells Tagify when to show the suggestions dropdown, when a minimum of N characters were typed.
+ - `maxItems` - Limits the number of items the suggestions selectbox will render
+
+```javascript
+var input = document.querySelector('input'),
+ tagify = new Tagify(input, {
+ whitelist : ['aaa', 'aaab', 'aaabb', 'aaabc', 'aaabd', 'aaabe', 'aaac', 'aaacc'],
+ dropdown : {
+ classname : "color-blue",
+ enabled : 0, // show the dropdown immediately on focus
+ maxItems : 5,
+ position : "text", // place the dropdown near the typed text
+ closeOnSelect : false, // keep the dropdown open after selecting a suggestion
+ highlightFirst: true
+ }
+ });
+```
+
+Will render
+
+```html
+
+
+
aaab
+
aaabb
+
aaabc
+
aaabd
+
aaabe
+
+
+```
+
+By default searching the suggestions is using [fuzzy-search](https://en.wikipedia.org/wiki/Approximate_string_matching) (see [settings](#settings)).
+
+If you wish to assign *alias* to items (in your suggestion list), add the `searchBy` property to *whitelist* items you wish
+to have an *alias* for.
+
+In the below example, typing a part of a string which is included in the `searchBy` property, for example *`land midd"`* -
+the suggested item which match the value "Israel" will be rendered in the suggestions (dropdown) list.
+
+### [Example](https://yaireo.github.io/tagify/#section-extra-properties) for a suggestion item alias
+
+```javascript
+whitelist = [
+ ...
+ { value:'Israel', code:'IL', searchBy:'holy land, desert, middle east' },
+ ...
+]
+```
+
+Another handy setting is `dropdown.searchKeys` which, like the above `dropdown.searchBy` setting, allows
+expanding the search of any typed terms to more than the `value` property of the whitelist items (if items are a *Collection*).
+
+### Example whitelist:
+
+```javascript
+[
+ {
+ value : 123456,
+ nickname : "foo",
+ email : "foo@mail.com"
+ },
+ {
+ value : 987654,
+ nickname : "bar",
+ email : "bar@mail.com"
+ },
+ ...more..
+]
+```
+
+// setting to search in other keys:
+```javascript
+{
+ dropdown: {
+ searchKeys: ["nickname", "email"] // fuzzy-search matching for those whitelist items' properties
+ }
+}
+```
+
+## Mixed-Content
+
+> To use this feature it must be toggled - see [settings](#settings).
+
+When mixing text with tags, the original textarea (or input) element will have a value as follows:
+
+ [[cartman]] and [[kyle]] do not know [[Homer simpson]]
+
+If the inital value of the textarea or input is formatted as the above example, tagify will try to
+automatically convert everything between `[[` & `]]` to a tag, if tag exists in the *whitelist*, so make
+sure when the Tagify instance is initialized, that it has tags with the correct `value` property that match
+the same values that appear between `[[` & `]]`.
+
+Applying the setting `dropdown.position:"text"` is encouraged for mixed-content tags, because the suggestions list
+will be rendered right next to the caret location and not the the bottom of the Tagify componenet, which might look
+weird when there is already a lot of content at multiple lines.
+
+If a tag does not exists in the *whitelist*, it may be created by the user and all you should do is listen to the `add` event and update your local/remote state.
+
+## Single-Value
+
+Similar to native `` element, but allows typing text as value.
+
+## React
+
+See [**live demo**](https://codesandbox.io/s/tagify-react-wrapper-oempc) for React integration examples.
+
+A Tagify React component is exported from [`react.tagify.js`](https://github.com/yairEO/tagify/blob/master/dist/react.tagify.js):
+
+> Note: You will need to inport Tagify's CSS also, either by javasceript or by SCSS `@import` (which is preferable)
+```javascript
+import Tags from "@yaireo/tagify/dist/react.tagify" // React-wrapper file
+import "@yaireo/tagify/dist/tagify.css" // Tagify CSS
+
+const App = () => {
+ return (
+ (e.persist(), console.log("CHANGED:", e.target.value))}
+ />
+ )
+})
+```
+
+To gain full access to Tagify's (instance) inner methods, A custom `ref` can be used:
+
+```jsx
+
+
+// or mix-mode
+
+```
+
+`` component is a shorthand for ``
+
+#### Updating the component's state
+
+The `settings` prop is **only used once** in the initialization process, please do not update it afterwards.
+
+---
+
+ 📖 List of (React) props for the <Tags/>
component
+
+
+Prop | Type | Updatable | Info
+----------------------- | ------------------------- |:---------:| -----------------------------------------------------------
+settings | Object | | See [*settings* section](#settings)
+name | String | ✔ | ` `'s element `name` attribute
+value | String/Array | ✔ | Initial value.
+defaultValue | String/Array | | Only affects the hidden ` ` element
+placeholder | String | ✔ | placeholder text for the component
+readOnly | Boolean | ✔ | Toggles `readonly` state. With capital `O`.
+tagifyRef | Object | | `useRef` hook refference for the component inner instance of vailla *Tagify* (for methods access)
+showFilteredDropdown | Boolean/String | ✔ | if `true` shows the suggestions dropdown. if assigned a String, show the dropdown pre-filtered.
+loading | Boolean | ✔ | Toggles `loading` state for the whole component
+whitelist | Array | ✔ | Sets the `whitelist` which is the basis for the suggestions dropdown & autocomplete
+className | String | | Component's optional class name to be added
+InputMode | String | | `"textarea"` will create a `
+
+---
+
+
+
+## Angular
+
+**TagifyComponent** which will be used by your template as ``
+
+
+ Example:
+
+```
+
+ testing tagify wrapper
+
+
+ clear
+ add Tags
+
+```
+
+
+**TagifyService**
+
+> (The tagifyService is a singletone injected by angular, do not create a new instance of it)
+Remember to add `TagifyService` to your module definition.
+
+
+ Example:
+
+```typescript
+import {Component, OnDestroy} from '@angular/core';
+import {TagifyService} from '@yaireo/tagify';
+
+@Component({
+ selector: 'app-root',
+ templateUrl: './app.component.html',
+ styleUrls: ['./app.component.css']
+})
+export class AppComponent implements OnDestroy {
+
+ constructor(private tagifyService: TagifyService) {}
+ public settings = { blacklist: ['fucking', 'shit']};
+
+ onAdd(tagify) {
+ console.log('added a tag', tagify);
+ }
+
+ onRemove(tags) {
+ console.log('removed a tag', tags);
+ }
+ clearTags() {
+ this.tagifyService.removeAll();
+ }
+ addTags() {
+ this.tagifyService.addTags(['this', 'is', 'cool']);
+ }
+ ngOnDestroy() {
+ this.tagifyService.destroy();
+ }
+}
+```
+
+
+
+
+## jQuery version
+
+`jQuery.tagify.js`
+
+A jQuery wrapper verison is also available, but I advise not using it because it's basically the exact same as the "normal"
+script (non-jqueryfied) and all the jQuery's wrapper does is allowing to chain the event listeners for ('add', 'remove', 'invalid')
+
+```javascript
+$('[name=tags]')
+ .tagify()
+ .on('add', function(e, tagData){
+ console.log('added', ...tagData) // data, index, and DOM node
+ });
+```
+
+Accessing methods can be done via the [`.data('tagify')`](https://api.jquery.com/data):
+
+```javascript
+$('[name=tags]').tagify();
+// get tags from the server (ajax) and add them:
+$('[name=tags]').data('tagify').addTags('aaa, bbb, ccc')
+````
+
+## HTML input & textarea attributes
+
+The below list of *attributes* affect *Tagify*.
+These can also be set by Tagify [settings](#settings) Object manually, and not *declerativly* (via attributes).
+
+Attribute | Example | Info
+----------------- | ----------------------------------------------------- | --------------------
+[pattern](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/pattern) | ` ` | Tag Regex pattern which tag input is validated by.
+[placeholder](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#htmlattrdefplaceholder) | ` ` | This attribute's value will be used as a constant placeholder, which is visible unless something is being typed.
+readOnly | ` ` | No user-interaction (add/remove/edit) allowed.
+autofocus | ` ` | Automatically focus the the Tagify component when the component is loaded
+required | ` ` | Adds a `required` attribute to the Tagify wrapper element. Does nothing more.
+
+
+
+## FAQ
+List of questions & scenarios which might come up during development with Tagify:
+
+
+ tags/whitelist data strcture
+
+Tagify does not accept just *any* kind of data structure.
+If a tag data is represented as an `Object`, it **must** contain a **unique** property `value`
+which Tagify uses to check if a tag already exists, among other things, so make sure it is present.
+
+**Incorrect**
+
+```javascript
+[{ "id":1, "name":"foo bar" }]
+```
+
+**Correct**
+
+```javascript
+[{ "id":1, "value": 1, "name":"foo bar" }]
+```
+
+```javascript
+[{ "value":1, "name":"foo bar" }]
+```
+
+```javascript
+[{ "value":"foo bar" }]
+```
+
+```javascript
+// ad a simple array of Strings
+["foo bar"]
+```
+
+
+---
+
+
+ Save changes (Ex. to a server)
+
+In framework-less projects, the developer should save the state of the Tagify component (somewhere), and
+the question is:
+**when should the state be saved?**
+On every change made to *Tagify's* internal state (`tagify.value` via the `update()` method).
+
+
+```javascript
+var tagify = new Tagify(...)
+
+// listen to "change" events on the "original" input/textarea element
+tagify.DOM.originalInput.addEventListener('change', onTagsChange)
+
+// This example uses async/await but you can use Promises, of course, if you prefer.
+async function onTagsChange(e){
+ const {name, value} = e.target
+ // "imaginary" async function "saveToServer" should get the field's name & value
+ await saveToServer(name, value)
+}
+```
+
+If you are using *React/Vue/Angular* or any "modern" framework, then you already know how to
+attach "onChange" event listeners to your ` `/`
+
+----
+
+
+ Render tags in one single line
+
+Stopping tags from wrapping to new lines, add this to your `.tagify` *selector CSS Rule*:
+
+```css
+flex-wrap: nowrap;
+````
+
+
+----
+
+
+ Submit on `Enter` key
+
+Tagify internally has `state` property, per `Tagify` instance
+and this may be useful for a variety of things when implementing a specific scenario.
+
+```js
+var tagify = new Tagify(...)
+var formElm = document.forms[0]; // just an example
+
+tagify.on('keydown', onTagifyKeyDown)
+
+function onTagifyKeyDown(e){
+ if( e.key == 'Enter' && // "enter" key pressed
+ !tagify.state.inputText && // assuming user is not in the middle oy adding a tag
+ !tagify.state.editing // user not editing a tag
+ ){
+ setTimeout(() => formElm.submit()) // put some buffer to make sure tagify has done with whatever, to be on the safe-side
+ }
+
+}
+```
+
+
+---
+
+* [Double-click tag fires both "edit" & "click" custom events](https://github.com/yairEO/tagify/issues/247)
+* [Manualy open the suggestions dropdown](https://github.com/yairEO/tagify/issues/254)
+* [Render your own suggestions dropdown](https://github.com/yairEO/tagify/issues/244)
+* [Allow max length on mix mode](https://github.com/yairEO/tagify/issues/252)
+* [Always show dropdown](https://github.com/yairEO/tagify/issues/253)
+* [Limit the length of a tag value (minimum & maximum)](https://github.com/yairEO/tagify/issues/245)
+* [Mixed mode initial value](https://github.com/yairEO/tagify/issues/237)
+* [Random colors for each tag](https://github.com/yairEO/tagify/issues/223)
+* [Format input value for server side](https://github.com/yairEO/tagify/issues/220)
+* [Writing to tagify textarea](https://github.com/yairEO/tagify/issues/294)
+* [Scroll all tags within one line, instead of growing vertically](https://github.com/yairEO/tagify/issues/145)
+* [Insert emoji at caret location when editing a tag](https://github.com/yairEO/tagify/issues/365)
+* [propagate `change` event](https://github.com/yairEO/tagify/issues/413)
+* [Manually update tag data after it was added](https://github.com/yairEO/tagify/issues/433)
+* [Ajax Whitelist with "enforceWhitelist" setting enabled](https://github.com/yairEO/tagify/issues/465)
+* [Custom (multiple) tag valitation & AJAX](https://github.com/yairEO/tagify/issues/474)
+* [Make tags from pasted multi-line text](https://github.com/yairEO/tagify/issues/160)
+
+## CSS Variables
+
+> Learn more about [CSS Variables](https://developer.mozilla.org/en-US/docs/Web/CSS/Using_CSS_custom_properties)) (custom properties)
+
+Tagify's utilizes *CSS variables* which allow easy customization without the need to manually write CSS.
+If you do wish to heavily style your Tagify components, then you can (and should) use the below variables within
+your modified styles as much as you can.
+
+For a *live* example, see the [demos page](https://yaireo.github.io/tagify/#section-different-look).
+
+Name | Info
+------------------------------- | --------------------------------
+--tags-border-color | The outer border color which surrounds tagify
+--tags-hover-border-color | *hover* state
+--tags-focus-border-color | *focus* state
+--tag-bg | Tag background color
+--tag-hover | Tag background color on hover (mouse)
+--tag-text-color | Tag text color
+--tag-text-color--edit | Tag text color when a Tag is being edited
+--tag-pad | Tag padding, from all sides. Ex. `.3em .5em`
+--tag--min-width | Minimum Tag width
+--tag--max-width | Maximum tag width, which gets trimmed with *hellip* after
+--tag-inset-shadow-size | This is the inner shadow size, which dictates the color of the Tags. It's important the size fits *exactly* to the tag. Change this if you change the `--tag-pad` or fontsize.
+--tag-invalid-color | For border color of edited tags with invalid value being typed into them
+--tag-invalid-bg | Background color for invalid Tags.
+--tag-remove-bg | Tag background color when hovering the `×` button.
+--tag-remove-btn-color | Remove (`×`) button text color
+--tag-remove-btn-bg | Remove (`×`) button background color
+--tag-remove-btn-bg--hover | Remove (`×`) button hover background color
+--loader-size | Loading animation size. `1em` is pretty big, default is a bit less.
+--tag-hide-transition | Controls the transition property when a tag is removed. default is '.3s'
+--placeholder-color | Placeholder text color
+--placeholder-color-focus | Placeholder text color when Tagify has focus and no input was typed
+--input-color | Input text color
+
+### Full list of Tagify's [SCSS variables](https://github.com/yairEO/tagify/blob/master/src/tagify.scss#L9-L24)
+
+
+## Methods
+
+`Tagify` is [prototype](https://developer.mozilla.org/en-US/docs/Learn/JavaScript/Objects/Object_prototypes) based and There are many methods, but I've chosen to list the most relevant ones:
+
+Name | Parameters | Info
+-------------------------- | --------------------------------------------------------------------------------------- | --------------------------------------------------------------------------
+`destroy` | | Reverts the input element back as it was before Tagify was applied
+`removeAllTags` | | Removes all tags and resets the original input tag's value property
+`addTags` | `Array`/`String`/`Object` tag(s) to add `Boolean` clear input after adding `Boolean` - skip adding invalids | Accepts a String (word, single or multiple with a delimiter), an Array of Objects (see above) or Strings
+`removeTags` | `Array`/`HTMLElement`/`String` tag(s) to remove `silent` does not update the component's value `tranDuration` Transition duration (in `ms`) | (#502) Remove single/multiple Tags. When nothing passed, removes last tag. `silent` - A flag, which when turned on, does not remove any value and does not update the original input value but simply removes the tag from tagify `tranDuration` - delay for animation, after which the tag will be removed from the DOM
+`addEmptyTag` | `Object` (`tagData`) | Create an empty tag (optionally with pre-defined data) and enters "edit" mode directly. [See demo](https://yaireo.github.io/tagify#section-different-look)
+`loadOriginalValues` | `String`/`Array` | Converts the input's value into tags. This method gets called automatically when instansiating Tagify. Also works for mixed-tags
+`getWhitelistItemsByValue` | `Object` | `{value}` - return an Array of found matching items (case-insensitive)
+`getTagIndexByValue` | `String` | Returns the index of a specific tag, by value
+`getTagElmByValue` | `String` | Returns the first matched tag node, if found
+`isTagDuplicate` | `String` | Returns how many tags already exists with that value
+`parseMixTags` | `String` | Converts a String argument (`[[foo]] and [[bar]] are..`) into HTML with mixed tags & texts
+`getTagElms` | | Returns a DOM nodes list of all the tags
+`getTagElmByValue` | `String` | Returns a specific tag DOM node by value
+`tagData` | `HTMLElement`, `Object` | set/get tag data on a tag element (has`.tagify__tag` class by default)
+`editTag` | `HTMLElement` | Goes to edit-mode in a specific tag
+`replaceTag` | `tagElm`, `Object` (`tagData`) | Exit a tag's edit-mode. if "tagData" exists, replace the tag element with new data and update Tagify value
+`loading` | `Boolean` | Toogle loading state on/off (Ex. AJAX whitelist pulling)
+`tagLoading` | `HTMLElement`, Boolean | same as above but for a specific tag element
+`createTagElem` | `Object` (`tagData`) | Returns a tag element from the supplied tag data
+`injectAtCaret` | `HTMLElement` (`injectedNode`) , `Object` (`range`) | Injects text or HTML node at last caret position. `range` parameter is *optional*
+`placeCaretAfterNode` | `HTMLElement` | Places the caret after a given node
+`insertAfterTag` | `HTMLElement` (tag element) , `HTMLElement`/`String` (whatever to insert after) |
+`toggleClass` | `Boolean` | Toggles `class` on the main *tagify* container (`scope`)
+`dropdown.selectAll` | | Add **all** whitelist items as tags and close the suggestion dropdown
+`updateValueByDOMTags` | | Iterate tag DOM nodes and re-build the `tagify.value` array (call this if tags get sorted manually)
+`parseTemplate` | `String`/`Function` (template name or function) , `Array` (data) | converts a template string (by selecting one from the `settings.templates` by name or supplying a template function which returns a String) into a DOM node
+`setReadonly` | `Boolean` | Toggles "readonly" mode on/off
+
+## Events
+
+All triggered events return the instance's scope (tagify).
+See `e.detail` for custom event additional data.
+
+
+ Example 1
+
+```javascript
+var tagify = new Tagify(...)
+
+// events can be chainable, and multiple events may be binded for the same callback
+tagify
+ .on('input', e => console.log(e.detail))
+ .on('edit:input edit:updated edit:start edit:keydown', e => console.log(e.type, e.detail))
+```
+
+
+
+ Example 2
+
+```javascript
+var tagify = new Tagify(inputNode, {
+ callbacks: {
+ "change": (e) => console.log(e.detail))
+ "dropdown:show": (e) => console.log(e.detail))
+ }
+})
+```
+
+
+Name | Info
+------------------ | --------------------------------------------------------------------------
+change | Any change to the value has occured. `e.details.value` callback listener argument is a *String*
+add | A tag has been added
+remove | A tag has been removed ([use `removeTag`](https://github.com/yairEO/tagify/issues/222) instead with *jQuery*)
+invalid | A tag has been added but did not pass vaildation. See [event detail](https://developer.mozilla.org/en-US/docs/Web/Guide/Events/Creating_and_triggering_events)
+input | [Input](https://developer.mozilla.org/en-US/docs/Web/Events/input) event, when a tag is being typed/edited. `e.detail` exposes `value`, `inputElm` & `isValid`
+click | Clicking a tag. Exposes the tag element, its index & data
+dblclick | Double-clicking a tag
+keydown | When tagify input has focus and a key was pressed
+focus | The component currently has focus
+blur | The component lost focus
+edit:input | Typing inside an edited tag
+edit:beforeUpdate | Just before a tag has been updated, while still in "edit" mode
+edit:updated | A tag as been updated (changed view editing or by directly calling the `replaceTag()` method)
+edit:start | A tag is now in "edit mode"
+edit:keydown | keydown event while an edited tag is in focus
+dropdown:show | Suggestions dropdown is to be rendered. The dropdown DOM node is passed in the callback, [see demo](https://yaireo.github.io/tagify/#section-basic).
+dropdown:hide | Suggestions dropdown has been removed from the DOM
+dropdown:select | Suggestions dropdown item selected (by mouse/keyboard/touch)
+dropdown:scroll | Tells the percentage scrolled. (`event.detail.percentage`)
+dropdown:noMatch | No whitelist suggestion item matched for the the typed input. At this point it is possible to manually set `tagify.suggestedListItems` to any possible custom value, for example: `[{ value:"default" }]`
+
+
+## Hooks
+
+**Promise**-based hooks for *async* program flow scenarios.
+
+Allows to "hook" (intervene) at certain points of the program, which were selected as a suitable place to
+**pause** the program flow and wait for further instructions on how/if to procceed.
+
+
+ For example, if a developer wishes to add a (native) confirmation popup before a tag is removed (by a user action):
+
+
+```javascript
+var input = document.querySelector('input')
+var tagify = new Tagify(input,{
+ hooks: {
+ /**
+ * Removes a tag
+ * @param {Array} tags [Array of Objects [{node:..., data:...}, {...}, ...]]
+ */
+ beforeRemoveTag : function( tags ){
+ return new Promise((resolve, reject) => {
+ confirm("Remove " + tags[0].data.value + "?")
+ ? resolve()
+ : reject()
+ })
+ }
+ }
+})
+```
+
+
+
+Name | Parameters | Info
+---------------------- | ------------------------------------------- | --------------------------------------------------------------------------
+beforeRemoveTag | Array (of Objects) | [Example](https://jsbin.com/xoseyux/edit?html,js,output)
+suggestionClick | Object (click event data) | [Example](https://jsbin.com/tuwihuf/edit?html,js,output)
+
+## [Settings](https://github.com/yairEO/tagify/blob/master/src/parts/defaults.js#L1)
+
+Name | Type | Default | Info
+----------------------- | ---------------------------- | ------------------------------------------- | --------------------------------------------------------------------------
+tagTextProp | String | `value` | Tag data Object property which will be displayed as the tag's text. Remember to keep "value" property unique .
+placeholder | String | | Placeholder text. If this attribute is set on an input/textarea element it will override this setting
+delimiters | String | `,` | [RegEx **string**] split tags by any of these delimiters. Example delimeters: ",|.| " (*comma*, *dot* or *whitespace*)
+pattern | String/RegEx | null | Validate input by RegEx pattern (can also be applied on the input itself as an attribute) Ex: `/[1-9]/`
+mode | String | null | Use `select` for single-value dropdown-like select box. See `mix` as value to allow mixed-content. The 'pattern' setting must be set to some character.
+mixTagsInterpolator | Array | `['[[', ']]']` | Interpolation for mix mode. Everything between these will become a tag
+mixTagsAllowedAfter | RegEx | `/,\|\.\|\:\|\s/` | Define conditions in which typed mix-tags content is allowing a tag to be created after.
+duplicates | Boolean | false | Should duplicate tags be allowed or not
+trim | Boolean | true | If `true` trim the tag's value (remove before/after whitespaces)
+enforceWhitelist | Boolean | false | Should ONLY use tags allowed in whitelist. In `mix-mode`, setting it to `false` will not allow creating new tags.
+autoComplete.enabled | Boolean | true | Tries to suggest the input's value while typing (match from whitelist) by adding the rest of term as grayed-out text
+autoComplete.rightKey | Boolean | false | If `true`, when `→` is pressed, use the suggested value to create a tag, else just auto-completes the input. In mixed-mode this is ignored and treated as "true"
+whitelist | Array | [] | An array of allowed tags (*Strings* or *Objects*). Also used for auto-completion when `autoCompletion.enabled` is `true`
+blacklist | Array | [] | An array of tags which aren't allowed
+addTagOnBlur | Boolean | true | Automatically adds the text which was inputed as a tag when blur event happens
+callbacks | Object | {} | Exposed callbacks object to be triggered on events: `'add'` / `'remove'` tags
+maxTags | Number | Infinity | Maximum number of allowed tags. when reached, adds a class "tagify--hasMaxTags" to ``
+editTags | Object/Number | {} | `false` or `null` will disallow editing
+editTags.clicks | Number | 2 | Number of clicks to enter "edit-mode": 1 for single click. Any other value is considered as double-click
+editTags.keepInvalid | Boolean | true | keeps invalid edits as-is until `esc` is pressed while in focus
+templates | Object | `wrapper`, `tag`, `dropdownItem` | Object consisting of functions which return template strings
+transformTag | Function | undefined | Takes a tag data as argument and allows mutating it before a tag is created or edited. Should not `return` anything, only **mutate**.
+keepInvalidTags | Boolean | false | If `true`, do not remove tags which did not pass validation
+skipInvalid | Boolean | false | If `true`, do not add invalid, temporary, tags before automatically removing them
+backspace | * | true | On pressing backspace key: `true` - remove last tag `edit` - edit last tag
+originalInputValueFormat| Function | | If you wish your original input/textarea `value` property format to other than the default (which I recommend keeping) you may use this and make sure it returns a *string*.
+mixMode.insertAfterTag | Node/String | `\u00A0` | `node` or `string` to add after a tag added |
+dropdown.enabled | Number | 2 | Minimum characters input for showing a suggestions list. `false` will not render a suggestions list.
+dropdown.caseSensitive | Boolean | false | if `true`, match **exact** item when a suggestion is selected (from the dropdown) and also more strict matching for dulpicate items. **Ensure** `fuzzySearch` is `false` for this to work.
+dropdown.maxItems | Number | 10 | Maximum items to show in the suggestions list
+dropdown.classname | String | `""` | Custom *classname* for the dropdown suggestions selectbox
+dropdown.fuzzySearch | Boolean | true | Enables filtering dropdown items values' by string *containing* and not only *beginning*
+dropdown.accentedSearch | Boolean | true | Enable searching for accented items in the whitelist without typing exact match (#491)
+dropdown.position | String | null | `manual` - will not render the dropdown, and you would need to do it yourself. [See demo](https://yaireo.github.io/tagify/#section-manual-suggestions) `text` - will place the dropdown next to the caret `input` - will place the dropdown next to the input `all` - normal, full-width design
+dropdown.highlightFirst | Boolean | false | When a suggestions list is shown, highlight the first item, and also suggest it in the input (The suggestion can be accepted with → key)
+dropdown.closeOnSelect | Boolean | true | close the dropdown after selecting an item, if `enabled:0` is set (which means always show dropdown on focus)
+dropdown.clearOnSelect | Boolean | true | Keep typed text after selecting a suggestion
+dropdown.mapValueTo | Function/String | | if whitelist is an Array of Objects: Ex. `[{value:'foo', email:'foo@a.com'},...]`) this setting controlls which data key will be printed in the dropdown. Ex. `mapValueTo: data => "To:" + data.email` Ex. `mapValueTo: "email"`
+dropdown.searchKeys | Array | `["value", "searchBy"]` | When a user types something and trying to match the whitelist items for suggestions, this setting allows matching other keys of a whitelist objects
+dropdown.appendTarget | HTMLNode | `document.body` | Target-Node which the *suggestions dropdown* is appended to (*only when rendered*)
diff --git a/node_modules/@yaireo/tagify/package.json b/node_modules/@yaireo/tagify/package.json
new file mode 100644
index 0000000..4817010
--- /dev/null
+++ b/node_modules/@yaireo/tagify/package.json
@@ -0,0 +1,119 @@
+{
+ "_from": "@yaireo/tagify",
+ "_id": "@yaireo/tagify@3.21.0",
+ "_inBundle": false,
+ "_integrity": "sha512-pTr85RlPa7skaUgWCZkaqKpwsAkAiNi5FdLbXFnrhTo+oDMZb0w2rndKIca2bggwWUSsHC+TP2BY7ju8KZu9Gg==",
+ "_location": "/@yaireo/tagify",
+ "_npmUser": {
+ "name": "vsync",
+ "email": "vsync.design@gmail.com"
+ },
+ "_phantomChildren": {},
+ "_requested": {
+ "type": "tag",
+ "registry": true,
+ "raw": "@yaireo/tagify",
+ "name": "@yaireo/tagify",
+ "escapedName": "@yaireo%2ftagify",
+ "scope": "@yaireo",
+ "rawSpec": "",
+ "saveSpec": null,
+ "fetchSpec": "latest"
+ },
+ "_requiredBy": [
+ "#USER",
+ "/"
+ ],
+ "_resolved": "https://registry.npmjs.org/@yaireo/tagify/-/tagify-3.21.0.tgz",
+ "_shasum": "4864edc827911d397b519d702791987445f97031",
+ "_spec": "@yaireo/tagify",
+ "_where": "/home/watney/Downloads/virtpool-master/virtpool",
+ "author": {
+ "name": "Yair Even-Or",
+ "email": "vsync.design@gmail.com"
+ },
+ "browserslist": [
+ ">1%",
+ "not dead",
+ "not ie < 11",
+ "not IE_Mob 11",
+ "not op_mini all"
+ ],
+ "bugs": {
+ "url": "https://github.com/yaireo/tagify/issues"
+ },
+ "bundleDependencies": false,
+ "deprecated": false,
+ "description": "lightweight, efficient Tags input component in Vanilla JS / React / Angular [super customizable, tiny size & top performance]",
+ "devDependencies": {
+ "@babel/core": "^7.5.5",
+ "@babel/plugin-proposal-object-rest-spread": "^7.9.6",
+ "@babel/plugin-transform-destructuring": "^7.9.5",
+ "@babel/preset-env": "^7.9.6",
+ "@babel/preset-react": "^7.9.4",
+ "@rollup/plugin-babel": "^5.1.0",
+ "@rollup/stream": "git+https://github.com/andremacola/stream.git",
+ "beepbeep": "^1.2.2",
+ "gulp": "^4.0.2",
+ "gulp-autoprefixer": "7.0.1",
+ "gulp-babel": "^8.0.0",
+ "gulp-bump": "^3.1.3",
+ "gulp-cached": "^1.1.1",
+ "gulp-clean-css": "^4.3.0",
+ "gulp-combine-mq": "^0.4.0",
+ "gulp-concat": "^2.6.1",
+ "gulp-css-globbing": "^0.2.2",
+ "gulp-eslint": "^6.0.0",
+ "gulp-filter": "^6.0.0",
+ "gulp-git": "^2.10.1",
+ "gulp-header-comment": "^0.8.0",
+ "gulp-insert": "^0.5.0",
+ "gulp-load-plugins": "^2.0.1",
+ "gulp-rename": "^1.4.0",
+ "gulp-replace": "^1.0.0",
+ "gulp-sass": "^4.1.0",
+ "gulp-sourcemaps": "^2.6.5",
+ "gulp-streamify": "^1.0.2",
+ "gulp-tag-version": "^1.3.1",
+ "gulp-tap": "^2.0.0",
+ "gulp-uglify": "^3.0.2",
+ "gulp-umd": "^2.0.0",
+ "gulp-util": "^3.0.8",
+ "gulp-watch": "^5.0.1",
+ "path": "^0.12.7",
+ "puppeteer": "^1.20.0",
+ "rollup": "^2.22.1",
+ "rollup-plugin-banner": "^0.2.1",
+ "rollup-plugin-terser": "^6.1.0",
+ "rollup-plugin-uglify": "^6.0.4",
+ "run-sequence": "^2.2.1",
+ "semver": "^7.3.2",
+ "vinyl-source-stream": "^2.0.0"
+ },
+ "homepage": "https://github.com/yairEO/tagify",
+ "jest": {
+ "preset": "jest-puppeteer"
+ },
+ "keywords": [
+ "tags",
+ "tagging",
+ "component",
+ "tag",
+ "ui"
+ ],
+ "license": "MIT",
+ "main": "./dist/tagify.min.js",
+ "name": "@yaireo/tagify",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/yairEO/tagify.git"
+ },
+ "scripts": {
+ "push": "git push origin",
+ "push:tags": "git push origin --tags",
+ "serve": "npx http-server -o index.html -c-1",
+ "start": "npx gulp@4.0.2",
+ "test": "jest"
+ },
+ "version": "3.21.0"
+}
diff --git a/node_modules/@yaireo/tagify/roadmap.md b/node_modules/@yaireo/tagify/roadmap.md
new file mode 100644
index 0000000..d0dc7b3
--- /dev/null
+++ b/node_modules/@yaireo/tagify/roadmap.md
@@ -0,0 +1,13 @@
+- [x] add SCSS variable for input color (not tag color)
+- [x] Make demo page *mobile-friendly* using *media-queries*
+- [x] dragable sortable tags
+- [ ] Make (regular-mode) tagify accessible by keyboard by navigating left/right arrow and able to delete tags
+- [ ] when "addTagOnBlur" is set to false and there is a text in the input and Tagify gets focus, the caret is not placed at the end of the input.
+ (need to check if the "focus" event was fired programatically and if it, place the caret at the end)
+- [ ] mix-mode: maybe convert `settings.pattern` to always be a regex. Currently it may be a String (this fails "validateTag" method)
+- [ ] mix-mode: add "prefix" to tags, so when double-clicking to edit, it will show it
+- [ ] maybe trigger the "invalid" event also for edited tags. need to think when exactly. probbaly not on "input" event, it's too much
+- [ ] use DOM mutation to detect changes and update "this.value" automatically
+- [ ] add examples of added tag CSS effects
+- [ ] allow templates to return DOM nodes and not just Strings
+- [ ] Make *readonly* tags work in *mix-mode*
diff --git a/node_modules/@yaireo/tagify/src/polyfills/Array.findIndex.js b/node_modules/@yaireo/tagify/src/polyfills/Array.findIndex.js
new file mode 100644
index 0000000..e89e6d5
--- /dev/null
+++ b/node_modules/@yaireo/tagify/src/polyfills/Array.findIndex.js
@@ -0,0 +1,28 @@
+if (!Array.prototype.findIndex) {
+ Object.defineProperty(Array.prototype, 'findIndex', {
+ value: function(predicate) {
+ if (this == null)
+ throw new TypeError('"this" is null or not defined');
+
+ var o = Object(this), len = o.length >>> 0;
+
+ if (typeof predicate !== 'function') {
+ throw new TypeError('predicate must be a function');
+ }
+
+ var thisArg = arguments[1], k = 0;
+
+ while (k < len) {
+ var kValue = o[k];
+ if (predicate.call(thisArg, kValue, k, o)) {
+ return k;
+ }
+ k++;
+ }
+
+ return -1;
+ },
+ configurable: true,
+ writable: true
+ })
+}
\ No newline at end of file
diff --git a/node_modules/@yaireo/tagify/src/polyfills/Array.some.js b/node_modules/@yaireo/tagify/src/polyfills/Array.some.js
new file mode 100644
index 0000000..554cfbd
--- /dev/null
+++ b/node_modules/@yaireo/tagify/src/polyfills/Array.some.js
@@ -0,0 +1,26 @@
+// Production steps of ECMA-262, Edition 5, 15.4.4.17
+// Reference: http://es5.github.io/#x15.4.4.17
+if (!Array.prototype.some) {
+ Array.prototype.some = function(fun, thisArg) {
+ 'use strict';
+
+ if (this == null) {
+ throw new TypeError('Array.prototype.some called on null or undefined');
+ }
+
+ if (typeof fun !== 'function') {
+ throw new TypeError();
+ }
+
+ var t = Object(this);
+ var len = t.length >>> 0;
+
+ for (var i = 0; i < len; i++) {
+ if (i in t && fun.call(thisArg, t[i], i, t)) {
+ return true;
+ }
+ }
+
+ return false;
+ };
+}
\ No newline at end of file
diff --git a/node_modules/@yaireo/tagify/src/polyfills/AutoUrlDetect.js b/node_modules/@yaireo/tagify/src/polyfills/AutoUrlDetect.js
new file mode 100644
index 0000000..62a24a1
--- /dev/null
+++ b/node_modules/@yaireo/tagify/src/polyfills/AutoUrlDetect.js
@@ -0,0 +1,3 @@
+// Avoid transformation text to link ie contentEditable mode
+// https://stackoverflow.com/q/7556007/104380
+document.execCommand("AutoUrlDetect", false, false);
\ No newline at end of file
diff --git a/node_modules/@yaireo/tagify/src/polyfills/Element.classList.js b/node_modules/@yaireo/tagify/src/polyfills/Element.classList.js
new file mode 100644
index 0000000..258baef
--- /dev/null
+++ b/node_modules/@yaireo/tagify/src/polyfills/Element.classList.js
@@ -0,0 +1,263 @@
+/*
+ * classList.js: Cross-browser full element.classList implementation.
+ * 1.2.20171210
+ *
+ * By Eli Grey, http://eligrey.com
+ * License: Dedicated to the public domain.
+ * See https://github.com/eligrey/classList.js/blob/master/LICENSE.md
+ */
+
+/*global self, document, DOMException */
+
+/*! @source http://purl.eligrey.com/github/classList.js/blob/master/classList.js */
+
+if ("document" in self) {
+
+// Full polyfill for browsers with no classList support
+// Including IE < Edge missing SVGElement.classList
+if (
+ !("classList" in document.createElement("_"))
+ || document.createElementNS
+ && !("classList" in document.createElementNS("http://www.w3.org/2000/svg","g"))
+) {
+
+(function (view) {
+
+"use strict";
+
+if (!('Element' in view)) return;
+
+var
+ classListProp = "classList"
+ , protoProp = "prototype"
+ , elemCtrProto = view.Element[protoProp]
+ , objCtr = Object
+ , strTrim = String[protoProp].trim || function () {
+ return this.replace(/^\s+|\s+$/g, "");
+ }
+ , arrIndexOf = Array[protoProp].indexOf || function (item) {
+ var
+ i = 0
+ , len = this.length
+ ;
+ for (; i < len; i++) {
+ if (i in this && this[i] === item) {
+ return i;
+ }
+ }
+ return -1;
+ }
+ // Vendors: please allow content code to instantiate DOMExceptions
+ , DOMEx = function (type, message) {
+ this.name = type;
+ this.code = DOMException[type];
+ this.message = message;
+ }
+ , checkTokenAndGetIndex = function (classList, token) {
+ if (token === "") {
+ throw new DOMEx(
+ "SYNTAX_ERR"
+ , "The token must not be empty."
+ );
+ }
+ if (/\s/.test(token)) {
+ throw new DOMEx(
+ "INVALID_CHARACTER_ERR"
+ , "The token must not contain space characters."
+ );
+ }
+ return arrIndexOf.call(classList, token);
+ }
+ , ClassList = function (elem) {
+ var
+ trimmedClasses = strTrim.call(elem.getAttribute("class") || "")
+ , classes = trimmedClasses ? trimmedClasses.split(/\s+/) : []
+ , i = 0
+ , len = classes.length
+ ;
+ for (; i < len; i++) {
+ this.push(classes[i]);
+ }
+ this._updateClassName = function () {
+ elem.setAttribute("class", this.toString());
+ };
+ }
+ , classListProto = ClassList[protoProp] = []
+ , classListGetter = function () {
+ return new ClassList(this);
+ }
+;
+// Most DOMException implementations don't allow calling DOMException's toString()
+// on non-DOMExceptions. Error's toString() is sufficient here.
+DOMEx[protoProp] = Error[protoProp];
+classListProto.item = function (i) {
+ return this[i] || null;
+};
+classListProto.contains = function (token) {
+ return ~checkTokenAndGetIndex(this, token + "");
+};
+classListProto.add = function () {
+ var
+ tokens = arguments
+ , i = 0
+ , l = tokens.length
+ , token
+ , updated = false
+ ;
+ do {
+ token = tokens[i] + "";
+ if (!~checkTokenAndGetIndex(this, token)) {
+ this.push(token);
+ updated = true;
+ }
+ }
+ while (++i < l);
+
+ if (updated) {
+ this._updateClassName();
+ }
+};
+classListProto.remove = function () {
+ var
+ tokens = arguments
+ , i = 0
+ , l = tokens.length
+ , token
+ , updated = false
+ , index
+ ;
+ do {
+ token = tokens[i] + "";
+ index = checkTokenAndGetIndex(this, token);
+ while (~index) {
+ this.splice(index, 1);
+ updated = true;
+ index = checkTokenAndGetIndex(this, token);
+ }
+ }
+ while (++i < l);
+
+ if (updated) {
+ this._updateClassName();
+ }
+};
+classListProto.toggle = function (token, force) {
+ var
+ result = this.contains(token)
+ , method = result ?
+ force !== true && "remove"
+ :
+ force !== false && "add"
+ ;
+
+ if (method) {
+ this[method](token);
+ }
+
+ if (force === true || force === false) {
+ return force;
+ } else {
+ return !result;
+ }
+};
+classListProto.replace = function (token, replacement_token) {
+ var index = checkTokenAndGetIndex(token + "");
+ if (~index) {
+ this.splice(index, 1, replacement_token);
+ this._updateClassName();
+ }
+}
+classListProto.toString = function () {
+ return this.join(" ");
+};
+
+if (objCtr.defineProperty) {
+ var classListPropDesc = {
+ get: classListGetter
+ , enumerable: true
+ , configurable: true
+ };
+ try {
+ objCtr.defineProperty(elemCtrProto, classListProp, classListPropDesc);
+ } catch (ex) { // IE 8 doesn't support enumerable:true
+ // adding undefined to fight this issue https://github.com/eligrey/classList.js/issues/36
+ // modernie IE8-MSW7 machine has IE8 8.0.6001.18702 and is affected
+ if (ex.number === undefined || ex.number === -0x7FF5EC54) {
+ classListPropDesc.enumerable = false;
+ objCtr.defineProperty(elemCtrProto, classListProp, classListPropDesc);
+ }
+ }
+} else if (objCtr[protoProp].__defineGetter__) {
+ elemCtrProto.__defineGetter__(classListProp, classListGetter);
+}
+
+}(self));
+
+}
+
+// There is full or partial native classList support, so just check if we need
+// to normalize the add/remove and toggle APIs.
+
+(function () {
+ "use strict";
+
+ var testElement = document.createElement("_");
+
+ testElement.classList.add("c1", "c2");
+
+ // Polyfill for IE 10/11 and Firefox <26, where classList.add and
+ // classList.remove exist but support only one argument at a time.
+ if (!testElement.classList.contains("c2")) {
+ var createMethod = function(method) {
+ var original = DOMTokenList.prototype[method];
+
+ DOMTokenList.prototype[method] = function(token) {
+ var i, len = arguments.length;
+
+ for (i = 0; i < len; i++) {
+ token = arguments[i];
+ original.call(this, token);
+ }
+ };
+ };
+ createMethod('add');
+ createMethod('remove');
+ }
+
+ testElement.classList.toggle("c3", false);
+
+ // Polyfill for IE 10 and Firefox <24, where classList.toggle does not
+ // support the second argument.
+ if (testElement.classList.contains("c3")) {
+ var _toggle = DOMTokenList.prototype.toggle;
+
+ DOMTokenList.prototype.toggle = function(token, force) {
+ if (1 in arguments && !this.contains(token) === !force) {
+ return force;
+ } else {
+ return _toggle.call(this, token);
+ }
+ };
+
+ }
+
+ // replace() polyfill
+ if (!("replace" in document.createElement("_").classList)) {
+ DOMTokenList.prototype.replace = function (token, replacement_token) {
+ var
+ tokens = this.toString().split(" ")
+ , index = tokens.indexOf(token + "")
+ ;
+ if (~index) {
+ tokens = tokens.slice(index);
+ this.remove.apply(this, tokens);
+ this.add(replacement_token);
+ this.add.apply(this, tokens.slice(1));
+ }
+ }
+ }
+
+ testElement = null;
+}());
+
+}
\ No newline at end of file
diff --git a/node_modules/@yaireo/tagify/src/polyfills/Element.closest.js b/node_modules/@yaireo/tagify/src/polyfills/Element.closest.js
new file mode 100644
index 0000000..0c52878
--- /dev/null
+++ b/node_modules/@yaireo/tagify/src/polyfills/Element.closest.js
@@ -0,0 +1,11 @@
+if (!Element.prototype.closest) {
+ Element.prototype.closest = function(s) {
+ var el = this;
+ if (!document.documentElement.contains(el)) return null;
+ do {
+ if (el.matches(s)) return el;
+ el = el.parentElement || el.parentNode;
+ } while (el !== null && el.nodeType === 1);
+ return null;
+ };
+}
\ No newline at end of file
diff --git a/node_modules/@yaireo/tagify/src/polyfills/Element.matches.js b/node_modules/@yaireo/tagify/src/polyfills/Element.matches.js
new file mode 100644
index 0000000..ebef87a
--- /dev/null
+++ b/node_modules/@yaireo/tagify/src/polyfills/Element.matches.js
@@ -0,0 +1,3 @@
+ // https://developer.mozilla.org/en-US/docs/Web/API/Element/closest
+ if (!Element.prototype.matches)
+ Element.prototype.matches = Element.prototype.msMatchesSelector || Element.prototype.webkitMatchesSelector;
\ No newline at end of file
diff --git a/node_modules/@yaireo/tagify/src/polyfills/Event.js b/node_modules/@yaireo/tagify/src/polyfills/Event.js
new file mode 100644
index 0000000..d6c353c
--- /dev/null
+++ b/node_modules/@yaireo/tagify/src/polyfills/Event.js
@@ -0,0 +1,13 @@
+// https://developer.mozilla.org/en-US/docs/Web/API/CustomEvent/CustomEvent#Polyfill
+function CustomEventPolyfill ( event, params ) {
+ params = params || { bubbles: false, cancelable: false, detail: undefined };
+ var evt = document.createEvent( 'CustomEvent' );
+ evt.initCustomEvent( event, params.bubbles, params.cancelable, params.detail );
+ return evt;
+}
+
+CustomEventPolyfill.prototype = window.Event.prototype;
+
+if ( typeof window.CustomEvent !== "function" ){
+ window.CustomEvent = CustomEventPolyfill;
+}
\ No newline at end of file
diff --git a/node_modules/@yaireo/tagify/src/polyfills/NodeList.forEach.js b/node_modules/@yaireo/tagify/src/polyfills/NodeList.forEach.js
new file mode 100644
index 0000000..25829ca
--- /dev/null
+++ b/node_modules/@yaireo/tagify/src/polyfills/NodeList.forEach.js
@@ -0,0 +1,3 @@
+if( window.NodeList && !NodeList.prototype.forEach ){
+ NodeList.prototype.forEach = Array.prototype.forEach;
+}
\ No newline at end of file
diff --git a/node_modules/@yaireo/tagify/src/polyfills/Object.assign.js b/node_modules/@yaireo/tagify/src/polyfills/Object.assign.js
new file mode 100644
index 0000000..a436c06
--- /dev/null
+++ b/node_modules/@yaireo/tagify/src/polyfills/Object.assign.js
@@ -0,0 +1,30 @@
+// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/assign#Polyfill
+//
+if (typeof Object.assign != 'function') {
+ // Must be writable: true, enumerable: false, configurable: true
+ Object.defineProperty(Object, "assign", {
+ value: function assign(target, varArgs) { // .length of function is 2
+ if (target == null) { // TypeError if undefined or null
+ throw new TypeError('Cannot convert undefined or null to object');
+ }
+
+ var to = Object(target);
+
+ for (var index = 1; index < arguments.length; index++) {
+ var nextSource = arguments[index];
+
+ if (nextSource != null) { // Skip over if undefined or null
+ for (var nextKey in nextSource) {
+ // Avoid bugs when hasOwnProperty is shadowed
+ if (Object.prototype.hasOwnProperty.call(nextSource, nextKey)) {
+ to[nextKey] = nextSource[nextKey];
+ }
+ }
+ }
+ }
+ return to;
+ },
+ writable: true,
+ configurable: true
+ });
+}
\ No newline at end of file
diff --git a/node_modules/@yaireo/tagify/src/polyfills/String.includes.js b/node_modules/@yaireo/tagify/src/polyfills/String.includes.js
new file mode 100644
index 0000000..1442ed4
--- /dev/null
+++ b/node_modules/@yaireo/tagify/src/polyfills/String.includes.js
@@ -0,0 +1,12 @@
+ if( !String.prototype.includes ){
+ String.prototype.includes = function(search, start) {
+ if (typeof start !== 'number')
+ start = 0;
+
+ if (start + search.length > this.length)
+ return false;
+
+ else
+ return this.indexOf(search, start) !== -1;
+ };
+}
\ No newline at end of file
diff --git a/node_modules/@yaireo/tagify/src/polyfills/String.trim.js b/node_modules/@yaireo/tagify/src/polyfills/String.trim.js
new file mode 100644
index 0000000..e39c179
--- /dev/null
+++ b/node_modules/@yaireo/tagify/src/polyfills/String.trim.js
@@ -0,0 +1,2 @@
+// 1. String.prototype.trim polyfill
+if (!"".trim) String.prototype.trim = function(){ return this.replace(/^[\s]+|[\s]+$/g, ''); };
\ No newline at end of file
diff --git a/node_modules/@yaireo/tagify/src/polyfills/es6-promise.js b/node_modules/@yaireo/tagify/src/polyfills/es6-promise.js
new file mode 100644
index 0000000..b2fa83b
--- /dev/null
+++ b/node_modules/@yaireo/tagify/src/polyfills/es6-promise.js
@@ -0,0 +1,1172 @@
+/*!
+ * @overview es6-promise - a tiny implementation of Promises/A+.
+ * @copyright Copyright (c) 2014 Yehuda Katz, Tom Dale, Stefan Penner and contributors (Conversion to ES6 API by Jake Archibald)
+ * @license Licensed under MIT license
+ * See https://raw.githubusercontent.com/stefanpenner/es6-promise/master/LICENSE
+ * @version v4.2.8+1e68dce6
+ */
+
+(function (global, factory) {
+ global.Promise = factory()
+}(window, (function () { 'use strict';
+
+function objectOrFunction(x) {
+ var type = typeof x;
+ return x !== null && (type === 'object' || type === 'function');
+}
+
+function isFunction(x) {
+ return typeof x === 'function';
+}
+
+
+
+var _isArray = void 0;
+if (Array.isArray) {
+ _isArray = Array.isArray;
+} else {
+ _isArray = function (x) {
+ return Object.prototype.toString.call(x) === '[object Array]';
+ };
+}
+
+var isArray = _isArray;
+
+var len = 0;
+var vertxNext = void 0;
+var customSchedulerFn = void 0;
+
+var asap = function asap(callback, arg) {
+ queue[len] = callback;
+ queue[len + 1] = arg;
+ len += 2;
+ if (len === 2) {
+ // If len is 2, that means that we need to schedule an async flush.
+ // If additional callbacks are queued before the queue is flushed, they
+ // will be processed by this flush that we are scheduling.
+ if (customSchedulerFn) {
+ customSchedulerFn(flush);
+ } else {
+ scheduleFlush();
+ }
+ }
+};
+
+function setScheduler(scheduleFn) {
+ customSchedulerFn = scheduleFn;
+}
+
+function setAsap(asapFn) {
+ asap = asapFn;
+}
+
+var browserWindow = typeof window !== 'undefined' ? window : undefined;
+var browserGlobal = browserWindow || {};
+var BrowserMutationObserver = browserGlobal.MutationObserver || browserGlobal.WebKitMutationObserver;
+var isNode = typeof self === 'undefined' && typeof process !== 'undefined' && {}.toString.call(process) === '[object process]';
+
+// test for web worker but not in IE10
+var isWorker = typeof Uint8ClampedArray !== 'undefined' && typeof importScripts !== 'undefined' && typeof MessageChannel !== 'undefined';
+
+// node
+function useNextTick() {
+ // node version 0.10.x displays a deprecation warning when nextTick is used recursively
+ // see https://github.com/cujojs/when/issues/410 for details
+ return function () {
+ return process.nextTick(flush);
+ };
+}
+
+// vertx
+function useVertxTimer() {
+ if (typeof vertxNext !== 'undefined') {
+ return function () {
+ vertxNext(flush);
+ };
+ }
+
+ return useSetTimeout();
+}
+
+function useMutationObserver() {
+ var iterations = 0;
+ var observer = new BrowserMutationObserver(flush);
+ var node = document.createTextNode('');
+ observer.observe(node, { characterData: true });
+
+ return function () {
+ node.data = iterations = ++iterations % 2;
+ };
+}
+
+// web worker
+function useMessageChannel() {
+ var channel = new MessageChannel();
+ channel.port1.onmessage = flush;
+ return function () {
+ return channel.port2.postMessage(0);
+ };
+}
+
+function useSetTimeout() {
+ // Store setTimeout reference so es6-promise will be unaffected by
+ // other code modifying setTimeout (like sinon.useFakeTimers())
+ var globalSetTimeout = setTimeout;
+ return function () {
+ return globalSetTimeout(flush, 1);
+ };
+}
+
+var queue = new Array(1000);
+function flush() {
+ for (var i = 0; i < len; i += 2) {
+ var callback = queue[i];
+ var arg = queue[i + 1];
+
+ callback(arg);
+
+ queue[i] = undefined;
+ queue[i + 1] = undefined;
+ }
+
+ len = 0;
+}
+
+function attemptVertx() {
+ try {
+ var vertx = Function('return this')().require('vertx');
+ vertxNext = vertx.runOnLoop || vertx.runOnContext;
+ return useVertxTimer();
+ } catch (e) {
+ return useSetTimeout();
+ }
+}
+
+var scheduleFlush = void 0;
+// Decide what async method to use to triggering processing of queued callbacks:
+if (isNode) {
+ scheduleFlush = useNextTick();
+} else if (BrowserMutationObserver) {
+ scheduleFlush = useMutationObserver();
+} else if (isWorker) {
+ scheduleFlush = useMessageChannel();
+} else if (browserWindow === undefined && typeof require === 'function') {
+ scheduleFlush = attemptVertx();
+} else {
+ scheduleFlush = useSetTimeout();
+}
+
+function then(onFulfillment, onRejection) {
+ var parent = this;
+
+ var child = new this.constructor(noop);
+
+ if (child[PROMISE_ID] === undefined) {
+ makePromise(child);
+ }
+
+ var _state = parent._state;
+
+
+ if (_state) {
+ var callback = arguments[_state - 1];
+ asap(function () {
+ return invokeCallback(_state, child, callback, parent._result);
+ });
+ } else {
+ subscribe(parent, child, onFulfillment, onRejection);
+ }
+
+ return child;
+}
+
+/**
+ `Promise.resolve` returns a promise that will become resolved with the
+ passed `value`. It is shorthand for the following:
+
+ ```javascript
+ let promise = new Promise(function(resolve, reject){
+ resolve(1);
+ });
+
+ promise.then(function(value){
+ // value === 1
+ });
+ ```
+
+ Instead of writing the above, your code now simply becomes the following:
+
+ ```javascript
+ let promise = Promise.resolve(1);
+
+ promise.then(function(value){
+ // value === 1
+ });
+ ```
+
+ @method resolve
+ @static
+ @param {Any} value value that the returned promise will be resolved with
+ Useful for tooling.
+ @return {Promise} a promise that will become fulfilled with the given
+ `value`
+*/
+function resolve$1(object) {
+ /*jshint validthis:true */
+ var Constructor = this;
+
+ if (object && typeof object === 'object' && object.constructor === Constructor) {
+ return object;
+ }
+
+ var promise = new Constructor(noop);
+ resolve(promise, object);
+ return promise;
+}
+
+var PROMISE_ID = Math.random().toString(36).substring(2);
+
+function noop() {}
+
+var PENDING = void 0;
+var FULFILLED = 1;
+var REJECTED = 2;
+
+function selfFulfillment() {
+ return new TypeError("You cannot resolve a promise with itself");
+}
+
+function cannotReturnOwn() {
+ return new TypeError('A promises callback cannot return that same promise.');
+}
+
+function tryThen(then$$1, value, fulfillmentHandler, rejectionHandler) {
+ try {
+ then$$1.call(value, fulfillmentHandler, rejectionHandler);
+ } catch (e) {
+ return e;
+ }
+}
+
+function handleForeignThenable(promise, thenable, then$$1) {
+ asap(function (promise) {
+ var sealed = false;
+ var error = tryThen(then$$1, thenable, function (value) {
+ if (sealed) {
+ return;
+ }
+ sealed = true;
+ if (thenable !== value) {
+ resolve(promise, value);
+ } else {
+ fulfill(promise, value);
+ }
+ }, function (reason) {
+ if (sealed) {
+ return;
+ }
+ sealed = true;
+
+ reject(promise, reason);
+ }, 'Settle: ' + (promise._label || ' unknown promise'));
+
+ if (!sealed && error) {
+ sealed = true;
+ reject(promise, error);
+ }
+ }, promise);
+}
+
+function handleOwnThenable(promise, thenable) {
+ if (thenable._state === FULFILLED) {
+ fulfill(promise, thenable._result);
+ } else if (thenable._state === REJECTED) {
+ reject(promise, thenable._result);
+ } else {
+ subscribe(thenable, undefined, function (value) {
+ return resolve(promise, value);
+ }, function (reason) {
+ return reject(promise, reason);
+ });
+ }
+}
+
+function handleMaybeThenable(promise, maybeThenable, then$$1) {
+ if (maybeThenable.constructor === promise.constructor && then$$1 === then && maybeThenable.constructor.resolve === resolve$1) {
+ handleOwnThenable(promise, maybeThenable);
+ } else {
+ if (then$$1 === undefined) {
+ fulfill(promise, maybeThenable);
+ } else if (isFunction(then$$1)) {
+ handleForeignThenable(promise, maybeThenable, then$$1);
+ } else {
+ fulfill(promise, maybeThenable);
+ }
+ }
+}
+
+function resolve(promise, value) {
+ if (promise === value) {
+ reject(promise, selfFulfillment());
+ } else if (objectOrFunction(value)) {
+ var then$$1 = void 0;
+ try {
+ then$$1 = value.then;
+ } catch (error) {
+ reject(promise, error);
+ return;
+ }
+ handleMaybeThenable(promise, value, then$$1);
+ } else {
+ fulfill(promise, value);
+ }
+}
+
+function publishRejection(promise) {
+ if (promise._onerror) {
+ promise._onerror(promise._result);
+ }
+
+ publish(promise);
+}
+
+function fulfill(promise, value) {
+ if (promise._state !== PENDING) {
+ return;
+ }
+
+ promise._result = value;
+ promise._state = FULFILLED;
+
+ if (promise._subscribers.length !== 0) {
+ asap(publish, promise);
+ }
+}
+
+function reject(promise, reason) {
+ if (promise._state !== PENDING) {
+ return;
+ }
+ promise._state = REJECTED;
+ promise._result = reason;
+
+ asap(publishRejection, promise);
+}
+
+function subscribe(parent, child, onFulfillment, onRejection) {
+ var _subscribers = parent._subscribers;
+ var length = _subscribers.length;
+
+
+ parent._onerror = null;
+
+ _subscribers[length] = child;
+ _subscribers[length + FULFILLED] = onFulfillment;
+ _subscribers[length + REJECTED] = onRejection;
+
+ if (length === 0 && parent._state) {
+ asap(publish, parent);
+ }
+}
+
+function publish(promise) {
+ var subscribers = promise._subscribers;
+ var settled = promise._state;
+
+ if (subscribers.length === 0) {
+ return;
+ }
+
+ var child = void 0,
+ callback = void 0,
+ detail = promise._result;
+
+ for (var i = 0; i < subscribers.length; i += 3) {
+ child = subscribers[i];
+ callback = subscribers[i + settled];
+
+ if (child) {
+ invokeCallback(settled, child, callback, detail);
+ } else {
+ callback(detail);
+ }
+ }
+
+ promise._subscribers.length = 0;
+}
+
+function invokeCallback(settled, promise, callback, detail) {
+ var hasCallback = isFunction(callback),
+ value = void 0,
+ error = void 0,
+ succeeded = true;
+
+ if (hasCallback) {
+ try {
+ value = callback(detail);
+ } catch (e) {
+ succeeded = false;
+ error = e;
+ }
+
+ if (promise === value) {
+ reject(promise, cannotReturnOwn());
+ return;
+ }
+ } else {
+ value = detail;
+ }
+
+ if (promise._state !== PENDING) {
+ // noop
+ } else if (hasCallback && succeeded) {
+ resolve(promise, value);
+ } else if (succeeded === false) {
+ reject(promise, error);
+ } else if (settled === FULFILLED) {
+ fulfill(promise, value);
+ } else if (settled === REJECTED) {
+ reject(promise, value);
+ }
+}
+
+function initializePromise(promise, resolver) {
+ try {
+ resolver(function resolvePromise(value) {
+ resolve(promise, value);
+ }, function rejectPromise(reason) {
+ reject(promise, reason);
+ });
+ } catch (e) {
+ reject(promise, e);
+ }
+}
+
+var id = 0;
+function nextId() {
+ return id++;
+}
+
+function makePromise(promise) {
+ promise[PROMISE_ID] = id++;
+ promise._state = undefined;
+ promise._result = undefined;
+ promise._subscribers = [];
+}
+
+function validationError() {
+ return new Error('Array Methods must be provided an Array');
+}
+
+var Enumerator = function () {
+ function Enumerator(Constructor, input) {
+ this._instanceConstructor = Constructor;
+ this.promise = new Constructor(noop);
+
+ if (!this.promise[PROMISE_ID]) {
+ makePromise(this.promise);
+ }
+
+ if (isArray(input)) {
+ this.length = input.length;
+ this._remaining = input.length;
+
+ this._result = new Array(this.length);
+
+ if (this.length === 0) {
+ fulfill(this.promise, this._result);
+ } else {
+ this.length = this.length || 0;
+ this._enumerate(input);
+ if (this._remaining === 0) {
+ fulfill(this.promise, this._result);
+ }
+ }
+ } else {
+ reject(this.promise, validationError());
+ }
+ }
+
+ Enumerator.prototype._enumerate = function _enumerate(input) {
+ for (var i = 0; this._state === PENDING && i < input.length; i++) {
+ this._eachEntry(input[i], i);
+ }
+ };
+
+ Enumerator.prototype._eachEntry = function _eachEntry(entry, i) {
+ var c = this._instanceConstructor;
+ var resolve$$1 = c.resolve;
+
+
+ if (resolve$$1 === resolve$1) {
+ var _then = void 0;
+ var error = void 0;
+ var didError = false;
+ try {
+ _then = entry.then;
+ } catch (e) {
+ didError = true;
+ error = e;
+ }
+
+ if (_then === then && entry._state !== PENDING) {
+ this._settledAt(entry._state, i, entry._result);
+ } else if (typeof _then !== 'function') {
+ this._remaining--;
+ this._result[i] = entry;
+ } else if (c === Promise$1) {
+ var promise = new c(noop);
+ if (didError) {
+ reject(promise, error);
+ } else {
+ handleMaybeThenable(promise, entry, _then);
+ }
+ this._willSettleAt(promise, i);
+ } else {
+ this._willSettleAt(new c(function (resolve$$1) {
+ return resolve$$1(entry);
+ }), i);
+ }
+ } else {
+ this._willSettleAt(resolve$$1(entry), i);
+ }
+ };
+
+ Enumerator.prototype._settledAt = function _settledAt(state, i, value) {
+ var promise = this.promise;
+
+
+ if (promise._state === PENDING) {
+ this._remaining--;
+
+ if (state === REJECTED) {
+ reject(promise, value);
+ } else {
+ this._result[i] = value;
+ }
+ }
+
+ if (this._remaining === 0) {
+ fulfill(promise, this._result);
+ }
+ };
+
+ Enumerator.prototype._willSettleAt = function _willSettleAt(promise, i) {
+ var enumerator = this;
+
+ subscribe(promise, undefined, function (value) {
+ return enumerator._settledAt(FULFILLED, i, value);
+ }, function (reason) {
+ return enumerator._settledAt(REJECTED, i, reason);
+ });
+ };
+
+ return Enumerator;
+}();
+
+/**
+ `Promise.all` accepts an array of promises, and returns a new promise which
+ is fulfilled with an array of fulfillment values for the passed promises, or
+ rejected with the reason of the first passed promise to be rejected. It casts all
+ elements of the passed iterable to promises as it runs this algorithm.
+
+ Example:
+
+ ```javascript
+ let promise1 = resolve(1);
+ let promise2 = resolve(2);
+ let promise3 = resolve(3);
+ let promises = [ promise1, promise2, promise3 ];
+
+ Promise.all(promises).then(function(array){
+ // The array here would be [ 1, 2, 3 ];
+ });
+ ```
+
+ If any of the `promises` given to `all` are rejected, the first promise
+ that is rejected will be given as an argument to the returned promises's
+ rejection handler. For example:
+
+ Example:
+
+ ```javascript
+ let promise1 = resolve(1);
+ let promise2 = reject(new Error("2"));
+ let promise3 = reject(new Error("3"));
+ let promises = [ promise1, promise2, promise3 ];
+
+ Promise.all(promises).then(function(array){
+ // Code here never runs because there are rejected promises!
+ }, function(error) {
+ // error.message === "2"
+ });
+ ```
+
+ @method all
+ @static
+ @param {Array} entries array of promises
+ @param {String} label optional string for labeling the promise.
+ Useful for tooling.
+ @return {Promise} promise that is fulfilled when all `promises` have been
+ fulfilled, or rejected if any of them become rejected.
+ @static
+*/
+function all(entries) {
+ return new Enumerator(this, entries).promise;
+}
+
+/**
+ `Promise.race` returns a new promise which is settled in the same way as the
+ first passed promise to settle.
+
+ Example:
+
+ ```javascript
+ let promise1 = new Promise(function(resolve, reject){
+ setTimeout(function(){
+ resolve('promise 1');
+ }, 200);
+ });
+
+ let promise2 = new Promise(function(resolve, reject){
+ setTimeout(function(){
+ resolve('promise 2');
+ }, 100);
+ });
+
+ Promise.race([promise1, promise2]).then(function(result){
+ // result === 'promise 2' because it was resolved before promise1
+ // was resolved.
+ });
+ ```
+
+ `Promise.race` is deterministic in that only the state of the first
+ settled promise matters. For example, even if other promises given to the
+ `promises` array argument are resolved, but the first settled promise has
+ become rejected before the other promises became fulfilled, the returned
+ promise will become rejected:
+
+ ```javascript
+ let promise1 = new Promise(function(resolve, reject){
+ setTimeout(function(){
+ resolve('promise 1');
+ }, 200);
+ });
+
+ let promise2 = new Promise(function(resolve, reject){
+ setTimeout(function(){
+ reject(new Error('promise 2'));
+ }, 100);
+ });
+
+ Promise.race([promise1, promise2]).then(function(result){
+ // Code here never runs
+ }, function(reason){
+ // reason.message === 'promise 2' because promise 2 became rejected before
+ // promise 1 became fulfilled
+ });
+ ```
+
+ An example real-world use case is implementing timeouts:
+
+ ```javascript
+ Promise.race([ajax('foo.json'), timeout(5000)])
+ ```
+
+ @method race
+ @static
+ @param {Array} promises array of promises to observe
+ Useful for tooling.
+ @return {Promise} a promise which settles in the same way as the first passed
+ promise to settle.
+*/
+function race(entries) {
+ /*jshint validthis:true */
+ var Constructor = this;
+
+ if (!isArray(entries)) {
+ return new Constructor(function (_, reject) {
+ return reject(new TypeError('You must pass an array to race.'));
+ });
+ } else {
+ return new Constructor(function (resolve, reject) {
+ var length = entries.length;
+ for (var i = 0; i < length; i++) {
+ Constructor.resolve(entries[i]).then(resolve, reject);
+ }
+ });
+ }
+}
+
+/**
+ `Promise.reject` returns a promise rejected with the passed `reason`.
+ It is shorthand for the following:
+
+ ```javascript
+ let promise = new Promise(function(resolve, reject){
+ reject(new Error('WHOOPS'));
+ });
+
+ promise.then(function(value){
+ // Code here doesn't run because the promise is rejected!
+ }, function(reason){
+ // reason.message === 'WHOOPS'
+ });
+ ```
+
+ Instead of writing the above, your code now simply becomes the following:
+
+ ```javascript
+ let promise = Promise.reject(new Error('WHOOPS'));
+
+ promise.then(function(value){
+ // Code here doesn't run because the promise is rejected!
+ }, function(reason){
+ // reason.message === 'WHOOPS'
+ });
+ ```
+
+ @method reject
+ @static
+ @param {Any} reason value that the returned promise will be rejected with.
+ Useful for tooling.
+ @return {Promise} a promise rejected with the given `reason`.
+*/
+function reject$1(reason) {
+ /*jshint validthis:true */
+ var Constructor = this;
+ var promise = new Constructor(noop);
+ reject(promise, reason);
+ return promise;
+}
+
+function needsResolver() {
+ throw new TypeError('You must pass a resolver function as the first argument to the promise constructor');
+}
+
+function needsNew() {
+ throw new TypeError("Failed to construct 'Promise': Please use the 'new' operator, this object constructor cannot be called as a function.");
+}
+
+/**
+ Promise objects represent the eventual result of an asynchronous operation. The
+ primary way of interacting with a promise is through its `then` method, which
+ registers callbacks to receive either a promise's eventual value or the reason
+ why the promise cannot be fulfilled.
+
+ Terminology
+ -----------
+
+ - `promise` is an object or function with a `then` method whose behavior conforms to this specification.
+ - `thenable` is an object or function that defines a `then` method.
+ - `value` is any legal JavaScript value (including undefined, a thenable, or a promise).
+ - `exception` is a value that is thrown using the throw statement.
+ - `reason` is a value that indicates why a promise was rejected.
+ - `settled` the final resting state of a promise, fulfilled or rejected.
+
+ A promise can be in one of three states: pending, fulfilled, or rejected.
+
+ Promises that are fulfilled have a fulfillment value and are in the fulfilled
+ state. Promises that are rejected have a rejection reason and are in the
+ rejected state. A fulfillment value is never a thenable.
+
+ Promises can also be said to *resolve* a value. If this value is also a
+ promise, then the original promise's settled state will match the value's
+ settled state. So a promise that *resolves* a promise that rejects will
+ itself reject, and a promise that *resolves* a promise that fulfills will
+ itself fulfill.
+
+
+ Basic Usage:
+ ------------
+
+ ```js
+ let promise = new Promise(function(resolve, reject) {
+ // on success
+ resolve(value);
+
+ // on failure
+ reject(reason);
+ });
+
+ promise.then(function(value) {
+ // on fulfillment
+ }, function(reason) {
+ // on rejection
+ });
+ ```
+
+ Advanced Usage:
+ ---------------
+
+ Promises shine when abstracting away asynchronous interactions such as
+ `XMLHttpRequest`s.
+
+ ```js
+ function getJSON(url) {
+ return new Promise(function(resolve, reject){
+ let xhr = new XMLHttpRequest();
+
+ xhr.open('GET', url);
+ xhr.onreadystatechange = handler;
+ xhr.responseType = 'json';
+ xhr.setRequestHeader('Accept', 'application/json');
+ xhr.send();
+
+ function handler() {
+ if (this.readyState === this.DONE) {
+ if (this.status === 200) {
+ resolve(this.response);
+ } else {
+ reject(new Error('getJSON: `' + url + '` failed with status: [' + this.status + ']'));
+ }
+ }
+ };
+ });
+ }
+
+ getJSON('/posts.json').then(function(json) {
+ // on fulfillment
+ }, function(reason) {
+ // on rejection
+ });
+ ```
+
+ Unlike callbacks, promises are great composable primitives.
+
+ ```js
+ Promise.all([
+ getJSON('/posts'),
+ getJSON('/comments')
+ ]).then(function(values){
+ values[0] // => postsJSON
+ values[1] // => commentsJSON
+
+ return values;
+ });
+ ```
+
+ @class Promise
+ @param {Function} resolver
+ Useful for tooling.
+ @constructor
+*/
+
+var Promise$1 = function () {
+ function Promise(resolver) {
+ this[PROMISE_ID] = nextId();
+ this._result = this._state = undefined;
+ this._subscribers = [];
+
+ if (noop !== resolver) {
+ typeof resolver !== 'function' && needsResolver();
+ this instanceof Promise ? initializePromise(this, resolver) : needsNew();
+ }
+ }
+
+ /**
+ The primary way of interacting with a promise is through its `then` method,
+ which registers callbacks to receive either a promise's eventual value or the
+ reason why the promise cannot be fulfilled.
+ ```js
+ findUser().then(function(user){
+ // user is available
+ }, function(reason){
+ // user is unavailable, and you are given the reason why
+ });
+ ```
+ Chaining
+ --------
+ The return value of `then` is itself a promise. This second, 'downstream'
+ promise is resolved with the return value of the first promise's fulfillment
+ or rejection handler, or rejected if the handler throws an exception.
+ ```js
+ findUser().then(function (user) {
+ return user.name;
+ }, function (reason) {
+ return 'default name';
+ }).then(function (userName) {
+ // If `findUser` fulfilled, `userName` will be the user's name, otherwise it
+ // will be `'default name'`
+ });
+ findUser().then(function (user) {
+ throw new Error('Found user, but still unhappy');
+ }, function (reason) {
+ throw new Error('`findUser` rejected and we're unhappy');
+ }).then(function (value) {
+ // never reached
+ }, function (reason) {
+ // if `findUser` fulfilled, `reason` will be 'Found user, but still unhappy'.
+ // If `findUser` rejected, `reason` will be '`findUser` rejected and we're unhappy'.
+ });
+ ```
+ If the downstream promise does not specify a rejection handler, rejection reasons will be propagated further downstream.
+ ```js
+ findUser().then(function (user) {
+ throw new PedagogicalException('Upstream error');
+ }).then(function (value) {
+ // never reached
+ }).then(function (value) {
+ // never reached
+ }, function (reason) {
+ // The `PedgagocialException` is propagated all the way down to here
+ });
+ ```
+ Assimilation
+ ------------
+ Sometimes the value you want to propagate to a downstream promise can only be
+ retrieved asynchronously. This can be achieved by returning a promise in the
+ fulfillment or rejection handler. The downstream promise will then be pending
+ until the returned promise is settled. This is called *assimilation*.
+ ```js
+ findUser().then(function (user) {
+ return findCommentsByAuthor(user);
+ }).then(function (comments) {
+ // The user's comments are now available
+ });
+ ```
+ If the assimliated promise rejects, then the downstream promise will also reject.
+ ```js
+ findUser().then(function (user) {
+ return findCommentsByAuthor(user);
+ }).then(function (comments) {
+ // If `findCommentsByAuthor` fulfills, we'll have the value here
+ }, function (reason) {
+ // If `findCommentsByAuthor` rejects, we'll have the reason here
+ });
+ ```
+ Simple Example
+ --------------
+ Synchronous Example
+ ```javascript
+ let result;
+ try {
+ result = findResult();
+ // success
+ } catch(reason) {
+ // failure
+ }
+ ```
+ Errback Example
+ ```js
+ findResult(function(result, err){
+ if (err) {
+ // failure
+ } else {
+ // success
+ }
+ });
+ ```
+ Promise Example;
+ ```javascript
+ findResult().then(function(result){
+ // success
+ }, function(reason){
+ // failure
+ });
+ ```
+ Advanced Example
+ --------------
+ Synchronous Example
+ ```javascript
+ let author, books;
+ try {
+ author = findAuthor();
+ books = findBooksByAuthor(author);
+ // success
+ } catch(reason) {
+ // failure
+ }
+ ```
+ Errback Example
+ ```js
+ function foundBooks(books) {
+ }
+ function failure(reason) {
+ }
+ findAuthor(function(author, err){
+ if (err) {
+ failure(err);
+ // failure
+ } else {
+ try {
+ findBoooksByAuthor(author, function(books, err) {
+ if (err) {
+ failure(err);
+ } else {
+ try {
+ foundBooks(books);
+ } catch(reason) {
+ failure(reason);
+ }
+ }
+ });
+ } catch(error) {
+ failure(err);
+ }
+ // success
+ }
+ });
+ ```
+ Promise Example;
+ ```javascript
+ findAuthor().
+ then(findBooksByAuthor).
+ then(function(books){
+ // found books
+ }).catch(function(reason){
+ // something went wrong
+ });
+ ```
+ @method then
+ @param {Function} onFulfilled
+ @param {Function} onRejected
+ Useful for tooling.
+ @return {Promise}
+ */
+
+ /**
+ `catch` is simply sugar for `then(undefined, onRejection)` which makes it the same
+ as the catch block of a try/catch statement.
+ ```js
+ function findAuthor(){
+ throw new Error('couldn't find that author');
+ }
+ // synchronous
+ try {
+ findAuthor();
+ } catch(reason) {
+ // something went wrong
+ }
+ // async with promises
+ findAuthor().catch(function(reason){
+ // something went wrong
+ });
+ ```
+ @method catch
+ @param {Function} onRejection
+ Useful for tooling.
+ @return {Promise}
+ */
+
+
+ Promise.prototype.catch = function _catch(onRejection) {
+ return this.then(null, onRejection);
+ };
+
+ /**
+ `finally` will be invoked regardless of the promise's fate just as native
+ try/catch/finally behaves
+
+ Synchronous example:
+
+ ```js
+ findAuthor() {
+ if (Math.random() > 0.5) {
+ throw new Error();
+ }
+ return new Author();
+ }
+
+ try {
+ return findAuthor(); // succeed or fail
+ } catch(error) {
+ return findOtherAuther();
+ } finally {
+ // always runs
+ // doesn't affect the return value
+ }
+ ```
+
+ Asynchronous example:
+
+ ```js
+ findAuthor().catch(function(reason){
+ return findOtherAuther();
+ }).finally(function(){
+ // author was either found, or not
+ });
+ ```
+
+ @method finally
+ @param {Function} callback
+ @return {Promise}
+ */
+
+
+ Promise.prototype.finally = function _finally(callback) {
+ var promise = this;
+ var constructor = promise.constructor;
+
+ if (isFunction(callback)) {
+ return promise.then(function (value) {
+ return constructor.resolve(callback()).then(function () {
+ return value;
+ });
+ }, function (reason) {
+ return constructor.resolve(callback()).then(function () {
+ throw reason;
+ });
+ });
+ }
+
+ return promise.then(callback, callback);
+ };
+
+ return Promise;
+}();
+
+Promise$1.prototype.then = then;
+Promise$1.all = all;
+Promise$1.race = race;
+Promise$1.resolve = resolve$1;
+Promise$1.reject = reject$1;
+Promise$1._setScheduler = setScheduler;
+Promise$1._setAsap = setAsap;
+Promise$1._asap = asap;
+
+/*global self*/
+function polyfill() {
+ var local = void 0;
+
+ if (typeof global !== 'undefined') {
+ local = global;
+ } else if (typeof self !== 'undefined') {
+ local = self;
+ } else {
+ try {
+ local = Function('return this')();
+ } catch (e) {
+ throw new Error('polyfill failed because global object is unavailable in this environment');
+ }
+ }
+
+ var P = local.Promise;
+
+ if (P) {
+ var promiseToString = null;
+ try {
+ promiseToString = Object.prototype.toString.call(P.resolve());
+ } catch (e) {
+ // silently ignored
+ }
+
+ if (promiseToString === '[object Promise]' && !P.cast) {
+ return;
+ }
+ }
+
+ local.Promise = Promise$1;
+}
+
+// Strange compat..
+Promise$1.polyfill = polyfill;
+Promise$1.Promise = Promise$1;
+
+return Promise$1;
+
+})));
+
+
+
+//# sourceMappingURL=es6-promise.map
diff --git a/node_modules/@yaireo/tagify/src/react.tagify.js b/node_modules/@yaireo/tagify/src/react.tagify.js
new file mode 100644
index 0000000..5d056dd
--- /dev/null
+++ b/node_modules/@yaireo/tagify/src/react.tagify.js
@@ -0,0 +1,190 @@
+import React, {useMemo, useEffect, useRef} from "react"
+import {renderToStaticMarkup} from "react-dom/server"
+import {string, array, func, bool, object, element, oneOfType} from "prop-types"
+import Tagify from "./tagify.min.js"
+
+const noop = _ => _
+
+// if a template is a React component, it should be outputed as a String (and not as a React component)
+function templatesToString(templates) {
+ if (templates) {
+ for (let templateName in templates) {
+ let isReactComp = String(templates[templateName]).includes(".createElement")
+
+ if (isReactComp) {
+ let Template = templates[templateName]
+ templates[templateName] = data => renderToStaticMarkup( )
+ }
+ }
+ }
+}
+
+const TagifyWrapper = ({
+ name,
+ value = "",
+ loading = false,
+ onInput = noop,
+ onAdd = noop,
+ onRemove = noop,
+ onEdit = noop,
+ onInvalid = noop,
+ onClick = noop,
+ onKeydown = noop,
+ onFocus = noop,
+ onBlur = noop,
+ onChange = noop,
+ readOnly,
+ children,
+ settings = {},
+ InputMode = "input",
+ autoFocus,
+ className,
+ whitelist,
+ tagifyRef,
+ placeholder = "",
+ defaultValue,
+ showDropdown
+}) => {
+ const mountedRef = useRef()
+ const inputElmRef = useRef()
+ const tagify = useRef()
+
+ const handleRef = elm => {
+ inputElmRef.current = elm
+ }
+
+ const inputAttrs = useMemo(() => ({
+ ref: handleRef,
+ name,
+ value: children
+ ? children
+ : typeof value === "string"
+ ? value
+ : JSON.stringify(value),
+ className,
+ readOnly,
+ onChange,
+ autoFocus,
+ placeholder,
+ defaultValue
+ }), [])
+
+ useEffect(() => {
+ templatesToString(settings.templates)
+
+ if (InputMode == "textarea")
+ settings.mode = "mix"
+
+ // "whitelist" prop takes precedence
+ if( whitelist && whitelist.length )
+ settings.whitelist = whitelist
+
+ const t = new Tagify(inputElmRef.current, settings)
+
+ onInput && t.on("input" , onInput)
+ onAdd && t.on("add" , onAdd)
+ onRemove && t.on("remove" , onRemove)
+ onEdit && t.on("edit" , onEdit)
+ onInvalid && t.on("invalid", onInvalid)
+ onKeydown && t.on("keydown", onKeydown)
+ onFocus && t.on("focus" , onFocus)
+ onBlur && t.on("blur" , onBlur)
+ onClick && t.on("click" , onClick)
+
+ // Bridge Tagify instance with parent component
+ if (tagifyRef) {
+ tagifyRef.current = t
+ }
+
+ tagify.current = t
+
+ // cleanup
+ return () => {
+ t.destroy()
+ }
+ }, [])
+
+ useEffect(() => {
+ if (mountedRef.current) {
+ tagify.current.settings.whitelist.length = 0
+
+ // replace whitelist array items
+ whitelist && whitelist.length && tagify.current.settings.whitelist.push(...whitelist)
+ }
+ }, [whitelist])
+
+ useEffect(() => {
+ if (mountedRef.current) {
+ tagify.current.loadOriginalValues(value)
+ }
+ }, [value])
+
+ useEffect(() => {
+ if (mountedRef.current) {
+ tagify.current.toggleClass(className)
+ }
+ }, [className])
+
+ useEffect(() => {
+ if (mountedRef.current) {
+ tagify.current.loading(loading)
+ }
+ }, [loading])
+
+ useEffect(() => {
+ if (mountedRef.current) {
+ tagify.current.setReadonly(readOnly)
+ }
+ }, [readOnly])
+
+ useEffect(() => {
+ const t = tagify.current
+
+ if (mountedRef.current) {
+ if (showDropdown) {
+ t.dropdown.show.call(t, showDropdown)
+ t.toggleFocusClass(true)
+ } else {
+ t.dropdown.hide.call(t)
+ }
+ }
+ }, [showDropdown])
+
+ useEffect(() => {
+ mountedRef.current = true
+ }, [])
+
+ return (
+ // a wrapper must be used because Tagify will appened inside it it's component,
+ // keeping the virtual-DOM out of the way
+
+
+
+ )
+}
+
+TagifyWrapper.propTypes = {
+ name: string,
+ value: oneOfType([string, array]),
+ loading: bool,
+ children: element,
+ onChange: func,
+ readOnly: bool,
+ settings: object,
+ InputMode: string,
+ autoFocus: bool,
+ className: string,
+ tagifyRef: object,
+ whitelist: array,
+ placeholder: string,
+ defaultValue: oneOfType([string, array]),
+ showDropdown: oneOfType([string, bool])
+}
+
+const Tags = React.memo(TagifyWrapper)
+Tags.displayName = "Tags"
+
+export const MixedTags = ({ children, ...rest }) =>
+ {children}
+
+export default Tags
diff --git a/node_modules/@yaireo/tagify/src/tagify.js b/node_modules/@yaireo/tagify/src/tagify.js
new file mode 100644
index 0000000..7947807
--- /dev/null
+++ b/node_modules/@yaireo/tagify/src/tagify.js
@@ -0,0 +1,1478 @@
+import { sameStr, removeCollectionProp, isObject, parseHTML, removeTextChildNodes, escapeHTML, extend } from './parts/helpers'
+import dropdownMethods from './parts/dropdown'
+import DEFAULTS from './parts/defaults'
+import templates from './parts/templates'
+import EventDispatcher from './parts/EventDispatcher'
+import events, { triggerChangeEvent } from './parts/events'
+
+/**
+ * @constructor
+ * @param {Object} input DOM element
+ * @param {Object} settings settings object
+ */
+function Tagify( input, settings ){
+ if( !input ){
+ console.warn('Tagify: ', 'input element not found', input)
+ return this
+ }
+
+ if( input.previousElementSibling && input.previousElementSibling.classList.contains('tagify') ){
+ console.warn('Tagify: ', 'input element is already Tagified', input)
+ return this
+ }
+
+ extend(this, EventDispatcher(this))
+ this.isFirefox = typeof InstallTrigger !== 'undefined'
+ this.isIE = window.document.documentMode; // https://developer.mozilla.org/en-US/docs/Web/API/Document/compatMode#Browser_compatibility
+
+ this.applySettings(input, settings||{})
+
+ this.state = {
+ inputText: '',
+ editing : false,
+ actions : {}, // UI actions for state-locking
+ mixMode : {},
+ dropdown: {},
+ flaggedTags: {} // in mix-mode, when a string is detetced as potential tag, and the user has chocen to close the suggestions dropdown, keep the record of the tasg here
+ }
+
+ this.value = [] // tags' data
+
+ // events' callbacks references will be stores here, so events could be unbinded
+ this.listeners = {}
+
+ this.DOM = {} // Store all relevant DOM elements in an Object
+
+ this.build(input)
+ this.getCSSVars()
+ this.loadOriginalValues()
+
+ this.events.customBinding.call(this);
+ this.events.binding.call(this)
+ input.autofocus && this.DOM.input.focus()
+}
+
+Tagify.prototype = {
+ dropdown: dropdownMethods,
+
+ TEXTS : {
+ empty : "empty",
+ exceed : "number of tags exceeded",
+ pattern : "pattern mismatch",
+ duplicate : "already exists",
+ notAllowed : "not allowed"
+ },
+
+ DEFAULTS,
+
+ customEventsList : ['change', 'add', 'remove', 'invalid', 'input', 'click', 'keydown', 'focus', 'blur', 'edit:input', 'edit:updated', 'edit:start', 'edit:keydown', 'dropdown:show', 'dropdown:hide', 'dropdown:select', 'dropdown:updated', 'dropdown:noMatch'],
+
+ trim(text){
+ return this.settings.trim ? text.trim() : text
+ },
+
+ // expose this handy utility function
+ parseHTML,
+
+ templates,
+
+ parseTemplate(template, data){
+ template = this.settings.templates[template] || template;
+ return this.parseHTML( template.apply(this, data) )
+ },
+
+ applySettings( input, settings ){
+ this.DEFAULTS.templates = this.templates;
+
+ var _s = this.settings = extend({}, this.DEFAULTS, settings);
+ _s.readonly = input.hasAttribute('readonly') // if "readonly" do not include an "input" element inside the Tags component
+ _s.placeholder = input.getAttribute('placeholder') || _s.placeholder || ""
+ _s.required = input.hasAttribute('required')
+
+ if( this.isIE )
+ _s.autoComplete = false; // IE goes crazy if this isn't false
+
+ ["whitelist", "blacklist"].forEach(name => {
+ var attrVal = input.getAttribute('data-' + name)
+ if( attrVal ){
+ attrVal = attrVal.split(_s.delimiters)
+ if( attrVal instanceof Array )
+ _s[name] = attrVal
+ }
+ })
+
+ // backward-compatibility for old version of "autoComplete" setting:
+ if( "autoComplete" in settings && !isObject(settings.autoComplete) ){
+ _s.autoComplete = this.DEFAULTS.autoComplete
+ _s.autoComplete.enabled = settings.autoComplete
+ }
+
+ if( _s.mode == 'mix' ){
+ _s.autoComplete.rightKey = true
+ _s.delimiters = settings.delimiters || null // default dlimiters in mix-mode must be NULL
+ }
+
+ if( input.pattern )
+ try { _s.pattern = new RegExp(input.pattern) }
+ catch(e){}
+
+ // Convert the "delimiters" setting into a REGEX object
+ if( this.settings.delimiters ){
+ try { _s.delimiters = new RegExp(this.settings.delimiters, "g") }
+ catch(e){}
+ }
+
+ // make sure the dropdown will be shown on "focus" and not only after typing something (in "select" mode)
+ if( _s.mode == 'select' )
+ _s.dropdown.enabled = 0
+
+ _s.dropdown.appendTarget = settings.dropdown && settings.dropdown.appendTarget
+ ? settings.dropdown.appendTarget
+ : document.body
+ },
+
+ /**
+ * Returns a string of HTML element attributes
+ * @param {Object} data [Tag data]
+ */
+ getAttributes( data ){
+ // only items which are objects have properties which can be used as attributes
+ if( Object.prototype.toString.call(data) != "[object Object]" )
+ return '';
+
+ var keys = Object.keys(data),
+ s = "", propName, i;
+
+ for( i=keys.length; i--; ){
+ propName = keys[i];
+ if( propName != 'class' && data.hasOwnProperty(propName) && data[propName] !== undefined )
+ s += " " + propName + (data[propName] !== undefined ? `="${data[propName]}"` : "");
+ }
+ return s;
+ },
+
+ /**
+ * Get the caret position relative to the viewport
+ * https://stackoverflow.com/q/58985076/104380
+ *
+ * @returns {object} left, top distance in pixels
+ */
+ getCaretGlobalPosition(){
+ const sel = document.getSelection()
+
+ if( sel.rangeCount ){
+ const r = sel.getRangeAt(0)
+ const node = r.startContainer
+ const offset = r.startOffset
+ let rect, r2;
+
+ if (offset > 0) {
+ r2 = document.createRange()
+ r2.setStart(node, offset - 1)
+ r2.setEnd(node, offset)
+ rect = r2.getBoundingClientRect()
+ return {left:rect.right, top:rect.top, bottom:rect.bottom}
+ }
+
+ if( node.getBoundingClientRect )
+ return node.getBoundingClientRect()
+ }
+
+ return {left:-9999, top:-9999}
+ },
+
+ /**
+ * Get specific CSS variables which are relevant to this script and parse them as needed.
+ * The result is saved on the instance in "this.CSSVars"
+ */
+ getCSSVars(){
+ var compStyle = getComputedStyle(this.DOM.scope, null)
+
+ const getProp = name => compStyle.getPropertyValue('--'+name)
+
+ function seprateUnitFromValue(a){
+ if( !a ) return {}
+ a = a.trim().split(' ')[0]
+ var unit = a.split(/\d+/g).filter(n=>n).pop().trim(),
+ value = +a.split(unit).filter(n=>n)[0].trim()
+ return {value, unit}
+ }
+
+ this.CSSVars = {
+ tagHideTransition: (({value, unit}) => unit=='s' ? value * 1000 : value)(seprateUnitFromValue(getProp('tag-hide-transition')))
+ }
+ },
+
+ /**
+ * builds the HTML of this component
+ * @param {Object} input [DOM element which would be "transformed" into "Tags"]
+ */
+ build( input ){
+ var DOM = this.DOM;
+
+ if( this.settings.mixMode.integrated ){
+ DOM.originalInput = null;
+ DOM.scope = input;
+ DOM.input = input;
+ }
+
+ else {
+ DOM.originalInput = input
+ DOM.scope = this.parseTemplate('wrapper', [input, this.settings])
+ DOM.input = DOM.scope.querySelector('.' + this.settings.classNames.input)
+ input.parentNode.insertBefore(DOM.scope, input)
+ }
+
+ if( this.settings.dropdown.enabled >= 0 )
+ this.dropdown.init.call(this)
+ },
+
+ /**
+ * revert any changes made by this component
+ */
+ destroy(){
+ this.DOM.scope.parentNode.removeChild(this.DOM.scope)
+ this.dropdown.hide.call(this, true)
+ clearTimeout(this.dropdownHide__bindEventsTimeout)
+ },
+
+ /**
+ * if the original input had any values, add them as tags
+ */
+ loadOriginalValues( value ){
+ var lastChild,
+ _s = this.settings;
+
+ value = value || (_s.mixMode.integrated
+ ? this.DOM.input.textContent
+ : this.DOM.originalInput.value)
+
+ if( value ){
+ this.removeAllTags()
+
+ if( _s.mode == 'mix' ){
+ this.parseMixTags(value.trim())
+
+ lastChild = this.DOM.input.lastChild;
+
+ if( !lastChild || lastChild.tagName != 'BR' )
+ this.DOM.input.insertAdjacentHTML('beforeend', ' ')
+ }
+
+ else{
+ try{
+ if( JSON.parse(value) instanceof Array )
+ value = JSON.parse(value)
+ }
+ catch(err){}
+ this.addTags(value).forEach(tag => tag && tag.classList.add(_s.classNames.tagNoAnimation))
+ }
+ }
+
+ else
+ this.postUpdate()
+
+ this.state.lastOriginalValueReported = _s.mixMode.integrated ? '' : this.DOM.originalInput.value
+ this.state.loadedOriginalValues = true
+ },
+
+ cloneEvent(e){
+ var clonedEvent = {}
+ for( var v in e )
+ clonedEvent[v] = e[v]
+ return clonedEvent
+ },
+
+ /**
+ * Toogle global loading state on/off
+ * Useful when fetching async whitelist while user is typing
+ * @param {Boolean} isLoading
+ */
+ loading( isLoading ){
+ this.state.isLoading = isLoading
+ // IE11 doesn't support toggle with second parameter
+ this.DOM.scope.classList[isLoading?"add":"remove"](this.settings.classNames.scopeLoading)
+ return this
+ },
+
+ /**
+ * Toogle specieif tag loading state on/off
+ * @param {Boolean} isLoading
+ */
+ tagLoading( tagElm, isLoading ){
+ if( tagElm )
+ // IE11 doesn't support toggle with second parameter
+ tagElm.classList[isLoading?"add":"remove"](this.settings.classNames.tagLoading)
+ return this
+ },
+
+ /**
+ * Toggles class on the main tagify container ("scope")
+ * @param {String} className
+ * @param {Boolean} force
+ */
+ toggleClass( className, force ){
+ // force = force === undefined ?
+ this.DOM.scope.classList.toggle(className, force)
+ },
+
+ toggleFocusClass( force ){
+ this.toggleClass(this.settings.classNames.focus, !!force)
+ },
+
+ triggerChangeEvent,
+
+ events,
+
+ fixFirefoxLastTagNoCaret(){
+ return // seems to be fixed in newer version of FF, so retiring below code (for now)
+ var inputElm = this.DOM.input
+
+ if( this.isFirefox && inputElm.childNodes.length && inputElm.lastChild.nodeType == 1 ){
+ inputElm.appendChild(document.createTextNode("\u200b"))
+ this.setRangeAtStartEnd(true)
+ return true
+ }
+ },
+
+ placeCaretAfterNode( node ){
+ if( !node ) return
+
+ var nextSibling = node.nextSibling,
+ sel = window.getSelection(),
+ range = sel.getRangeAt(0);
+
+ if (sel.rangeCount) {
+ range.setStartBefore(nextSibling || node);
+ range.setEndBefore(nextSibling || node);
+ sel.removeAllRanges();
+ sel.addRange(range);
+ }
+ },
+
+ insertAfterTag( tagElm, newNode ){
+ newNode = newNode || this.settings.mixMode.insertAfterTag;
+
+ if( !tagElm || !newNode ) return
+
+ newNode = typeof newNode == 'string'
+ ? document.createTextNode(newNode)
+ : newNode
+
+ tagElm.appendChild(newNode)
+ tagElm.parentNode.insertBefore(newNode, tagElm.nextSibling)
+ return newNode
+ },
+
+ /**
+ * Enters a tag into "edit" mode
+ * @param {Node} tagElm the tag element to edit. if nothing specified, use last last
+ */
+ editTag( tagElm, opts ){
+ tagElm = tagElm || this.getLastTag()
+ opts = opts || {}
+
+ this.dropdown.hide.call(this)
+
+ var _s = this.settings;
+
+ function getEditableElm(){
+ return tagElm.querySelector('.' + _s.classNames.tagText)
+ }
+
+ var editableElm = getEditableElm(),
+ tagIdx = this.getNodeIndex(tagElm),
+ tagData = this.tagData(tagElm),
+ _CB = this.events.callbacks,
+ that = this,
+ isValid = true,
+ delayed_onEditTagBlur = function(){
+ setTimeout(() => _CB.onEditTagBlur.call(that, getEditableElm()))
+ }
+
+ if( !editableElm ){
+ console.warn('Cannot find element in Tag template: .', _s.classNames.tagText);
+ return;
+ }
+
+ if( tagData instanceof Object && "editable" in tagData && !tagData.editable )
+ return
+
+ editableElm.setAttribute('contenteditable', true)
+ tagElm.classList.add( _s.classNames.tagEditing )
+
+ // cache the original data, on the DOM node, before any modification ocurs, for possible revert
+ this.tagData(tagElm, {
+ __originalData: extend({}, tagData),
+ __originalHTML: tagElm.innerHTML
+ })
+
+ editableElm.addEventListener('focus', _CB.onEditTagFocus.bind(this, tagElm))
+ editableElm.addEventListener('blur', delayed_onEditTagBlur)
+ editableElm.addEventListener('input', _CB.onEditTagInput.bind(this, editableElm))
+ editableElm.addEventListener('keydown', e => _CB.onEditTagkeydown.call(this, e, tagElm))
+
+ editableElm.focus()
+ this.setRangeAtStartEnd(false, editableElm)
+
+ if( !opts.skipValidation )
+ isValid = this.editTagToggleValidity(tagElm, tagData.value)
+
+ editableElm.originalIsValid = isValid
+
+ this.trigger("edit:start", { tag:tagElm, index:tagIdx, data:tagData, isValid })
+
+ return this
+ },
+
+ editTagToggleValidity( tagElm, value ){
+ var tagData = this.tagData(tagElm),
+ toggleState;
+
+ if( !tagData ){
+ console.warn("tag has no data: ", tagElm, tagData)
+ return;
+ }
+
+ toggleState = !!(tagData.__isValid && tagData.__isValid != true);
+
+ //this.validateTag(tagData);
+
+ tagElm.classList.toggle(this.settings.classNames.tagInvalid, toggleState)
+ return tagData.__isValid
+ },
+
+ onEditTagDone(tagElm, tagData){
+ tagElm = tagElm || this.state.editing.scope
+ tagData = tagData || {}
+
+ var eventData = { tag:tagElm, index:this.getNodeIndex(tagElm), previousData:this.tagData(tagElm), data:tagData };
+
+ this.trigger("edit:beforeUpdate", eventData)
+
+ this.state.editing = false;
+ delete tagData.__originalData
+ delete tagData.__originalHTML
+
+ if( tagElm && tagData[this.settings.tagTextProp] ){
+ this.editTagToggleValidity(tagElm)
+ this.replaceTag(tagElm, tagData)
+ }
+
+ else if(tagElm)
+ this.removeTags(tagElm)
+
+ this.trigger("edit:updated", eventData)
+ this.dropdown.hide.call(this)
+
+ // check if any of the current tags which might have been marked as "duplicate" should be now un-marked
+ if( this.settings.keepInvalidTags )
+ this.reCheckInvalidTags()
+ },
+
+ /**
+ * Replaces an exisitng tag with a new one. Used for updating a tag's data
+ * @param {Object} tagElm [DOM node to replace]
+ * @param {Object} tagData [data to create new tag from]
+ */
+ replaceTag(tagElm, tagData){
+ if( !tagData || !tagData.value )
+ tagData = tagElm.__tagifyTagData
+
+ // if tag is invalid, make the according changes in the newly created element
+ if( tagData.__isValid && tagData.__isValid != true )
+ extend( tagData, this.getInvalidTagAttrs(tagData, tagData.__isValid) )
+
+ var newTag = this.createTagElem(tagData)
+
+ // update DOM
+ tagElm.parentNode.replaceChild(newTag, tagElm)
+ this.updateValueByDOMTags()
+ },
+
+ /**
+ * update "value" (Array of Objects) by traversing all valid tags
+ */
+ updateValueByDOMTags(){
+ this.value.length = 0;
+
+ [].forEach.call(this.getTagElms(), node => {
+ if( node.classList.contains(this.settings.classNames.tagNotAllowed) ) return
+ this.value.push( this.tagData(node) )
+ })
+
+ this.update()
+ },
+
+ /** https://stackoverflow.com/a/59156872/104380
+ * @param {Boolean} start indicating where to place it (start or end of the node)
+ * @param {Object} node DOM node to place the caret at
+ */
+ setRangeAtStartEnd( start, node ){
+ start = typeof start == 'number' ? start : !!start
+ node = node || this.DOM.input;
+ node = node.lastChild || node;
+ var sel = document.getSelection()
+
+ try{
+ if( sel.rangeCount >= 1 ){
+ ['Start', 'End'].forEach(pos =>
+ sel.getRangeAt(0)["set" + pos](node, start ? start : node.length)
+ )
+ }
+ } catch(err){
+ console.warn("Tagify: ", err)
+ }
+ },
+
+ /**
+ * injects nodes/text at caret position, which is saved on the "state" when "blur" event gets triggered
+ * @param {Node} injectedNode [the node to inject at the caret position]
+ * @param {Object} selection [optional selection Object. must have "anchorNode" & "anchorOffset"]
+ */
+ injectAtCaret( injectedNode, range ){
+ range = range || this.state.selection.range
+
+ if( !range ) return;
+
+ if( typeof injectedNode == 'string' )
+ injectedNode = document.createTextNode(injectedNode);
+
+ range.deleteContents()
+
+ range.insertNode(injectedNode)
+
+ this.setRangeAtStartEnd(false, injectedNode)
+
+ this.updateValueByDOMTags() // updates internal "this.value"
+ this.update() // updates original input/textarea
+
+ return this
+ },
+
+ /**
+ * input bridge for accessing & setting
+ * @type {Object}
+ */
+ input : {
+ set( s = '', updateDOM = true ){
+ var hideDropdown = this.settings.dropdown.closeOnSelect
+ this.state.inputText = s
+
+ if( updateDOM )
+ this.DOM.input.innerHTML = s;
+
+ if( !s && hideDropdown )
+ this.dropdown.hide.bind(this)
+
+ this.input.autocomplete.suggest.call(this);
+ this.input.validate.call(this);
+ },
+
+ /**
+ * Marks the tagify's input as "invalid" if the value did not pass "validateTag()"
+ */
+ validate(){
+ var isValid = !this.state.inputText || this.validateTag({value:this.state.inputText}) === true;
+
+ this.DOM.input.classList.toggle(this.settings.classNames.inputInvalid, !isValid)
+
+ return isValid
+ },
+
+ // remove any child DOM elements that aren't of type TEXT (like )
+ normalize( node ){
+ var clone = node || this.DOM.input, //.cloneNode(true),
+ v = [];
+
+ // when a text was pasted in FF, the "this.DOM.input" element will have but no newline symbols (\n), and this will
+ // result in tags not being properly created if one wishes to create a separate tag per newline.
+ clone.childNodes.forEach(n => n.nodeType==3 && v.push(n.nodeValue))
+ v = v.join("\n")
+
+ try{
+ // "delimiters" might be of a non-regex value, where this will fail ("Tags With Properties" example in demo page):
+ v = v.replace(/(?:\r\n|\r|\n)/g, this.settings.delimiters.source.charAt(0))
+ }
+ catch(err){}
+
+ v = v.replace(/\s/g, ' ') // replace NBSPs with spaces characters
+
+ if( this.settings.trim )
+ v = v.replace(/^\s+/, '') // trimLeft
+
+ return v
+ },
+
+ /**
+ * suggest the rest of the input's value (via CSS "::after" using "content:attr(...)")
+ * @param {String} s [description]
+ */
+ autocomplete : {
+ suggest( data ){
+ if( !this.settings.autoComplete.enabled ) return;
+
+ data = data || {}
+
+ if( typeof data == 'string' )
+ data = {value:data}
+
+ var suggestedText = data.value ? ''+data.value : '',
+ suggestionStart = suggestedText.substr(0, this.state.inputText.length).toLowerCase(),
+ suggestionTrimmed = suggestedText.substring(this.state.inputText.length);
+
+ if( !suggestedText || !this.state.inputText || suggestionStart != this.state.inputText.toLowerCase() ){
+ this.DOM.input.removeAttribute("data-suggest");
+ delete this.state.inputSuggestion
+ }
+ else{
+ this.DOM.input.setAttribute("data-suggest", suggestionTrimmed);
+ this.state.inputSuggestion = data
+ }
+ },
+
+ /**
+ * sets the suggested text as the input's value & cleanup the suggestion autocomplete.
+ * @param {String} s [text]
+ */
+ set( s ){
+ var dataSuggest = this.DOM.input.getAttribute('data-suggest'),
+ suggestion = s || (dataSuggest ? this.state.inputText + dataSuggest : null);
+
+ if( suggestion ){
+ if( this.settings.mode == 'mix' ){
+ this.replaceTextWithNode( document.createTextNode(this.state.tag.prefix + suggestion) )
+ }
+ else{
+ this.input.set.call(this, suggestion);
+ this.setRangeAtStartEnd()
+ }
+
+ this.input.autocomplete.suggest.call(this);
+ this.dropdown.hide.call(this);
+
+ return true;
+ }
+
+ return false;
+ }
+ }
+ },
+
+ /**
+ * returns the index of the the tagData within the "this.value" array collection.
+ * since values should be unique, it is suffice to only search by "value" property
+ * @param {Object} tagData
+ */
+ getTagIdx( tagData ){
+ return this.value.findIndex(item => item.value == tagData.value )
+ },
+
+ getNodeIndex( node ){
+ var index = 0;
+
+ if( node )
+ while( (node = node.previousElementSibling) )
+ index++;
+
+ return index;
+ },
+
+ getTagElms( ...classess ){
+ var classname = ['.' + this.settings.classNames.tag, ...classess].join('.')
+ return [].slice.call(this.DOM.scope.querySelectorAll(classname)) // convert nodeList to Array - https://stackoverflow.com/a/3199627/104380
+ },
+
+ /**
+ * gets the last non-readonly, not-in-the-proccess-of-removal tag
+ */
+ getLastTag(){
+ var lastTag = this.DOM.scope.querySelectorAll(`.${this.settings.classNames.tag}:not(.${this.settings.classNames.tagHide}):not([readonly])`);
+ return lastTag[lastTag.length - 1];
+ },
+
+ /** Setter/Getter
+ * Each tag DOM node contains a custom property called "__tagifyTagData" which hosts its data
+ * @param {Node} tagElm
+ * @param {Object} data
+ */
+ tagData(tagElm, data, override){
+ if( !tagElm ){
+ console.warn("tag elment doesn't exist",tagElm, data)
+ return data
+ }
+
+ if( data )
+ tagElm.__tagifyTagData = override
+ ? data
+ : extend({}, tagElm.__tagifyTagData || {}, data)
+
+ return tagElm.__tagifyTagData
+ },
+
+ /**
+ * Searches if any tag with a certain value already exis
+ * @param {String/Object} v [text value / tag data object]
+ * @return {Boolean}
+ */
+ isTagDuplicate( value, caseSensitive ){
+ var duplications,
+ _s = this.settings;
+
+ // duplications are irrelevant for this scenario
+ if( _s.mode == 'select' )
+ return false
+
+ duplications = this.value.reduce((acc, item) =>
+ sameStr( this.trim(""+value), item.value, caseSensitive || _s.dropdown.caseSensitive )
+ ? acc+1
+ : acc
+ , 0)
+
+ return duplications
+ },
+
+ getTagIndexByValue( value ){
+ var indices = [];
+
+ this.getTagElms().forEach((tagElm, i) => {
+ if( sameStr( this.trim(tagElm.textContent), value, this.settings.dropdown.caseSensitive ) )
+ indices.push(i)
+ })
+
+ return indices;
+ },
+
+ getTagElmByValue( value ){
+ var tagIdx = this.getTagIndexByValue(value)[0]
+ return this.getTagElms()[tagIdx]
+ },
+
+ /**
+ * Temporarily marks a tag element (by value or Node argument)
+ * @param {Object} tagElm [a specific "tag" element to compare to the other tag elements siblings]
+ */
+ flashTag( tagElm ){
+ if( tagElm ){
+ tagElm.classList.add(this.settings.classNames.tagFlash)
+ setTimeout(() => { tagElm.classList.remove(this.settings.classNames.tagFlash) }, 100)
+ }
+ },
+
+ /**
+ * checks if text is in the blacklist
+ */
+ isTagBlacklisted( v ){
+ v = this.trim(v.toLowerCase());
+ return this.settings.blacklist.filter(x => (""+x).toLowerCase() == v).length;
+ },
+
+ /**
+ * checks if text is in the whitelist
+ */
+ isTagWhitelisted( v ){
+ return !!this.getWhitelistItem(v)
+ /*
+ return this.settings.whitelist.some(item =>
+ typeof v == 'string'
+ ? sameStr(this.trim(v), (item.value || item))
+ : sameStr(JSON.stringify(item), JSON.stringify(v))
+ )
+ */
+ },
+
+ /**
+ * Returns the first whitelist item matched, by value (if match found)
+ * @param {String} value [text to match by]
+ */
+ getWhitelistItem( value, prop, whitelist ){
+ var result,
+ prop = prop || 'value',
+ _s = this.settings,
+ whitelist = whitelist || _s.whitelist,
+ _cs = _s.dropdown.caseSensitive;
+
+ whitelist.some(_wi => {
+ var _wiv = typeof _wi == 'string' ? _wi : _wi[prop],
+ isSameStr = sameStr(_wiv, value, _cs)
+
+ if( isSameStr ){
+ result = typeof _wi == 'string' ? {value:_wi} : _wi
+ return true
+ }
+ })
+
+ // first iterate the whitelist, try find maches by "value" and if that fails
+ // and a "tagTextProp" is set to be other than "value", try that also
+ if( !result && prop == 'value' && _s.tagTextProp != 'value' ){
+ // if found, adds the first which matches
+ result = this.getWhitelistItem(value, _s.tagTextProp, whitelist)
+ }
+
+ return result
+ },
+
+ /**
+ * validate a tag object BEFORE the actual tag will be created & appeneded
+ * @param {String} s
+ * @param {String} uid [unique ID, to not inclue own tag when cheking for duplicates]
+ * @return {Boolean/String} ["true" if validation has passed, String for a fail]
+ */
+ validateTag( tagData ){
+ var _s = this.settings,
+ // when validating a tag in edit-mode, need to take "tagTextProp" into consideration
+ prop = "value" in tagData ? "value" : _s.tagTextProp,
+ v = this.trim(tagData[prop] + "");
+
+ // check for definitive empty value
+ if( !(tagData[prop]+"").trim() )
+ return this.TEXTS.empty;
+
+ // check if pattern should be used and if so, use it to test the value
+ if( _s.pattern && _s.pattern instanceof RegExp && !(_s.pattern.test(v)) )
+ return this.TEXTS.pattern;
+
+ // if duplicates are not allowed and there is a duplicate
+ if( !_s.duplicates && this.isTagDuplicate(v, this.state.editing) )
+ return this.TEXTS.duplicate;
+
+ if( this.isTagBlacklisted(v) || (_s.enforceWhitelist && !this.isTagWhitelisted(v)) )
+ return this.TEXTS.notAllowed;
+
+ return true
+ },
+
+ getInvalidTagAttrs(tagData, validation){
+ return {
+ "aria-invalid" : true,
+ "class": `${tagData.class || ''} ${this.settings.classNames.tagNotAllowed}`.trim(),
+ "title": validation
+ }
+ },
+
+ hasMaxTags(){
+ if( this.value.length >= this.settings.maxTags )
+ return this.TEXTS.exceed;
+ return false;
+ },
+
+ setReadonly( isReadonly ){
+ var _s = this.settings;
+
+ document.activeElement.blur() // exists possible edit-mode
+ _s.readonly = isReadonly
+ this.DOM.scope[(isReadonly?'set':'remove') + 'Attribute']('readonly', true)
+
+ if( _s.mode == 'mix' ){
+ this.DOM.input.contentEditable = !isReadonly
+ }
+ },
+
+ /**
+ * pre-proccess the tagsItems, which can be a complex tagsItems like an Array of Objects or a string comprised of multiple words
+ * so each item should be iterated on and a tag created for.
+ * @return {Array} [Array of Objects]
+ */
+ normalizeTags( tagsItems ){
+ var {whitelist, delimiters, mode, tagTextProp, enforceWhitelist} = this.settings,
+ whitelistMatches = [],
+ whitelistWithProps = whitelist ? whitelist[0] instanceof Object : false,
+ // checks if this is a "collection", meanning an Array of Objects
+ isArray = tagsItems instanceof Array,
+ mapStringToCollection = s => (s+"").split(delimiters).filter(n => n).map(v => ({ [tagTextProp]:this.trim(v) }))
+
+ if( typeof tagsItems == 'number' )
+ tagsItems = tagsItems.toString()
+
+ // if the argument is a "simple" String, ex: "aaa, bbb, ccc"
+ if( typeof tagsItems == 'string' ){
+ if( !tagsItems.trim() ) return [];
+
+ // go over each tag and add it (if there were multiple ones)
+ tagsItems = mapStringToCollection(tagsItems)
+ }
+
+ // is is an Array of Strings, convert to an Array of Objects
+ else if( isArray ){
+ // flatten the 2D array
+ tagsItems = [].concat(...tagsItems.map(item => item.value
+ ? item // mapStringToCollection(item.value).map(newItem => ({...item,...newItem}))
+ : mapStringToCollection(item)
+ ))
+ }
+
+ // search if the tag exists in the whitelist as an Object (has props),
+ // to be able to use its properties
+ if( whitelistWithProps ){
+ tagsItems.forEach(item => {
+ var whitelistMatchesValues = whitelistMatches.map(a=>a.value)
+
+ // if suggestions are shown, they are already filtered, so it's easier to use them,
+ // because the whitelist might also include items which have already been added
+ var filteredList = this.dropdown.filterListItems.call(this, item[tagTextProp], { exact:true })
+ // also filter out items which have already been matches in previous iterations
+ .filter(filteredItem => !whitelistMatchesValues.includes(filteredItem.value))
+
+ // get the best match out of list of possible matches.
+ // if there was a single item in the filtered list, use that one
+ var matchObj = filteredList.length > 1
+ ? this.getWhitelistItem(item[tagTextProp], tagTextProp, filteredList)
+ : filteredList[0]
+
+ if( matchObj && matchObj instanceof Object ){
+ whitelistMatches.push( matchObj ) // set the Array (with the found Object) as the new value
+ }
+ else if( mode != 'mix' && !enforceWhitelist ){
+ if( item.value == undefined )
+ item.value = item[tagTextProp]
+ whitelistMatches.push(item)
+ }
+ })
+
+ // if( whitelistMatches.length )
+ tagsItems = whitelistMatches
+ }
+
+ return tagsItems;
+ },
+
+ /**
+ * Parse the initial value of a textarea (or input) element and generate mixed text w/ tags
+ * https://stackoverflow.com/a/57598892/104380
+ * @param {String} s
+ */
+ parseMixTags( s ){
+ var {mixTagsInterpolator, duplicates, transformTag, enforceWhitelist, maxTags, tagTextProp} = this.settings,
+ tagsDataSet = [];
+
+ s = s.split(mixTagsInterpolator[0]).map((s1, i) => {
+ var s2 = s1.split(mixTagsInterpolator[1]),
+ preInterpolated = s2[0],
+ maxTagsReached = tagsDataSet.length == maxTags,
+ textProp,
+ tagData,
+ tagElm;
+
+ try{
+ // skip numbers and go straight to the "catch" statement
+ if( preInterpolated == +preInterpolated )
+ throw Error
+ tagData = JSON.parse(preInterpolated)
+ } catch(err){
+ tagData = this.normalizeTags(preInterpolated)[0] //{value:preInterpolated}
+ }
+
+ if( !maxTagsReached &&
+ s2.length > 1 &&
+ (!enforceWhitelist || this.isTagWhitelisted(tagData.value)) &&
+ !(!duplicates && this.isTagDuplicate(tagData.value)) ){
+ transformTag.call(this, tagData)
+
+ // in case "tagTextProp" setting is set to other than "value" and this tag does not have this prop
+ textProp = tagData[tagTextProp] ? tagTextProp : 'value'
+ tagData[textProp] = this.trim(tagData[textProp])
+
+ tagElm = this.createTagElem(tagData)
+ tagsDataSet.push( tagData )
+ tagElm.classList.add(this.settings.classNames.tagNoAnimation)
+
+ s2[0] = tagElm.outerHTML //+ "" // put a zero-space at the end so the caret won't jump back to the start (when the last input's child element is a tag)
+ this.value.push(tagData)
+ }
+ else if(s1)
+ return i ? mixTagsInterpolator[0] + s1 : s1
+
+ return s2.join('')
+ }).join('')
+
+ this.DOM.input.innerHTML = s
+ this.DOM.input.appendChild(document.createTextNode(''))
+ this.DOM.input.normalize()
+ this.getTagElms().forEach((elm, idx) => this.tagData(elm, tagsDataSet[idx]))
+ this.update({withoutChangeEvent:true})
+ return s
+ },
+
+ /**
+ * For mixed-mode: replaces a text starting with a prefix with a wrapper element (tag or something)
+ * First there *has* to be a "this.state.tag" which is a string that was just typed and is staring with a prefix
+ */
+ replaceTextWithNode( newWrapperNode, strToReplace ){
+ if( !this.state.tag && !strToReplace ) return;
+
+ strToReplace = strToReplace || this.state.tag.prefix + this.state.tag.value;
+ var idx, nodeToReplace,
+ selection = window.getSelection(),
+ nodeAtCaret = selection.anchorNode,
+ firstSplitOffset = this.state.tag.delimiters ? this.state.tag.delimiters.length : 0;
+
+ // STEP 1: ex. replace #ba with the tag "bart" where "|" is where the caret is:
+ // CURRENT STATE: "foo #ba #ba| #ba"
+
+ // split the text node at the index of the caret
+ nodeAtCaret.splitText(selection.anchorOffset - firstSplitOffset)
+
+ // node 0: "foo #ba #ba|"
+ // node 1: " #ba"
+
+ // get index of LAST occurence of "#ba"
+ idx = nodeAtCaret.nodeValue.lastIndexOf(strToReplace)
+
+ nodeToReplace = nodeAtCaret.splitText(idx)
+
+ // node 0: "foo #ba "
+ // node 1: "#ba" <- nodeToReplace
+
+ newWrapperNode && nodeAtCaret.parentNode.replaceChild(newWrapperNode, nodeToReplace)
+
+ // must NOT normalize contenteditable or it will cause unwanetd issues:
+ // https://monosnap.com/file/ZDVmRvq5upYkidiFedvrwzSswegWk7
+ // nodeAtCaret.parentNode.normalize()
+
+ return true;
+ },
+
+ /**
+ * For selecting a single option (not used for multiple tags, but for "mode:select" only)
+ * @param {Object} tagElm Tag DOM node
+ * @param {Object} tagData Tag data
+ */
+ selectTag( tagElm, tagData ){
+ if( this.settings.enforceWhitelist && !this.isTagWhitelisted(tagData.value) )
+ return
+
+ this.input.set.call(this, tagData.value, true)
+
+ // place the caret at the end of the input, only if a dropdown option was selected (and not by manually typing another value and clicking "TAB")
+ if( this.state.actions.selectOption )
+ setTimeout(this.setRangeAtStartEnd.bind(this))
+
+ if( this.getLastTag() )
+ this.replaceTag(this.getLastTag(), tagData)
+ else
+ this.appendTag(tagElm)
+
+ this.value[0] = tagData
+ this.trigger('add', { tag:tagElm, data:tagData })
+ this.update()
+
+ return [tagElm]
+ },
+
+ /**
+ * add an empty "tag" element in an editable state
+ */
+ addEmptyTag( initialData ){
+ var tagData = extend({ value:"" }, initialData || {}),
+ tagElm = this.createTagElem(tagData)
+
+ this.tagData(tagElm, tagData)
+
+ // add the tag to the component's DOM
+ this.appendTag(tagElm)
+ this.editTag(tagElm, {skipValidation:true})
+ },
+
+ /**
+ * add a "tag" element to the "tags" component
+ * @param {String/Array} tagsItems [A string (single or multiple values with a delimiter), or an Array of Objects or just Array of Strings]
+ * @param {Boolean} clearInput [flag if the input's value should be cleared after adding tags]
+ * @param {Boolean} skipInvalid [do not add, mark & remove invalid tags]
+ * @return {Array} Array of DOM elements (tags)
+ */
+ addTags( tagsItems, clearInput, skipInvalid = this.settings.skipInvalid ){
+ var tagElems = [], _s = this.settings;
+
+ if( !tagsItems || tagsItems.length == 0 ){
+ // is mode is "select" clean all tags
+ if( _s.mode == 'select' )
+ this.removeAllTags()
+ return tagElems
+ }
+
+ // converts Array/String/Object to an Array of Objects
+ tagsItems = this.normalizeTags(tagsItems)
+
+ if( _s.mode == 'mix' ){
+ return this.addMixTags(tagsItems)
+ }
+
+ if( _s.mode == 'select' )
+ clearInput = false
+
+ this.DOM.input.removeAttribute('style')
+
+ tagsItems.forEach(tagData => {
+ var tagElm,
+ tagElmParams = {},
+ originalData = Object.assign({}, tagData, {value:tagData.value+""});
+
+ // shallow-clone tagData so later modifications will not apply to the source
+ tagData = Object.assign({}, originalData)
+ tagData.__isValid = this.hasMaxTags() || this.validateTag(tagData)
+
+ _s.transformTag.call(this, tagData)
+
+ if( tagData.__isValid !== true ){
+ if( skipInvalid )
+ return
+
+ // originalData is kept because it might be that this tag is invalid because it is a duplicate of another,
+ // and if that other tags is edited/deleted, this one should be re-validated and if is no more a duplicate - restored
+ extend(tagElmParams, this.getInvalidTagAttrs(tagData, tagData.__isValid), {__preInvalidData:originalData})
+
+ if( tagData.__isValid == this.TEXTS.duplicate )
+ // mark, for a brief moment, the tag (this this one) which THIS CURRENT tag is a duplcate of
+ this.flashTag( this.getTagElmByValue(tagData.value) )
+ }
+ /////////////////////////////////////////////////////
+
+
+ if( tagData.readonly )
+ tagElmParams["aria-readonly"] = true
+
+ // Create tag HTML element
+ tagElm = this.createTagElem( extend({}, tagData, tagElmParams) )
+ tagElems.push(tagElm)
+
+ // mode-select overrides
+ if( _s.mode == 'select' ){
+ return this.selectTag(tagElm, tagData)
+ }
+
+ // add the tag to the component's DOM
+ this.appendTag(tagElm)
+
+ if( tagData.__isValid && tagData.__isValid === true ){
+ // update state
+ this.value.push(tagData)
+ this.update()
+ this.trigger('add', {tag:tagElm, index:this.value.length - 1, data:tagData})
+ }
+ else{
+ this.trigger("invalid", {data:tagData, index:this.value.length, tag:tagElm, message:tagData.__isValid})
+ if( !_s.keepInvalidTags )
+ // remove invalid tags (if "keepInvalidTags" is set to "false")
+ setTimeout(() => this.removeTags(tagElm, true), 1000)
+ }
+
+ this.dropdown.position.call(this) // reposition the dropdown because the just-added tag might cause a new-line
+ })
+
+ if( tagsItems.length && clearInput ){
+ this.input.set.call(this);
+ }
+
+ this.dropdown.refilter.call(this)
+ return tagElems
+ },
+
+ /**
+ * Adds a mix-content tag
+ * @param {String/Array} tagsItems [A string (single or multiple values with a delimiter), or an Array of Objects or just Array of Strings]
+ */
+ addMixTags( tagsItems ){
+ var _s = this.settings,
+ tagElm,
+ createdFromDelimiters = this.state.tag.delimiters
+
+ _s.transformTag.call(this, tagsItems[0])
+
+ tagsItems[0].prefix = tagsItems[0].prefix || this.state.tag ? this.state.tag.prefix : (_s.pattern.source||_s.pattern)[0];
+
+ // TODO: should check if the tag is valid
+ tagElm = this.createTagElem(tagsItems[0])
+
+ // tries to replace a taged textNode with a tagElm, and if not able,
+ // insert the new tag to the END if "addTags" was called from outside
+ if( !this.replaceTextWithNode(tagElm) ){
+ this.DOM.input.appendChild(tagElm)
+ }
+
+ setTimeout(()=> tagElm.classList.add(this.settings.classNames.tagNoAnimation), 300)
+
+ this.value.push(tagsItems[0])
+ this.update()
+
+ // fixes a firefox bug where if the last child of the input is a tag and not a text, the input cannot get focus (by Tab key)
+ !createdFromDelimiters && setTimeout(() => {
+ var elm = this.insertAfterTag(tagElm) || tagElm;
+ this.placeCaretAfterNode(elm)
+ }, this.isFirefox ? 100 : 0)
+
+ this.state.tag = null
+ this.trigger('add', extend({}, {tag:tagElm}, {data:tagsItems[0]}))
+
+ return tagElm
+ },
+
+ /**
+ * appened (validated) tag to the component's DOM scope
+ */
+ appendTag(tagElm){
+ var insertBeforeNode = this.DOM.scope.lastElementChild;
+
+ if( insertBeforeNode === this.DOM.input )
+ this.DOM.scope.insertBefore(tagElm, insertBeforeNode);
+ else
+ this.DOM.scope.appendChild(tagElm);
+ },
+
+ /**
+ * creates a DOM tag element and injects it into the component (this.DOM.scope)
+ * @param {Object} tagData [text value & properties for the created tag]
+ * @return {Object} [DOM element]
+ */
+ createTagElem( tagData ){
+ var tagElm,
+ templateData = extend({}, tagData, { value:escapeHTML(tagData.value+"") });
+
+ if( this.settings.readonly )
+ tagData.readonly = true
+
+ tagElm = this.parseTemplate('tag', [templateData])
+
+ // crucial for proper caret placement when deleting content. if textNodes are allowed as children of
+ // a tag element, a browser bug casues the caret to misplaced inside the tag element (especcially affects "readonly" tags)
+ removeTextChildNodes(tagElm)
+
+ // while( tagElm.lastChild.nodeType == 3 )
+ // tagElm.lastChild.parentNode.removeChild(tagElm.lastChild)
+
+ this.tagData(tagElm, tagData)
+
+ return tagElm;
+ },
+
+ /**
+ * find all invalid tags and re-check them
+ */
+ reCheckInvalidTags(){
+ var _s = this.settings,
+ selector = `.${_s.classNames.tag}.${_s.classNames.tagNotAllowed}`,
+ tagElms = this.DOM.scope.querySelectorAll(selector);
+
+ [].forEach.call(tagElms, node => {
+ var tagData = this.tagData(node),
+ wasNodeDuplicate = node.getAttribute('title') == this.TEXTS.duplicate,
+ isNodeValid = this.validateTag(tagData) === true;
+
+ // if this tag node was marked as a dulpicate, unmark. (might have been marked as "notAllowed" for other reasons)
+ if( wasNodeDuplicate && isNodeValid ){
+ if( tagData.__preInvalidData )
+ tagData = tagData.__preInvalidData
+ else
+ // start fresh
+ tagData = {value:tagData.value}
+
+ this.replaceTag(node, tagData)
+ }
+ })
+ },
+
+ /**
+ * Removes a tag
+ * @param {Array|Node|String} tagElms [DOM element(s) or a String value. if undefined or null, remove last added tag]
+ * @param {Boolean} silent [A flag, which when turned on, does not remove any value and does not update the original input value but simply removes the tag from tagify]
+ * @param {Number} tranDuration [Transition duration in MS]
+ * TODO: Allow multiple tags to be removed at-once
+ */
+ removeTags( tagElms, silent, tranDuration ){
+ var tagsToRemove;
+
+ tagElms = tagElms && tagElms instanceof HTMLElement
+ ? [tagElms]
+ : tagElms instanceof Array
+ ? tagElms
+ : tagElms
+ ? [tagElms]
+ : [this.getLastTag()]
+
+ // normalize tagElms array values:
+ // 1. removing invalid items
+ // 2, if an item is String try to get the matching Tag HTML node
+ // 3. get the tag data
+ // 4. return a collection of Objects
+ tagsToRemove = tagElms.reduce((elms, tagElm) => {
+ if( tagElm && typeof tagElm == 'string')
+ tagElm = this.getTagElmByValue(tagElm)
+
+ if( tagElm )
+ // because the DOM node might be removed by async animation, the state will be updated while
+ // the node might still be in the DOM, so the "update" method should know which nodes to ignore
+ elms.push({
+ node: tagElm,
+ idx: this.getTagIdx(this.tagData(tagElm)), // this.getNodeIndex(tagElm); // this.getTagIndexByValue(tagElm.textContent)
+ data: this.tagData(tagElm, {'__removed':true})
+ })
+
+ return elms
+ }, [])
+
+ tranDuration = typeof tranDuration == "number" ? tranDuration : this.CSSVars.tagHideTransition
+
+ if( this.settings.mode == 'select' ){
+ tranDuration = 0;
+ this.input.set.call(this)
+ }
+
+ // if only a single tag is to be removed
+ if( tagsToRemove.length == 1 ){
+ if( tagsToRemove[0].node.classList.contains(this.settings.classNames.tagNotAllowed) )
+ silent = true
+ }
+
+ if( !tagsToRemove.length )
+ return;
+
+ this.settings.hooks.beforeRemoveTag(tagsToRemove, {tagify:this})
+ .then(() => {
+ function removeNode( tag ){
+ if( !tag.node.parentNode ) return
+
+ tag.node.parentNode.removeChild(tag.node)
+
+ if( !silent ){
+ // this.removeValueById(tagData.__uid)
+ this.trigger('remove', { tag:tag.node, index:tag.idx, data:tag.data })
+ this.dropdown.refilter.call(this)
+ this.dropdown.position.call(this)
+ this.DOM.input.normalize() // best-practice when in mix-mode (safe to do always anyways)
+
+ // check if any of the current tags which might have been marked as "duplicate" should be now un-marked
+ if( this.settings.keepInvalidTags )
+ this.reCheckInvalidTags()
+ }
+ else if( this.settings.keepInvalidTags )
+ this.trigger('remove', { tag:tag.node, index:tag.idx })
+ }
+
+ function animation( tag ){
+ tag.node.style.width = parseFloat(window.getComputedStyle(tag.node).width) + 'px'
+ document.body.clientTop // force repaint for the width to take affect before the "hide" class below
+ tag.node.classList.add(this.settings.classNames.tagHide)
+
+ // manual timeout (hack, since transitionend cannot be used because of hover)
+ setTimeout(removeNode.bind(this), tranDuration, tag)
+ }
+
+ if( tranDuration && tranDuration > 10 && tagsToRemove.length == 1 )
+ animation.call(this, tagsToRemove[0])
+ else
+ tagsToRemove.forEach(removeNode.bind(this))
+
+ // update state regardless of animation
+ if( !silent ){
+ tagsToRemove.forEach(tag => {
+ // remove "__removed" so the comparison in "getTagIdx" could work
+ var tagData = Object.assign({}, tag.data) // shallow clone
+ delete tagData.__removed
+
+ var tagIdx = this.getTagIdx(tagData)
+ if( tagIdx > -1 )
+ this.value.splice(tagIdx, 1)
+ })
+
+ // that.removeValueById(tagData.__uid)
+ this.update() // update the original input with the current value
+ }
+ }
+ )
+ .catch(reason => {})
+ },
+
+ removeAllTags(){
+ this.value = []
+
+ if( this.settings.mode == 'mix' )
+ this.DOM.input.innerHTML = ''
+ else
+ Array.prototype.slice.call(this.getTagElms()).forEach(elm => elm.parentNode.removeChild(elm))
+
+ this.dropdown.position.call(this)
+
+ if( this.settings.mode == 'select' )
+ this.input.set.call(this)
+
+ this.update()
+ },
+
+ postUpdate(){
+ var classNames = this.settings.classNames,
+ hasValue = this.settings.mode == 'mix'
+ ? this.settings.mixMode.integrated
+ ? this.DOM.input.textContent
+ : this.DOM.originalInput.value
+ : this.value.length;
+
+ this.toggleClass(classNames.hasMaxTags, this.value.length >= this.settings.maxTags)
+ this.toggleClass(classNames.hasNoTags, !this.value.length)
+ this.toggleClass(classNames.empty, !hasValue)
+ },
+
+ /**
+ * update the origianl (hidden) input field's value
+ * see - https://stackoverflow.com/q/50957841/104380
+ */
+ update( args ){
+ var inputElm = this.DOM.originalInput,
+ { withoutChangeEvent } = args || {},
+ value = removeCollectionProp(this.value, ['__isValid', '__removed']);
+
+ if( !this.settings.mixMode.integrated ){
+ inputElm.value = this.settings.mode == 'mix'
+ ? this.getMixedTagsAsString(value)
+ : value.length
+ ? this.settings.originalInputValueFormat
+ ? this.settings.originalInputValueFormat(value)
+ : JSON.stringify(value)
+ : ""
+ }
+
+ this.postUpdate()
+
+ if( !withoutChangeEvent && this.state.loadedOriginalValues )
+ this.triggerChangeEvent()
+ },
+
+ getMixedTagsAsString(){
+ var result = "",
+ that = this,
+ i = 0,
+ _interpolator = this.settings.mixTagsInterpolator;
+
+ function iterateChildren(rootNode){
+ rootNode.childNodes.forEach((node) => {
+ if( node.nodeType == 1 ){
+ if( node.classList.contains(that.settings.classNames.tag) && that.tagData(node) ){
+ if( that.tagData(node).__removed )
+ return;
+ else
+ result += _interpolator[0] + JSON.stringify( node.__tagifyTagData ) + _interpolator[1]
+ return
+ }
+
+ if( node.tagName == 'BR' && (node.parentNode == that.DOM.input || node.parentNode.childNodes.length == 1 ) ){
+ result += "\r\n";
+ }
+
+ else if( node.tagName == 'DIV' || node.tagName == 'P' ){
+ result += "\r\n";
+ iterateChildren(node)
+ }
+ }
+ else
+ result += node.textContent;
+ })
+ }
+
+ iterateChildren(this.DOM.input)
+
+ return result;
+ }
+}
+
+// legacy support for changed methods names
+Tagify.prototype.removeTag = Tagify.prototype.removeTags
+
+export default Tagify
\ No newline at end of file
diff --git a/node_modules/@yaireo/tagify/src/tagify.polyfills.js b/node_modules/@yaireo/tagify/src/tagify.polyfills.js
new file mode 100644
index 0000000..d0cd953
--- /dev/null
+++ b/node_modules/@yaireo/tagify/src/tagify.polyfills.js
@@ -0,0 +1,12 @@
+import "./polyfills/String.trim"
+import "./polyfills/NodeList.forEach"
+import "./polyfills/Array.findIndex"
+import "./polyfills/Array.some"
+import "./polyfills/String.includes"
+import "./polyfills/Object.assign"
+import "./polyfills/Event"
+import "./polyfills/Element.matches"
+import "./polyfills/Element.closest"
+import "./polyfills/AutoUrlDetect"
+import "./polyfills/Element.classList"
+import "./polyfills/es6-promise"
\ No newline at end of file
diff --git a/node_modules/@yaireo/tagify/src/tagify.scss b/node_modules/@yaireo/tagify/src/tagify.scss
new file mode 100644
index 0000000..dd972a9
--- /dev/null
+++ b/node_modules/@yaireo/tagify/src/tagify.scss
@@ -0,0 +1,674 @@
+:root {
+ --tagify-dd-color-primary: rgb(53,149,246); // should be same as "$tags-focus-border-color"
+ --tagify-dd-bg-color: white;
+}
+
+.tagify{
+ // SCSS "default" allows overriding variables BEFORE they are set in the below lines of code
+ $self: &;
+ $tags-border-color : #DDD !default;
+ $tags-hover-border-color : #CCC !default;
+ $tags-focus-border-color : #3595f6 !default;
+ $tagMargin : 5px !default;
+ $tag-pad : .3em .5em !default;
+ $tag-min-width : 1ch !default;
+ $tag-max-width : auto !default;
+ $tag-text-color : black !default;
+ $tag-text-color--edit : black !default;
+ $tag-bg : #E5E5E5 !default;
+ $tag-hover : #D3E2E2 !default;
+ $tag-remove : #D39494 !default;
+ $tag-remove-btn-color : $tag-text-color !default;
+ $tag-remove-btn-bg : none !default;
+ $tag-remove-btn-bg--hover: darken($tag-remove, 8) !default;
+ $tag-invalid-color : $tag-remove !default;
+ $tag-invalid-bg : rgba($tag-remove, .5) !default;
+ $tag-inset-shadow-size : 1.1em !default;
+ $tag-hide-transition : .3s !default;
+ $placeholder-color : rgba($tag-text-color, .4) !default;
+ $placeholder-color-focus : rgba($tag-text-color, .25) !default;
+ $input-color : inherit !default;
+ $tagify-dd-bg-color : white !default;
+ $tagify-dd-color-primary : rgb(53,149,246) !default;
+
+ // CSS variables
+ --tags-border-color : #{$tags-border-color};
+ --tags-hover-border-color : #{$tags-hover-border-color};
+ --tags-focus-border-color : #{$tags-focus-border-color};
+ --tag-bg : #{$tag-bg};
+ --tag-hover : #{$tag-hover};
+ --tag-text-color : #{$tag-text-color};
+ --tag-text-color--edit : #{$tag-text-color--edit};
+ --tag-pad : #{$tag-pad};
+ --tag-inset-shadow-size : #{$tag-inset-shadow-size};
+ --tag-invalid-color : #{$tag-invalid-color};
+ --tag-invalid-bg : #{$tag-invalid-bg};
+ --tag-remove-bg : #{rgba($tag-remove, .3)};
+ --tag-remove-btn-color : #{$tag-remove-btn-color};
+ --tag-remove-btn-bg : #{$tag-remove-btn-bg};
+ --tag-remove-btn-bg--hover : #{$tag-remove-btn-bg--hover};
+ --input-color : #{$input-color};
+ --tag--min-width : #{$tag-min-width};
+ --tag--max-width : #{$tag-max-width};
+ --tag-hide-transition : #{$tag-hide-transition};
+ --placeholder-color : #{$placeholder-color};
+ --placeholder-color-focus : #{$placeholder-color-focus};
+ --loader-size : .8em;
+
+ @mixin firefox {
+ @at-root {
+ @-moz-document url-prefix() {
+ & { @content; }
+ }
+ }
+ }
+
+ @mixin placeholder( $show:true ){
+ transition: .2s ease-out;
+
+ @if $show == true {
+ opacity: 1;
+ transform: none;
+ }
+ @else {
+ opacity: 0;
+ transform: translatex(6px);
+ }
+ }
+
+ @mixin loader(){
+ content: '';
+ vertical-align: middle;
+ opacity: 1;
+ width: .7em;
+ height: .7em;
+ width: var(--loader-size);
+ height: var(--loader-size);
+ border: 3px solid;
+ border-color: #EEE #BBB #888 transparent;
+ border-radius: 50%;
+ animation: rotateLoader .4s infinite linear;
+ }
+
+ @mixin tagReadonlyBG{
+ background: linear-gradient(45deg, var(--tag-bg) 25%,
+ transparent 25%,
+ transparent 50%,
+ var(--tag-bg) 50%,
+ var(--tag-bg) 75%,
+ transparent 75%,
+ transparent) 0/5px 5px;
+ box-shadow: none;
+ filter: brightness(.95);
+ }
+
+ @keyframes tags--bump{
+ 30% { transform: scale(1.2); }
+ }
+
+ @keyframes rotateLoader {
+ to{ transform: rotate(1turn) }
+ }
+
+ display : flex;
+ align-items : flex-start;
+ flex-wrap : wrap;
+ border : 1px solid $tags-border-color;
+ border : 1px solid var(--tags-border-color);
+ padding : 0;
+ line-height : 1.1;
+ cursor : text;
+ outline : none;
+ position : relative;
+ box-sizing : border-box;
+ transition : .1s;
+
+ &:hover{
+ border-color: $tags-hover-border-color;
+ border-color: var(--tags-hover-border-color);
+ }
+
+ &.tagify--focus{
+ transition: 0s;
+ border-color: $tags-focus-border-color;
+ border-color: var(--tags-focus-border-color);
+ }
+
+ // Global "read-only" mode (no input button)
+ &[readonly]{
+ &:not(.tagify--mix){
+ cursor: default;
+ > #{ $self }__input{
+ visibility: hidden;
+ width: 0;
+ margin: $tagMargin 0;
+ }
+
+ #{ $self }__tag > div{
+ padding: $tag-pad;
+ padding: var(--tag-pad);
+ &::before{
+ @include tagReadonlyBG;
+ }
+ }
+ }
+
+ #{ $self }__tag__removeBtn{ display:none; }
+
+ }
+
+ &--loading{
+ #{ $self }__input{
+ &::before{ content:none; }
+ &::after{
+ @include loader;
+ margin: -2px 0 -2px .5em;
+ }
+ &:empty{
+ &::after{
+ margin-left:0;
+ }
+ }
+ }
+ }
+
+ ///////////////////////////////////////////
+ // Hides originals
+ + input,
+ + textarea{ display:none !important; }
+
+ &__tag{
+ display : inline-flex;
+ align-items: center;
+ margin : $tagMargin 0 $tagMargin $tagMargin;
+ position : relative;
+ z-index : 1;
+ outline : none;
+ cursor : default;
+ transition : .13s ease-out;
+
+ > div{ // :not([contenteditable])
+ vertical-align : top;
+ box-sizing : border-box;
+ max-width : 100%;
+ padding : $tag-pad;
+ padding : var(--tag-pad, $tag-pad);
+ color : $tag-text-color;
+ color : var(--tag-text-color, $tag-text-color);
+ line-height : inherit;
+ border-radius : 3px;
+ // user-select : none; // should allow selecting text if the user wishes to copy something
+ white-space : nowrap;
+ transition : .13s ease-out;
+
+ > *{
+ white-space : pre-wrap;
+ overflow : hidden;
+ text-overflow : ellipsis;
+ display : inline-block;
+ vertical-align : top;
+ min-width : $tag-min-width;
+ max-width : $tag-max-width;
+ min-width : var(--tag--min-width, $tag-min-width);
+ max-width : var(--tag--max-width, $tag-max-width);
+ transition : .8s ease, .1s color;
+
+
+ &[contenteditable]{
+ outline: none;
+ user-select: text;
+ cursor: text;
+ // fix: sometimes the caret after the last character wasn't visible (when setting {backspace:"edit"})
+ margin: -2px;
+ padding: 2px;
+ max-width: 350px;
+ }
+ }
+
+ &::before{
+ content: '';
+ position: absolute;
+ border-radius: inherit;
+ left:0; top:0; right:0; bottom:0;
+ z-index: -1;
+ pointer-events:none;
+ transition: 120ms ease;
+ animation : tags--bump .3s ease-out 1;
+
+ box-shadow: 0 0 0 $tag-inset-shadow-size $tag-bg inset;
+ box-shadow: 0 0 0 var(--tag-inset-shadow-size, $tag-inset-shadow-size) var(--tag-bg, $tag-bg) inset;
+ }
+ }
+
+ &:hover:not([readonly]){
+ div{ // :not([contenteditable])
+ &::before{
+ $size: -$tagMargin/2;
+ $size: -2px;
+ top:$size; right:$size; bottom:$size; left:$size;
+ box-shadow: 0 0 0 $tag-inset-shadow-size $tag-hover inset;
+ box-shadow: 0 0 0 var(--tag-inset-shadow-size, $tag-inset-shadow-size) var(--tag-hover, $tag-hover) inset;
+ // box-shadow: 0 0 0 0 $tag-remove inset
+ }
+ // background:nth($tagColor,2);
+ //background:none;
+ // box-shadow: 0 0 0 2px $tag-hover inset;
+ // transition:50ms;
+ }
+ }
+
+ &--loading{
+ pointer-events: none;
+
+ .tagify__tag__removeBtn{
+ display: none;
+ }
+
+ &::after{
+ --loader-size: .4em;
+ @include loader;
+ margin: 0 .5em 0 -.1em;
+ }
+ }
+
+ &--flash{
+ div::before{ animation:none; }
+ }
+
+ &--hide{
+ width : 0 !important;
+ padding-left : 0;
+ padding-right : 0;
+ margin-left : 0;
+ margin-right : 0;
+ opacity : 0;
+ transform : scale(0);
+ transition : $tag-hide-transition;
+ transition : var(--tag-hide-transition, $tag-hide-transition);
+ pointer-events : none;
+
+ > div > *{
+ white-space: nowrap;
+ }
+ }
+
+ { $self }{
+ &--noAnim{
+ > div::before{
+ animation:none;
+ }
+ }
+
+ &--notAllowed:not(.tagify__tag--editable){
+ div{
+ > span{ opacity:.5; } // filter:blur(.2px);
+ &::before{
+ box-shadow: 0 0 0 $tag-inset-shadow-size $tag-invalid-bg inset !important;
+ box-shadow: 0 0 0 var(--tag-inset-shadow-size, $tag-inset-shadow-size) var(--tag-invalid-bg, $tag-invalid-bg) inset !important;
+ transition: .2s;
+ }
+ }
+ }
+ }
+
+ &[readonly]{
+ #{ $self }__tag__removeBtn{ display:none; }
+ > div{// padding: $tag-pad;
+ &::before{
+ @include tagReadonlyBG;
+ }
+ }
+ }
+
+ &--editable{
+ > div{
+ color : $tag-text-color--edit;
+ color : var(--tag-text-color--edit, $tag-text-color--edit);
+
+ &::before{
+ box-shadow: 0 0 0 2px $tag-hover inset !important;
+ box-shadow: 0 0 0 2px var(--tag-hover, $tag-hover) inset !important;
+ }
+ }
+
+ > #{$self}__tag__removeBtn{
+ pointer-events: none;
+
+ &::after{
+ opacity: 0;
+ transform: translateX(100%) translateX(5px);
+ }
+ }
+
+ &.tagify--invalid{
+ > div{
+ &::before{
+ box-shadow: 0 0 0 2px $tag-invalid-color inset !important;
+ box-shadow: 0 0 0 2px var(--tag-invalid-color, $tag-invalid-color) inset !important;
+ }
+ }
+ }
+ }
+
+ &__removeBtn{
+ $size: 14px;
+
+ order : 5;
+ display : inline-flex;
+ align-items : center;
+ justify-content: center;
+ border-radius : 50px;
+ cursor : pointer;
+ font : #{$size}/1 Arial;
+ background : $tag-remove-btn-bg;
+ background : var(--tag-remove-btn-bg, $tag-remove-btn-bg);
+ color : $tag-remove-btn-color;
+ color : var(--tag-remove-btn-color, $tag-remove-btn-color);
+
+ width : $size;
+ height : $size;
+ margin-right : $size/3;
+ margin-left : -$size/3;
+
+ overflow : hidden;
+ transition : .2s ease-out;
+
+ &::after{
+ content: "\00D7";
+ transition: .3s, color 0s;
+ }
+
+ &:hover{
+ color: white;
+ background: $tag-remove-btn-bg--hover;
+ background: var(--tag-remove-btn-bg--hover, $tag-remove-btn-bg--hover);
+ // + span{ box-shadow: 0 0 0 2px $tag-remove inset; transition:.2s; }
+ + div{
+ > span{ opacity:.5; } // filter:blur(.2px);
+ &::before{
+ box-shadow: 0 0 0 $tag-inset-shadow-size rgba($tag-remove, .3) inset !important;
+ box-shadow: 0 0 0 var(--tag-inset-shadow-size, $tag-inset-shadow-size) var(--tag-remove-bg, rgba($tag-remove, .3)) inset !important;
+ transition: box-shadow .2s;
+ }
+ }
+ }
+ }
+ }
+
+
+ &:not(#{$self}--mix){
+ #{ $self }__input{
+ // https://stackoverflow.com/a/13470210/104380
+ br { display:none; }
+ * { display:inline; white-space:nowrap; }
+ }
+ }
+
+ ///////////////////////////////////////////
+ // Holds the placeholder & the tags input
+ &__input{
+ $placeholder-width : 110px;
+ flex-grow: 1;
+ display: inline-block;
+ min-width: $placeholder-width;
+ margin: $tagMargin;
+ padding: $tag-pad;
+ padding: var(--tag-pad, $tag-pad);
+ line-height: inherit;
+ position: relative;
+ white-space: pre-wrap; // #160 Line break (\n) as delimeter
+ color: $input-color;
+ color: var(--input-color, $input-color);
+ box-sizing: inherit;
+
+ &:empty{
+ @include firefox {
+ // clicking twice on the input (not fast) disallows typing (bug) only when the input has "display:flex".
+ // disabled the below rule for the above reason:
+ // display: flex; // https://bugzilla.mozilla.org/show_bug.cgi?id=904846#c45
+ }
+
+ &::before{
+ @include placeholder;
+ display: inline-block;
+ width: auto;
+
+ #{ $self }--mix &{
+ display: inline-block;
+ }
+ }
+ }
+
+ &:focus{
+ outline:none;
+
+ &::before{
+ @include placeholder(false);
+
+ /* ALL MS BROWSERS: hide placeholder (on focus) otherwise the caret is places after it, which is weird */
+ /* IE10+ CSS styles go here */
+ @media all and (-ms-high-contrast: none), (-ms-high-contrast: active) {
+ display: none;
+ }
+ /* IE Edge 12+ CSS styles go here */
+ @supports ( -ms-ime-align:auto ) {
+ display: none;
+ }
+ }
+
+ &:empty{
+ &::before{
+ @include placeholder(true);
+
+ // Seems to be fixed! no need for the below hack
+ // @include firefox {
+ // // remove ":after" pseudo element: https://bugzilla.mozilla.org/show_bug.cgi?id=904846#c45
+ // content: unset;
+ // // display:inline-block;
+ // }
+
+ color: $placeholder-color-focus;
+ color: var(--placeholder-color-focus);
+ }
+
+ &::after{
+ @include firefox {
+ display: none;
+ }
+ }
+ }
+ }
+
+ &::before{
+ content: attr(data-placeholder);
+ height: 1em;
+ line-height: 1em;
+ margin: auto 0;
+ z-index: 1;
+ color: $placeholder-color;
+ color: var(--placeholder-color);
+ white-space: nowrap;
+ pointer-events: none;
+ opacity: 0;
+ position: absolute;
+
+ #{$self}--mix &{
+ display: none;
+ position: static;
+ line-height: inherit;
+ }
+ }
+/* Seems firefox newer versions don't need this any more
+ @supports ( -moz-appearance:none ){
+ &::before{
+ line-height: inherit;
+ position:relative;
+ }
+ }
+*/
+ // tries to suggest the rest of the value from the first item in the whitelist which matches it
+ &::after{
+ content: attr(data-suggest);
+ display: inline-block;
+ white-space: pre; /* allows spaces at the beginning */
+ color: $tag-text-color;
+ opacity: .3;
+ pointer-events:none;
+ max-width: 100px;
+ }
+
+ // &--invalid{
+ // // color: $invalid-input-color;
+ // }
+
+ /*
+ in "mix mode" the tags are inside the "input" element
+ */
+ #{ $self }__tag{
+ margin: 0; // a developer can choose to have automatic horizontal margin ("1ch" advised) between tags or use manual keyboard spaces
+ // line-height: 1.1;
+
+ > div{
+ padding-top:0; padding-bottom:0;
+ }
+ }
+ }
+
+ &--mix {
+ display: block; // display:flex makes Chrome generates
when pressing ENTER key
+
+ #{ $self }__input{
+ padding: $tagMargin;
+ margin: 0;
+ width: 100%;
+ height: 100%;
+ line-height: 1.5;
+
+ &::before{ height:auto; }
+
+ // no suggested-complete are shown in mix-mode while higilighting dropdown options
+ &::after{ content:none; }
+ }
+ }
+
+ &--select{
+ &::after{
+ $size: 16px;
+ content: '>';
+ opacity: .5;
+ position: absolute;
+ top: 50%;
+ right: 0;
+ bottom: 0;
+ font: $size monospace;
+ line-height: $size/2;
+ height: $size/2;
+ pointer-events: none;
+ transform: translate(-150%, -50%) scaleX(1.2) rotate(90deg);
+ transition: .2s ease-in-out;
+ }
+
+ &[aria-expanded=true]{
+ &::after{
+ transform: translate(-150%, -50%) rotate(270deg) scaleY(1.2);
+ }
+ }
+
+ #{$self}__tag{
+ position: absolute;
+ top: 0;
+ right: 1.8em;
+ bottom: 0;
+ div{
+ display: none;
+ }
+ }
+
+ #{$self}__input{
+ width: 100%;
+ }
+ }
+
+ &--invalid{
+ --tags-border-color : #{$tag-invalid-color};
+ }
+
+ // Since the dropdown is an external element, which is positioned directly on the body element
+ // it cannot ingerit the CSS variables applied on the ".Tagify" element
+ &__dropdown{
+ $dropdown: &;
+ $trans: .25s cubic-bezier(0,1,.5,1);
+ position: absolute;
+ z-index: 9999;
+ transform: translateY(1px);
+ overflow: hidden;
+
+ &[placement="top"]{
+ margin-top: 0;
+ transform: translateY(-100%);
+ #{$dropdown}__wrapper{
+ border-top-width: 1px;
+ border-bottom-width: 0;
+ }
+ }
+
+ // when the dropdown shows next to the caret while typing
+ &[position="text"]{
+ box-shadow: 0 0 0 3px rgba(var(--tagify-dd-color-primary), .1);
+ font-size: .9em;
+ #{$dropdown}__wrapper{
+ border-width: 1px;
+ }
+ }
+
+ &__wrapper{
+ max-height: 300px;
+ overflow: hidden;
+ background: $tagify-dd-bg-color;
+ background: var(--tagify-dd-bg-color);
+ border: 1px solid $tags-focus-border-color;
+ border-color: var(--tagify-dd-color-primary);
+ border-top-width: 0;
+ box-shadow: 0 2px 4px -2px rgba(black,.2);
+ // box-sizing: border-box;
+ transition: $trans;
+
+ &:hover{
+ overflow: auto;
+ }
+ }
+
+ // intial state, pre-rendered
+ &--initial{
+ #{$dropdown}__wrapper{
+ max-height: 20px;
+ transform: translateY(-1em);
+ }
+
+ &[placement="top"]{
+ #{$dropdown}__wrapper{
+ transform: translateY(2em);
+ }
+ }
+ }
+
+ &__item{
+ box-sizing: inherit;
+ padding: $tag-pad;
+ margin: 1px;
+ cursor: pointer;
+ border-radius: 2px;
+ position: relative;
+ outline: none;
+
+ &--active{
+ background: $tagify-dd-color-primary;
+ background: var(--tagify-dd-color-primary);
+ color: white;
+ }
+ &:active{
+ filter: brightness(105%);
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..a9bc76c
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,11 @@
+{
+ "requires": true,
+ "lockfileVersion": 1,
+ "dependencies": {
+ "@yaireo/tagify": {
+ "version": "3.21.0",
+ "resolved": "https://registry.npmjs.org/@yaireo/tagify/-/tagify-3.21.0.tgz",
+ "integrity": "sha512-pTr85RlPa7skaUgWCZkaqKpwsAkAiNi5FdLbXFnrhTo+oDMZb0w2rndKIca2bggwWUSsHC+TP2BY7ju8KZu9Gg=="
+ }
+ }
+}
diff --git a/tags.sql b/tags.sql
new file mode 100644
index 0000000..6ccff83
--- /dev/null
+++ b/tags.sql
@@ -0,0 +1,5 @@
+CREATE TABLE IF NOT EXISTS tags(
+ id INTEGER PRIMARY KEY,
+ tag VARCHAR,
+
+)
diff --git a/templates/assets/css/bootstrap-tagsinput.css b/templates/assets/css/bootstrap-tagsinput.css
new file mode 100644
index 0000000..b31f01c
--- /dev/null
+++ b/templates/assets/css/bootstrap-tagsinput.css
@@ -0,0 +1,55 @@
+.bootstrap-tagsinput {
+ background-color: #fff;
+ border: 1px solid #ccc;
+ box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
+ display: inline-block;
+ padding: 4px 6px;
+ color: #555;
+ vertical-align: middle;
+ border-radius: 4px;
+ max-width: 100%;
+ line-height: 22px;
+ cursor: text;
+}
+.bootstrap-tagsinput input {
+ border: none;
+ box-shadow: none;
+ outline: none;
+ background-color: transparent;
+ padding: 0 6px;
+ margin: 0;
+ width: auto;
+ max-width: inherit;
+}
+.bootstrap-tagsinput.form-control input::-moz-placeholder {
+ color: #777;
+ opacity: 1;
+}
+.bootstrap-tagsinput.form-control input:-ms-input-placeholder {
+ color: #777;
+}
+.bootstrap-tagsinput.form-control input::-webkit-input-placeholder {
+ color: #777;
+}
+.bootstrap-tagsinput input:focus {
+ border: none;
+ box-shadow: none;
+}
+.bootstrap-tagsinput .tag {
+ margin-right: 2px;
+ color: white;
+}
+.bootstrap-tagsinput .tag [data-role="remove"] {
+ margin-left: 8px;
+ cursor: pointer;
+}
+.bootstrap-tagsinput .tag [data-role="remove"]:after {
+ content: "x";
+ padding: 0px 2px;
+}
+.bootstrap-tagsinput .tag [data-role="remove"]:hover {
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2), 0 1px 2px rgba(0, 0, 0, 0.05);
+}
+.bootstrap-tagsinput .tag [data-role="remove"]:hover:active {
+ box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);
+}
diff --git a/templates/assets/js/bootstrap-tagsinput.js b/templates/assets/js/bootstrap-tagsinput.js
new file mode 100644
index 0000000..2b403f7
--- /dev/null
+++ b/templates/assets/js/bootstrap-tagsinput.js
@@ -0,0 +1,646 @@
+(function ($) {
+ "use strict";
+
+ var defaultOptions = {
+ tagClass: function(item) {
+ return 'label label-info';
+ },
+ itemValue: function(item) {
+ return item ? item.toString() : item;
+ },
+ itemText: function(item) {
+ return this.itemValue(item);
+ },
+ itemTitle: function(item) {
+ return null;
+ },
+ freeInput: true,
+ addOnBlur: true,
+ maxTags: undefined,
+ maxChars: undefined,
+ confirmKeys: [13, 44],
+ delimiter: ',',
+ delimiterRegex: null,
+ cancelConfirmKeysOnEmpty: true,
+ onTagExists: function(item, $tag) {
+ $tag.hide().fadeIn();
+ },
+ trimValue: false,
+ allowDuplicates: false
+ };
+
+ /**
+ * Constructor function
+ */
+ function TagsInput(element, options) {
+ this.itemsArray = [];
+
+ this.$element = $(element);
+ this.$element.hide();
+
+ this.isSelect = (element.tagName === 'SELECT');
+ this.multiple = (this.isSelect && element.hasAttribute('multiple'));
+ this.objectItems = options && options.itemValue;
+ this.placeholderText = element.hasAttribute('placeholder') ? this.$element.attr('placeholder') : '';
+ this.inputSize = Math.max(1, this.placeholderText.length);
+
+ this.$container = $('
');
+ this.$input = $(' ').appendTo(this.$container);
+
+ this.$element.before(this.$container);
+
+ this.build(options);
+ }
+
+ TagsInput.prototype = {
+ constructor: TagsInput,
+
+ /**
+ * Adds the given item as a new tag. Pass true to dontPushVal to prevent
+ * updating the elements val()
+ */
+ add: function(item, dontPushVal, options) {
+ var self = this;
+
+ if (self.options.maxTags && self.itemsArray.length >= self.options.maxTags)
+ return;
+
+ // Ignore falsey values, except false
+ if (item !== false && !item)
+ return;
+
+ // Trim value
+ if (typeof item === "string" && self.options.trimValue) {
+ item = $.trim(item);
+ }
+
+ // Throw an error when trying to add an object while the itemValue option was not set
+ if (typeof item === "object" && !self.objectItems)
+ throw("Can't add objects when itemValue option is not set");
+
+ // Ignore strings only containg whitespace
+ if (item.toString().match(/^\s*$/))
+ return;
+
+ // If SELECT but not multiple, remove current tag
+ if (self.isSelect && !self.multiple && self.itemsArray.length > 0)
+ self.remove(self.itemsArray[0]);
+
+ if (typeof item === "string" && this.$element[0].tagName === 'INPUT') {
+ var delimiter = (self.options.delimiterRegex) ? self.options.delimiterRegex : self.options.delimiter;
+ var items = item.split(delimiter);
+ if (items.length > 1) {
+ for (var i = 0; i < items.length; i++) {
+ this.add(items[i], true);
+ }
+
+ if (!dontPushVal)
+ self.pushVal();
+ return;
+ }
+ }
+
+ var itemValue = self.options.itemValue(item),
+ itemText = self.options.itemText(item),
+ tagClass = self.options.tagClass(item),
+ itemTitle = self.options.itemTitle(item);
+
+ // Ignore items allready added
+ var existing = $.grep(self.itemsArray, function(item) { return self.options.itemValue(item) === itemValue; } )[0];
+ if (existing && !self.options.allowDuplicates) {
+ // Invoke onTagExists
+ if (self.options.onTagExists) {
+ var $existingTag = $(".tag", self.$container).filter(function() { return $(this).data("item") === existing; });
+ self.options.onTagExists(item, $existingTag);
+ }
+ return;
+ }
+
+ // if length greater than limit
+ if (self.items().toString().length + item.length + 1 > self.options.maxInputLength)
+ return;
+
+ // raise beforeItemAdd arg
+ var beforeItemAddEvent = $.Event('beforeItemAdd', { item: item, cancel: false, options: options});
+ self.$element.trigger(beforeItemAddEvent);
+ if (beforeItemAddEvent.cancel)
+ return;
+
+ // register item in internal array and map
+ self.itemsArray.push(item);
+
+ // add a tag element
+
+ var $tag = $('' + htmlEncode(itemText) + ' ');
+ $tag.data('item', item);
+ self.findInputWrapper().before($tag);
+ $tag.after(' ');
+
+ // add if item represents a value not present in one of the 's options
+ if (self.isSelect && !$('option[value="' + encodeURIComponent(itemValue) + '"]',self.$element)[0]) {
+ var $option = $('' + htmlEncode(itemText) + ' ');
+ $option.data('item', item);
+ $option.attr('value', itemValue);
+ self.$element.append($option);
+ }
+
+ if (!dontPushVal)
+ self.pushVal();
+
+ // Add class when reached maxTags
+ if (self.options.maxTags === self.itemsArray.length || self.items().toString().length === self.options.maxInputLength)
+ self.$container.addClass('bootstrap-tagsinput-max');
+
+ self.$element.trigger($.Event('itemAdded', { item: item, options: options }));
+ },
+
+ /**
+ * Removes the given item. Pass true to dontPushVal to prevent updating the
+ * elements val()
+ */
+ remove: function(item, dontPushVal, options) {
+ var self = this;
+
+ if (self.objectItems) {
+ if (typeof item === "object")
+ item = $.grep(self.itemsArray, function(other) { return self.options.itemValue(other) == self.options.itemValue(item); } );
+ else
+ item = $.grep(self.itemsArray, function(other) { return self.options.itemValue(other) == item; } );
+
+ item = item[item.length-1];
+ }
+
+ if (item) {
+ var beforeItemRemoveEvent = $.Event('beforeItemRemove', { item: item, cancel: false, options: options });
+ self.$element.trigger(beforeItemRemoveEvent);
+ if (beforeItemRemoveEvent.cancel)
+ return;
+
+ $('.tag', self.$container).filter(function() { return $(this).data('item') === item; }).remove();
+ $('option', self.$element).filter(function() { return $(this).data('item') === item; }).remove();
+ if($.inArray(item, self.itemsArray) !== -1)
+ self.itemsArray.splice($.inArray(item, self.itemsArray), 1);
+ }
+
+ if (!dontPushVal)
+ self.pushVal();
+
+ // Remove class when reached maxTags
+ if (self.options.maxTags > self.itemsArray.length)
+ self.$container.removeClass('bootstrap-tagsinput-max');
+
+ self.$element.trigger($.Event('itemRemoved', { item: item, options: options }));
+ },
+
+ /**
+ * Removes all items
+ */
+ removeAll: function() {
+ var self = this;
+
+ $('.tag', self.$container).remove();
+ $('option', self.$element).remove();
+
+ while(self.itemsArray.length > 0)
+ self.itemsArray.pop();
+
+ self.pushVal();
+ },
+
+ /**
+ * Refreshes the tags so they match the text/value of their corresponding
+ * item.
+ */
+ refresh: function() {
+ var self = this;
+ $('.tag', self.$container).each(function() {
+ var $tag = $(this),
+ item = $tag.data('item'),
+ itemValue = self.options.itemValue(item),
+ itemText = self.options.itemText(item),
+ tagClass = self.options.tagClass(item);
+
+ // Update tag's class and inner text
+ $tag.attr('class', null);
+ $tag.addClass('tag ' + htmlEncode(tagClass));
+ $tag.contents().filter(function() {
+ return this.nodeType == 3;
+ })[0].nodeValue = htmlEncode(itemText);
+
+ if (self.isSelect) {
+ var option = $('option', self.$element).filter(function() { return $(this).data('item') === item; });
+ option.attr('value', itemValue);
+ }
+ });
+ },
+
+ /**
+ * Returns the items added as tags
+ */
+ items: function() {
+ return this.itemsArray;
+ },
+
+ /**
+ * Assembly value by retrieving the value of each item, and set it on the
+ * element.
+ */
+ pushVal: function() {
+ var self = this,
+ val = $.map(self.items(), function(item) {
+ return self.options.itemValue(item).toString();
+ });
+
+ self.$element.val(val, true).trigger('change');
+ },
+
+ /**
+ * Initializes the tags input behaviour on the element
+ */
+ build: function(options) {
+ var self = this;
+
+ self.options = $.extend({}, defaultOptions, options);
+ // When itemValue is set, freeInput should always be false
+ if (self.objectItems)
+ self.options.freeInput = false;
+
+ makeOptionItemFunction(self.options, 'itemValue');
+ makeOptionItemFunction(self.options, 'itemText');
+ makeOptionFunction(self.options, 'tagClass');
+
+ // Typeahead Bootstrap version 2.3.2
+ if (self.options.typeahead) {
+ var typeahead = self.options.typeahead || {};
+
+ makeOptionFunction(typeahead, 'source');
+
+ self.$input.typeahead($.extend({}, typeahead, {
+ source: function (query, process) {
+ function processItems(items) {
+ var texts = [];
+
+ for (var i = 0; i < items.length; i++) {
+ var text = self.options.itemText(items[i]);
+ map[text] = items[i];
+ texts.push(text);
+ }
+ process(texts);
+ }
+
+ this.map = {};
+ var map = this.map,
+ data = typeahead.source(query);
+
+ if ($.isFunction(data.success)) {
+ // support for Angular callbacks
+ data.success(processItems);
+ } else if ($.isFunction(data.then)) {
+ // support for Angular promises
+ data.then(processItems);
+ } else {
+ // support for functions and jquery promises
+ $.when(data)
+ .then(processItems);
+ }
+ },
+ updater: function (text) {
+ self.add(this.map[text]);
+ return this.map[text];
+ },
+ matcher: function (text) {
+ return (text.toLowerCase().indexOf(this.query.trim().toLowerCase()) !== -1);
+ },
+ sorter: function (texts) {
+ return texts.sort();
+ },
+ highlighter: function (text) {
+ var regex = new RegExp( '(' + this.query + ')', 'gi' );
+ return text.replace( regex, "$1 " );
+ }
+ }));
+ }
+
+ // typeahead.js
+ if (self.options.typeaheadjs) {
+ var typeaheadConfig = null;
+ var typeaheadDatasets = {};
+
+ // Determine if main configurations were passed or simply a dataset
+ var typeaheadjs = self.options.typeaheadjs;
+ if ($.isArray(typeaheadjs)) {
+ typeaheadConfig = typeaheadjs[0];
+ typeaheadDatasets = typeaheadjs[1];
+ } else {
+ typeaheadDatasets = typeaheadjs;
+ }
+
+ self.$input.typeahead(typeaheadConfig, typeaheadDatasets).on('typeahead:selected', $.proxy(function (obj, datum) {
+ if (typeaheadDatasets.valueKey)
+ self.add(datum[typeaheadDatasets.valueKey]);
+ else
+ self.add(datum);
+ self.$input.typeahead('val', '');
+ }, self));
+ }
+
+ self.$container.on('click', $.proxy(function(event) {
+ if (! self.$element.attr('disabled')) {
+ self.$input.removeAttr('disabled');
+ }
+ self.$input.focus();
+ }, self));
+
+ if (self.options.addOnBlur && self.options.freeInput) {
+ self.$input.on('focusout', $.proxy(function(event) {
+ // HACK: only process on focusout when no typeahead opened, to
+ // avoid adding the typeahead text as tag
+ if ($('.typeahead, .twitter-typeahead', self.$container).length === 0) {
+ self.add(self.$input.val());
+ self.$input.val('');
+ }
+ }, self));
+ }
+
+
+ self.$container.on('keydown', 'input', $.proxy(function(event) {
+ var $input = $(event.target),
+ $inputWrapper = self.findInputWrapper();
+
+ if (self.$element.attr('disabled')) {
+ self.$input.attr('disabled', 'disabled');
+ return;
+ }
+
+ switch (event.which) {
+ // BACKSPACE
+ case 8:
+ if (doGetCaretPosition($input[0]) === 0) {
+ var prev = $inputWrapper.prev();
+ if (prev.length) {
+ self.remove(prev.data('item'));
+ }
+ }
+ break;
+
+ // DELETE
+ case 46:
+ if (doGetCaretPosition($input[0]) === 0) {
+ var next = $inputWrapper.next();
+ if (next.length) {
+ self.remove(next.data('item'));
+ }
+ }
+ break;
+
+ // LEFT ARROW
+ case 37:
+ // Try to move the input before the previous tag
+ var $prevTag = $inputWrapper.prev();
+ if ($input.val().length === 0 && $prevTag[0]) {
+ $prevTag.before($inputWrapper);
+ $input.focus();
+ }
+ break;
+ // RIGHT ARROW
+ case 39:
+ // Try to move the input after the next tag
+ var $nextTag = $inputWrapper.next();
+ if ($input.val().length === 0 && $nextTag[0]) {
+ $nextTag.after($inputWrapper);
+ $input.focus();
+ }
+ break;
+ default:
+ // ignore
+ }
+
+ // Reset internal input's size
+ var textLength = $input.val().length,
+ wordSpace = Math.ceil(textLength / 5),
+ size = textLength + wordSpace + 1;
+ $input.attr('size', Math.max(this.inputSize, $input.val().length));
+ }, self));
+
+ self.$container.on('keypress', 'input', $.proxy(function(event) {
+ var $input = $(event.target);
+
+ if (self.$element.attr('disabled')) {
+ self.$input.attr('disabled', 'disabled');
+ return;
+ }
+
+ var text = $input.val(),
+ maxLengthReached = self.options.maxChars && text.length >= self.options.maxChars;
+ if (self.options.freeInput && (keyCombinationInList(event, self.options.confirmKeys) || maxLengthReached)) {
+ // Only attempt to add a tag if there is data in the field
+ if (text.length !== 0) {
+ self.add(maxLengthReached ? text.substr(0, self.options.maxChars) : text);
+ $input.val('');
+ }
+
+ // If the field is empty, let the event triggered fire as usual
+ if (self.options.cancelConfirmKeysOnEmpty === false) {
+ event.preventDefault();
+ }
+ }
+
+ // Reset internal input's size
+ var textLength = $input.val().length,
+ wordSpace = Math.ceil(textLength / 5),
+ size = textLength + wordSpace + 1;
+ $input.attr('size', Math.max(this.inputSize, $input.val().length));
+ }, self));
+
+ // Remove icon clicked
+ self.$container.on('click', '[data-role=remove]', $.proxy(function(event) {
+ if (self.$element.attr('disabled')) {
+ return;
+ }
+ self.remove($(event.target).closest('.tag').data('item'));
+ }, self));
+
+ // Only add existing value as tags when using strings as tags
+ if (self.options.itemValue === defaultOptions.itemValue) {
+ if (self.$element[0].tagName === 'INPUT') {
+ self.add(self.$element.val());
+ } else {
+ $('option', self.$element).each(function() {
+ self.add($(this).attr('value'), true);
+ });
+ }
+ }
+ },
+
+ /**
+ * Removes all tagsinput behaviour and unregsiter all event handlers
+ */
+ destroy: function() {
+ var self = this;
+
+ // Unbind events
+ self.$container.off('keypress', 'input');
+ self.$container.off('click', '[role=remove]');
+
+ self.$container.remove();
+ self.$element.removeData('tagsinput');
+ self.$element.show();
+ },
+
+ /**
+ * Sets focus on the tagsinput
+ */
+ focus: function() {
+ this.$input.focus();
+ },
+
+ /**
+ * Returns the internal input element
+ */
+ input: function() {
+ return this.$input;
+ },
+
+ /**
+ * Returns the element which is wrapped around the internal input. This
+ * is normally the $container, but typeahead.js moves the $input element.
+ */
+ findInputWrapper: function() {
+ var elt = this.$input[0],
+ container = this.$container[0];
+ while(elt && elt.parentNode !== container)
+ elt = elt.parentNode;
+
+ return $(elt);
+ }
+ };
+
+ /**
+ * Register JQuery plugin
+ */
+ $.fn.tagsinput = function(arg1, arg2, arg3) {
+ var results = [];
+
+ this.each(function() {
+ var tagsinput = $(this).data('tagsinput');
+ // Initialize a new tags input
+ if (!tagsinput) {
+ tagsinput = new TagsInput(this, arg1);
+ $(this).data('tagsinput', tagsinput);
+ results.push(tagsinput);
+
+ if (this.tagName === 'SELECT') {
+ $('option', $(this)).attr('selected', 'selected');
+ }
+
+ // Init tags from $(this).val()
+ $(this).val($(this).val());
+ } else if (!arg1 && !arg2) {
+ // tagsinput already exists
+ // no function, trying to init
+ results.push(tagsinput);
+ } else if(tagsinput[arg1] !== undefined) {
+ // Invoke function on existing tags input
+ if(tagsinput[arg1].length === 3 && arg3 !== undefined){
+ var retVal = tagsinput[arg1](arg2, null, arg3);
+ }else{
+ var retVal = tagsinput[arg1](arg2);
+ }
+ if (retVal !== undefined)
+ results.push(retVal);
+ }
+ });
+
+ if ( typeof arg1 == 'string') {
+ // Return the results from the invoked function calls
+ return results.length > 1 ? results : results[0];
+ } else {
+ return results;
+ }
+ };
+
+ $.fn.tagsinput.Constructor = TagsInput;
+
+ /**
+ * Most options support both a string or number as well as a function as
+ * option value. This function makes sure that the option with the given
+ * key in the given options is wrapped in a function
+ */
+ function makeOptionItemFunction(options, key) {
+ if (typeof options[key] !== 'function') {
+ var propertyName = options[key];
+ options[key] = function(item) { return item[propertyName]; };
+ }
+ }
+ function makeOptionFunction(options, key) {
+ if (typeof options[key] !== 'function') {
+ var value = options[key];
+ options[key] = function() { return value; };
+ }
+ }
+ /**
+ * HtmlEncodes the given value
+ */
+ var htmlEncodeContainer = $('
');
+ function htmlEncode(value) {
+ if (value) {
+ return htmlEncodeContainer.text(value).html();
+ } else {
+ return '';
+ }
+ }
+
+ /**
+ * Returns the position of the caret in the given input field
+ * http://flightschool.acylt.com/devnotes/caret-position-woes/
+ */
+ function doGetCaretPosition(oField) {
+ var iCaretPos = 0;
+ if (document.selection) {
+ oField.focus ();
+ var oSel = document.selection.createRange();
+ oSel.moveStart ('character', -oField.value.length);
+ iCaretPos = oSel.text.length;
+ } else if (oField.selectionStart || oField.selectionStart == '0') {
+ iCaretPos = oField.selectionStart;
+ }
+ return (iCaretPos);
+ }
+
+ /**
+ * Returns boolean indicates whether user has pressed an expected key combination.
+ * @param object keyPressEvent: JavaScript event object, refer
+ * http://www.w3.org/TR/2003/WD-DOM-Level-3-Events-20030331/ecma-script-binding.html
+ * @param object lookupList: expected key combinations, as in:
+ * [13, {which: 188, shiftKey: true}]
+ */
+ function keyCombinationInList(keyPressEvent, lookupList) {
+ var found = false;
+ $.each(lookupList, function (index, keyCombination) {
+ if (typeof (keyCombination) === 'number' && keyPressEvent.which === keyCombination) {
+ found = true;
+ return false;
+ }
+
+ if (keyPressEvent.which === keyCombination.which) {
+ var alt = !keyCombination.hasOwnProperty('altKey') || keyPressEvent.altKey === keyCombination.altKey,
+ shift = !keyCombination.hasOwnProperty('shiftKey') || keyPressEvent.shiftKey === keyCombination.shiftKey,
+ ctrl = !keyCombination.hasOwnProperty('ctrlKey') || keyPressEvent.ctrlKey === keyCombination.ctrlKey;
+ if (alt && shift && ctrl) {
+ found = true;
+ return false;
+ }
+ }
+ });
+
+ return found;
+ }
+
+ /**
+ * Initialize tagsinput behaviour on inputs and selects which have
+ * data-role=tagsinput
+ */
+ $(function() {
+ $("input[data-role=tagsinput], select[multiple][data-role=tagsinput]").tagsinput();
+ });
+})(window.jQuery);