diff --git a/wire/modules/Inputfield/InputfieldTextTags/InputfieldTextTags.css b/wire/modules/Inputfield/InputfieldTextTags/InputfieldTextTags.css
new file mode 100644
index 00000000..971c98db
--- /dev/null
+++ b/wire/modules/Inputfield/InputfieldTextTags/InputfieldTextTags.css
@@ -0,0 +1 @@
+.InputfieldTextTags label.pw-hidden{display:none}.InputfieldTextTags input.InputfieldTextTagsSelect:not(.selectized),.InputfieldTextTags input.InputfieldTextTagsInput:not(.selectized){color:#f0f3f7}.Inputfield .selectize-input{border:1px solid #b1c3d4 #cbd7e3 #cbd7e3 #cbd7e3;border-color:#b1c3d4 #cbd7e3 #cbd7e3 #cbd7e3;box-shadow:none}.Inputfield .selectize-control .selectize-input.has-items>div{background:#f0f3f7;white-space:nowrap;border:1px solid #cbd7e3;border-radius:3px}.Inputfield .selectize-control .selectize-input.has-items>div a.remove{color:#555}.Inputfield .selectize-input:not(.has-items){background:#f0f3f7}
diff --git a/wire/modules/Inputfield/InputfieldTextTags/InputfieldTextTags.js b/wire/modules/Inputfield/InputfieldTextTags/InputfieldTextTags.js
new file mode 100644
index 00000000..dcbd8b72
--- /dev/null
+++ b/wire/modules/Inputfield/InputfieldTextTags/InputfieldTextTags.js
@@ -0,0 +1,99 @@
+function InputfieldTextTags($parent) {
+
+ if(typeof $parent === "undefined") $parent = $('.InputfieldForm');
+
+ var $inputs = jQuery('.InputfieldTextTagsInput:not(.selectized)', $parent);
+ var $selects = jQuery('.InputfieldTextTagsSelect:not(.selectized)', $parent);
+
+ if($inputs.length) {
+ $inputs.selectize({
+ plugins: ['remove_button', 'drag_drop'],
+ delimiter: ' ',
+ persist: false,
+ createOnBlur: true,
+ submitOnReturn: false,
+ create: function(input) {
+ return {
+ value: input,
+ text: input
+ }
+ }
+ });
+ }
+
+ if($selects.length) {
+ $selects.each(function() {
+ var $select = $(this);
+ var configName = $select.attr('data-cfgname');
+ var allowUserTags = $select.hasClass('InputfieldTextTagsSelectOnly') ? false : true;
+ var tags = [];
+ var tagsList = [];
+ var n = 0;
+ if(configName.length) {
+ tags = ProcessWire.config[configName];
+ } else {
+ tags = $select.attr('data-tags');
+ tags = JSON.parse(tags);
+ }
+ for(var tag in tags) {
+ var label = tags[tag];
+ tagsList[n] = { value: tag, label: label };
+ n++;
+ }
+ $select.selectize({
+ plugins: ['remove_button', 'drag_drop'],
+ delimiter: ' ',
+ persist: true,
+ submitOnReturn: false,
+ closeAfterSelect: true,
+ copyClassesToDropdown: false,
+ createOnBlur: true,
+ maxItems: null,
+ valueField: 'value',
+ labelField: 'label',
+ searchField: ['value', 'label'],
+ options: tagsList,
+ create: function(input) {
+ return {
+ value: input,
+ text: input
+ }
+ },
+ createFilter: function(input) {
+ if(allowUserTags) return true;
+ allow = false;
+ for(var n = 0; n < tags.length; n++) {
+ if(typeof tagsList[input] !== "undefined") {
+ allow = true;
+ break;
+ }
+ }
+ return allow;
+ },
+ /*
+ onDropdownOpen: function($dropdown) {
+ $dropdown.closest('li, .InputfieldImageEdit').css('z-index', 100);
+ },
+ onDropdownClose: function($dropdown) {
+ $dropdown.closest('li, .InputfieldImageEdit').css('z-index', 'auto');
+ },
+ */
+ render: {
+ item: function(item, escape) {
+ if(typeof item.label === "undefined" || !item.label.length) item.label = item.value;
+ return '
' + escape(item.label) + '
';
+ },
+ option: function(item, escape) {
+ if(typeof item.label === "undefined" || !item.label.length) item.label = item.value;
+ return '' + escape(item.label) + '
';
+ }
+ }
+ });
+ });
+ }
+}
+
+jQuery(document).ready(function($) {
+ InputfieldTextTags();
+ $(document).on('reloaded', '.InputfieldTextTags', function() { InputfieldTextTags($(this)); });
+});
\ No newline at end of file
diff --git a/wire/modules/Inputfield/InputfieldTextTags/InputfieldTextTags.min.js b/wire/modules/Inputfield/InputfieldTextTags/InputfieldTextTags.min.js
new file mode 100644
index 00000000..1ce9e54d
--- /dev/null
+++ b/wire/modules/Inputfield/InputfieldTextTags/InputfieldTextTags.min.js
@@ -0,0 +1 @@
+function InputfieldTextTags($parent){if(typeof $parent==="undefined")$parent=$(".InputfieldForm");var $inputs=jQuery(".InputfieldTextTagsInput:not(.selectized)",$parent);var $selects=jQuery(".InputfieldTextTagsSelect:not(.selectized)",$parent);if($inputs.length){$inputs.selectize({plugins:["remove_button","drag_drop"],delimiter:" ",persist:false,createOnBlur:true,submitOnReturn:false,create:function(input){return{value:input,text:input}}})}if($selects.length){$selects.each(function(){var $select=$(this);var configName=$select.attr("data-cfgname");var allowUserTags=$select.hasClass("InputfieldTextTagsSelectOnly")?false:true;var tags=[];var tagsList=[];var n=0;if(configName.length){tags=ProcessWire.config[configName]}else{tags=$select.attr("data-tags");tags=JSON.parse(tags)}for(var tag in tags){var label=tags[tag];tagsList[n]={value:tag,label:label};n++}$select.selectize({plugins:["remove_button","drag_drop"],delimiter:" ",persist:true,submitOnReturn:false,closeAfterSelect:true,copyClassesToDropdown:false,createOnBlur:true,maxItems:null,valueField:"value",labelField:"label",searchField:["value","label"],options:tagsList,create:function(input){return{value:input,text:input}},createFilter:function(input){if(allowUserTags)return true;allow=false;for(var n=0;n"+escape(item.label)+""},option:function(item,escape){if(typeof item.label==="undefined"||!item.label.length)item.label=item.value;return""+escape(item.label)+"
"}}})})}}jQuery(document).ready(function($){InputfieldTextTags();$(document).on("reloaded",".InputfieldTextTags",function(){InputfieldTextTags($(this))})});
\ No newline at end of file
diff --git a/wire/modules/Inputfield/InputfieldTextTags/InputfieldTextTags.module b/wire/modules/Inputfield/InputfieldTextTags/InputfieldTextTags.module
new file mode 100644
index 00000000..6c6dbb24
--- /dev/null
+++ b/wire/modules/Inputfield/InputfieldTextTags/InputfieldTextTags.module
@@ -0,0 +1,746 @@
+get('InputfieldTextTags');
+ * $f->attr('name', 'tags');
+ *
+ * // allow for user-entered tags input (true or false, default=false)
+ * $f->allowUserTags = true;
+ *
+ * // predefined selectable tags (tag and optional label)
+ * $f->addTag('foo');
+ * $f->addTag('bar', 'This is Bar'); // optional label
+ * $f->addTag('baz', 'This is Baz'); // optional label
+ *
+ * // set currently entered/selected tags
+ * $f->val('foo bar');
+ * $f->val([ 'foo', 'bar' ]); // this also works
+ * ~~~~~
+ *
+ * @property array|string $tagsList Array of tags [ 'tag' => 'label' ], or newline separated string of "tag=label", or use addTag() to populate.
+ * @property int|bool $allowUserTags Allow user-entered tags?
+ * @property string $value
+ * @property-read array $arrayValue
+ *
+ * ProcessWire 3.x, Copyright 2021 by Ryan Cramer
+ * https://processwire.com
+ *
+ */
+class InputfieldTextTags extends Inputfield
+ implements InputfieldHasTextValue, InputfieldSupportsArrayValue, InputfieldHasSelectableOptions, InputfieldHasSortableValue {
+
+ public static function getModuleInfo() {
+ return array(
+ 'title' => __('Text Tags', __FILE__), // Module Title
+ 'summary' => __('Enables input of user entered tags or selection of predefined tags.', __FILE__), // Module Summary
+ 'version' => 1,
+ 'icon' => 'tags',
+ );
+ }
+
+ /**
+ * Construct
+ *
+ * #pw-internal
+ *
+ */
+ public function __construct() {
+ $this->set('tagsList', array());
+ $this->set('allowUserTags', 0);
+ parent::__construct();
+ }
+
+ /**
+ * Wired to PW
+ *
+ * #pw-internal
+ *
+ */
+ public function wired() {
+ parent::wired();
+ $languages = $this->wire()->languages;
+ if($languages) {
+ foreach($languages as $language) {
+ if(!$language->isDefault()) $this->set("tagsList$language->id", array());
+ }
+ }
+ }
+
+ /**
+ * Get property
+ *
+ * #pw-internal
+ *
+ * @param string $key
+ * @return array|mixed|null|string
+ *
+ */
+ public function get($key) {
+ if($key === 'arrayValue') return $this->getArrayValue();
+ return parent::get($key);
+ }
+
+ /**
+ * Set property
+ *
+ * #pw-internal
+ *
+ * @param string $key
+ * @param mixed $value
+ * @return Inputfield|InputfieldTextTags|WireData
+ *
+ */
+ public function set($key, $value) {
+ if(strpos($key, 'tagsList') === 0) {
+ if($key === 'tagsList') return $this->setTagsList($value);
+ list(,$languageId) = explode('tagsList', $key, 2);
+ return $this->setTagsList($value, (int) $languageId);
+ }
+ return parent::set($key, $value);
+ }
+
+ /**
+ * Set attribute
+ *
+ * #pw-internal
+ *
+ * @param array|string $key
+ * @param array|int|string $value
+ * @return self|Inputfield
+ *
+ */
+ public function setAttribute($key, $value) {
+ if($key === 'value') {
+ if(is_object($value) && $value instanceof WireArray) {
+ $value = explode('|', (string) $value);
+ }
+ if(is_array($value)) {
+ $value = $this->tagArrayToString($value);
+ }
+ }
+ return parent::setAttribute($key, $value);
+ }
+
+ /**
+ * Get array value
+ *
+ * For InputfieldSupportsArrayValue interface
+ *
+ * #pw-internal
+ *
+ * @return array
+ *
+ */
+ public function getArrayValue() {
+ $value = parent::getAttribute('value');
+ $value = $this->tagStringToArray($value);
+ return $value;
+ }
+
+ /**
+ * Set value as an array
+ *
+ * For InputfieldSupportsArrayValue interface
+ *
+ * #pw-internal
+ *
+ * @param array $value
+ *
+ */
+ public function setArrayValue(array $value) {
+ $this->setAttribute('value', $value);
+ }
+
+ /**
+ * Convert string of tags to array
+ *
+ * #pw-internal
+ *
+ * @param string $tagString
+ * @return array
+ *
+ */
+ public function tagStringToArray($tagString) {
+ $tagString = trim($tagString);
+ $tagArray = array();
+ if(!strlen($tagString)) return $tagArray;
+ $a = explode(' ', $tagString);
+ foreach($a as $key => $tag) {
+ $tag = trim("$tag");
+ if(!strlen($tag)) continue;
+ if(strpos($tag, '_') === 0 && ctype_digit(substr($tag, 1))) $tag = ltrim($tag, '_');
+ $tagArray[$tag] = $tag;
+ }
+ return $tagArray;
+ }
+
+ /**
+ * Convert array of tags to string
+ *
+ * #pw-internal
+ *
+ * @param array $tagArray
+ * @return string
+ *
+ */
+ public function tagArrayToString(array $tagArray) {
+ return trim(implode(' ', $tagArray));
+ }
+
+ /**
+ * Given tags string or array, return array of [ 'tag' => 'label' ]
+ *
+ * Public API usages likely would prefer the static tagsLabels() method instead.
+ *
+ * #pw-internal
+ *
+ * @param string|array $tags
+ * @param Language|int|string|null $language
+ * @return array
+ *
+ */
+ public function tagsToLabels($tags, $language = null) {
+ if(!is_array($tags)) $tags = $this->tagStringToArray($tags);
+ if(empty($tags)) return array();
+ $labels = $this->getTagLabels($language);
+ $a = array();
+ foreach($tags as $tag) {
+ $label = isset($labels[$tag]) ? $labels[$tag] : $tag;
+ $a[$tag] = $label;
+ }
+ return $a;
+ }
+
+ /**
+ * Convert string of tagsList (tag definitions) to array
+ *
+ * #pw-internal
+ *
+ * @param string $tagString
+ * @param bool $allowLabels
+ * @return array
+ *
+ */
+ protected function tagsListStringToArray($tagString, $allowLabels = true) {
+
+ $tagString = trim($tagString);
+ $tagArray = array();
+
+ if(!strlen($tagString)) return $tagArray;
+
+ $regex = $allowLabels ? '/[\r\n\t]+/' : '/[\s\r\n\t]+/';
+ $a = preg_split($regex, $tagString);
+
+ foreach($a as $key => $tag) {
+ $tag = trim("$tag");
+ if(!strlen($tag)) continue;
+ if(strpos($tag, '=') !== false) {
+ list($tag, $label) = explode('=', $tag, 2);
+ if(!$allowLabels) $label = $tag;
+ } else {
+ $label = $tag;
+ }
+ if(strpos($tag, '_') === 0 && ctype_digit(substr($tag, 1))) {
+ $tagIsLabel = $tag === $label;
+ $tag = ltrim($tag, '_');
+ if($tagIsLabel) $label = $tag;
+ }
+ $tagArray[$tag] = $label;
+ }
+
+ return $tagArray;
+ }
+
+ /**
+ * Convert given tags array to tagsList definition string
+ *
+ * #pw-internal
+ *
+ * @param array $tags
+ * @param string $delimiter
+ * @return string
+ *
+ */
+ protected function tagsListArrayToString(array $tags, $delimiter = "\n") {
+ $items = array();
+ foreach($tags as $tagName => $tagLabel) {
+ if($tagName === $tagLabel) {
+ $items[$tagName] = $tagName;
+ } else {
+ $items[$tagName] = "$tagName=$tagLabel";
+ }
+ }
+ return implode($delimiter, $items);
+ }
+
+ /**
+ * Get all selectable tags and labels, optionally for specific language
+ *
+ * #pw-group-settings
+ *
+ * @param Language|int|string|null $language
+ * @param bool $getArray
+ * @return array|string
+ *
+ */
+ public function getTagsList($language = null, $getArray = true) {
+ $tags = parent::get('tagsList'); /** @var array $tags */
+ if($language) {
+ $key = $this->languageKey($language, 'tagsList');
+ if($key !== 'tagsList') {
+ $langTags = parent::get($key); /** @var array $langTags */
+ $tags = array_merge($tags, $langTags);
+ }
+ }
+ if(!$getArray) return $this->tagsListArrayToString($tags);
+ return $tags;
+ }
+
+ /**
+ * Set all selectable tags and labels, optionally for specific language
+ *
+ * #pw-group-settings
+ *
+ * @param array|string $tags Array of [ 'tag' => 'label', 'tag2' => 'label2' ] or newline string of "tag=label\ntag2=label2\n..."
+ * @param Language|int|string|null $language
+ * @return self
+ *
+ */
+ public function setTagsList($tags, $language = null) {
+ if(is_string($tags)) $tags = $this->tagsListStringToArray($tags);
+ $key = $language === null ? 'tagsList' : $this->languageKey($language, 'tagsList');
+ parent::set($key, $tags);
+ return $this;
+ }
+
+ /**
+ * Add a predefined tag
+ *
+ * #pw-group-settings
+ *
+ * @param string $tag
+ * @param string $label
+ * @param Language|int|string|null $language
+ * @return self
+ *
+ */
+ public function addTag($tag, $label = '', $language = null) {
+ $key = $this->languageKey($language, 'tagsList');
+ $tagsList = $this->get($key);
+ if(!strlen($label)) $label = $tag;
+ $tagsList[$tag] = $label;
+ parent::set($key, $tagsList);
+ if($language && $key !== 'tagsList') {
+ $tagsList = $this->tagsList;
+ if(!isset($tagsList[$tag])) {
+ $tagsList[$tag] = $tag;
+ parent::set('tagsList', $tagsList);
+ }
+ }
+ return $this;
+ }
+
+ /**
+ * Remove tag
+ *
+ * #pw-group-settings
+ *
+ * @param string $tag
+ * @return self
+ *
+ */
+ public function removeTag($tag) {
+ $tagsList = parent::get('tagsList');
+ unset($tagsList[$tag]);
+ $languages = $this->wire()->languages;
+ if(!$languages) return $this;
+ foreach($languages as $language) {
+ if($language->isDefault()) continue;
+ $tagsList = parent::get("tagsList$language");
+ if(!is_array($tagsList)) continue;
+ if(!empty($tagsList[$tag])) unset($tagsList[$tag]);
+ }
+ return $this;
+ }
+
+ /**
+ * Get labels for all tags
+ *
+ * #pw-group-settings
+ *
+ * @param Language|int|string|null $language
+ * @return array
+ *
+ */
+ public function getTagLabels($language = null) {
+ return $this->getTagsList($language);
+ }
+
+ /**
+ * Get label for given tag
+ *
+ * #pw-group-settings
+ *
+ * - Returns given tag if it has no label.
+ * - Returns blank string if given tag is not in list and user entered tags are not allowed.
+ *
+ * @param string $tag
+ * @param Language|int|string|null $language
+ * @return mixed
+ *
+ */
+ public function getTagLabel($tag, $language = null) {
+ if(!$language && $this->wire()->langauges) $language = $this->wire()->user->language;
+ $tags = $this->getTagsList($language);
+ if(isset($tags[$tag])) return $tags[$tag];
+ if($this->allowUserTags) return $tag;
+ return '';
+ }
+
+ /**
+ * Set label for tag
+ *
+ * #pw-group-settings
+ *
+ * @param string $tag
+ * @param string $label
+ * @param Language|int|string|null $language
+ * @return self
+ *
+ */
+ public function setTagLabel($tag, $label, $language = null) {
+ return $this->addTag($tag, $label, $language);
+ }
+
+ /**
+ * Get property name for non-default language
+ *
+ * #pw-internal
+ *
+ * @param string|int|Language $language
+ * @param string $key
+ * @return string
+ * @throws WireException
+ *
+ */
+ protected function languageKey($language, $key) {
+ if(!$language) return $key;
+ $languages = $this->wire()->languages;
+ if(!$languages) return $key;
+ if(!wireInstanceOf($language, 'Language')) $language = $languages->get($language);
+ if(!$language) throw new WireException('Invalid language');
+ if(!$language->isDefault()) $key .= $language->id;
+ return $key;
+ }
+
+ /**
+ * Render ready
+ *
+ * #pw-internal
+ *
+ * @param Inputfield $parent
+ * @param bool $renderValueMode
+ * @return bool
+ * @throws WireException
+ *
+ */
+ public function renderReady(Inputfield $parent = null, $renderValueMode = false) {
+ /** @var JqueryUI $jQueryUI */
+ $jQueryUI = $this->wire()->modules->get('JqueryUI');
+ $jQueryUI->use('selectize');
+ $config = $this->wire()->config;
+ $url = $config->urls($this->className());
+ $config->scripts->add($url . 'InputfieldTextTags.js');
+ $config->styles->add($url . 'InputfieldTextTags.css');
+ $this->addClass('InputfieldNoFocus', 'wrapClass');
+ return parent::renderReady($parent, $renderValueMode);
+ }
+
+ /**
+ * Render Inputfield
+ *
+ * #pw-internal
+ *
+ * @return string
+ *
+ */
+ public function ___render() {
+
+ $attrs = $this->getAttributes();
+ unset($attrs['class']);
+
+ $language = $this->wire()->user->language;
+ $tags = $this->getTagsList($language && $language->id ? $language : null);
+ $classes = array();
+ $classes[] = count($tags) ? 'InputfieldTextTagsSelect' : 'InputfieldTextTagsInput';
+
+ if($this->allowUserTags) {
+ $value = $this->tagStringToArray($this->val());
+ foreach($value as $tag) {
+ if(!isset($tags[$tag])) $tags[$tag] = $tag;
+ }
+ } else {
+ $classes[] = 'InputfieldTextTagsSelectOnly';
+ }
+
+ $a = $tags;
+ $tags = array();
+ foreach($a as $tag => $label) {
+ // ensure no digit-only tags which do not survive json_encode()
+ if(ctype_digit("$tag")) $tag = "_$tag";
+ $tags[$tag] = $label;
+ }
+
+ $config = $this->wire()->config;
+ $class = $this->className();
+
+ if($this->hasField) {
+ // page editor
+ $name = $class . '_' . $this->hasField->name . '__tags';
+ $data = $config->$name ? $config->$name : array();
+ $data = array_unique(array_merge($data, $tags));
+ $config->js($name, $data);
+ $attrs['data-cfgname'] = $name;
+ } else {
+ // other usages
+ $attrs['data-cfgname'] = '';
+ $attrs['data-tags'] = json_encode($tags, JSON_UNESCAPED_UNICODE);
+ }
+
+ $attrs['class'] = trim(implode(' ', $classes));
+ $attrs['value'] = $this->encodeNumericTags($this->val());
+ if(empty($attrs['type'])) $attrs['type'] = 'text';
+ $attrStr = $this->getAttributesString($attrs);
+
+ $out = "";
+
+ return $out;
+ }
+
+ /**
+ * Render value
+ *
+ * #pw-internal
+ *
+ * @return string
+ *
+ */
+ public function ___renderValue() {
+ return $this->wire()->sanitizer->entities($this->val());
+ }
+
+ /**
+ * Process input
+ *
+ * #pw-internal
+ *
+ * @param WireInputData $input
+ * @return $this
+ *
+ */
+ public function ___processInput(WireInputData $input) {
+ parent::___processInput($input);
+ $val = $this->val();
+ $value = $this->validateValue($val);
+ if($val !== $value) $this->val($value);
+ return $this;
+ }
+
+ /**
+ * Encode numeric tags (like page IDs) so they aren’t lost by JSON encoding
+ *
+ * #pw-internal
+ *
+ * @param string|array $tags
+ * @param bool $getArray
+ * @return array|string
+ *
+ */
+ protected function encodeNumericTags($tags, $getArray = false) {
+ if(!is_array($tags)) $tags = $this->tagStringToArray($tags);
+ foreach($tags as $key => $tag) {
+ if(ctype_digit("$tag")) $tags[$key] = "_$tag";
+ }
+ return $getArray ? $tags : implode(' ', $tags);
+ }
+
+ /**
+ * Validate and return given tags string
+ *
+ * #pw-internal
+ *
+ * @param string|array $tags
+ * @return string
+ *
+ */
+ protected function validateValue($tags) {
+ if(!is_array($tags)) {
+ $tags = $this->tagStringToArray($tags);
+ }
+ if(!$this->allowUserTags) {
+ $validTags = $this->getTagsList();
+ foreach(array_keys($tags) as $tag) {
+ if(!isset($validTags[$tag])) {
+ unset($tags[$tag]);
+ $this->error(sprintf($this->_('Removed invalid tag value: %s'), $tag));
+ }
+ }
+ }
+ return trim(implode(' ', $tags));
+ }
+
+ /**
+ * Add a selectable option
+ *
+ * For InputfieldHasSelectableOptions interface
+ *
+ * #pw-internal
+ *
+ * @param string|int $value
+ * @param string|null $label
+ * @param array|null $attributes
+ * @return self|$this
+ *
+ */
+ public function addOption($value, $label = null, array $attributes = null) {
+ return $this->addTag($value, $label);
+ }
+
+ /**
+ * Add selectable option with label, optionally for specific language
+ *
+ * For InputfieldHasSelectableOptions interface
+ *
+ * #pw-internal
+ *
+ * @param string|int $value
+ * @param string $label
+ * @param Language|null $language
+ * @return self|$this
+ *
+ */
+ public function addOptionLabel($value, $label, $language = null) {
+ return $this->addTag($value, $label, $language);
+ }
+
+ /**
+ * Static utility function to convert a tags string to an array of [ 'tag' => 'label' ]
+ *
+ * There isn’t currently a dedicated FieldtypeTextTags module, so if you want to convert a string of tags
+ * (as would be returned from a $page “text” field value) you can use this static helper method to convert
+ * the string of tags to an array of labels indexed by tag.
+ *
+ * Note: returned tags and labels are entity-encoded when current $page API var output formatting is ON.
+ *
+ * ~~~~~
+ * $field = $fields->get('tags'); // tags field using FieldtypeText
+ * $tags = $page->get('tags'); // page value (string of tags, i.e. "foo bar baz")
+ * $labels = InputfieldTextTags::tagsLabels($field, $tags);
+ * foreach($labels as $tag => $label) {
+ * echo "$tag: $label";
+ * }
+ * ~~~~~
+ *
+ * #pw-group-helpers
+ *
+ * @param Field $field
+ * @param string|array|null $tags
+ * @return array
+ *
+ */
+ public static function tagsLabels(Field $field, $tags = null) {
+ if(is_string($tags) && !strlen($tags)) return array();
+ /** @var InputfieldTextTags $inputfield */
+ $inputfield = $field->wire()->modules->getModule('InputfieldTextTags', array('noInit' => true));
+ $inputfield->setTagsList($field->get('tagsList'));
+ $languages = $field->wire()->languages;
+ if($languages) {
+ $userLanguage = $field->wire()->user->language;
+ foreach($languages as $language) {
+ if($language->isDefault() || $language->id != $userLanguage->id) continue;
+ $tagsList = $field->get("tagsList$language");
+ if(!empty($tagsList)) $inputfield->setTagsList($tagsList, $language);
+ }
+ } else {
+ $userLanguage = null;
+ }
+ if($tags === null) {
+ // return all tags to labels
+ $labels = $inputfield->getTagsList($userLanguage);
+ } else {
+ // return tags to labels matching given tags
+ $labels = $inputfield->tagsToLabels($tags, $userLanguage);
+ }
+ if($field->wire()->page->of()) {
+ // entity encode labels when page output formatting is on
+ $sanitizer = $field->wire()->sanitizer;
+ $a = $labels;
+ $labels = array();
+ foreach($a as $tag => $label) {
+ $tag = $sanitizer->entities1($tag);
+ $label = $sanitizer->entities($label);
+ $labels[$tag] = $label;
+ }
+ }
+ return $labels;
+ }
+
+
+ /**
+ * Config
+ *
+ * #pw-internal
+ *
+ * @return InputfieldWrapper
+ *
+ */
+ public function ___getConfigInputfields() {
+
+ $moduleInfo = self::getModuleInfo();
+ $modules = $this->wire()->modules;
+ $inputfields = $this->hasFieldtype ? new InputfieldWrapper() : parent::___getConfigInputfields();
+ $languages = $this->wire()->languages;
+
+ /** @var InputfieldFieldset $fieldset */
+ $fieldset = $modules->get('InputfieldFieldset');
+ $fieldset->attr('name', '_tags_settings');
+ $fieldset->label = $moduleInfo['title'];
+ $fieldset->icon = 'tags';
+ $inputfields->prepend($fieldset);
+
+ if($this->hasFieldtype && $this->hasFieldtype != 'FieldtypeText') {
+ $fieldset->description = $this->_('There are currently no configurable settings.');
+ return $inputfields;
+ }
+
+ /** @var InputfieldToggle $f */
+ $f = $modules->get('InputfieldToggle');
+ $f->attr('name', 'allowUserTags');
+ $f->label = $this->_('Allow user to enter their own tags?');
+ $f->val($this->allowUserTags);
+ $fieldset->add($f);
+
+ /** @var InputfieldTextarea $f */
+ $f = $modules->get('InputfieldTextarea');
+ $f->attr('name', 'tagsList');
+ $f->label = $this->label = $this->_('Predefined tags');
+ $f->description = $this->_('Enter predefined tags, 1 per line. To define separate tag and label, specify `tag=label` on the line.');
+ $f->notes = $this->_('Tags may not contain whitespace but labels can.');
+ $f->val($this->tagsListArrayToString($this->tagsList));
+ if($languages) {
+ $f->description .= ' ' . $this->_('To define separate labels per-language, re-enter each tag (with label) for each language.');
+ $f->useLanguages = true;
+ foreach($languages as $language) {
+ if(!$language->isDefault()) $f->set("value$language", $this->tagsListArrayToString($this->get("tagsList$language")));
+ }
+ }
+ $fieldset->add($f);
+
+ return $inputfields;
+ }
+
+}
diff --git a/wire/modules/Inputfield/InputfieldTextTags/InputfieldTextTags.scss b/wire/modules/Inputfield/InputfieldTextTags/InputfieldTextTags.scss
new file mode 100644
index 00000000..4204ad65
--- /dev/null
+++ b/wire/modules/Inputfield/InputfieldTextTags/InputfieldTextTags.scss
@@ -0,0 +1,33 @@
+$tag-background-color: #f0f3f7;
+$tag-border-color: #cbd7e3;
+$tag-border-colors: #b1c3d4 #cbd7e3 #cbd7e3 #cbd7e3;
+
+.InputfieldTextTags {
+ label.pw-hidden {
+ display: none;
+ }
+ input.InputfieldTextTagsSelect:not(.selectized),
+ input.InputfieldTextTagsInput:not(.selectized) {
+ color: $tag-background-color;
+ }
+}
+
+.Inputfield {
+ .selectize-input {
+ border: 1px solid $tag-border-colors;
+ border-color: $tag-border-colors;
+ box-shadow: none;
+ }
+ .selectize-control .selectize-input.has-items > div {
+ background: $tag-background-color;
+ white-space: nowrap;
+ border: 1px solid $tag-border-color;
+ border-radius: 3px;
+ a.remove {
+ color: #555;
+ }
+ }
+ .selectize-input:not(.has-items) {
+ background: $tag-background-color;
+ }
+}