Skip to content

Instantly share code, notes, and snippets.

@mindflowgo
Created December 8, 2024 05:45
Show Gist options
  • Save mindflowgo/95d6ac632f6496c816145fafa2899366 to your computer and use it in GitHub Desktop.
Save mindflowgo/95d6ac632f6496c816145fafa2899366 to your computer and use it in GitHub Desktop.
InputTags_Basic
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- Bootstrap 5 not used by Input Tags -->
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
<!-- Only CSS used by InputTags -->
<link rel="stylesheet" href="/mindflowgo/pen/PwYNQVe.css">
</head>
<body>
<div class="container mt-5">
<h1 class="text-center mb-4">Tags Input</h1>
<div class="mb-3">
<p class="mb-2">Tag List:</p>
<!-- specify the ul LIST element to show the tags -->
<ul id="tagsList"><li><strong>List:</strong></li></ul>
<!-- include the input box to input the tags -->
<p><i>Type something and press Enter</i></p>
<input type="text" id="tagsInput" class="form-control mt-2" spellcheck="false" placeholder="Enter a tag" />
</div>
<div class="mb-3">
<p class="mb-2">List results: <strong><span id="tagsData"></span></strong></p>
<div class="mt-5 d-grid gap-2 d-md-flex justify-content-md-start">
<button class="btn btn-secondary ms-md-2" onClick="btnAddTag('hello')">Add Tag 'hello'</button>
</div>
</div>
</div>
<!--Simple tags input implementation-->
<script type="module">
import InputTags from "/mindflowgo/pen/PwYNQVe.js"
function displayTags( _tags ){
console.log( `[displayTags] called, with tags: ${_tags}` );
document.querySelector('#tagsData').innerHTML = _tags;
}
function btnAddTag( _tag ){
inputTags.addTag(_tag);
}
const inputTags = new InputTags({
inputId: "tagsInput", listId: "tagsList",
unique: true, updateFn: displayTags,
});
// export module functions for DOM
window.btnAddTag = btnAddTag;
</script>
</body>
</html>
/***************************************************************
* = TAG INPUT = v1.0
*
* Simple tag input engine to allow styling input tags in your code.
* Pure javascript, unobtrusive to other code, but DOES require DOM
* access.
*
* By Filipe Laborde ([email protected]), 7-Dec-2024
*
* Inspired by https://github.com/rk4bir/simple-tags-input - (c) 2022 Raihan Kabir
*
* MIT License: Use as you wish along with the risks of using this.
*
* USAGE:
* import InputTags from '...'
*
* const inputTags = new InputTags({
* listId: "tagsList", inputId: "tagsInput", outputId: "saveInput", // DOM elements to attach to
* updateFn: mySave // Pass in function to call with updated tag items
* specialKeys: true, delimiter: ';', // To record special keys (ex. arrow, etc)
* tags: ['first','second'], unique: false,
* autocompleteList: [ "One", "Two", "AutoSelect3", "AutoSelect4"]
* });
*
* Example reason for changing delimiter from comma: when pressing special keys (Ctrl,Alt), if
* multiple pressed
*/
export default class InputTags {
constructor(props) {
const { tags, unique, delimiter, specialKeys, updateFn, inputId, listId, outputId, autocompleteList }= props;
const settings = {
tagCnt: 0,
tags: [],
unique: unique || false,
delimiter: delimiter || ',',
specialKeys: specialKeys || false,
updateFn: updateFn || undefined,
searchItems: autocompleteList || [],
listID: listId,
listEl: null,
inputEl: null,
outputEl: null,
searchListEl: null,
}
Object.assign(this, settings);
// initialize plugin
try {
this.inputEl = document.getElementById(inputId);
this.listEl = document.getElementById(listId);
if (this.inputEl.tagName != "INPUT" || this.listEl.tagName != "UL") {
throw new Error("TagsInput: NEED EXISTING input and list element: inputEl, listEl");
}
this.outputEl = document.getElementById(outputId) || undefined;
this.listEl.classList.add("tagsList");
// keyup: allows default behavior (ex. Enter = next item)
// keydown: intercepts keys, must display/move manually
this.inputEl.addEventListener( this.specialKeys ? "keydown" : "keyup", this.handleInput.bind(this));
document.addEventListener(`__${this.listID}_`, this.handleTagEvent.bind(this));
// create autocomplete
if( this.searchItems.length>0 ){
this.createAutoCompleteElement();
this.inputEl.addEventListener( "keyup", this.handleAutoCompleteList.bind(this));
}
if( tags && tags.length>0 )
tags.forEach( tag => this.addTag(tag) );
} catch (e) {
throw new Error("TagsInput: failed setup, quitting.");
}
}
createAutoCompleteElement() {
// create search list `ul` element and set to `this.searchListEl`
const elName = this.listID + '_autocomplete';
const el = `<ul id='${elName}' class='tagsAutocompleteList' style='display: none'></ul>`;
this.inputEl.insertAdjacentHTML("afterend", el);
this.searchListEl = document.getElementById(elName);
}
saveOutput(){
// output to a specified input field (ex. hidden) (if given)
const outputData = this.tags.join(this.delimiter);
if( this.outputEl ) this.outputEl.value = outputData;
// calling specified function with tag output (if given)
if( this.updateFn ) this.updateFn(outputData);
}
encodeHTMLEntities(text) {
return text.replace(/[\u00A0-\u9999<>\&'"]/g, c => '&#'+c.charCodeAt(0)+';')
}
escapeQuotes(text,slash=false) {
// we do the \\ as well so it's a sort of double-escape, because it un-escapes one level for suggestion box
return text.replace(/(['"])/g, c => (slash ? '\\' : '') + '&#'+c.charCodeAt(0)+';')
}
getTags() {
return this.tags;
}
addTag(tag) {
/* Add a new tag to the list, if multiple delimiter (ex. comma)-separated, they each become individual tags */
let _html = '';
tag.split(this.delimiter).forEach(tag => {
tag = tag.trim();
if( tag != '' && (!this.unique || !this.tags.includes(tag)) ){
this.tags.push(tag);
this.tagCnt++; // each new entry new cnt, so always unique
const itemID = this.listID + '_' + this.tagCnt;
// htmlEntities on html; and escape ' for data-item in case messages structure
_html += `<li id='${itemID}' data-item='${this.escapeQuotes(tag)}'>${this.encodeHTMLEntities(tag)} `
+`<span onclick="_tagAction('remove','${this.listID}','${itemID}')">X</span></li>`;
}
});
this.listEl.innerHTML += _html;
this.saveOutput();
}
suggestTag(tagArray) {
const _html = tagArray.map(tag => `<li onclick="_tagAction('add','${this.listID}','','${this.escapeQuotes(tag,true)}')">${this.encodeHTMLEntities(tag)}</li>` ).join('');
return _html;
}
removeTag(itemID) {
// as tag-data may not be unique, we use the unique-DOM-id created for entry
const itemEl = document.getElementById(itemID);
if( !itemEl ) return;
itemEl.remove();
// now refresh tags based on actual DOM elements present
this.tags = [];
document.querySelectorAll(`#${this.listID} LI`).forEach(el => this.tags.push(el.dataset.item));
this.saveOutput();
}
handleInput(e) {
let key = e.key; // e.code provides Left/Right for Meta,Alt,etc.
if( this.specialKeys ){
// won't show these special keys if first pressed
const ignoreSpecialKeys = ['Shift'];
// we will create tag immediately for any of these special keys pressed
const firstSpecialKeys = ['Backspace','Enter','←','→','↑','↓'];
// will allow groupings of these
const specialKeyList = ['Control','Meta','Alt'];
// map any special keys we want symbols to appear for, remap here (and then reference them by their new symbol!)
const symbolMap = {
ArrowLeft: "←",
ArrowUp: "↑",
ArrowRight: "→",
ArrowDown: "↓"
}
if( symbolMap[key] )
key = symbolMap[key];
if( ignoreSpecialKeys.includes(key) ){ //e.target.value.length == 0 &&
// we don't want showing shift as it's usually to uppercase a letter
return;
} else if( e.target.value.length == 0 && firstSpecialKeys.includes(key) ){
// SPECIAL KEY + First keypress (arrows,backspace,enter): immediate tag generation
// (their behaviour changes beyond first character to help navigation);
e.preventDefault();
this.addTag(key);
e.target.value = "";
return;
} else if( key == "Enter" ) {
// always intercept and prevent enter's default
e.preventDefault();
} else {
// clear any empty commas (,,) - build array of comma separated keys in input
const priorKeys = e.target.value.split(',').filter(_key => _key.trim() !== '');
// if it's special characters, we only allow some, and ONLY with other special characters; ignore rest
const allSpecial = priorKeys.every(_key => specialKeyList.includes(_key));
const keyAlreadyExists = priorKeys.includes(key);
if( key.length>2 ){
// SPECIAL KEYS - add if unique and prior are special too
if( allSpecial && !keyAlreadyExists && specialKeyList.includes(key) ) {
// if prior are special, we allow adding new unique ones
e.target.value += (e.target.value.length>0 ? ',' : '') + key;
}
// special key but, already exists, not on list, etc, so ignoring:
// BUT letting system default behaviours bubble -> else add: else { preventDefault(); }
} else if( allSpecial && e.target.value.length>0 ){
// NORMAL KEY: are all prior keys special? -> insert comma before key!
// preventDefault in case holding special key and pressing normal (don't want system behaviour)
e.preventDefault();
e.target.value += ',' + key;
}
// otherwise handle normally
}
}
// Normal processing for keys - add to input (and do search-items if available), unless Enter in which we create tag
if( key == "Enter") {
// insert new tag
this.addTag(e.target.value);
e.target.value = "";
}
}
handleAutoCompleteList(e) {
// on keyup so after key actions complete
const q = e.target.value.trim();
let results = [];
if( q.length>1 ){
results = this.searchItems.filter(item => item.toLowerCase().indexOf(q.toLowerCase()) != -1);
const _html = "<p class='tagsAutocompleteListHeader'>Search Result:</p>"
+ this.suggestTag(results);
this.searchListEl.innerHTML = _html;
}
this.searchListEl.style.display =( q.length>1 && results.length>0 ? 'block' : 'none' );
}
handleTagEvent(e) {
// Handles outside plugin tasks (add/remove tag via event listener)
const { action, itemID, tag }= e.detail;
if (action == 'add'){
this.addTag(tag);
this.inputEl.value = '';
this.inputEl.focus();
} else if (action == 'remove'){
this.removeTag(itemID);
}
if( this.searchListEl ) this.searchListEl.style.display = 'none';
}
} // END of class: TagsInput
// DOM accessible function (injected into HTML)
function _tagAction(action, listID, itemID, tag='') {
let eventDetails = {
bubbles: true,
cancelable: true,
detail: { action, itemID, tag }
};
const event = new CustomEvent(`__${listID}_`, eventDetails);
document.dispatchEvent(event);
}
// Expose function to HTML
window._tagAction = _tagAction;
.tagsList{
display: flex;
flex-wrap: wrap;
border: none;
padding-left: 0px;
}
.tagsList li{
color: #333;
list-style: none;
border-radius: 5px;
background: #d4d7ff;
padding: 5px 8px 5px 10px;
border: 1px solid #e3e1e1;
margin-right: 1px
}
.tagsList li span{
height: 15px;
width: 15px;
color: #808080;
margin-left: 3px;
font-size: 12px;
cursor: pointer;
border-radius: 2px;
border: 1px solid #e3e1e1;
padding: 2px;
background: #dfdfdf;
justify-content: center;
}
.tagsAutocompleteList {
position: relative;
padding-left: 0px;
border: 1px solid lightgrey;
margin-top: 5px;
max-height: 200px;
overflow-y: auto;
}
.tagsAutocompleteList li{
list-style: none;
border-bottom: 1px solid lightgrey;
padding: 5px;
cursor: pointer;
}
.tagsAutocompleteList li:hover {
background: #f5f5f5;
font-weight: 600;
}
.tagsAutocompleteListHeader {
border-bottom: 1px solid lightgrey;
margin-bottom: 0px;
font-weight: bold;
padding: 5px;
font-style: italic
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment