├── package.json ├── LICENSE ├── dist ├── css │ └── multi-select-tag.min.css └── js │ └── multi-select-tag.min.js ├── src ├── css │ └── multi-select-tag.css └── js │ └── multi-select-tag.js ├── .gitignore └── README.md /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "multi-select-tag", 3 | "version": "4.0.1", 4 | "description": "HTML multiple tag selection input.", 5 | "scripts": { 6 | "test": "echo \"Error: no test specified\" && exit 1", 7 | "minify-js": "minify src/js/multi-select-tag.js > dist/js/multi-select-tag.min.js", 8 | "minify-css": "minify src/css/multi-select-tag.css > dist/css/multi-select-tag.min.css", 9 | "minify": "npm run minify-js && npm run minify-css" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/habibmhamadi/multi-select-tag.git" 14 | }, 15 | "keywords": [ 16 | "multi", 17 | "select", 18 | "multiple", 19 | "multi-select", 20 | "multiple-select", 21 | "tag", 22 | "vue", 23 | "tailwind", 24 | "css", 25 | "react", 26 | "html" 27 | ], 28 | "author": "Habib Mhamadi", 29 | "license": "MIT", 30 | "bugs": { 31 | "url": "https://github.com/habibmhamadi/multi-select-tag/issues" 32 | }, 33 | "homepage": "https://github.com/habibmhamadi/multi-select-tag#readme", 34 | "dependencies": { 35 | "minify": "^9.1.0" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Habibullah Mohammadi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /dist/css/multi-select-tag.min.css: -------------------------------------------------------------------------------- 1 | .multi-select-tag{all:revert!important;font-family:inherit!important}.multi-select-tag .wrapper{position:relative}.multi-select-tag .tag-container{display:flex;padding:.5rem;flex-wrap:wrap;gap:.5rem;border:1px solid #e5e7eb;border-radius:.25rem;font-size:.875rem;line-height:1.25rem;background-color:#fff}.multi-select-tag .tag-input{padding:.25rem;flex-grow:1;outline:2px solid transparent;outline-offset:2px;border:none;background-color:#fff;margin:0;line-height:1.25rem}.multi-select-tag .dropdown{overflow:auto;position:absolute;z-index:10;padding:0;margin-top:.25rem;border-radius:.25rem;border:1px solid #e5e7eb;width:100%;max-height:15rem;background-color:#fff;box-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -2px rgba(0,0,0,.05)}.multi-select-tag .li{padding:.5rem;cursor:pointer;list-style-type:none}.multi-select-tag .li:hover{background-color:#e5e7eb}.multi-select-tag .li-arrow{background-color:#d1d5db}.multi-select-tag .tag-item{display:flex;padding-top:.25rem;padding-bottom:.25rem;padding-left:.5rem;padding-right:.5rem;align-items:center;border-radius:.25rem;color:#1e40af;background-color:#dbeafe}.multi-select-tag .cross{margin-left:.25rem;cursor:pointer}.multi-select-tag .hidden{display:none} 2 | -------------------------------------------------------------------------------- /src/css/multi-select-tag.css: -------------------------------------------------------------------------------- 1 | .multi-select-tag { 2 | all: revert !important; 3 | font-family: inherit !important; 4 | } 5 | .multi-select-tag .wrapper { 6 | position: relative; 7 | } 8 | .multi-select-tag .tag-container { 9 | display: flex; 10 | padding: 0.5rem; 11 | flex-wrap: wrap; 12 | gap: 0.5rem; 13 | border: 1px solid #e5e7eb; 14 | border-radius: 0.25rem; 15 | font-size: 0.875rem; 16 | line-height: 1.25rem; 17 | background-color: #ffffff; 18 | } 19 | .multi-select-tag .tag-input { 20 | padding: 0.25rem; 21 | flex-grow: 1; 22 | outline: 2px solid transparent; 23 | outline-offset: 2px; 24 | border: none; 25 | background-color: #ffffff; 26 | margin: 0; 27 | line-height: 1.25rem; 28 | } 29 | .multi-select-tag .dropdown { 30 | overflow: auto; 31 | position: absolute; 32 | z-index: 10; 33 | padding: 0px; 34 | margin-top: 0.25rem; 35 | border-radius: 0.25rem; 36 | border: 1px solid #e5e7eb; 37 | width: 100%; 38 | max-height: 15rem; 39 | background-color: #ffffff; 40 | box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); 41 | } 42 | .multi-select-tag .li { 43 | padding: 0.5rem; 44 | cursor: pointer; 45 | list-style-type: none; 46 | } 47 | .multi-select-tag .li:hover { 48 | background-color: #E5E7EB; 49 | } 50 | .multi-select-tag .li-arrow { 51 | background-color: #D1D5DB; 52 | } 53 | .multi-select-tag .tag-item { 54 | display: flex; 55 | padding-top: 0.25rem; 56 | padding-bottom: 0.25rem; 57 | padding-left: 0.5rem; 58 | padding-right: 0.5rem; 59 | align-items: center; 60 | border-radius: 0.25rem; 61 | color: #1E40AF; 62 | background-color: #DBEAFE; 63 | } 64 | .multi-select-tag .cross { 65 | margin-left: 0.25rem; 66 | cursor: pointer; 67 | } 68 | .multi-select-tag .hidden { 69 | display: none; 70 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | package-lock.json 18 | .vscode 19 | 20 | # Directory for instrumented libs generated by jscoverage/JSCover 21 | lib-cov 22 | 23 | # Coverage directory used by tools like istanbul 24 | coverage 25 | *.lcov 26 | 27 | # nyc test coverage 28 | .nyc_output 29 | 30 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 31 | .grunt 32 | 33 | # Bower dependency directory (https://bower.io/) 34 | bower_components 35 | 36 | # node-waf configuration 37 | .lock-wscript 38 | 39 | # Compiled binary addons (https://nodejs.org/api/addons.html) 40 | build/Release 41 | 42 | # Dependency directories 43 | node_modules/ 44 | jspm_packages/ 45 | 46 | # TypeScript v1 declaration files 47 | typings/ 48 | 49 | # TypeScript cache 50 | *.tsbuildinfo 51 | 52 | # Optional npm cache directory 53 | .npm 54 | 55 | # Optional eslint cache 56 | .eslintcache 57 | 58 | # Microbundle cache 59 | .rpt2_cache/ 60 | .rts2_cache_cjs/ 61 | .rts2_cache_es/ 62 | .rts2_cache_umd/ 63 | 64 | # Optional REPL history 65 | .node_repl_history 66 | 67 | # Output of 'npm pack' 68 | *.tgz 69 | 70 | # Yarn Integrity file 71 | .yarn-integrity 72 | 73 | # dotenv environment variables file 74 | .env 75 | .env.test 76 | 77 | # parcel-bundler cache (https://parceljs.org/) 78 | .cache 79 | 80 | # Next.js build output 81 | .next 82 | 83 | # Nuxt.js build / generate output 84 | .nuxt 85 | # dist 86 | 87 | # Gatsby files 88 | .cache/ 89 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 90 | # https://nextjs.org/blog/next-9-1#public-directory-support 91 | # public 92 | .idea 93 | 94 | # vuepress build output 95 | .vuepress/dist 96 | 97 | # Serverless directories 98 | .serverless/ 99 | 100 | # FuseBox cache 101 | .fusebox/ 102 | 103 | # DynamoDB Local files 104 | .dynamodb/ 105 | 106 | # TernJS port file 107 | .tern-port 108 | 109 | index.html -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Multi-Select-Tag 2 | 3 | MultiSelectTag is a lightweight, closure-based JavaScript library that transforms a standard HTML `` element. 11 | - **Public API:** Helper methods `selectAll()`, `clearAll()`, and `getSelectedTags()`. 12 | - **Multiple Instances:** Each instance is independent and encapsulated. 13 | 14 | 15 | ## Installation 16 | 17 | Simply copy and paste the following css link and js script to your html file head and body. 18 | ```html 19 | 20 | 21 | ``` 22 | ```html 23 | 24 | 25 | ``` 26 | 27 | ## Usage 28 | 29 | Create a multiple select element with options. Preselected options (via the selected attribute) will be automatically loaded. 30 | ```html 31 | 38 | ``` 39 | Pass the `id` of your select element and an optional configuration object to create an instance of the widget. 40 | ```html 41 | 51 | ``` 52 |
53 | 54 | ## Using the Public API 55 | The library exposes a minimal public API: 56 | 57 | 58 | ```javascript 59 | tagSelector.selectAll(); // Select All Options 60 | tagSelector.clearAll(); // Clear All Selections 61 | tagSelector.getSelectedTags(); // Get Currently Selected Tags 62 | ``` 63 |

64 | 65 | ## Customization & Styling 66 | For customization feel free to download the css file and apply your own styles inside the defined classes. 67 |

68 | 69 | ## Contribute 70 | Report bugs and suggest feature in [issue tracker](https://github.com/habibmhamadi/multi-select-tag/issues). Feel free to `Fork` and send `Pull Requests`. 71 | 72 | 73 | ## License 74 | 75 | [MIT](https://github.com/habibmhamadi/multi-select-tag/blob/main/LICENSE) 76 | -------------------------------------------------------------------------------- /dist/js/multi-select-tag.min.js: -------------------------------------------------------------------------------- 1 | function MultiSelectTag(e,t){var n,l,r,i,a,o=[],c=t.onChange||function(){},d=t.required||!1,u="number"==typeof t.maxSelection?t.maxSelection:1/0,s=t.placeholder||"Search",f=[],v=[],p=-1;if(!(n=document.getElementById(e)))throw new Error("Select element not found.");if("SELECT"!==n.tagName)throw new Error("Element is not a select element.");n.style.display="none";for(var h=0;h-1){var t=a.children[p];t&&t.scrollIntoView({block:"nearest"})}}else a.classList.add("hidden")}function w(){for(var e=r.querySelectorAll(".tag-item"),t=0;t=u||(f.find((function(t){return t.id===e.id}))||f.push({id:e.id,label:e.label}),i.value="",v=o.filter((function(e){return e.label.toLowerCase().includes(i.value.toLowerCase())})),p=-1,w(),E(),b(),c(f))}function b(){for(var e=0;e\n
\n \n
\n \n `,r=l.querySelector("#selected-tags"),i=l.querySelector("#tag-input"),a=l.querySelector("#dropdown"),i.addEventListener("input",(function(e){var t=e.target.value.toLowerCase();v=o.filter((function(e){return e.label.toLowerCase().includes(t)})),p=-1,E()})),i.addEventListener("keydown",(function(e){var t=a.querySelectorAll("li");if("Backspace"===e.key&&""===i.value&&f.length>0)return f.pop(),w(),E(),b(),c(f),void e.preventDefault();if("ArrowDown"===e.key){if(e.preventDefault(),0===t.length)return;p=(p+1)%t.length,E()}else if("ArrowUp"===e.key){if(e.preventDefault(),0===t.length)return;p=(p-1+t.length)%t.length,E()}else if("Enter"===e.key&&(e.preventDefault(),p>-1&&t[p])){var n=t[p].textContent,l=o.find((function(e){return e.label===n}));l&&L(l)}})),document.addEventListener("click",(function(e){l.contains(e.target)||(p=-1,a.classList.add("hidden"))})),i.addEventListener("focus",(function(){E()})),w(),b(),{selectAll:function(){for(var e=0;e=u);e++){var t=o[e];f.find((function(e){return e.id===t.id}))||f.push({id:t.id,label:t.label})}i.value="",v=o.slice(),p=-1,w(),E(),b(),c(f)},clearAll:function(){f=[],w(),E(),b(),c(f)},getSelectedTags:function(){return f}}} 2 | -------------------------------------------------------------------------------- /src/js/multi-select-tag.js: -------------------------------------------------------------------------------- 1 | // Author: Habib Mhamadi 2 | // Email: habibmhamadi@gmail.com 3 | 4 | function MultiSelectTag(selectElOrId, config) { 5 | config = config || {}; 6 | 7 | // Private variables 8 | var selectElement, 9 | optionsData = [], 10 | container, 11 | onChange = config.onChange || function() {}, 12 | required = config.required || false, 13 | maxSelection = typeof config.maxSelection === 'number' ? config.maxSelection : Infinity, 14 | placeholder = config.placeholder || 'Search', 15 | selectedTags = [], 16 | filteredOptions = [], 17 | highlightedIndex = -1, 18 | selectedTagsContainer, 19 | tagInput, 20 | dropdown; 21 | 22 | // Resolve the select element from a string id. 23 | selectElement = document.getElementById(selectElOrId); 24 | if (!selectElement) { 25 | throw new Error("Select element not found."); 26 | } 27 | if (selectElement.tagName !== 'SELECT') { 28 | throw new Error("Element is not a select element."); 29 | } 30 | 31 | // Hide the original select element. 32 | selectElement.style.display = 'none'; 33 | 34 | // Read options from the select element. 35 | for (var i = 0; i < selectElement.options.length; i++) { 36 | var option = selectElement.options[i]; 37 | optionsData.push({ 38 | id: option.value, 39 | label: option.text, 40 | preselected: option.selected 41 | }); 42 | } 43 | 44 | // Create a container for the widget and insert it after the select. 45 | container = document.createElement('div'); 46 | container.className = 'multi-select-tag'; 47 | selectElement.parentNode.insertBefore(container, selectElement.nextSibling); 48 | 49 | // Preselect any options marked as selected. 50 | for (var j = 0; j < optionsData.length; j++) { 51 | if (optionsData[j].preselected) { 52 | selectedTags.push({ id: optionsData[j].id, label: optionsData[j].label }); 53 | } 54 | } 55 | filteredOptions = optionsData.slice(); 56 | 57 | // Private function: Build the widget's HTML structure. 58 | function init() { 59 | container.innerHTML = ` 60 |
61 |
62 | 63 |
64 | 65 |
`; 66 | selectedTagsContainer = container.querySelector('#selected-tags'); 67 | tagInput = container.querySelector('#tag-input'); 68 | dropdown = container.querySelector('#dropdown'); 69 | bindEvents(); 70 | } 71 | 72 | // Private function: Bind event listeners. 73 | function bindEvents() { 74 | tagInput.addEventListener('input', function(e) { 75 | var searchTerm = e.target.value.toLowerCase(); 76 | filteredOptions = optionsData.filter(function(opt) { 77 | return opt.label.toLowerCase().includes(searchTerm); 78 | }); 79 | highlightedIndex = -1; 80 | renderDropdown(); 81 | }); 82 | 83 | tagInput.addEventListener('keydown', function(e) { 84 | var visibleOptions = dropdown.querySelectorAll('li'); 85 | if (e.key === 'Backspace' && tagInput.value === '') { 86 | if (selectedTags.length > 0) { 87 | selectedTags.pop(); 88 | renderSelectedTags(); 89 | renderDropdown(); 90 | syncToSelect(); 91 | onChange(selectedTags); 92 | e.preventDefault(); 93 | return; 94 | } 95 | } 96 | if (e.key === 'ArrowDown') { 97 | e.preventDefault(); 98 | if (visibleOptions.length === 0) return; 99 | highlightedIndex = (highlightedIndex + 1) % visibleOptions.length; 100 | renderDropdown(); 101 | } else if (e.key === 'ArrowUp') { 102 | e.preventDefault(); 103 | if (visibleOptions.length === 0) return; 104 | highlightedIndex = (highlightedIndex - 1 + visibleOptions.length) % visibleOptions.length; 105 | renderDropdown(); 106 | } else if (e.key === 'Enter') { 107 | e.preventDefault(); 108 | if (highlightedIndex > -1 && visibleOptions[highlightedIndex]) { 109 | var selectedLabel = visibleOptions[highlightedIndex].textContent; 110 | var option = optionsData.find(function(opt) { 111 | return opt.label === selectedLabel; 112 | }); 113 | if (option) { 114 | selectTag(option); 115 | } 116 | } 117 | } 118 | }); 119 | 120 | document.addEventListener('click', function(e) { 121 | if (!container.contains(e.target)) { 122 | highlightedIndex = -1; 123 | dropdown.classList.add('hidden'); 124 | } 125 | }); 126 | 127 | tagInput.addEventListener('focus', function() { 128 | renderDropdown(); 129 | }); 130 | } 131 | 132 | // Private function: Render the dropdown list, excluding already selected options. 133 | function renderDropdown() { 134 | dropdown.innerHTML = ''; 135 | var visibleOptions = filteredOptions.filter(function(opt) { 136 | return !selectedTags.find(function(tag) { 137 | return tag.id === opt.id; 138 | }); 139 | }); 140 | if (visibleOptions.length === 0) { 141 | dropdown.classList.add('hidden'); 142 | return; 143 | } 144 | visibleOptions.forEach(function(option, index) { 145 | var li = document.createElement('li'); 146 | li.textContent = option.label; 147 | li.className = 'li'; 148 | if (index === highlightedIndex) { 149 | li.classList.add('li-arrow'); 150 | } 151 | li.addEventListener('click', function() { 152 | selectTag(option); 153 | }); 154 | dropdown.appendChild(li); 155 | }); 156 | dropdown.classList.remove('hidden'); 157 | if (highlightedIndex > -1) { 158 | var highlightedItem = dropdown.children[highlightedIndex]; 159 | if (highlightedItem) { 160 | highlightedItem.scrollIntoView({ block: 'nearest' }); 161 | } 162 | } 163 | } 164 | 165 | // Private function: Render the selected tags. 166 | function renderSelectedTags() { 167 | var tagItems = selectedTagsContainer.querySelectorAll('.tag-item'); 168 | for (var k = 0; k < tagItems.length; k++) { 169 | tagItems[k].remove(); 170 | } 171 | selectedTags.forEach(function(tag) { 172 | var span = document.createElement('span'); 173 | span.className = 'tag-item'; 174 | span.textContent = tag.label; 175 | var closeBtn = document.createElement('span'); 176 | closeBtn.className = 'cross'; 177 | closeBtn.innerHTML = '×'; 178 | closeBtn.addEventListener('click', function() { 179 | deselectTag(tag); 180 | }); 181 | span.appendChild(closeBtn); 182 | selectedTagsContainer.insertBefore(span, tagInput); 183 | }); 184 | } 185 | 186 | // Private function: Add a tag to the selection. 187 | function selectTag(option) { 188 | if (selectedTags.length >= maxSelection) return; 189 | if (!selectedTags.find(function(tag) { return tag.id === option.id; })) { 190 | selectedTags.push({ id: option.id, label: option.label }); 191 | } 192 | tagInput.value = ''; 193 | filteredOptions = optionsData.filter(function(opt) { 194 | return opt.label.toLowerCase().includes(tagInput.value.toLowerCase()); 195 | }); 196 | highlightedIndex = -1; 197 | renderSelectedTags(); 198 | renderDropdown(); 199 | syncToSelect(); 200 | onChange(selectedTags); 201 | } 202 | 203 | // Private function: Remove a tag from the selection. 204 | function deselectTag(tag) { 205 | selectedTags = selectedTags.filter(function(t) { 206 | return t.id !== tag.id; 207 | }); 208 | renderSelectedTags(); 209 | renderDropdown(); 210 | syncToSelect(); 211 | onChange(selectedTags); 212 | } 213 | 214 | // Private function: Synchronize the widget's selection with the underlying select element. 215 | function syncToSelect() { 216 | for (var i = 0; i < selectElement.options.length; i++) { 217 | var optionElem = selectElement.options[i]; 218 | var found = selectedTags.find(function(tag) { 219 | return tag.id === optionElem.value; 220 | }); 221 | optionElem.selected = !!found; 222 | } 223 | if (required) { 224 | tagInput.required = selectedTags.length ? false : true; 225 | } else { 226 | tagInput.required = false; 227 | } 228 | } 229 | 230 | // Initialize the widget. 231 | init(); 232 | renderSelectedTags(); 233 | syncToSelect(); 234 | 235 | // Public API: Return an object exposing only public methods. 236 | return { 237 | selectAll: function() { 238 | for (var i = 0; i < optionsData.length; i++) { 239 | if (selectedTags.length >= maxSelection) break; 240 | var opt = optionsData[i]; 241 | if (!selectedTags.find(function(tag) { return tag.id === opt.id; })) { 242 | selectedTags.push({ id: opt.id, label: opt.label }); 243 | } 244 | } 245 | tagInput.value = ''; 246 | filteredOptions = optionsData.slice(); 247 | highlightedIndex = -1; 248 | renderSelectedTags(); 249 | renderDropdown(); 250 | syncToSelect(); 251 | onChange(selectedTags); 252 | }, 253 | clearAll: function() { 254 | selectedTags = []; 255 | renderSelectedTags(); 256 | renderDropdown(); 257 | syncToSelect(); 258 | onChange(selectedTags); 259 | }, 260 | getSelectedTags: function() { 261 | return selectedTags; 262 | } 263 | }; 264 | } --------------------------------------------------------------------------------