From d792841d2c331ecd5661b37d4e62d5cc91226f8a Mon Sep 17 00:00:00 2001 From: Thomas Date: Tue, 6 Aug 2024 16:39:18 +0200 Subject: [PATCH] add licence --- tags.js | 10 +--------- tags.min.js | 4 ++++ tags.min.js.map | 4 ++-- 3 files changed, 7 insertions(+), 11 deletions(-) diff --git a/tags.js b/tags.js index a006e3d..7f344f1 100644 --- a/tags.js +++ b/tags.js @@ -1,14 +1,6 @@ /** * Bootstrap 5 (and 4!) tags - * - * Turns your select[multiple] into nice tags lists - * - * Required Bootstrap 5 styles: - * - badge - * - background-color utility - * - text-truncate utility - * - forms - * - dropdown + * @license MIT */ // #region config diff --git a/tags.min.js b/tags.min.js index 69ff1be..ce9bf29 100644 --- a/tags.min.js +++ b/tags.min.js @@ -1,2 +1,6 @@ var M={items:[],allowNew:!1,showAllSuggestions:!1,badgeStyle:"primary",allowClear:!1,clearEnd:!1,selected:[],regex:"",separator:[],max:0,clearLabel:"Clear",searchLabel:"Type a value",showDropIcon:!0,keepOpen:!1,allowSame:!1,baseClass:"",placeholder:"",addOnBlur:!1,showDisabled:!1,hideNativeValidation:!1,suggestionsThreshold:-1,maximumItems:0,autoselectFirst:!0,updateOnSelect:!1,highlightTyped:!1,highlightClass:"",fullWidth:!0,fixed:!1,fuzzy:!1,startsWith:!1,singleBadge:!1,activeClasses:["bg-primary","text-white"],labelField:"label",valueField:"value",searchFields:["label"],queryParam:"query",server:"",serverMethod:"GET",serverParams:{},serverDataKey:"data",fetchOptions:{},liveServer:!1,noCache:!0,allowHtml:!1,debounceTime:300,notFoundMessage:"",inputFilter:a=>a,sanitizer:a=>W(a),onRenderItem:(a,t,e)=>e.config("allowHtml")?t:e.config("sanitizer")(t),onSelectItem:(a,t)=>{},onClearItem:(a,t)=>{},onCreateItem:(a,t)=>{},onBlur:(a,t)=>{},onFocus:(a,t)=>{},onCanAdd:(a,t,e)=>{},confirmClear:(a,t)=>Promise.resolve(),confirmAdd:(a,t)=>Promise.resolve(),onServerResponse:(a,t)=>a.json(),onServerError:(a,t,e)=>{a.name==="AbortError"||t.aborted||console.error(a)}},v="tags-",H="is-loading",N="is-active",g="is-invalid",z="is-max-reached",A="show",b="data-value",E="next",D="prev",R="form-control-focus",q="form-placeholder-shown",j="form-control-disabled",I=new WeakMap,P=0,L=window.bootstrap&&window.bootstrap.Tooltip;function _(a,t=300){let e;return(...i)=>{clearTimeout(e),e=setTimeout(()=>{a.apply(this,i)},t)}}function J(a,t=null){let e=p("span");document.body.appendChild(e),e.style.fontSize=t||"inherit",e.style.height="auto",e.style.width="auto",e.style.position="absolute",e.style.whiteSpace="no-wrap",e.innerHTML=W(a);let i=Math.ceil(e.clientWidth);return document.body.removeChild(e),i}function W(a){return a.replace(/[\x26\x0A\<>'"]/g,function(t){return"&#"+t.charCodeAt(0)+";"})}function U(a){return a.normalize("NFD").replace(/[\u0300-\u036f]/g,"")}function F(a){return a?U(a.toString()).toLowerCase():""}function $(a,t){if(a.indexOf(t)>=0)return!0;let e=0;for(let i=0;ie[i],t)}var y=class{constructor(t,e={}){if(!(t instanceof HTMLElement)){console.error("Invalid element",t);return}I.set(t,this),P++,this.i=t,this.M(e),this.p=!1,this.h=!1,this.H=_(()=>{this.w(!0)},this.e.debounceTime),this.c=!0,this.N(),this.l=p("div"),this.a=p("div"),this.n=p("ul"),this.s=p("input"),this.l.appendChild(this.a),this.i.parentElement.insertBefore(this.l,this.i),this.z(),this.R(),this.q(),this.j(),this.P(),this.resetState(),this.handleEvent=i=>{this.A(i)},this.e.fixed&&(document.addEventListener("scroll",this,!0),window.addEventListener("resize",this)),["focus","blur","input","keydown","paste"].forEach(i=>{this.s.addEventListener(i,this)}),["mousemove","mouseleave"].forEach(i=>{this.n.addEventListener(i,this)}),this.loadData(!0)}static init(t="select[multiple]",e={},i=!1){let s=document.querySelectorAll(t);for(let l=0;l{this.s.removeEventListener(t,this)}),["mousemove","mouseleave"].forEach(t=>{this.n.removeEventListener(t,this)}),this.e.fixed&&(document.removeEventListener("scroll",this,!0),window.removeEventListener("resize",this)),this.i.style.display="block",this.l.parentElement.removeChild(this.l),this.parentForm&&this.parentForm.removeEventListener("reset",this),I.delete(this.i)}handleEvent(t){this.A(t)}A(t){["scroll","resize"].includes(t.type)?(this.L&&window.cancelAnimationFrame(this.L),this.L=window.requestAnimationFrame(()=>{this[`on${t.type}`](t)})):this[`on${t.type}`](t)}M(t={}){this.e=Object.assign({},M,{showDropIcon:!!this.d()});let e=this.i.dataset.config?JSON.parse(this.i.dataset.config):{},i={...t,...e,...this.i.dataset};for(let[s,l]of Object.entries(M)){if(s=="config"||i[s]===void 0)continue;let n=i[s];switch(typeof l){case"number":this.e[s]=parseInt(n);break;case"boolean":this.e[s]=V(n);break;case"string":this.e[s]=n.toString();break;case"object":this.e[s]=n,typeof n=="string"&&(["{","["].includes(n[0])?this.e[s]=JSON.parse(n):this.e[s]=n.split(n.includes("|")?"|":","));break;case"function":this.e[s]=typeof n=="string"?n.split(".").reduce((r,o)=>r[o],window):n,this.e[s]||console.error("Invalid function",n);break;default:this.e[s]=n;break}}this.e.placeholder||(this.e.placeholder=this.B()),this.e.suggestionsThreshold==-1&&(this.e.suggestionsThreshold=this.e.liveServer?1:0)}config(t=null){return t?this.e[t]:this.e}setConfig(t,e){this.e[t]=e}N(){for(this.overflowParent=null,this.parentForm=this.i.parentElement;this.parentForm&&(this.parentForm.style.overflow==="hidden"&&(this.overflowParent=this.parentForm),this.parentForm=this.parentForm.parentElement,!(this.parentForm&&this.parentForm.nodeName=="FORM")););this.parentForm&&this.parentForm.addEventListener("reset",this)}B(){if(this.i.hasAttribute("placeholder"))return this.i.getAttribute("placeholder");if(this.i.dataset.placeholder)return this.i.dataset.placeholder;let t=this.i.querySelector("option");return!t||!this.e.autoselectFirst?"":(S(t,"selected"),t.selected=!1,t.value?"":t.textContent)}q(){let t=this.i;this.e.hideNativeValidation?(t.style.position="absolute",t.style.left="-9999px"):t.style.cssText="height:1px;width:1px;opacity:0;padding:0;margin:0;border:0;float:left;flex-basis:100%;min-height:unset;",t.tabIndex=-1,t.addEventListener("focus",e=>{this.onclick(e)}),t.addEventListener("invalid",e=>{this.l.classList.add(g)})}P(){let t=this.n;t.classList.add("dropdown-menu",v+"menu"),t.id=v+"menu-"+P,t.setAttribute("role","menu");let e=t.style;e.padding="0",e.maxHeight="280px",this.e.fullWidth||(e.maxWidth="360px"),this.e.fixed&&(e.position="fixed"),e.overflowY="auto",e.overscrollBehavior="contain",e.textAlign="unset",t.addEventListener("mouseenter",i=>{this.h=!1}),this.l.appendChild(t),this.s.setAttribute("aria-controls",t.id)}z(){let t=this.l;t.classList.add("form-control","dropdown"),["form-select-lg","form-select-sm","is-invalid","is-valid"].forEach(e=>{this.i.classList.contains(e)&&t.classList.add(e)}),this.e.suggestionsThreshold==0&&this.e.showDropIcon&&t.classList.add("form-select"),this.overflowParent&&(t.style.position="inherit"),t.style.height="auto",t.addEventListener("click",this)}R(){this.a.addEventListener("click",e=>{this.isDisabled()||this.s.style.visibility!="hidden"&&this.s.focus()});let t=this.a.style;t.display="flex",t.alignItems="center",t.flexWrap="wrap"}j(){let t=this.s;t.type="text",t.autocomplete="off",t.spellcheck=!1,x(t,{"aria-autocomplete":"list","aria-haspopup":"menu","aria-expanded":"false","aria-label":this.e.searchLabel,role:"combobox"}),t.style.cssText="background-color:transparent;color:currentColor;border:0;padding:0;outline:0;max-width:100%",this.resetSearchInput(!0),this.a.appendChild(t),this.V=window.getComputedStyle(t).direction==="rtl"}onfocus(t){this.l.classList.add(R),this.showOrSearch(),this.e.onFocus(t,this)}onblur(t){if(this.p&&t.relatedTarget&&t.relatedTarget.classList.contains("modal")){this.s.focus();return}this.afteronblur(t)}afteronblur(t){this.o&&this.o.abort();let e=!0;if(this.e.addOnBlur&&this.s.value&&(e=this.C()),this.l.classList.remove(R),this.hideSuggestions(e),this.c){let i=this.getSelection(),s={selection:i?i.dataset.value:null,input:this.s.value};this.e.onBlur(t,this),this.i.dispatchEvent(new CustomEvent("tags.blur",{bubbles:!0,detail:s}))}}onpaste(t){let i=(t.clipboardData||window.clipboardData).getData("text/plain").replace(/\r\n|\n/g," ");if(i.length>2&&this.e.separator.length){let s=G(i,this.e.separator).filter(l=>l);s.length>1&&(t.preventDefault(),s.forEach(l=>{this.E(l)}))}}E(t){let e=t,i={};if(this.e.allowNew)i.new=1;else{let s=this.getSelection();if(!s)return;t=s.getAttribute(b),e=s.dataset.label}this.e.confirmAdd(t,this).then(()=>{this.m(e,t,i)}).catch(()=>{})}oninput(t){let e=this.e.inputFilter(this.s.value);if(e!=this.s.value&&(this.s.value=e),e){let i=e.slice(-1);if(this.e.separator.length&&this.e.separator.includes(i)){this.s.value=this.s.value.slice(0,-1);let s=this.s.value;this.E(s);return}}setTimeout(()=>{this.r()}),this.showOrSearch()}onkeydown(t){let e=t.keyCode||t.key,i=t.target;switch(t.keyCode==229&&(e=i.value.charAt(i.selectionStart-1).charCodeAt(0)),e){case 13:case"Enter":t.preventDefault(),this.C();break;case 38:case"ArrowUp":t.preventDefault(),this.h=!0,this.g(D);break;case 40:case"ArrowDown":t.preventDefault(),this.h=!0,this.isDropdownVisible()?this.g(E):this.showOrSearch(!1);break;case 8:case"Backspace":let s=this.getLastItem();this.s.value.length==0&&s&&this.e.confirmClear(s,this).then(()=>{this.removeLastItem(),this.r(),this.showOrSearch()}).catch(()=>{});break;case 27:case"Escape":this.s.focus(),this.hideSuggestions();break}}onmousemove(t){this.p=!0,this.h=!1}onmouseleave(t){this.p=!1,this.removeSelection()}onscroll(t){this.b()}onresize(t){this.b()}onclick(t=null){!this.isSingle()&&this.isMaxReached()||this.s.focus()}onreset(t){this.reset()}loadData(t=!1){Object.keys(this.e.items).length>0?this.setData(this.e.items,!0):this.resetSuggestions(!0),this.e.server&&(this.e.liveServer||this.w(!t))}W(){let t=this.i.selectedOptions||[];for(let e=0;e({value:s.getAttribute("value"),label:s.textContent,disabled:s.disabled,selected:s.selected,title:s.title,data:Object.assign({disabled:s.disabled},s.dataset)}),i=Array.from(this.i.children).filter(s=>s.hasAttribute("label")||!s.disabled||this.e.showDisabled).map(s=>s.hasAttribute("label")?{group:s.getAttribute("label"),items:Array.from(s.children).map(l=>e(l))}:e(s));this.setData(i,t)}C(){let t=this.getSelection();if(t)return t.click(),!0;if(this.e.allowNew&&this.s.value){let e=this.s.value;return this.e.confirmAdd(e,this).then(()=>{this.m(e,e,{new:1})}).catch(()=>{}),!0}return!1}w(t=!1){this.o&&this.o.abort(),this.o=new AbortController;let e=this.i.dataset.serverParams||{};typeof e=="string"&&(e=JSON.parse(e));let i=Object.assign({},this.e.serverParams,e);if(i[this.e.queryParam]=this.s.value,this.e.noCache&&(i.t=Date.now()),i.related){let r=document.getElementById(i.related);if(r){i.related=r.value;let o=r.getAttribute("name");o&&(i[o]=r.value)}}let s=new URLSearchParams(i),l=this.e.server,n=Object.assign(this.e.fetchOptions,{method:this.e.serverMethod||"GET",signal:this.o.signal});n.method==="POST"?n.body=s:l+="?"+s.toString(),this.l.classList.add(H),fetch(l,n).then(r=>this.e.onServerResponse(r,this)).then(r=>{let o=X(this.e.serverDataKey,r)||r;this.setData(o,!t),this.o=null,t&&this.v()}).catch(r=>{this.e.onServerError(r,this.o.signal,this)}).finally(r=>{this.l.classList.remove(H)})}m(t,e=null,i={}){if(!this.canAdd(t,i))return null;let s=this.addItem(t,e,i);return this.S(),this.e.keepOpen?this.v():this.resetSearchInput(),s}f(t){if(t.style.display==="none")return!1;let e=t.firstElementChild;return e.tagName==="A"&&!e.classList.contains("disabled")}g(t=E,e=null){let i=this.getSelection();if(i){let s=t===E?"nextSibling":"previousSibling";e=i.parentNode;do e=e[s];while(e&&!this.f(e));e?i.classList.remove(...this.u()):i&&(e=i.parentElement)}else{if(t===D)return e;if(!e)for(e=this.n.firstChild;e&&!this.f(e);)e=e.nextSibling}if(e){let s=e.offsetHeight,l=e.offsetTop,n=e.parentNode,r=n.offsetHeight,o=n.scrollHeight,h=n.offsetTop;if(s===0&&setTimeout(()=>{n.scrollTop=0}),t===D){let f=l-h>10?l-h:0;n.scrollTop=f}else l+s-(r+n.scrollTop)>0&&s>0&&(n.scrollTop=l+s-r+1,n.scrollTop+r>=o-10&&(n.scrollTop=l-h));let c=e.querySelector("a");c.classList.add(...this.u()),this.s.setAttribute("aria-activedescendant",c.id),this.e.updateOnSelect&&(this.s.value=c.dataset.label,this.r())}else this.s.setAttribute("aria-activedescendant","");return e}r(){this.l.classList.remove(q),this.s.value?this.s.size=this.s.value.length:this.getSelectedValues().length?(this.s.placeholder="",this.s.size=1):(this.s.size=this.e.placeholder.length>0?this.e.placeholder.length:1,this.s.placeholder=this.e.placeholder,this.l.classList.add(q));let t=this.s.value||this.s.placeholder,e=window.getComputedStyle(this.l).fontSize,i=J(t,e)+16;this.s.style.width=i+"px"}_(t){for(;this.n.lastChild;)this.n.removeChild(this.n.lastChild);let e=0,i=1;for(let s=0;s',this.n.appendChild(s)}}I(t,e){if(!t[this.e.valueField])return;let i=t[this.e.valueField],s=t[this.e.labelField],l=this.e.onRenderItem(t,s,this),n=p("li");n.setAttribute("role","menuitem"),t.group_id&&n.setAttribute("data-group-id",""+t.group_id),t.title&&(n.setAttribute("title",t.title),n.setAttribute("data-bs-placement","left"));let r=p("a");n.append(r),r.id=this.n.id+"-"+e,r.classList.add("dropdown-item","text-truncate"),t.disabled&&r.classList.add("disabled"),r.setAttribute(b,i),r.dataset.label=s;let o={};this.e.searchFields.forEach(c=>{o[c]=t[c]}),r.dataset.searchData=JSON.stringify(o),r.setAttribute("href","#"),r.innerHTML=l,this.n.appendChild(n);let h=this.O()===5;t.title&&L&&h&&L.getOrCreateInstance(n),r.addEventListener("mouseenter",c=>{this.h||(this.removeSelection(),n.querySelector("a").classList.add(...this.u()))}),r.addEventListener("mousedown",c=>{c.preventDefault()}),r.addEventListener("click",c=>{c.preventDefault(),c.stopPropagation(),this.e.confirmAdd(i,this).then(()=>{this.m(s,i,t.data),this.e.onSelectItem(t,this)}).catch(()=>{})})}initialOptions(){return this.i.querySelectorAll("option[data-init]")}T(){this.i.querySelectorAll("option").forEach(t=>{S(t,"selected")})}reset(){this.removeAll(),this.c=!1;let t=this.initialOptions();this.T();for(let e=0;ee.value)}getAvailableValues(){let t=this.i.querySelectorAll("option");return Array.from(t).map(e=>e.value)}showOrSearch(t=!0){if(t&&!this.F()){this.hideSuggestions(!1);return}this.e.liveServer?this.H():this.v()}hideSuggestions(t=!0){this.n.classList.remove(A),x(this.s,{"aria-expanded":"false"}),this.removeSelection(),t&&this.l.classList.remove(g)}toggleSuggestions(t=!0,e=!0){this.n.classList.contains(A)?this.hideSuggestions(e):this.showOrSearch(t)}F(){return this.isDisabled()||this.isMaxReached()?!1:this.s.value.length>=this.e.suggestionsThreshold}v(){if(this.s.style.visibility=="hidden")return;let t=F(this.s.value),e={},i=this.n.querySelectorAll("li"),s=0,l=null,n=!1,r={};for(let o=0;o0){let u=JSON.parse(c.dataset.searchData);this.e.searchFields.forEach(m=>{let C=F(u[m]),T=!1;if(this.e.fuzzy)T=$(C,t);else{let k=C.indexOf(t);T=this.e.startsWith?k===0:k>=0}T&&(d=!0)})}let w=d||t.length===0;if(f||d?(s++,B(h),h.dataset.groupId&&(r[h.dataset.groupId]=!0),!l&&this.f(h)&&w&&(l=h),this.e.maximumItems>0&&s>this.e.maximumItems&&O(h)):O(h),this.e.highlightTyped){let u=c.textContent,m=F(u).indexOf(t),C=u.substring(0,m)+`${u.substring(m,m+t.length)}`+u.substring(m+t.length,u.length);c.innerHTML=C}this.f(h)&&(n=!0)}if(!this.e.allowNew&&!(t.length===0&&!n)&&this.l.classList.add(g),this.e.allowNew&&this.e.regex&&this.isInvalid()&&this.l.classList.remove(g),Array.from(i).filter(o=>o.dataset.id).forEach(o=>{r[o.dataset.id]===!0&&B(o)}),n&&(this.l.classList.remove(g),l&&this.e.autoselectFirst&&(this.removeSelection(),this.g(E,l))),s===0)if(this.e.notFoundMessage){let o=this.n.querySelector("."+v+"not-found");o.style.display="block";let h=this.e.notFoundMessage.replace("{{tag}}",this.s.value);o.innerHTML=`${h}`,this.k()}else this.hideSuggestions(!1);else this.k()}k(){let t=this.n.classList.contains(A);t||(this.n.classList.add(A),x(this.s,{"aria-expanded":"true"})),this.b(t)}b(t=!1){let e=this.V,i=this.e.fixed,s=this.e.fullWidth,l=this.s.getBoundingClientRect(),n=this.l.getBoundingClientRect(),r=0,o=0;if(i?s?(r=n.x,o=n.y+n.height+2):(r=l.x,o=l.y+l.height):s?(r=0,o=n.height+2):(r=this.s.offsetLeft,o=this.s.offsetHeight+this.s.offsetTop),e&&!s&&(r-=this.n.offsetWidth-l.width),!s){let f=Math.min(window.innerWidth,document.body.offsetWidth),d=e?l.x+l.width-this.n.offsetWidth-1:f-1-(l.x+this.n.offsetWidth);d<0&&(r=e?r-d:r+d)}s&&(this.n.style.width=this.l.offsetWidth+"px"),t||(this.n.style.transform="unset"),Object.assign(this.n.style,{left:r+"px",top:o+"px"});let h=this.n.getBoundingClientRect(),c=window.innerHeight;if(h.y+h.height>c||this.n.style.transform.includes("translateY")){let f=s?n.height+4:l.height;this.n.style.transform="translateY(calc(-100.1% - "+f+"px))"}}O(){let t=5,e=window.jQuery;return e&&e.fn.tooltip&&e.fn.tooltip.Constructor&&(t=parseInt(e.fn.tooltip.Constructor.VERSION.charAt(0))),t}J(t){return!!Array.from(this.i.querySelectorAll("option")).find(s=>s.textContent==t&&s.getAttribute("selected"))}U(t){let i=Array.from(this.i.querySelectorAll("option")).filter(s=>s.textContent==t);return!(i.length>0&&!i.find(l=>!l.getAttribute("selected")))}hasItem(t){for(let e of this.e.items){let i=e.items||[e];for(let s of i)if(s[this.e.labelField]==t)return!0}return!1}getItem(t){for(let e of this.e.items){let i=e.items||[e];for(let s of i)if(s[this.e.valueField]==t)return s}return null}$(t){return new RegExp(this.e.regex.trim()).test(t)}getSelection(){return this.n.querySelector("a."+N)}removeSelection(){let t=this.getSelection();t&&t.classList.remove(...this.u())}u(){return[...this.e.activeClasses,N]}getActiveSelection(){return this.getSelection()}removeActiveSelection(){return this.removeSelection()}removeAll(){this.getSelectedValues().forEach(e=>{this.removeItem(e,!0)}),this.r()}removeLastItem(t=!1){let e=this.getLastItem();e&&this.removeItem(e,t)}getLastItem(){let t=this.a.querySelectorAll("span."+v+"badge");return t.length?t[t.length-1].getAttribute(b):void 0}enable(){this.i.setAttribute("disabled",""),this.resetState()}disable(){S(this.i,"disabled"),this.resetState()}isDisabled(){return this.i.hasAttribute("disabled")||this.i.disabled||this.i.hasAttribute("readonly")}isDropdownVisible(){return this.n.classList.contains(A)}isInvalid(){return this.l.classList.contains(g)}isSingle(){return!this.i.hasAttribute("multiple")}isMaxReached(){return this.e.max&&this.getSelectedValues().length>=this.e.max}canAdd(t,e={}){if(!t||e.new&&!this.e.allowNew||!e.new&&!this.hasItem(t)||this.isDisabled())return!1;if(!this.isSingle()&&!this.e.allowSame){if(e.new){if(this.J(t))return!1}else if(!this.U(t))return!1}return this.isMaxReached()?!1:this.e.regex&&e.new&&!this.$(t)?(this.l.classList.add(g),!1):this.e.onCanAdd&&this.e.onCanAdd(t,e,this)===!1?(this.l.classList.add(g),!1):!0}getData(){return this.e.items}setData(t,e=!1){Array.isArray(t)||(t=Object.entries(t).map(([i,s])=>({value:i,label:s}))),this.e.items!=t&&(this.e.items=t),e&&(this.T(),t.reduce((s,l)=>s.concat(l.group?l.items:[l]),[]).forEach(s=>{let l=s[this.e.valueField],n=s[this.e.labelField];if(l&&(s.selected||this.e.selected.includes(l))){let r=this.addItem(n,l,s.data);r&&r.setAttribute("data-init","true")}})),this._(t),this.S()}d(t=null,e="",i=0){let l="option"+(t===null?"":'[value="'+CSS.escape(t)+'"]')+e;return this.i.querySelectorAll(l)[i]||null}setItem(t,e={}){let i=null,s=this.d(t,":not([selected])");s&&(i=this.addItem(s.textContent,s.value,e));let l=this.getItem(t);if(l){let n=l[this.e.valueField],r=l[this.e.labelField];i=this.addItem(r,n,e)}return this.r(),this.D(),i}addItem(t,e=null,i={}){e||(e=t),this.isSingle()&&this.getSelectedValues().length&&this.removeLastItem(!0);let s=this.d(e,":not([selected])");if(!s){s=p("option"),s.value=e,s.innerText=t;for(let[l,n]of Object.entries(i))s.dataset[l]=n;this.i.appendChild(s),this.e.onCreateItem(s,this)}return s&&(i=Object.assign({title:s.getAttribute("title")},i,s.dataset)),s.setAttribute("selected","selected"),s.selected=!0,this.G(t,e,i),this.c&&this.i.dispatchEvent(new Event("change",{bubbles:!0})),s}S(){let t=this.i.innerHTML;this.i.innerHTML="",this.i.innerHTML=t,this.r()}G(t,e=null,i={}){let s=this.O()===5,l=i.disabled&&V(i.disabled),n=this.e.allowClear&&!l,r=this.e.allowHtml?t:this.e.sanitizer(t),o=p("span"),h=[v+"badge"],c=this.isSingle()&&!this.e.singleBadge;if(!c){h.push("badge");let d=this.e.badgeStyle;i.badgeStyle&&(d=i.badgeStyle),i.badgeClass&&h.push(...i.badgeClass.split(" ")),this.e.baseClass?h.push(...this.e.baseClass.split(" ")):s?h=[...h,"bg-"+d,"text-truncate"]:h=[...h,"badge-"+d],o.style.maxWidth="100%"}l&&h.push("disabled","opacity-50");let f=c?0:2;if(o.style.margin=f+"px 6px "+f+"px 0px",o.style.marginBlock=f+"px",o.style.marginInline="0px 6px",o.style.display="flex",o.style.alignItems="center",o.classList.add(...h),o.setAttribute(b,e),i.title&&o.setAttribute("title",i.title),n){let d=h.includes("text-dark")||c?"btn-close":"btn-close btn-close-white",w="margin-inline: 0px 6px;",u="left";this.e.clearEnd&&(u="right"),u=="right"&&(w="margin-inline: 6px 0px;");let m=s?'':'';r=u=="left"?m+r:r+m}o.innerHTML=r,this.a.insertBefore(o,this.s),i.title&&L&&s&&L.getOrCreateInstance(o),n&&o.querySelector("button").addEventListener("click",d=>{d.preventDefault(),d.stopPropagation(),this.isDisabled()||this.e.confirmClear(e,this).then(()=>{this.removeItem(e),document.activeElement.blur(),this.r()}).catch(()=>{})})}getHolder(){return this.l}clear(){this.hideSuggestions(),this.reset()}updateData(t){this.setData(t,!1),this.reset()}removeItem(t,e=!1){let i=CSS.escape(t),s=this.a.querySelectorAll("span["+b+'="'+i+'"]');if(!s.length)return;let l=s.length-1,n=s[l];n&&(n.dataset.bsOriginalTitle&&L.getOrCreateInstance(n).dispose(),n.remove());let r=this.d(t,"[selected]",l);r&&(S(r,"selected"),r.selected=!1,this.c&&!e&&this.i.dispatchEvent(new Event("change",{bubbles:!0}))),this.s.style.visibility=="hidden"&&!this.isMaxReached()&&(this.s.style.visibility="visible",this.l.classList.remove(z)),e||this.e.onClearItem(t,this)}},Y=y;export{Y as default}; +/** + * Bootstrap 5 (and 4!) tags + * @license MIT + */ //# sourceMappingURL=tags.min.js.map diff --git a/tags.min.js.map b/tags.min.js.map index cf0d407..72b249c 100644 --- a/tags.min.js.map +++ b/tags.min.js.map @@ -1,7 +1,7 @@ { "version": 3, "sources": ["tags.js"], - "sourcesContent": ["/**\r\n * Bootstrap 5 (and 4!) tags\r\n *\r\n * Turns your select[multiple] into nice tags lists\r\n *\r\n * Required Bootstrap 5 styles:\r\n * - badge\r\n * - background-color utility\r\n * - text-truncate utility\r\n * - forms\r\n * - dropdown\r\n */\r\n\r\n// #region config\r\n\r\n/**\r\n * @callback EventCallback\r\n * @param {Event} event\r\n * @param {Tags} inst\r\n * @returns {void}\r\n */\r\n\r\n/**\r\n * @callback ServerCallback\r\n * @param {Response} response\r\n * @param {Tags} inst\r\n * @returns {Promise}\r\n */\r\n\r\n/**\r\n * @callback ErrorCallback\r\n * @param {Error} e\r\n * @param {AbortSignal} signal\r\n * @param {Tags} inst\r\n * @returns {void}\r\n */\r\n\r\n/**\r\n * @callback ModalItemCallback\r\n * @param {String} value\r\n * @param {Tags} inst\r\n * @returns {Promise}\r\n */\r\n\r\n/**\r\n * @callback RenderCallback\r\n * @param {Suggestion} item\r\n * @param {String} label\r\n * @param {Tags} inst\r\n * @returns {String}\r\n */\r\n\r\n/**\r\n * @callback ItemCallback\r\n * @param {Suggestion} item\r\n * @param {Tags} inst\r\n * @returns {void}\r\n */\r\n\r\n/**\r\n * @callback ValueCallback\r\n * @param {String} value\r\n * @param {Tags} inst\r\n * @returns {void}\r\n */\r\n\r\n/**\r\n * @callback AddCallback\r\n * @param {String} value\r\n * @param {Object} data\r\n * @param {Tags} inst\r\n * @returns {void|Boolean}\r\n */\r\n\r\n/**\r\n * @callback CreateCallback\r\n * @param {HTMLOptionElement} option\r\n * @param {Tags} inst\r\n * @returns {void}\r\n */\r\n\r\n/**\r\n * @typedef Config\r\n * @property {Array} items Source items\r\n * @property {Boolean} allowNew Allows creation of new tags\r\n * @property {Boolean} showAllSuggestions Show all suggestions even if they don't match. Disables validation.\r\n * @property {String} badgeStyle Color of the badge (color can be configured per option as well)\r\n * @property {Boolean} allowClear Show a clear icon\r\n * @property {Boolean} clearEnd Place clear icon at the end\r\n * @property {Array} selected A list of initially selected values\r\n * @property {String} regex Regex for new tags\r\n * @property {Array|String} separator A list (pipe separated) of characters that should act as separator (default is using enter key)\r\n * @property {Number} max Limit to a maximum of tags (0 = no limit)\r\n * @property {String} placeholder Provides a placeholder if none are provided as the first empty option\r\n * @property {String} clearLabel Text as clear tooltip\r\n * @property {String} searchLabel Default placeholder\r\n * @property {Boolean} showDropIcon Show dropdown icon\r\n * @property {Boolean} keepOpen Keep suggestions open after selection, clear on focus out\r\n * @property {Boolean} allowSame Allow same tags used multiple times\r\n * @property {String} baseClass Customize the class applied to badges\r\n * @property {Boolean} addOnBlur Add new tags on blur (only if allowNew is enabled)\r\n * @property {Boolean} showDisabled Show disabled tags\r\n * @property {Boolean} hideNativeValidation Hide native validation tooltips\r\n * @property {Number} suggestionsThreshold Number of chars required to show suggestions\r\n * @property {Number} maximumItems Maximum number of items to display\r\n * @property {Boolean} autoselectFirst Always select the first item\r\n * @property {Boolean} updateOnSelect Update input value on selection (doesn't play nice with autoselectFirst)\r\n * @property {Boolean} highlightTyped Highlight matched part of the suggestion\r\n * @property {String} highlightClass Class applied to the mark element\r\n * @property {Boolean} fullWidth Match the width on the input field\r\n * @property {Boolean} fixed Use fixed positioning (solve overflow issues)\r\n * @property {Boolean} fuzzy Fuzzy search\r\n * @property {Boolean} startsWith Must start with the string. Defaults to false (it matches any position).\r\n * @property {Boolean} singleBadge Show badge for single elements\r\n * @property {Array} activeClasses By default: [\"bg-primary\", \"text-white\"]\r\n * @property {String} labelField Key for the label\r\n * @property {String} valueField Key for the value\r\n * @property {Array} searchFields Key for the search\r\n * @property {String} queryParam Name of the param passed to endpoint (query by default)\r\n * @property {String} server Endpoint for data provider\r\n * @property {String} serverMethod HTTP request method for data provider, default is GET\r\n * @property {String|Object} serverParams Parameters to pass along to the server. You can specify a \"related\" key with the id of a related field.\r\n * @property {String} serverDataKey By default: data\r\n * @property {Object} fetchOptions Any other fetch options (https://developer.mozilla.org/en-US/docs/Web/API/fetch#syntax)\r\n * @property {Boolean} liveServer Should the endpoint be called each time on input\r\n * @property {Boolean} noCache Prevent caching by appending a timestamp\r\n * @property {Boolean} allowHtml Allow html in input (can lead to script injection)\r\n * @property {Function} inputFilter Function to filter input\r\n * @property {Function} sanitizer Alternative function to sanitize content\r\n * @property {Number} debounceTime Debounce time for live server\r\n * @property {String} notFoundMessage Display a no suggestions found message. Leave empty to disable\r\n * @property {RenderCallback} onRenderItem Callback function that returns the suggestion\r\n * @property {ItemCallback} onSelectItem Callback function to call on selection\r\n * @property {ValueCallback} onClearItem Callback function to call on clear\r\n * @property {CreateCallback} onCreateItem Callback function when an item is created\r\n * @property {EventCallback} onBlur Callback function on blur\r\n * @property {EventCallback} onFocus Callback function on focus\r\n * @property {AddCallback} onCanAdd Callback function to validate item. Return false to show validation message.\r\n * @property {ServerCallback} onServerResponse Callback function to process server response. Must return a Promise\r\n * @property {ErrorCallback} onServerError Callback function to process server errors.\r\n * @property {ModalItemCallback} confirmClear Allow modal confirmation of clear. Must return a Promise\r\n * @property {ModalItemCallback} confirmAdd Allow modal confirmation of add. Must return a Promise\r\n */\r\n\r\n/**\r\n * @typedef Suggestion\r\n * @property {String} value Can be overriden by config valueField\r\n * @property {String} label Can be overriden by config labelField\r\n * @property {String} title\r\n * @property {Boolean} disabled\r\n * @property {Object} data\r\n * @property {Boolean} [selected]\r\n * @property {Number} [group_id]\r\n */\r\n\r\n/**\r\n * @typedef SuggestionGroup\r\n * @property {String} group\r\n * @property {Array} items\r\n */\r\n\r\n/**\r\n * @type {Config}\r\n */\r\nconst DEFAULTS = {\r\n items: [],\r\n allowNew: false,\r\n showAllSuggestions: false,\r\n badgeStyle: \"primary\",\r\n allowClear: false,\r\n clearEnd: false,\r\n selected: [],\r\n regex: \"\",\r\n separator: [],\r\n max: 0,\r\n clearLabel: \"Clear\",\r\n searchLabel: \"Type a value\",\r\n showDropIcon: true,\r\n keepOpen: false,\r\n allowSame: false,\r\n baseClass: \"\",\r\n placeholder: \"\",\r\n addOnBlur: false,\r\n showDisabled: false,\r\n hideNativeValidation: false,\r\n suggestionsThreshold: -1,\r\n maximumItems: 0,\r\n autoselectFirst: true,\r\n updateOnSelect: false,\r\n highlightTyped: false,\r\n highlightClass: \"\",\r\n fullWidth: true,\r\n fixed: false,\r\n fuzzy: false,\r\n startsWith: false,\r\n singleBadge: false,\r\n activeClasses: [\"bg-primary\", \"text-white\"],\r\n labelField: \"label\",\r\n valueField: \"value\",\r\n searchFields: [\"label\"],\r\n queryParam: \"query\",\r\n server: \"\",\r\n serverMethod: \"GET\",\r\n serverParams: {},\r\n serverDataKey: \"data\",\r\n fetchOptions: {},\r\n liveServer: false,\r\n noCache: true,\r\n allowHtml: false,\r\n debounceTime: 300,\r\n notFoundMessage: \"\",\r\n inputFilter: (str) => str,\r\n sanitizer: (str) => sanitize(str),\r\n onRenderItem: (item, label, inst) => {\r\n if (inst.config(\"allowHtml\")) {\r\n return label;\r\n }\r\n return inst.config(\"sanitizer\")(label);\r\n },\r\n onSelectItem: (item, inst) => {},\r\n onClearItem: (value, inst) => {},\r\n onCreateItem: (option, inst) => {},\r\n onBlur: (event, inst) => {},\r\n onFocus: (event, inst) => {},\r\n onCanAdd: (text, data, inst) => {},\r\n confirmClear: (item, inst) => Promise.resolve(),\r\n confirmAdd: (item, inst) => Promise.resolve(),\r\n onServerResponse: (response, inst) => {\r\n return response.json();\r\n },\r\n onServerError: (e, signal, inst) => {\r\n // Current version of Firefox rejects the promise with a DOMException\r\n if (e.name === \"AbortError\" || signal.aborted) {\r\n return;\r\n }\r\n console.error(e);\r\n },\r\n};\r\n\r\n// #endregion\r\n\r\n// #region constants\r\n\r\nconst CLASS_PREFIX = \"tags-\";\r\nconst LOADING_CLASS = \"is-loading\";\r\nconst ACTIVE_CLASS = \"is-active\";\r\nconst INVALID_CLASS = \"is-invalid\";\r\nconst MAX_REACHED_CLASS = \"is-max-reached\";\r\nconst SHOW_CLASS = \"show\";\r\nconst VALUE_ATTRIBUTE = \"data-value\";\r\nconst NEXT = \"next\";\r\nconst PREV = \"prev\";\r\nconst FOCUS_CLASS = \"form-control-focus\"; // should match form-control:focus\r\nconst PLACEHOLDER_CLASS = \"form-placeholder-shown\"; // should match :placeholder-shown\r\nconst DISABLED_CLASS = \"form-control-disabled\"; // should match form-control:disabled\r\nconst INSTANCE_MAP = new WeakMap();\r\nlet counter = 0;\r\n//@ts-ignore\r\nlet tooltip = window.bootstrap && window.bootstrap.Tooltip;\r\n\r\n// #endregion\r\n\r\n// #region functions\r\n\r\n/**\r\n * @param {Function} func\r\n * @param {number} timeout\r\n * @returns {Function}\r\n */\r\nfunction debounce(func, timeout = 300) {\r\n let timer;\r\n return (...args) => {\r\n clearTimeout(timer);\r\n timer = setTimeout(() => {\r\n //@ts-ignore\r\n func.apply(this, args);\r\n }, timeout);\r\n };\r\n}\r\n\r\n/**\r\n * @param {string} text\r\n * @param {string} size\r\n * @returns {Number}\r\n */\r\nfunction calcTextWidth(text, size = null) {\r\n const span = ce(\"span\");\r\n document.body.appendChild(span);\r\n span.style.fontSize = size || \"inherit\";\r\n span.style.height = \"auto\";\r\n span.style.width = \"auto\";\r\n span.style.position = \"absolute\";\r\n span.style.whiteSpace = \"no-wrap\";\r\n span.innerHTML = sanitize(text);\r\n const width = Math.ceil(span.clientWidth);\r\n document.body.removeChild(span);\r\n return width;\r\n}\r\n\r\n/**\r\n * @link https://stackoverflow.com/questions/3043775/how-to-escape-html\r\n * @param {string} text\r\n * @returns {string}\r\n */\r\nfunction sanitize(text) {\r\n return text.replace(/[\\x26\\x0A\\<>'\"]/g, function (r) {\r\n return \"&#\" + r.charCodeAt(0) + \";\";\r\n });\r\n}\r\n\r\n/**\r\n * @param {String} str\r\n * @returns {String}\r\n */\r\nfunction removeDiacritics(str) {\r\n return str.normalize(\"NFD\").replace(/[\\u0300-\\u036f]/g, \"\");\r\n}\r\n\r\n/**\r\n * @param {String|Number} str\r\n * @returns {String}\r\n */\r\nfunction normalize(str) {\r\n if (!str) {\r\n return \"\";\r\n }\r\n return removeDiacritics(str.toString()).toLowerCase();\r\n}\r\n\r\n/**\r\n * A simple fuzzy match algorithm that checks if chars are matched\r\n * in order in the target string\r\n *\r\n * @param {String} str\r\n * @param {String} lookup\r\n * @returns {Boolean}\r\n */\r\nfunction fuzzyMatch(str, lookup) {\r\n if (str.indexOf(lookup) >= 0) {\r\n return true;\r\n }\r\n let pos = 0;\r\n for (let i = 0; i < lookup.length; i++) {\r\n const c = lookup[i];\r\n if (c == \" \") continue;\r\n pos = str.indexOf(c, pos) + 1;\r\n if (pos <= 0) {\r\n return false;\r\n }\r\n }\r\n return true;\r\n}\r\n\r\n/**\r\n * @param {HTMLElement} item\r\n */\r\nfunction hideItem(item) {\r\n item.style.display = \"none\";\r\n attrs(item, {\r\n \"aria-hidden\": \"true\",\r\n });\r\n}\r\n\r\n/**\r\n * @param {HTMLElement} item\r\n */\r\nfunction showItem(item) {\r\n item.style.display = \"list-item\";\r\n attrs(item, {\r\n \"aria-hidden\": \"false\",\r\n });\r\n}\r\n\r\n/**\r\n * @param {HTMLElement} el\r\n * @param {Object} attrs\r\n */\r\nfunction attrs(el, attrs) {\r\n for (const [k, v] of Object.entries(attrs)) {\r\n el.setAttribute(k, v);\r\n }\r\n}\r\n\r\n/**\r\n * @param {HTMLElement} el\r\n * @param {string} attr\r\n */\r\nfunction rmAttr(el, attr) {\r\n if (el.hasAttribute(attr)) {\r\n el.removeAttribute(attr);\r\n }\r\n}\r\n\r\n/**\r\n * Allow 1/0, true/false as strings\r\n * @param {any} value\r\n * @returns {Boolean}\r\n */\r\nfunction parseBool(value) {\r\n return [\"true\", \"false\", \"1\", \"0\", true, false].includes(value) && !!JSON.parse(value);\r\n}\r\n\r\n/**\r\n * @template {keyof HTMLElementTagNameMap} K\r\n * @param {K|String} tagName Name of the element\r\n * @returns {*}\r\n */\r\nfunction ce(tagName) {\r\n return document.createElement(tagName);\r\n}\r\n\r\n/**\r\n *\r\n * @param {String} str\r\n * @param {Array} tokens\r\n * @returns {Array}\r\n */\r\nfunction splitMulti(str, tokens) {\r\n let tempChar = tokens[0];\r\n for (let i = 1; i < tokens.length; i++) {\r\n str = str.split(tokens[i]).join(tempChar);\r\n }\r\n return str.split(tempChar);\r\n}\r\n\r\nfunction nested(str, obj = \"window\") {\r\n return str.split(\".\").reduce((r, p) => r[p], obj);\r\n}\r\n\r\n/**\r\n * @param {HTMLElement} el\r\n * @param {HTMLElement} newEl\r\n * @returns {HTMLElement}\r\n */\r\n// function insertAfter(el, newEl) {\r\n// return el.parentNode.insertBefore(newEl, el.nextSibling);\r\n// }\r\n\r\n// #endregion\r\n\r\nclass Tags {\r\n /**\r\n * @param {HTMLSelectElement} el\r\n * @param {Object|Config} config\r\n */\r\n constructor(el, config = {}) {\r\n if (!(el instanceof HTMLElement)) {\r\n console.error(\"Invalid element\", el);\r\n return;\r\n }\r\n INSTANCE_MAP.set(el, this);\r\n counter++;\r\n this._selectElement = el;\r\n\r\n this._configure(config);\r\n\r\n // private vars\r\n this._isMouse = false;\r\n this._keyboardNavigation = false;\r\n this._searchFunc = debounce(() => {\r\n this._loadFromServer(true);\r\n }, this._config.debounceTime);\r\n this._fireEvents = true;\r\n\r\n this._configureParent();\r\n\r\n // Create elements\r\n this._holderElement = ce(\"div\"); // this is the one holding the fake input and the dropmenu\r\n this._containerElement = ce(\"div\"); // this is the one for the fake input (labels + input)\r\n this._dropElement = ce(\"ul\"); // this dropdown list\r\n this._searchInput = ce(\"input\"); // the input element\r\n this._holderElement.appendChild(this._containerElement);\r\n\r\n // insert before select, this helps having native validation tooltips positioned properly\r\n this._selectElement.parentElement.insertBefore(this._holderElement, this._selectElement);\r\n // insertAfter(this._selectElement, this._holderElement);\r\n\r\n // Configure them\r\n this._configureHolderElement();\r\n this._configureContainerElement();\r\n this._configureSelectElement();\r\n this._configureSearchInput();\r\n this._configureDropElement();\r\n this.resetState();\r\n\r\n // Rebind handleEvent to make sure the scope will not change\r\n this.handleEvent = (ev) => {\r\n this._handleEvent(ev);\r\n };\r\n\r\n if (this._config.fixed) {\r\n document.addEventListener(\"scroll\", this, true); // capture input for all scrollables elements\r\n window.addEventListener(\"resize\", this);\r\n }\r\n\r\n // Add listeners (remove then on dispose()). See handleEvent.\r\n [\"focus\", \"blur\", \"input\", \"keydown\", \"paste\"].forEach((type) => {\r\n this._searchInput.addEventListener(type, this);\r\n });\r\n [\"mousemove\", \"mouseleave\"].forEach((type) => {\r\n this._dropElement.addEventListener(type, this);\r\n });\r\n\r\n this.loadData(true);\r\n }\r\n\r\n // #region Core\r\n\r\n /**\r\n * Attach to all elements matched by the selector\r\n * @param {string} selector\r\n * @param {Object} opts\r\n * @param {Boolean} reset\r\n */\r\n static init(selector = \"select[multiple]\", opts = {}, reset = false) {\r\n /**\r\n * @type {NodeListOf}\r\n */\r\n let list = document.querySelectorAll(selector);\r\n for (let i = 0; i < list.length; i++) {\r\n const inst = Tags.getInstance(list[i]);\r\n if (inst && !reset) {\r\n continue;\r\n }\r\n if (inst) {\r\n inst.dispose();\r\n }\r\n new Tags(list[i], opts);\r\n }\r\n }\r\n\r\n /**\r\n * @param {HTMLSelectElement} el\r\n */\r\n static getInstance(el) {\r\n if (INSTANCE_MAP.has(el)) {\r\n return INSTANCE_MAP.get(el);\r\n }\r\n }\r\n\r\n dispose() {\r\n [\"focus\", \"blur\", \"input\", \"keydown\", \"paste\"].forEach((type) => {\r\n this._searchInput.removeEventListener(type, this);\r\n });\r\n [\"mousemove\", \"mouseleave\"].forEach((type) => {\r\n this._dropElement.removeEventListener(type, this);\r\n });\r\n\r\n if (this._config.fixed) {\r\n document.removeEventListener(\"scroll\", this, true);\r\n window.removeEventListener(\"resize\", this);\r\n }\r\n\r\n // restore select, remove our custom stuff and unbind parent\r\n this._selectElement.style.display = \"block\";\r\n this._holderElement.parentElement.removeChild(this._holderElement);\r\n if (this.parentForm) {\r\n this.parentForm.removeEventListener(\"reset\", this);\r\n }\r\n\r\n INSTANCE_MAP.delete(this._selectElement);\r\n }\r\n\r\n /**\r\n * event-polyfill compat / handleEvent is expected on class\r\n * @link https://github.com/lifaon74/events-polyfill/issues/10\r\n * @param {Event} event\r\n */\r\n handleEvent(event) {\r\n this._handleEvent(event);\r\n }\r\n\r\n /**\r\n * @link https://gist.github.com/WebReflection/ec9f6687842aa385477c4afca625bbf4#handling-events\r\n * @param {Event} event\r\n */\r\n _handleEvent(event) {\r\n // debounce scroll and resize\r\n const debounced = [\"scroll\", \"resize\"];\r\n if (debounced.includes(event.type)) {\r\n if (this._timer) window.cancelAnimationFrame(this._timer);\r\n this._timer = window.requestAnimationFrame(() => {\r\n this[`on${event.type}`](event);\r\n });\r\n } else {\r\n this[`on${event.type}`](event);\r\n }\r\n }\r\n\r\n /**\r\n * @param {Config|Object} config\r\n */\r\n _configure(config = {}) {\r\n this._config = Object.assign({}, DEFAULTS, {\r\n // Hide icon by default if no value\r\n showDropIcon: this._findOption() ? true : false,\r\n });\r\n\r\n const json = this._selectElement.dataset.config ? JSON.parse(this._selectElement.dataset.config) : {};\r\n // Handle options, using arguments first, then json config and then data attr as override\r\n const o = { ...config, ...json, ...this._selectElement.dataset };\r\n\r\n // Typecast provided options based on defaults types\r\n for (const [key, defaultValue] of Object.entries(DEFAULTS)) {\r\n // Check for undefined keys\r\n if (key == \"config\" || o[key] === void 0) {\r\n continue;\r\n }\r\n const value = o[key];\r\n switch (typeof defaultValue) {\r\n case \"number\":\r\n this._config[key] = parseInt(value);\r\n break;\r\n case \"boolean\":\r\n this._config[key] = parseBool(value);\r\n break;\r\n case \"string\":\r\n this._config[key] = value.toString();\r\n break;\r\n case \"object\":\r\n this._config[key] = value;\r\n if (typeof value === \"string\") {\r\n if ([\"{\", \"[\"].includes(value[0])) {\r\n // JSON like string\r\n this._config[key] = JSON.parse(value);\r\n } else {\r\n // CSV or pipe separated string\r\n this._config[key] = value.split(value.includes(\"|\") ? \"|\" : \",\");\r\n }\r\n }\r\n break;\r\n case \"function\":\r\n // Find a global function with this name\r\n this._config[key] = typeof value === \"string\" ? value.split(\".\").reduce((r, p) => r[p], window) : value;\r\n if (!this._config[key]) {\r\n console.error(\"Invalid function\", value);\r\n }\r\n break;\r\n default:\r\n this._config[key] = value;\r\n break;\r\n }\r\n }\r\n\r\n // Dynamic default values\r\n if (!this._config.placeholder) {\r\n this._config.placeholder = this._getPlaceholder();\r\n }\r\n if (this._config.suggestionsThreshold == -1) {\r\n // if we don't have ajax auto completion, behave like a select by default\r\n this._config.suggestionsThreshold = this._config.liveServer ? 1 : 0;\r\n }\r\n }\r\n\r\n /**\r\n * @param {String} k\r\n * @returns {*}\r\n */\r\n config(k = null) {\r\n return k ? this._config[k] : this._config;\r\n }\r\n\r\n /**\r\n * @param {String} k\r\n * @param {*} v\r\n */\r\n setConfig(k, v) {\r\n this._config[k] = v;\r\n }\r\n\r\n // #endregion\r\n\r\n // #region Html\r\n\r\n /**\r\n * Find overflow parent for positioning\r\n * and bind reset event of the parent form\r\n */\r\n _configureParent() {\r\n this.overflowParent = null;\r\n this.parentForm = this._selectElement.parentElement;\r\n while (this.parentForm) {\r\n if (this.parentForm.style.overflow === \"hidden\") {\r\n this.overflowParent = this.parentForm;\r\n }\r\n this.parentForm = this.parentForm.parentElement;\r\n if (this.parentForm && this.parentForm.nodeName == \"FORM\") {\r\n break;\r\n }\r\n }\r\n if (this.parentForm) {\r\n this.parentForm.addEventListener(\"reset\", this);\r\n }\r\n }\r\n\r\n /**\r\n * @returns {string}\r\n */\r\n _getPlaceholder() {\r\n // Use placeholder and data-placeholder in priority\r\n if (this._selectElement.hasAttribute(\"placeholder\")) {\r\n return this._selectElement.getAttribute(\"placeholder\");\r\n }\r\n if (this._selectElement.dataset.placeholder) {\r\n return this._selectElement.dataset.placeholder;\r\n }\r\n // Fallback to first option if no value\r\n let firstOption = this._selectElement.querySelector(\"option\");\r\n if (!firstOption || !this._config.autoselectFirst) {\r\n return \"\";\r\n }\r\n rmAttr(firstOption, \"selected\");\r\n firstOption.selected = false;\r\n return !firstOption.value ? firstOption.textContent : \"\";\r\n }\r\n\r\n _configureSelectElement() {\r\n const selectEl = this._selectElement;\r\n\r\n // Hiding the select should keep it focusable, otherwise we get this\r\n // An invalid form control with name='...' is not focusable.\r\n // If it's not focusable, we need to remove the native validation attributes\r\n\r\n // If we use display none, we don't get the focus event\r\n // selectEl.style.display = \"none\";\r\n\r\n // If we position it like this, the html5 validation message will not display properly\r\n if (this._config.hideNativeValidation) {\r\n // This position dont break render within input-group and is focusable\r\n selectEl.style.position = \"absolute\";\r\n selectEl.style.left = \"-9999px\";\r\n } else {\r\n // Hide but keep it focusable. If 0 height, no native validation message will show\r\n // It is placed below so that native tooltip is displayed properly\r\n // Flex basis is required for input-group otherwise it breaks the layout\r\n selectEl.style.cssText = `height:1px;width:1px;opacity:0;padding:0;margin:0;border:0;float:left;flex-basis:100%;min-height:unset;`;\r\n }\r\n\r\n // Make sure it's not usable using tab\r\n selectEl.tabIndex = -1;\r\n\r\n // No need for custom label click event if select is focusable\r\n // const label = document.querySelector('label[for=\"' + selectEl.getAttribute(\"id\") + '\"]');\r\n // if (label) {\r\n // label.addEventListener(\"click\", this);\r\n // }\r\n\r\n // It can be focused by clicking on the label\r\n selectEl.addEventListener(\"focus\", (event) => {\r\n this.onclick(event);\r\n });\r\n\r\n // When using regular html5 validation, make sure our fake element get the proper class\r\n selectEl.addEventListener(\"invalid\", (event) => {\r\n this._holderElement.classList.add(INVALID_CLASS);\r\n });\r\n }\r\n\r\n /**\r\n * Configure drop element\r\n * Needs to be called after searchInput is created\r\n */\r\n _configureDropElement() {\r\n const dropEl = this._dropElement;\r\n dropEl.classList.add(...[\"dropdown-menu\", CLASS_PREFIX + \"menu\"]);\r\n dropEl.id = CLASS_PREFIX + \"menu-\" + counter;\r\n dropEl.setAttribute(\"role\", \"menu\");\r\n\r\n const dropStyles = dropEl.style;\r\n dropStyles.padding = \"0\"; // avoid ugly space before option\r\n dropStyles.maxHeight = \"280px\";\r\n if (!this._config.fullWidth) {\r\n dropStyles.maxWidth = \"360px\";\r\n }\r\n if (this._config.fixed) {\r\n dropStyles.position = \"fixed\";\r\n }\r\n dropStyles.overflowY = \"auto\";\r\n // Prevent scrolling the menu from scrolling the page\r\n // @link https://developer.mozilla.org/en-US/docs/Web/CSS/overscroll-behavior\r\n dropStyles.overscrollBehavior = \"contain\";\r\n dropStyles.textAlign = \"unset\"; // otherwise RTL is not good\r\n\r\n // If the mouse was outside, entering remove keyboard nav mode\r\n dropEl.addEventListener(\"mouseenter\", (event) => {\r\n this._keyboardNavigation = false;\r\n });\r\n this._holderElement.appendChild(dropEl);\r\n\r\n // include aria-controls with the value of the id of the suggested list of values.\r\n this._searchInput.setAttribute(\"aria-controls\", dropEl.id);\r\n }\r\n\r\n _configureHolderElement() {\r\n const holder = this._holderElement;\r\n holder.classList.add(...[\"form-control\", \"dropdown\"]);\r\n // Reflect size (we must use form-select-xx because we may use form-select) and validation\r\n [\"form-select-lg\", \"form-select-sm\", \"is-invalid\", \"is-valid\"].forEach((className) => {\r\n if (this._selectElement.classList.contains(className)) {\r\n holder.classList.add(className);\r\n }\r\n });\r\n\r\n // It is really more like a dropdown\r\n if (this._config.suggestionsThreshold == 0 && this._config.showDropIcon) {\r\n holder.classList.add(\"form-select\");\r\n }\r\n\r\n // If we have an overflow parent, we can simply inherit styles\r\n if (this.overflowParent) {\r\n holder.style.position = \"inherit\";\r\n }\r\n // Prevent fixed height due to form-control in bs4\r\n holder.style.height = \"auto\";\r\n\r\n // Without this, clicking on a floating label won't always focus properly\r\n holder.addEventListener(\"click\", this);\r\n }\r\n\r\n _configureContainerElement() {\r\n this._containerElement.addEventListener(\"click\", (event) => {\r\n if (this.isDisabled()) {\r\n return;\r\n }\r\n if (this._searchInput.style.visibility != \"hidden\") {\r\n this._searchInput.focus();\r\n }\r\n });\r\n\r\n // Add some extra css to help positioning\r\n const containerStyles = this._containerElement.style;\r\n containerStyles.display = \"flex\";\r\n containerStyles.alignItems = \"center\";\r\n containerStyles.flexWrap = \"wrap\";\r\n }\r\n\r\n _configureSearchInput() {\r\n const searchInput = this._searchInput;\r\n\r\n searchInput.type = \"text\";\r\n searchInput.autocomplete = \"off\";\r\n searchInput.spellcheck = false;\r\n // note: firefox doesn't support the properties so we use attributes\r\n // @link https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-autocomplete\r\n // @link https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-expanded\r\n // use the aria-expanded state on the element with role combobox to communicate that the list is displayed.\r\n // https://developer.mozilla.org/en-US/docs/Web/API/Element/ariaLabel\r\n attrs(searchInput, {\r\n \"aria-autocomplete\": \"list\",\r\n \"aria-haspopup\": \"menu\",\r\n \"aria-expanded\": \"false\",\r\n \"aria-label\": this._config.searchLabel,\r\n role: \"combobox\",\r\n });\r\n searchInput.style.cssText = `background-color:transparent;color:currentColor;border:0;padding:0;outline:0;max-width:100%`;\r\n this.resetSearchInput(true);\r\n\r\n this._containerElement.appendChild(searchInput);\r\n this._rtl = window.getComputedStyle(searchInput).direction === \"rtl\";\r\n }\r\n\r\n // #endregion\r\n\r\n // #region Events\r\n\r\n onfocus(event) {\r\n this._holderElement.classList.add(FOCUS_CLASS);\r\n this.showOrSearch();\r\n this._config.onFocus(event, this);\r\n }\r\n\r\n onblur(event) {\r\n // Clicking on the scroll in a modal blur the element incorrectly\r\n if (this._isMouse && event.relatedTarget && event.relatedTarget.classList.contains(\"modal\")) {\r\n // Restore focus\r\n this._searchInput.focus();\r\n return;\r\n }\r\n this.afteronblur(event);\r\n }\r\n\r\n /**\r\n * This is triggered externally by a document click handler\r\n * Scrolling in the suggestion triggers the blur event and will close the suggestion\r\n * so we cannot rely on the blur event of the input element\r\n * We check for click and focus events (click when clicking outside, focus when tabbing...)\r\n * @param {Event} event\r\n */\r\n afteronblur(event) {\r\n // Cancel any pending request\r\n if (this._abortController) {\r\n this._abortController.abort();\r\n }\r\n let clearValidation = true;\r\n if (this._config.addOnBlur && this._searchInput.value) {\r\n clearValidation = this._enterValue();\r\n }\r\n this._holderElement.classList.remove(FOCUS_CLASS);\r\n this.hideSuggestions(clearValidation);\r\n if (this._fireEvents) {\r\n const sel = this.getSelection();\r\n const data = {\r\n selection: sel ? sel.dataset.value : null,\r\n input: this._searchInput.value,\r\n };\r\n this._config.onBlur(event, this);\r\n this._selectElement.dispatchEvent(new CustomEvent(\"tags.blur\", { bubbles: true, detail: data }));\r\n }\r\n }\r\n\r\n onpaste(ev) {\r\n //@ts-ignore\r\n const clipboardData = ev.clipboardData || window.clipboardData;\r\n const data = clipboardData.getData(\"text/plain\").replace(/\\r\\n|\\n/g, \" \");\r\n // Deal with copy paste including separators\r\n if (data.length > 2 && this._config.separator.length) {\r\n //@ts-ignore\r\n const splitData = splitMulti(data, this._config.separator).filter((n) => n);\r\n if (splitData.length > 1) {\r\n ev.preventDefault();\r\n splitData.forEach((value) => {\r\n this._addPastedValue(value);\r\n });\r\n }\r\n }\r\n }\r\n\r\n _addPastedValue(value) {\r\n let label = value;\r\n let addData = {};\r\n if (!this._config.allowNew) {\r\n const sel = this.getSelection();\r\n if (!sel) {\r\n return;\r\n }\r\n value = sel.getAttribute(VALUE_ATTRIBUTE);\r\n label = sel.dataset.label;\r\n } else {\r\n addData.new = 1;\r\n }\r\n this._config\r\n .confirmAdd(value, this)\r\n .then(() => {\r\n this._add(label, value, addData);\r\n })\r\n .catch(() => {});\r\n return;\r\n }\r\n\r\n oninput(ev) {\r\n const data = this._config.inputFilter(this._searchInput.value);\r\n if (data != this._searchInput.value) {\r\n this._searchInput.value = data;\r\n }\r\n\r\n // Add item if a separator is used\r\n // On mobile or copy paste, it can pass multiple chars (eg: when pressing space and it formats the string)\r\n if (data) {\r\n const lastChar = data.slice(-1);\r\n if (this._config.separator.length && this._config.separator.includes(lastChar)) {\r\n // Remove separator even if adding is prevented\r\n this._searchInput.value = this._searchInput.value.slice(0, -1);\r\n let value = this._searchInput.value;\r\n this._addPastedValue(value);\r\n return;\r\n }\r\n }\r\n\r\n // Adjust input width to current content\r\n setTimeout(() => {\r\n this._adjustWidth();\r\n });\r\n\r\n // Check if we should display suggestions\r\n this.showOrSearch();\r\n }\r\n\r\n /**\r\n * keypress doesn't send arrow keys, so we use keydown\r\n * @param {KeyboardEvent} event\r\n */\r\n onkeydown(event) {\r\n // Keycode reference : https://css-tricks.com/snippets/javascript/javascript-keycodes/\r\n let key = event.keyCode || event.key;\r\n /**\r\n * @type {HTMLInputElement}\r\n */\r\n // @ts-ignore\r\n const target = event.target;\r\n\r\n // Android virtual keyboard might always return 229\r\n if (event.keyCode == 229) {\r\n key = target.value.charAt(target.selectionStart - 1).charCodeAt(0);\r\n }\r\n\r\n // Keyboard keys\r\n switch (key) {\r\n case 13:\r\n case \"Enter\":\r\n event.preventDefault();\r\n this._enterValue();\r\n break;\r\n case 38:\r\n case \"ArrowUp\":\r\n event.preventDefault();\r\n this._keyboardNavigation = true;\r\n this._moveSelection(PREV);\r\n break;\r\n case 40:\r\n case \"ArrowDown\":\r\n event.preventDefault();\r\n this._keyboardNavigation = true;\r\n if (this.isDropdownVisible()) {\r\n this._moveSelection(NEXT);\r\n } else {\r\n // show menu regardless of input length\r\n this.showOrSearch(false);\r\n }\r\n break;\r\n case 8:\r\n case \"Backspace\":\r\n // If the current item is empty, remove the last one\r\n const lastItem = this.getLastItem();\r\n if (this._searchInput.value.length == 0 && lastItem) {\r\n this._config\r\n .confirmClear(lastItem, this)\r\n .then(() => {\r\n this.removeLastItem();\r\n this._adjustWidth();\r\n this.showOrSearch();\r\n })\r\n .catch(() => {});\r\n }\r\n break;\r\n case 27:\r\n case \"Escape\":\r\n this._searchInput.focus();\r\n this.hideSuggestions();\r\n break;\r\n }\r\n }\r\n\r\n onmousemove(e) {\r\n this._isMouse = true;\r\n // Moving the mouse means no longer using keyboard\r\n this._keyboardNavigation = false;\r\n }\r\n\r\n onmouseleave(e) {\r\n this._isMouse = false;\r\n // remove selection\r\n this.removeSelection();\r\n }\r\n\r\n onscroll(e) {\r\n this._positionMenu();\r\n }\r\n\r\n onresize(e) {\r\n this._positionMenu();\r\n }\r\n\r\n onclick(e = null) {\r\n if (!this.isSingle() && this.isMaxReached()) {\r\n return;\r\n }\r\n // Focus on input when clicking on element or focusing select\r\n this._searchInput.focus();\r\n }\r\n\r\n onreset(e) {\r\n this.reset();\r\n }\r\n\r\n // #endregion\r\n\r\n /**\r\n * @param {Boolean} init called during init\r\n */\r\n loadData(init = false) {\r\n if (Object.keys(this._config.items).length > 0) {\r\n this.setData(this._config.items, true);\r\n } else {\r\n // This will setData at the end\r\n this.resetSuggestions(true);\r\n }\r\n\r\n if (this._config.server) {\r\n if (this._config.liveServer) {\r\n // No need to load anything since it will happen when typing\r\n // Initial values are loaded from config items or from provided options\r\n } else {\r\n this._loadFromServer(!init);\r\n }\r\n }\r\n }\r\n\r\n /**\r\n * Make sure we have valid selected attributes\r\n */\r\n _setSelectedAttributes() {\r\n // we use selectedOptions because single select can have a selected option without a selected attribute if it's the first value\r\n const selectedOptions = this._selectElement.selectedOptions || [];\r\n for (let j = 0; j < selectedOptions.length; j++) {\r\n // Enforce selected attr for consistency\r\n if (selectedOptions[j].value && !selectedOptions[j].hasAttribute(\"selected\")) {\r\n selectedOptions[j].setAttribute(\"selected\", \"selected\");\r\n }\r\n }\r\n }\r\n\r\n resetState() {\r\n if (this.isDisabled()) {\r\n this._holderElement.setAttribute(\"readonly\", \"\");\r\n this._searchInput.setAttribute(\"disabled\", \"\");\r\n this._holderElement.classList.add(DISABLED_CLASS);\r\n } else {\r\n rmAttr(this._holderElement, \"readonly\");\r\n rmAttr(this._searchInput, \"disabled\");\r\n this._holderElement.classList.remove(DISABLED_CLASS);\r\n }\r\n }\r\n\r\n /**\r\n * Reset suggestions from select element\r\n * Iterates over option children then calls setData\r\n * @param {Boolean} init called during init\r\n */\r\n resetSuggestions(init = false) {\r\n this._setSelectedAttributes();\r\n\r\n const convertOption = (option) => {\r\n return {\r\n value: option.getAttribute(\"value\"),\r\n label: option.textContent,\r\n disabled: option.disabled,\r\n //@ts-ignore\r\n selected: option.selected,\r\n title: option.title,\r\n data: Object.assign(\r\n {\r\n disabled: option.disabled, // pass as data as well\r\n },\r\n option.dataset\r\n ),\r\n };\r\n };\r\n\r\n let suggestions = Array.from(this._selectElement.children)\r\n .filter(\r\n /**\r\n * @param {HTMLOptionElement|HTMLOptGroupElement} option\r\n */\r\n (option) => {\r\n return option.hasAttribute(\"label\") || !option.disabled || this._config.showDisabled;\r\n }\r\n )\r\n .map(\r\n /**\r\n * @param {HTMLOptionElement|HTMLOptGroupElement} option\r\n */\r\n (option) => {\r\n if (option.hasAttribute(\"label\")) {\r\n return {\r\n group: option.getAttribute(\"label\"),\r\n items: Array.from(option.children).map((option) => {\r\n return convertOption(option);\r\n }),\r\n };\r\n }\r\n return convertOption(option);\r\n }\r\n );\r\n\r\n this.setData(suggestions, init);\r\n }\r\n\r\n /**\r\n * Try to add the current value\r\n * @returns {Boolean}\r\n */\r\n _enterValue() {\r\n let selection = this.getSelection();\r\n if (selection) {\r\n selection.click();\r\n return true;\r\n } else {\r\n // We use what is typed if not selected and not empty\r\n if (this._config.allowNew && this._searchInput.value) {\r\n let text = this._searchInput.value;\r\n this._config\r\n .confirmAdd(text, this)\r\n .then(() => {\r\n this._add(text, text, { new: 1 });\r\n })\r\n .catch(() => {});\r\n return true;\r\n }\r\n }\r\n return false;\r\n }\r\n\r\n /**\r\n * @param {Boolean} show Show menu after load. False during init\r\n */\r\n _loadFromServer(show = false) {\r\n if (this._abortController) {\r\n this._abortController.abort();\r\n }\r\n this._abortController = new AbortController();\r\n\r\n // Read data params dynamically as well (eg: for vue JS)\r\n let extraParams = this._selectElement.dataset.serverParams || {};\r\n if (typeof extraParams == \"string\") {\r\n extraParams = JSON.parse(extraParams);\r\n }\r\n const params = Object.assign({}, this._config.serverParams, extraParams);\r\n // Pass current value\r\n params[this._config.queryParam] = this._searchInput.value;\r\n // Prevent caching\r\n if (this._config.noCache) {\r\n params.t = Date.now();\r\n }\r\n // We have a related field\r\n if (params.related) {\r\n /**\r\n * @type {HTMLInputElement}\r\n */\r\n //@ts-ignore\r\n const input = document.getElementById(params.related);\r\n if (input) {\r\n params.related = input.value;\r\n const inputName = input.getAttribute(\"name\");\r\n if (inputName) {\r\n params[inputName] = input.value;\r\n }\r\n }\r\n }\r\n\r\n const urlParams = new URLSearchParams(params);\r\n let url = this._config.server;\r\n let fetchOptions = Object.assign(this._config.fetchOptions, {\r\n method: this._config.serverMethod || \"GET\",\r\n signal: this._abortController.signal,\r\n });\r\n\r\n if (fetchOptions.method === \"POST\") {\r\n fetchOptions.body = urlParams;\r\n } else {\r\n url += \"?\" + urlParams.toString();\r\n }\r\n\r\n this._holderElement.classList.add(LOADING_CLASS);\r\n fetch(url, fetchOptions)\r\n .then((r) => this._config.onServerResponse(r, this))\r\n .then((suggestions) => {\r\n const data = nested(this._config.serverDataKey, suggestions) || suggestions;\r\n this.setData(data, !show);\r\n this._abortController = null;\r\n if (show) {\r\n this._showSuggestions();\r\n }\r\n })\r\n .catch((e) => {\r\n this._config.onServerError(e, this._abortController.signal, this);\r\n })\r\n .finally((e) => {\r\n this._holderElement.classList.remove(LOADING_CLASS);\r\n });\r\n }\r\n\r\n /**\r\n * Wrapper for the public addItem method that check if the item\r\n * can be added\r\n *\r\n * @param {string} text\r\n * @param {string} value\r\n * @param {object} data\r\n * @returns {HTMLOptionElement|null}\r\n */\r\n _add(text, value = null, data = {}) {\r\n if (!this.canAdd(text, data)) {\r\n return null;\r\n }\r\n const el = this.addItem(text, value, data);\r\n this._resetHtmlState();\r\n if (this._config.keepOpen) {\r\n this._showSuggestions();\r\n } else {\r\n this.resetSearchInput();\r\n }\r\n\r\n return el;\r\n }\r\n\r\n /**\r\n * @param {HTMLElement} li\r\n * @returns {Boolean}\r\n */\r\n _isItemEnabled(li) {\r\n if (li.style.display === \"none\") {\r\n return false;\r\n }\r\n const fc = li.firstElementChild;\r\n return fc.tagName === \"A\" && !fc.classList.contains(\"disabled\");\r\n }\r\n\r\n /**\r\n * @param {String} dir\r\n * @param {*|HTMLElement} sel\r\n * @returns {HTMLElement}\r\n */\r\n _moveSelection(dir = NEXT, sel = null) {\r\n const active = this.getSelection();\r\n\r\n // select first li if visible\r\n if (!active) {\r\n // no active selection, cannot go back\r\n if (dir === PREV) {\r\n return sel;\r\n }\r\n // find first enabled item\r\n if (!sel) {\r\n sel = this._dropElement.firstChild;\r\n while (sel && !this._isItemEnabled(sel)) {\r\n sel = sel[\"nextSibling\"];\r\n }\r\n }\r\n } else {\r\n const sibling = dir === NEXT ? \"nextSibling\" : \"previousSibling\";\r\n\r\n // Iterate over visible li\r\n sel = active.parentNode;\r\n do {\r\n sel = sel[sibling];\r\n } while (sel && !this._isItemEnabled(sel));\r\n\r\n // We have a new selection\r\n if (sel) {\r\n // Remove classes from current active\r\n active.classList.remove(...this._activeClasses());\r\n } else if (active) {\r\n // Use active element as selection\r\n sel = active.parentElement;\r\n }\r\n }\r\n\r\n if (sel) {\r\n // Scroll if necessary\r\n const selHeight = sel.offsetHeight;\r\n const selTop = sel.offsetTop;\r\n const parent = sel.parentNode;\r\n const parentHeight = parent.offsetHeight;\r\n const parentScrollHeight = parent.scrollHeight;\r\n const parentTop = parent.offsetTop;\r\n\r\n // Reset scroll, this can happen if menu was scrolled then hidden\r\n if (selHeight === 0) {\r\n setTimeout(() => {\r\n parent.scrollTop = 0;\r\n });\r\n }\r\n\r\n if (dir === PREV) {\r\n // Don't use scrollIntoView as it scrolls the whole window\r\n // Avoid minor top scroll due to headers\r\n const scrollTop = selTop - parentTop > 10 ? selTop - parentTop : 0;\r\n parent.scrollTop = scrollTop;\r\n } else {\r\n // This is the equivalent of scrollIntoView(false) but only for parent node\r\n // Only scroll if the element is not visible\r\n const scrollNeeded = selTop + selHeight - (parentHeight + parent.scrollTop);\r\n if (scrollNeeded > 0 && selHeight > 0) {\r\n parent.scrollTop = selTop + selHeight - parentHeight + 1;\r\n // On last element, make sure we scroll the the bottom\r\n if (parent.scrollTop + parentHeight >= parentScrollHeight - 10) {\r\n parent.scrollTop = selTop - parentTop;\r\n }\r\n }\r\n }\r\n\r\n // Adjust link\r\n const a = sel.querySelector(\"a\");\r\n a.classList.add(...this._activeClasses());\r\n this._searchInput.setAttribute(\"aria-activedescendant\", a.id);\r\n if (this._config.updateOnSelect) {\r\n this._searchInput.value = a.dataset.label;\r\n this._adjustWidth();\r\n }\r\n } else {\r\n this._searchInput.setAttribute(\"aria-activedescendant\", \"\");\r\n }\r\n return sel;\r\n }\r\n\r\n /**\r\n * Adjust the field to fit its content and show/hide placeholder if needed\r\n */\r\n _adjustWidth() {\r\n this._holderElement.classList.remove(PLACEHOLDER_CLASS);\r\n if (this._searchInput.value) {\r\n this._searchInput.size = this._searchInput.value.length;\r\n } else {\r\n // Show the placeholder only if empty\r\n if (this.getSelectedValues().length) {\r\n this._searchInput.placeholder = \"\";\r\n this._searchInput.size = 1;\r\n } else {\r\n this._searchInput.size = this._config.placeholder.length > 0 ? this._config.placeholder.length : 1;\r\n this._searchInput.placeholder = this._config.placeholder;\r\n this._holderElement.classList.add(PLACEHOLDER_CLASS);\r\n }\r\n }\r\n\r\n // If the string contains ascii chars or strange font, input size may be wrong\r\n // We cannot only rely on the size attribute\r\n const v = this._searchInput.value || this._searchInput.placeholder;\r\n const computedFontSize = window.getComputedStyle(this._holderElement).fontSize;\r\n const w = calcTextWidth(v, computedFontSize) + 16;\r\n this._searchInput.style.width = w + \"px\"; // Don't use minWidth as it would prevent using maxWidth\r\n }\r\n\r\n /**\r\n * Add suggestions to the drop element\r\n * @param {Array} suggestions\r\n */\r\n _buildSuggestions(suggestions) {\r\n while (this._dropElement.lastChild) {\r\n this._dropElement.removeChild(this._dropElement.lastChild);\r\n }\r\n let idx = 0;\r\n let groupId = 1; // start at one, because data-id = \"\" + 0 doesn't do anything\r\n for (let i = 0; i < suggestions.length; i++) {\r\n const suggestion = suggestions[i];\r\n\r\n if (!suggestion) {\r\n continue;\r\n }\r\n\r\n // Handle optgroups\r\n if (suggestion[\"group\"] && suggestion[\"items\"]) {\r\n const newChild = ce(\"li\");\r\n newChild.setAttribute(\"role\", \"presentation\");\r\n newChild.dataset.id = \"\" + groupId;\r\n const newChildSpan = ce(\"span\");\r\n newChild.append(newChildSpan);\r\n newChildSpan.classList.add(...[\"dropdown-header\", \"text-truncate\"]);\r\n newChildSpan.innerHTML = this._config.sanitizer(suggestion[\"group\"]);\r\n this._dropElement.appendChild(newChild);\r\n\r\n if (suggestion[\"items\"]) {\r\n for (let j = 0; j < suggestion[\"items\"].length; j++) {\r\n const groupSuggestion = suggestion[\"items\"][j];\r\n groupSuggestion.group_id = groupId;\r\n this._buildSuggestionsItem(suggestion[\"items\"][j], idx);\r\n idx++;\r\n }\r\n }\r\n\r\n groupId++;\r\n }\r\n\r\n //@ts-ignore\r\n this._buildSuggestionsItem(suggestion, idx);\r\n idx++;\r\n }\r\n\r\n // Create the not found message\r\n if (this._config.notFoundMessage) {\r\n const notFound = ce(\"li\");\r\n notFound.setAttribute(\"role\", \"presentation\");\r\n notFound.classList.add(CLASS_PREFIX + \"not-found\");\r\n // Actual message is refreshed on typing, but we need item for consistency\r\n notFound.innerHTML = ``;\r\n this._dropElement.appendChild(notFound);\r\n }\r\n }\r\n\r\n /**\r\n * @param {Suggestion} suggestion\r\n * @param {Number} i The global counter\r\n */\r\n _buildSuggestionsItem(suggestion, i) {\r\n if (!suggestion[this._config.valueField]) {\r\n return;\r\n }\r\n\r\n const value = suggestion[this._config.valueField];\r\n const label = suggestion[this._config.labelField];\r\n\r\n let textContent = this._config.onRenderItem(suggestion, label, this);\r\n\r\n const newChild = ce(\"li\");\r\n // role must be menuitem when used with menu\r\n // see https://github.com/lekoala/bootstrap5-tags/issues/114\r\n newChild.setAttribute(\"role\", \"menuitem\");\r\n if (suggestion.group_id) {\r\n newChild.setAttribute(\"data-group-id\", \"\" + suggestion.group_id);\r\n }\r\n if (suggestion.title) {\r\n newChild.setAttribute(\"title\", suggestion.title);\r\n newChild.setAttribute(\"data-bs-placement\", \"left\");\r\n }\r\n const newChildLink = ce(\"a\");\r\n newChild.append(newChildLink);\r\n newChildLink.id = this._dropElement.id + \"-\" + i;\r\n newChildLink.classList.add(...[\"dropdown-item\", \"text-truncate\"]);\r\n if (suggestion.disabled) {\r\n newChildLink.classList.add(...[\"disabled\"]);\r\n }\r\n newChildLink.setAttribute(VALUE_ATTRIBUTE, value);\r\n newChildLink.dataset.label = label;\r\n\r\n const searchData = {};\r\n this._config.searchFields.forEach((sf) => {\r\n searchData[sf] = suggestion[sf];\r\n });\r\n newChildLink.dataset.searchData = JSON.stringify(searchData);\r\n newChildLink.setAttribute(\"href\", \"#\");\r\n // sanitized if needed by onRenderItem\r\n newChildLink.innerHTML = textContent;\r\n this._dropElement.appendChild(newChild);\r\n\r\n // tooltips\r\n const v5 = this._getBootstrapVersion() === 5;\r\n if (suggestion.title && tooltip && v5) {\r\n tooltip.getOrCreateInstance(newChild);\r\n }\r\n\r\n // Hover sets active item\r\n newChildLink.addEventListener(\"mouseenter\", (event) => {\r\n // Don't trigger enter if using arrows\r\n if (this._keyboardNavigation) {\r\n return;\r\n }\r\n this.removeSelection();\r\n newChild.querySelector(\"a\").classList.add(...this._activeClasses());\r\n });\r\n newChildLink.addEventListener(\"mousedown\", (event) => {\r\n // Otherwise searchInput would lose focus and close the menu\r\n event.preventDefault();\r\n });\r\n newChildLink.addEventListener(\"click\", (event) => {\r\n event.preventDefault();\r\n event.stopPropagation();\r\n this._config\r\n .confirmAdd(value, this)\r\n .then(() => {\r\n this._add(label, value, suggestion.data);\r\n this._config.onSelectItem(suggestion, this);\r\n })\r\n .catch(() => {});\r\n });\r\n }\r\n\r\n /**\r\n * @returns {NodeListOf}\r\n */\r\n initialOptions() {\r\n return this._selectElement.querySelectorAll(\"option[data-init]\");\r\n }\r\n\r\n /**\r\n * Call this before looping in a list that calls addItem\r\n * This will make sure addItem will not add incorrectly options to the select\r\n */\r\n _removeSelectedAttrs() {\r\n this._selectElement.querySelectorAll(\"option\").forEach((opt) => {\r\n rmAttr(opt, \"selected\");\r\n });\r\n }\r\n\r\n reset() {\r\n this.removeAll();\r\n\r\n // Reset doesn't fire change event\r\n this._fireEvents = false;\r\n const opts = this.initialOptions();\r\n this._removeSelectedAttrs();\r\n for (let j = 0; j < opts.length; j++) {\r\n const iv = opts[j];\r\n const data = Object.assign(\r\n {},\r\n {\r\n disabled: iv.hasAttribute(\"disabled\"),\r\n },\r\n iv.dataset\r\n );\r\n this.addItem(iv.textContent, iv.value, data);\r\n }\r\n this._resetHtmlState();\r\n this._fireEvents = true;\r\n }\r\n\r\n /**\r\n * @param {Boolean} init Pass true during init\r\n */\r\n resetSearchInput(init = false) {\r\n this._searchInput.value = \"\";\r\n this._adjustWidth();\r\n\r\n this._checkMax();\r\n\r\n // Single select is a special case\r\n if (this.isSingle() && !init) {\r\n //@ts-ignore\r\n document.activeElement.blur();\r\n this.hideSuggestions();\r\n\r\n return;\r\n }\r\n\r\n // Extra things to do when not during init\r\n if (!init) {\r\n if (!this._shouldShow()) {\r\n this.hideSuggestions();\r\n }\r\n\r\n // Trigger input even to show suggestions if needed when focused\r\n if (this._searchInput === document.activeElement) {\r\n this._searchInput.dispatchEvent(new Event(\"input\"));\r\n }\r\n }\r\n }\r\n\r\n /**\r\n * We use visibility instead of display to keep layout intact\r\n */\r\n _checkMax() {\r\n if (this.isMaxReached()) {\r\n this._holderElement.classList.add(MAX_REACHED_CLASS);\r\n this._searchInput.style.visibility = \"hidden\";\r\n } else {\r\n if (this._searchInput.style.visibility == \"hidden\") {\r\n this._searchInput.style.visibility = \"visible\";\r\n }\r\n }\r\n }\r\n\r\n /**\r\n * @returns {Array}\r\n */\r\n getSelectedValues() {\r\n // option[selected] is used rather that selectedOptions as it works more consistently\r\n /**\r\n * @type {NodeListOf}\r\n */\r\n const selected = this._selectElement.querySelectorAll(\"option[selected]\");\r\n return Array.from(selected).map((el) => el.value);\r\n }\r\n\r\n /**\r\n * @returns {Array}\r\n */\r\n getAvailableValues() {\r\n /**\r\n * @type {NodeListOf}\r\n */\r\n const selected = this._selectElement.querySelectorAll(\"option\");\r\n return Array.from(selected).map((el) => el.value);\r\n }\r\n\r\n /**\r\n * Show suggestions or search them depending on live server\r\n * @param {Boolean} check\r\n */\r\n showOrSearch(check = true) {\r\n if (check && !this._shouldShow()) {\r\n // focusing should not clear validation\r\n this.hideSuggestions(false);\r\n return;\r\n }\r\n if (this._config.liveServer) {\r\n this._searchFunc();\r\n } else {\r\n this._showSuggestions();\r\n }\r\n }\r\n\r\n /**\r\n * The element create with buildSuggestions\r\n * @param {Boolean} clearValidation\r\n */\r\n hideSuggestions(clearValidation = true) {\r\n this._dropElement.classList.remove(SHOW_CLASS);\r\n attrs(this._searchInput, {\r\n \"aria-expanded\": \"false\",\r\n });\r\n this.removeSelection();\r\n if (clearValidation) {\r\n this._holderElement.classList.remove(INVALID_CLASS);\r\n }\r\n }\r\n\r\n /**\r\n * Show or hide suggestions\r\n * @param {Boolean} check Show suggestions regardless if shouldShow conditions\r\n * @param {Boolean} clearValidation\r\n */\r\n toggleSuggestions(check = true, clearValidation = true) {\r\n if (this._dropElement.classList.contains(SHOW_CLASS)) {\r\n this.hideSuggestions(clearValidation);\r\n } else {\r\n this.showOrSearch(check);\r\n }\r\n }\r\n\r\n /**\r\n * Do we have enough input to show suggestions ?\r\n * @returns {Boolean}\r\n */\r\n _shouldShow() {\r\n if (this.isDisabled() || this.isMaxReached()) {\r\n return false;\r\n }\r\n return this._searchInput.value.length >= this._config.suggestionsThreshold;\r\n }\r\n\r\n /**\r\n * The element create with buildSuggestions\r\n */\r\n _showSuggestions() {\r\n // Never show suggestions if you cannot add new values\r\n if (this._searchInput.style.visibility == \"hidden\") {\r\n return;\r\n }\r\n\r\n const lookup = normalize(this._searchInput.value);\r\n\r\n const valueCounter = {};\r\n\r\n // Filter the list according to search string\r\n const list = this._dropElement.querySelectorAll(\"li\");\r\n let count = 0;\r\n let firstItem = null;\r\n let hasPossibleValues = false;\r\n let visibleGroups = {};\r\n for (let i = 0; i < list.length; i++) {\r\n /**\r\n * @type {HTMLLIElement}\r\n */\r\n let item = list[i];\r\n /**\r\n * @type {HTMLAnchorElement|HTMLSpanElement}\r\n */\r\n //@ts-ignore\r\n let link = item.firstElementChild;\r\n\r\n // This is the empty result message or a header\r\n if (link instanceof HTMLSpanElement) {\r\n // We will show it later\r\n if (item.dataset.id) {\r\n visibleGroups[item.dataset.id] = false;\r\n }\r\n hideItem(item);\r\n continue;\r\n }\r\n\r\n // Remove previous selection\r\n link.classList.remove(...this._activeClasses());\r\n\r\n // Hide selected values\r\n if (!this._config.allowSame) {\r\n const v = link.getAttribute(VALUE_ATTRIBUTE);\r\n // Find if the matching option is already selected by index to deal with same values\r\n valueCounter[v] = valueCounter[v] || 0;\r\n const opt = this._findOption(link.getAttribute(VALUE_ATTRIBUTE), \"[selected]\", valueCounter[v]++);\r\n if (opt) {\r\n hideItem(item);\r\n continue;\r\n }\r\n }\r\n\r\n // Check search length since we can trigger dropdown with arrow\r\n const showAllSuggestions = this._config.showAllSuggestions || lookup.length === 0;\r\n // Do we find a matching string or do we display immediately ?\r\n let isMatched = lookup.length == 0 && this._config.suggestionsThreshold === 0;\r\n if (!showAllSuggestions && lookup.length > 0) {\r\n // match on any field\r\n const searchData = JSON.parse(link.dataset.searchData);\r\n this._config.searchFields.forEach((sf) => {\r\n const text = normalize(searchData[sf]);\r\n let found = false;\r\n if (this._config.fuzzy) {\r\n found = fuzzyMatch(text, lookup);\r\n } else {\r\n const idx = text.indexOf(lookup);\r\n found = this._config.startsWith ? idx === 0 : idx >= 0;\r\n }\r\n if (found) {\r\n isMatched = true;\r\n }\r\n });\r\n }\r\n\r\n const selectFirst = isMatched || lookup.length === 0;\r\n if (showAllSuggestions || isMatched) {\r\n count++;\r\n showItem(item);\r\n if (item.dataset.groupId) {\r\n visibleGroups[item.dataset.groupId] = true;\r\n }\r\n // Only select as first item if its matching or no lookup\r\n if (!firstItem && this._isItemEnabled(item) && selectFirst) {\r\n firstItem = item;\r\n }\r\n if (this._config.maximumItems > 0 && count > this._config.maximumItems) {\r\n hideItem(item);\r\n }\r\n } else {\r\n hideItem(item);\r\n }\r\n\r\n if (this._config.highlightTyped) {\r\n // using .textContent removes any html that can be present (eg: mark added through highlightTyped)\r\n const textContent = link.textContent;\r\n const idx = normalize(textContent).indexOf(lookup);\r\n const highlighted =\r\n textContent.substring(0, idx) +\r\n `${textContent.substring(idx, idx + lookup.length)}` +\r\n textContent.substring(idx + lookup.length, textContent.length);\r\n link.innerHTML = highlighted;\r\n }\r\n\r\n if (this._isItemEnabled(item)) {\r\n hasPossibleValues = true;\r\n }\r\n }\r\n\r\n // No item and we don't allow new items => error\r\n if (!this._config.allowNew && !(lookup.length === 0 && !hasPossibleValues)) {\r\n this._holderElement.classList.add(INVALID_CLASS);\r\n }\r\n\r\n // If we allow new elements, regex validation should happen on canAdd instead\r\n if (this._config.allowNew && this._config.regex && this.isInvalid()) {\r\n this._holderElement.classList.remove(INVALID_CLASS);\r\n }\r\n\r\n // Show all groups with visible values\r\n Array.from(list)\r\n .filter((li) => {\r\n return li.dataset.id;\r\n })\r\n .forEach((li) => {\r\n if (visibleGroups[li.dataset.id] === true) {\r\n showItem(li);\r\n }\r\n });\r\n\r\n if (hasPossibleValues) {\r\n // Remove validation message if we show selectable values\r\n this._holderElement.classList.remove(INVALID_CLASS);\r\n\r\n // Autoselect first\r\n if (firstItem && this._config.autoselectFirst) {\r\n this.removeSelection();\r\n this._moveSelection(NEXT, firstItem);\r\n }\r\n }\r\n\r\n // Remove dropdown if list is empty\r\n if (count === 0) {\r\n if (this._config.notFoundMessage) {\r\n /**\r\n * @type {HTMLElement}\r\n */\r\n const notFound = this._dropElement.querySelector(\".\" + CLASS_PREFIX + \"not-found\");\r\n notFound.style.display = \"block\";\r\n const notFoundMessage = this._config.notFoundMessage.replace(\"{{tag}}\", this._searchInput.value);\r\n notFound.innerHTML = `${notFoundMessage}`;\r\n this._showDropdown();\r\n } else {\r\n // Remove dropdown if not found (do not clear validation)\r\n this.hideSuggestions(false);\r\n }\r\n } else {\r\n // Or show it if necessary\r\n this._showDropdown();\r\n }\r\n }\r\n\r\n _showDropdown() {\r\n const isVisible = this._dropElement.classList.contains(SHOW_CLASS);\r\n if (!isVisible) {\r\n this._dropElement.classList.add(SHOW_CLASS);\r\n attrs(this._searchInput, {\r\n \"aria-expanded\": \"true\",\r\n });\r\n }\r\n this._positionMenu(isVisible);\r\n }\r\n\r\n /**\r\n * @param {Boolean} wasVisible\r\n */\r\n _positionMenu(wasVisible = false) {\r\n const isRTL = this._rtl;\r\n const fixed = this._config.fixed;\r\n const fullWidth = this._config.fullWidth;\r\n const bounds = this._searchInput.getBoundingClientRect();\r\n const holderBounds = this._holderElement.getBoundingClientRect();\r\n\r\n let left = 0;\r\n let top = 0;\r\n\r\n if (fixed) {\r\n // In full width, use holder as left reference, otherwise use input\r\n if (fullWidth) {\r\n left = holderBounds.x;\r\n top = holderBounds.y + holderBounds.height + 2; // 2px offset\r\n } else {\r\n left = bounds.x;\r\n top = bounds.y + bounds.height;\r\n }\r\n } else {\r\n // When positioning is not fixed, we leave it up to the browser\r\n // it may not work in complex situations with scrollable overflows, etc\r\n if (fullWidth) {\r\n // Stick it at the start\r\n left = 0;\r\n // Move it below\r\n top = holderBounds.height + 2; // 2px offset\r\n } else {\r\n // Position next to input (offsetLeft != bounds.x)\r\n left = this._searchInput.offsetLeft;\r\n top = this._searchInput.offsetHeight + this._searchInput.offsetTop;\r\n }\r\n }\r\n\r\n // Align end\r\n if (isRTL && !fullWidth) {\r\n left -= this._dropElement.offsetWidth - bounds.width;\r\n }\r\n\r\n // Horizontal overflow\r\n if (!fullWidth) {\r\n const w = Math.min(window.innerWidth, document.body.offsetWidth);\r\n const hdiff = isRTL\r\n ? bounds.x + bounds.width - this._dropElement.offsetWidth - 1\r\n : w - 1 - (bounds.x + this._dropElement.offsetWidth);\r\n if (hdiff < 0) {\r\n left = isRTL ? left - hdiff : left + hdiff;\r\n }\r\n }\r\n\r\n // Use full holder width\r\n if (fullWidth) {\r\n this._dropElement.style.width = this._holderElement.offsetWidth + \"px\";\r\n }\r\n\r\n if (!wasVisible) {\r\n // Reset any height overflow adjustement\r\n this._dropElement.style.transform = \"unset\";\r\n }\r\n\r\n Object.assign(this._dropElement.style, {\r\n // Position element\r\n left: left + \"px\",\r\n top: top + \"px\",\r\n });\r\n\r\n // Overflow height\r\n const dropBounds = this._dropElement.getBoundingClientRect();\r\n const h = window.innerHeight;\r\n\r\n // We display above input if it overflows\r\n if (dropBounds.y + dropBounds.height > h || this._dropElement.style.transform.includes(\"translateY\")) {\r\n // We need to add the offset twice\r\n const topOffset = fullWidth ? holderBounds.height + 4 : bounds.height;\r\n // In chrome, we need 100.1% to avoid blurry text\r\n // @link https://stackoverflow.com/questions/32034574/font-looks-blurry-after-translate-in-chrome\r\n this._dropElement.style.transform = \"translateY(calc(-100.1% - \" + topOffset + \"px))\";\r\n }\r\n }\r\n\r\n /**\r\n * @returns {Number}\r\n */\r\n _getBootstrapVersion() {\r\n let ver = 5;\r\n // If we have jQuery and the tooltip plugin for BS4\r\n //@ts-ignore\r\n let jq = window.jQuery;\r\n if (jq && jq.fn.tooltip && jq.fn.tooltip.Constructor) {\r\n ver = parseInt(jq.fn.tooltip.Constructor.VERSION.charAt(0));\r\n }\r\n return ver;\r\n }\r\n\r\n /**\r\n * Find if label is already selected (based on attribute)\r\n * @param {string} text\r\n * @returns {Boolean}\r\n */\r\n _isSelected(text) {\r\n const arr = Array.from(this._selectElement.querySelectorAll(\"option\"));\r\n const selOpt = arr.find((el) => el.textContent == text && el.getAttribute(\"selected\"));\r\n return selOpt ? true : false;\r\n }\r\n\r\n /**\r\n * Find if label is already selectable (based on attribute)\r\n * @param {string} text\r\n * @returns {Boolean}\r\n */\r\n _isSelectable(text) {\r\n const arr = Array.from(this._selectElement.querySelectorAll(\"option\"));\r\n const opts = arr.filter((el) => el.textContent == text);\r\n // Only consider actual