diff --git a/.github/workflows/jest.yml b/.github/workflows/jest.yml
new file mode 100644
index 00000000..7161e94d
--- /dev/null
+++ b/.github/workflows/jest.yml
@@ -0,0 +1,27 @@
+name: 'Jest unit tests'
+on:
+ push:
+ paths-ignore:
+ - '**/dist/**'
+ - '**/*.md'
+ - '.github/**'
+ - '.vscode/**'
+ pull_request:
+ branches:
+ - develop
+ paths-ignore:
+ - '**/dist/**'
+ - '**/*.md'
+ - '.github/**'
+ - '.vscode/**'
+jobs:
+ jest:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '21.5.0'
+ cache: 'npm'
+ - run: npm ci
+ - run: npm run test
diff --git a/.prettierignore b/.prettierignore
new file mode 100644
index 00000000..581edad3
--- /dev/null
+++ b/.prettierignore
@@ -0,0 +1 @@
+**/dist
diff --git a/README.md b/README.md
index 39fc21eb..262c62ef 100644
--- a/README.md
+++ b/README.md
@@ -5,6 +5,7 @@
Advanced select dropdown
[](https://www.npmjs.com/package/slim-select)
+
## Support
@@ -133,6 +134,7 @@ new SlimSelect({
disabled: false,
alwaysOpen: false,
showSearch: true,
+ focusSearch: true,
searchPlaceholder: 'Search',
searchText: 'No Results',
searchingText: 'Searching...',
diff --git a/docs/assets/home.css b/docs/assets/home.css
index f1d162a3..29572f0d 100644
--- a/docs/assets/home.css
+++ b/docs/assets/home.css
@@ -1 +1 @@
-#home .samples{display:flex;justify-content:space-between;gap:var(--spacing)}#home .samples .single,#home .samples .multi{flex:1 1 50%}#home .support .links{display:flex;height:50px;flex-direction:row;gap:var(--spacing);overflow:hidden}#home .support .links .github-sponsor{height:100%;width:200px;border:0;color-scheme:dark}#home .support .links .buycoffee img{height:100%;width:auto}#home .ads{display:flex;flex-direction:row;gap:var(--spacing-half)}#home .features{flex:1 1 auto;display:flex;flex-direction:column}#home .features .header{padding:0 0 var(--spacing) 0}#home .features .row{justify-content:space-around}#home .features .row .list{display:flex;flex-direction:column;list-style:none;padding:0;width:200px;margin:0 auto}#home .features .row .list li{display:flex;align-items:center;padding:0 0 5px;font-weight:700;font-size:20px}#home .features .row .list li img{width:20px;padding:0 8px 0 0}
+#home .samples{display:flex;justify-content:space-between;gap:var(--spacing)}#home .samples .single,#home .samples .multi{flex:1 1 50%}#home .support .links{display:flex;height:50px;flex-direction:row;gap:var(--spacing);overflow:hidden}#home .support .links .github-sponsor{height:100%;width:200px;border:0;color-scheme:dark}#home .support .links .buycoffee img{height:100%;width:auto}#home .ads{display:flex;flex-direction:row;gap:var(--spacing-half)}#home .features{flex:1 1 auto;display:flex;flex-direction:column}#home .features .header{padding:0 0 var(--spacing) 0}#home .features .row{justify-content:space-around}#home .features .row .list{display:flex;flex-direction:column;list-style:none;padding:0;width:200px;margin:0 auto}#home .features .row .list li{display:flex;align-items:center;padding:0 0 5px;font-weight:700;font-size:20px}#home .features .row .list li img{width:20px;padding:0 8px 0 0}#home .frameworks .framework-items{display:flex;flex-direction:row}
diff --git a/docs/assets/home.js b/docs/assets/home.js
index ba3e32b8..4ad0e42b 100644
--- a/docs/assets/home.js
+++ b/docs/assets/home.js
@@ -1 +1 @@
-import{d as o,S as t,_ as c,r as a,o as n,c as r,a as e,b as h,w as d,e as m,f as i}from"./index.js";const p=o({name:"Home",data(){return{single:null,multiple:null}},mounted(){this.single=new t({select:this.$refs.slimSingle}),this.multiple=new t({select:this.$refs.slimMulti}),setTimeout(()=>{this.single&&this.single.open(),setTimeout(()=>{this.single&&this.single.setSelected("best")},500),setTimeout(()=>{this.single&&this.single.setSelected("select")},1e3),setTimeout(()=>{this.single&&this.single.setSelected("ever")},1500),setTimeout(()=>{this.single&&this.single.close()},2e3),this.multiple&&this.multiple.open(),setTimeout(()=>{this.multiple&&this.multiple.setSelected(["best"])},500),setTimeout(()=>{this.multiple&&this.multiple.setSelected(["best","select"])},1e3),setTimeout(()=>{this.multiple&&this.multiple.setSelected(["best","select","ever"])},1500),setTimeout(()=>{this.multiple&&this.multiple.close()},2e3)},500)},unmounted(){this.single&&this.single.destroy(),this.multiple&&this.multiple.destroy()}}),s="data:image/svg+xml,%3csvg%20version='1.1'%20xmlns='http://www.w3.org/2000/svg'%20xmlns:xlink='http://www.w3.org/1999/xlink'%20x='0px'%20y='0px'%20width='45.701px'%20height='45.7px'%20viewBox='0%200%2045.701%2045.7'%20style='enable-background:new%200%200%2045.701%2045.7;'%20xml:space='preserve'%3e%3cpath%20fill='%235897fb'%20d='M20.687,38.332c-2.072,2.072-5.434,2.072-7.505,0L1.554,26.704c-2.072-2.071-2.072-5.433,0-7.504%20c2.071-2.072,5.433-2.072,7.505,0l6.928,6.927c0.523,0.522,1.372,0.522,1.896,0L36.642,7.368c2.071-2.072,5.433-2.072,7.505,0%20c0.995,0.995,1.554,2.345,1.554,3.752c0,1.407-0.559,2.757-1.554,3.752L20.687,38.332z'/%3e%3c/svg%3e",u={id:"home",class:"content"},_={class:"samples row"},g={class:"single"},f=e("h2",{id:"sample-select-header"},"Single Select",-1),v={ref:"slimSingle","aria-labelledby":"sample-select-header"},b=e("option",{"data-placeholder":"true"},null,-1),w=e("option",{value:"best"},"Best",-1),S=e("option",{value:"select"},"Select",-1),k=e("option",{value:"ever"},"Ever",-1),x=[b,w,S,k],y={class:"multi"},T=e("h2",null,"Multi Select",-1),L={ref:"slimMulti",multiple:""},C=e("option",{value:"best"},"Best",-1),B=e("option",{value:"select"},"Select",-1),M=e("option",{value:"ever"},"Ever",-1),H=[C,B,M],$=m('
Help support creators that make development easier!
No Dependencies ~30kb - ~5kb gzip Single Select Multiple Select Addable Options Html Options Settable Data Callback Events Placeholders Advanced Search Tabbable Disable Options Light Css Light Color Scheme Style Inheritance Clean Animations Performant Typescript
',9),N={class:"frameworks"},z=e("h2",{class:"header"},"Frameworks",-1),A=e("p",null,[i(" SlimSelect is in the process of adding a few framework integrations."),e("br"),i(" If you are an expert in any specific framework and would like to help out, please reach out! ")],-1),E={class:"row frameworks"},V=e("svg",{viewBox:"0 0 128 128",width:"100",height:"100"},[e("path",{fill:"#42b883",d:"M78.8,10L64,35.4L49.2,10H0l64,110l64-110C128,10,78.8,10,78.8,10z"}),e("path",{fill:"#35495e",d:"M78.8,10L64,35.4L49.2,10H25.6L64,76l38.4-66H78.8z"})],-1);function D(O,F,I,P,j,q){const l=a("router-link");return n(),r("div",u,[e("div",_,[e("div",g,[f,e("select",v,x,512)]),e("div",y,[T,e("select",L,H,512)])]),$,e("div",N,[z,A,e("div",E,[h(l,{to:"frameworks#vue"},{default:d(()=>[V]),_:1})])])])}const J=c(p,[["render",D]]);export{J as default};
+import{d as c,S as t,_ as n,r as a,o as r,c as d,a as e,b as h,w as m,e as p,f as i}from"./index.js";const u=c({name:"Home",data(){return{single:null,multiple:null}},mounted(){this.single=new t({select:this.$refs.slimSingle}),this.multiple=new t({select:this.$refs.slimMulti}),setTimeout(()=>{this.single&&this.single.open(),setTimeout(()=>{this.single&&this.single.setSelected("best")},500),setTimeout(()=>{this.single&&this.single.setSelected("select")},1e3),setTimeout(()=>{this.single&&this.single.setSelected("ever")},1500),setTimeout(()=>{this.single&&this.single.close()},2e3),this.multiple&&this.multiple.open(),setTimeout(()=>{this.multiple&&this.multiple.setSelected(["best"])},500),setTimeout(()=>{this.multiple&&this.multiple.setSelected(["best","select"])},1e3),setTimeout(()=>{this.multiple&&this.multiple.setSelected(["best","select","ever"])},1500),setTimeout(()=>{this.multiple&&this.multiple.close()},2e3)},500)},unmounted(){this.single&&this.single.destroy(),this.multiple&&this.multiple.destroy()},methods:{handleClick(){window.dispatchEvent(new Event("nav-updated"))}}}),s="data:image/svg+xml,%3csvg%20version='1.1'%20xmlns='http://www.w3.org/2000/svg'%20xmlns:xlink='http://www.w3.org/1999/xlink'%20x='0px'%20y='0px'%20width='45.701px'%20height='45.7px'%20viewBox='0%200%2045.701%2045.7'%20style='enable-background:new%200%200%2045.701%2045.7;'%20xml:space='preserve'%3e%3cpath%20fill='%235897fb'%20d='M20.687,38.332c-2.072,2.072-5.434,2.072-7.505,0L1.554,26.704c-2.072-2.071-2.072-5.433,0-7.504%20c2.071-2.072,5.433-2.072,7.505,0l6.928,6.927c0.523,0.522,1.372,0.522,1.896,0L36.642,7.368c2.071-2.072,5.433-2.072,7.505,0%20c0.995,0.995,1.554,2.345,1.554,3.752c0,1.407-0.559,2.757-1.554,3.752L20.687,38.332z'/%3e%3c/svg%3e",_={id:"home",class:"content"},g={class:"samples row"},f={class:"single"},v=e("h2",{id:"sample-select-header"},"Single Select",-1),b={ref:"slimSingle","aria-labelledby":"sample-select-header"},w=e("option",{"data-placeholder":"true"},null,-1),S=e("option",{value:"best"},"Best",-1),k=e("option",{value:"select"},"Select",-1),x=e("option",{value:"ever"},"Ever",-1),y=[w,S,k,x],C={class:"multi"},T=e("h2",null,"Multi Select",-1),L={ref:"slimMulti",multiple:""},B=e("option",{value:"best"},"Best",-1),M=e("option",{value:"select"},"Select",-1),E=e("option",{value:"ever"},"Ever",-1),H=[B,M,E],$=p('Help support creators that make development easier!
No Dependencies ~30kb - ~5kb gzip Single Select Multiple Select Addable Options Html Options Settable Data Callback Events Placeholders Advanced Search Tabbable Disable Options Light Css Light Color Scheme Style Inheritance Clean Animations Performant Typescript
',9),N={class:"frameworks"},z=e("h2",{class:"header"},"Frameworks",-1),A=e("p",null,[i(" SlimSelect is in the process of adding a few framework integrations."),e("br"),i(" If you are an expert in any specific framework and would like to help out, please reach out! ")],-1),V={class:"framework-items"},D=e("svg",{viewBox:"0 0 128 128",width:"100",height:"100"},[e("path",{fill:"#42b883",d:"M78.8,10L64,35.4L49.2,10H0l64,110l64-110C128,10,78.8,10,78.8,10z"}),e("path",{fill:"#35495e",d:"M78.8,10L64,35.4L49.2,10H25.6L64,76l38.4-66H78.8z"})],-1);function O(l,F,I,P,j,q){const o=a("router-link");return r(),d("div",_,[e("div",g,[e("div",f,[v,e("select",b,y,512)]),e("div",C,[T,e("select",L,H,512)])]),$,e("div",N,[z,A,e("div",V,[h(o,{to:"/vue",onClick:l.handleClick},{default:m(()=>[D]),_:1},8,["onClick"])])])])}const J=n(u,[["render",O]]);export{J as default};
diff --git a/docs/assets/index.js b/docs/assets/index.js
index db047fd8..d7d125a1 100644
--- a/docs/assets/index.js
+++ b/docs/assets/index.js
@@ -45,6 +45,7 @@
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
+
*/var tp=nt,mt=v1;function F(e){for(var t="https://reactjs.org/docs/error-decoder.html?invariant="+e,n=1;n"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),la=Object.prototype.hasOwnProperty,y1=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,yf={},wf={};function w1(e){return la.call(wf,e)?!0:la.call(yf,e)?!1:y1.test(e)?wf[e]=!0:(yf[e]=!0,!1)}function S1(e,t,n,r){if(n!==null&&n.type===0)return!1;switch(typeof t){case"function":case"symbol":return!0;case"boolean":return r?!1:n!==null?!n.acceptsBooleans:(e=e.toLowerCase().slice(0,5),e!=="data-"&&e!=="aria-");default:return!1}}function x1(e,t,n,r){if(t===null||typeof t>"u"||S1(e,t,n,r))return!0;if(r)return!1;if(n!==null)switch(n.type){case 3:return!t;case 4:return t===!1;case 5:return isNaN(t);case 6:return isNaN(t)||1>t}return!1}function qe(e,t,n,r,s,l,i){this.acceptsBooleans=t===2||t===3||t===4,this.attributeName=r,this.attributeNamespace=s,this.mustUseProperty=n,this.propertyName=e,this.type=t,this.sanitizeURL=l,this.removeEmptyString=i}var Be={};"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach(function(e){Be[e]=new qe(e,0,!1,e,null,!1,!1)});[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach(function(e){var t=e[0];Be[t]=new qe(t,1,!1,e[1],null,!1,!1)});["contentEditable","draggable","spellCheck","value"].forEach(function(e){Be[e]=new qe(e,2,!1,e.toLowerCase(),null,!1,!1)});["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(e){Be[e]=new qe(e,2,!1,e,null,!1,!1)});"allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope".split(" ").forEach(function(e){Be[e]=new qe(e,3,!1,e.toLowerCase(),null,!1,!1)});["checked","multiple","muted","selected"].forEach(function(e){Be[e]=new qe(e,3,!0,e,null,!1,!1)});["capture","download"].forEach(function(e){Be[e]=new qe(e,4,!1,e,null,!1,!1)});["cols","rows","size","span"].forEach(function(e){Be[e]=new qe(e,6,!1,e,null,!1,!1)});["rowSpan","start"].forEach(function(e){Be[e]=new qe(e,5,!1,e.toLowerCase(),null,!1,!1)});var Au=/[\-:]([a-z])/g;function Ou(e){return e[1].toUpperCase()}"accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height".split(" ").forEach(function(e){var t=e.replace(Au,Ou);Be[t]=new qe(t,1,!1,e,null,!1,!1)});"xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type".split(" ").forEach(function(e){var t=e.replace(Au,Ou);Be[t]=new qe(t,1,!1,e,"http://www.w3.org/1999/xlink",!1,!1)});["xml:base","xml:lang","xml:space"].forEach(function(e){var t=e.replace(Au,Ou);Be[t]=new qe(t,1,!1,e,"http://www.w3.org/XML/1998/namespace",!1,!1)});["tabIndex","crossOrigin"].forEach(function(e){Be[e]=new qe(e,1,!1,e.toLowerCase(),null,!1,!1)});Be.xlinkHref=new qe("xlinkHref",1,!1,"xlink:href","http://www.w3.org/1999/xlink",!0,!1);["src","href","action","formAction"].forEach(function(e){Be[e]=new qe(e,1,!1,e.toLowerCase(),null,!0,!0)});function Pu(e,t,n,r){var s=Be.hasOwnProperty(t)?Be[t]:null;(s!==null?s.type!==0:r||!(2_r||(e.current=Ra[_r],Ra[_r]=null,_r--)}function he(e,t){_r++,Ra[_r]=e.current,e.current=t}var Un={},Ke=Hn(Un),lt=Hn(!1),sr=Un;function Qr(e,t){var n=e.type.contextTypes;if(!n)return Un;var r=e.stateNode;if(r&&r.__reactInternalMemoizedUnmaskedChildContext===t)return r.__reactInternalMemoizedMaskedChildContext;var s={},l;for(l in n)s[l]=t[l];return r&&(e=e.stateNode,e.__reactInternalMemoizedUnmaskedChildContext=t,e.__reactInternalMemoizedMaskedChildContext=s),s}function it(e){return e=e.childContextTypes,e!=null}function ci(){me(lt),me(Ke)}function Gf(e,t,n){if(Ke.current!==Un)throw Error(F(168));he(Ke,t),he(lt,n)}function Zp(e,t,n){var r=e.stateNode;if(t=t.childContextTypes,typeof r.getChildContext!="function")return n;r=r.getChildContext();for(var s in r)if(!(s in t))throw Error(F(108,_1(e)||"Unknown",s));return Ce({},n,r)}function fi(e){return e=(e=e.stateNode)&&e.__reactInternalMemoizedMergedChildContext||Un,sr=Ke.current,he(Ke,e),he(lt,lt.current),!0}function Yf(e,t,n){var r=e.stateNode;if(!r)throw Error(F(169));n?(e=Zp(e,t,sr),r.__reactInternalMemoizedMergedChildContext=e,me(lt),me(Ke),he(Ke,e)):me(lt),he(lt,n)}var nn=null,Qi=!1,No=!1;function Jp(e){nn===null?nn=[e]:nn.push(e)}function Bw(e){Qi=!0,Jp(e)}function $n(){if(!No&&nn!==null){No=!0;var e=0,t=fe;try{var n=nn;for(fe=1;e>=i,s-=i,sn=1<<32-Dt(t)+s|n<R?(D=k,k=null):D=k.sibling;var U=p(m,k,h[R],y);if(U===null){k===null&&(k=D);break}e&&k&&U.alternate===null&&t(m,k),f=l(U,f,R),C===null?S=U:C.sibling=U,C=U,k=D}if(R===h.length)return n(m,k),we&&Kn(m,R),S;if(k===null){for(;RR?(D=k,k=null):D=k.sibling;var ee=p(m,k,U.value,y);if(ee===null){k===null&&(k=D);break}e&&k&&ee.alternate===null&&t(m,k),f=l(ee,f,R),C===null?S=ee:C.sibling=ee,C=ee,k=D}if(U.done)return n(m,k),we&&Kn(m,R),S;if(k===null){for(;!U.done;R++,U=h.next())U=d(m,U.value,y),U!==null&&(f=l(U,f,R),C===null?S=U:C.sibling=U,C=U);return we&&Kn(m,R),S}for(k=r(m,k);!U.done;R++,U=h.next())U=w(k,m,R,U.value,y),U!==null&&(e&&U.alternate!==null&&k.delete(U.key===null?R:U.key),f=l(U,f,R),C===null?S=U:C.sibling=U,C=U);return e&&k.forEach(function(ke){return t(m,ke)}),we&&Kn(m,R),S}function L(m,f,h,y){if(typeof h=="object"&&h!==null&&h.type===yr&&h.key===null&&(h=h.props.children),typeof h=="object"&&h!==null){switch(h.$$typeof){case pl:e:{for(var S=h.key,C=f;C!==null;){if(C.key===S){if(S=h.type,S===yr){if(C.tag===7){n(m,C.sibling),f=s(C,h.props.children),f.return=m,m=f;break e}}else if(C.elementType===S||typeof S=="object"&&S!==null&&S.$$typeof===En&&nd(S)===C.type){n(m,C.sibling),f=s(C,h.props),f.ref=fs(m,C,h),f.return=m,m=f;break e}n(m,C);break}else t(m,C);C=C.sibling}h.type===yr?(f=rr(h.props.children,m.mode,y,h.key),f.return=m,m=f):(y=Kl(h.type,h.key,h.props,null,m.mode,y),y.ref=fs(m,f,h),y.return=m,m=y)}return i(m);case vr:e:{for(C=h.key;f!==null;){if(f.key===C)if(f.tag===4&&f.stateNode.containerInfo===h.containerInfo&&f.stateNode.implementation===h.implementation){n(m,f.sibling),f=s(f,h.children||[]),f.return=m,m=f;break e}else{n(m,f);break}else t(m,f);f=f.sibling}f=Bo(h,m.mode,y),f.return=m,m=f}return i(m);case En:return C=h._init,L(m,f,C(h._payload),y)}if(gs(h))return x(m,f,h,y);if(is(h))return _(m,f,h,y);kl(m,h)}return typeof h=="string"&&h!==""||typeof h=="number"?(h=""+h,f!==null&&f.tag===6?(n(m,f.sibling),f=s(f,h),f.return=m,m=f):(n(m,f),f=zo(h,m.mode,y),f.return=m,m=f),i(m)):n(m,f)}return L}var Gr=lm(!0),im=lm(!1),il={},Zt=Hn(il),Zs=Hn(il),Js=Hn(il);function Xn(e){if(e===il)throw Error(F(174));return e}function Zu(e,t){switch(he(Js,t),he(Zs,e),he(Zt,il),e=t.nodeType,e){case 9:case 11:t=(t=t.documentElement)?t.namespaceURI:pa(null,"");break;default:e=e===8?t.parentNode:t,t=e.namespaceURI||null,e=e.tagName,t=pa(t,e)}me(Zt),he(Zt,t)}function Yr(){me(Zt),me(Zs),me(Js)}function om(e){Xn(Js.current);var t=Xn(Zt.current),n=pa(t,e.type);t!==n&&(he(Zs,e),he(Zt,n))}function Ju(e){Zs.current===e&&(me(Zt),me(Zs))}var Ee=Hn(0);function vi(e){for(var t=e;t!==null;){if(t.tag===13){var n=t.memoizedState;if(n!==null&&(n=n.dehydrated,n===null||n.data==="$?"||n.data==="$!"))return t}else if(t.tag===19&&t.memoizedProps.revealOrder!==void 0){if(t.flags&128)return t}else if(t.child!==null){t.child.return=t,t=t.child;continue}if(t===e)break;for(;t.sibling===null;){if(t.return===null||t.return===e)return null;t=t.return}t.sibling.return=t.return,t=t.sibling}return null}var Mo=[];function Xu(){for(var e=0;en?n:4,e(!0);var r=Fo.transition;Fo.transition={};try{e(!1),t()}finally{fe=n,Fo.transition=r}}function _m(){return kt().memoizedState}function $w(e,t,n){var r=Dn(e);if(n={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null},Cm(e))km(t,n);else if(n=tm(e,t,n,r),n!==null){var s=Je();bt(n,e,r,s),Am(n,t,r)}}function Ww(e,t,n){var r=Dn(e),s={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null};if(Cm(e))km(t,s);else{var l=e.alternate;if(e.lanes===0&&(l===null||l.lanes===0)&&(l=t.lastRenderedReducer,l!==null))try{var i=t.lastRenderedState,o=l(i,n);if(s.hasEagerState=!0,s.eagerState=o,zt(o,i)){var a=t.interleaved;a===null?(s.next=s,Gu(t)):(s.next=a.next,a.next=s),t.interleaved=s;return}}catch{}finally{}n=tm(e,t,s,r),n!==null&&(s=Je(),bt(n,e,r,s),Am(n,t,r))}}function Cm(e){var t=e.alternate;return e===_e||t!==null&&t===_e}function km(e,t){Ts=yi=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function Am(e,t,n){if(n&4194240){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,Fu(e,n)}}var wi={readContext:Ct,useCallback:Ue,useContext:Ue,useEffect:Ue,useImperativeHandle:Ue,useInsertionEffect:Ue,useLayoutEffect:Ue,useMemo:Ue,useReducer:Ue,useRef:Ue,useState:Ue,useDebugValue:Ue,useDeferredValue:Ue,useTransition:Ue,useMutableSource:Ue,useSyncExternalStore:Ue,useId:Ue,unstable_isNewReconciler:!1},Qw={readContext:Ct,useCallback:function(e,t){return Wt().memoizedState=[e,t===void 0?null:t],e},useContext:Ct,useEffect:sd,useImperativeHandle:function(e,t,n){return n=n!=null?n.concat([e]):null,Hl(4194308,4,ym.bind(null,t,e),n)},useLayoutEffect:function(e,t){return Hl(4194308,4,e,t)},useInsertionEffect:function(e,t){return Hl(4,2,e,t)},useMemo:function(e,t){var n=Wt();return t=t===void 0?null:t,e=e(),n.memoizedState=[e,t],e},useReducer:function(e,t,n){var r=Wt();return t=n!==void 0?n(t):t,r.memoizedState=r.baseState=t,e={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:t},r.queue=e,e=e.dispatch=$w.bind(null,_e,e),[r.memoizedState,e]},useRef:function(e){var t=Wt();return e={current:e},t.memoizedState=e},useState:rd,useDebugValue:rc,useDeferredValue:function(e){return Wt().memoizedState=e},useTransition:function(){var e=rd(!1),t=e[0];return e=Hw.bind(null,e[1]),Wt().memoizedState=e,[t,e]},useMutableSource:function(){},useSyncExternalStore:function(e,t,n){var r=_e,s=Wt();if(we){if(n===void 0)throw Error(F(407));n=n()}else{if(n=t(),Ie===null)throw Error(F(349));ir&30||cm(r,t,n)}s.memoizedState=n;var l={value:n,getSnapshot:t};return s.queue=l,sd(dm.bind(null,r,l,e),[e]),r.flags|=2048,el(9,fm.bind(null,r,l,n,t),void 0,null),n},useId:function(){var e=Wt(),t=Ie.identifierPrefix;if(we){var n=ln,r=sn;n=(r&~(1<<32-Dt(r)-1)).toString(32)+n,t=":"+t+"R"+n,n=Xs++,0<\/script>",e=e.removeChild(e.firstChild)):typeof r.is=="string"?e=i.createElement(n,{is:r.is}):(e=i.createElement(n),n==="select"&&(i=e,r.multiple?i.multiple=!0:r.size&&(i.size=r.size))):e=i.createElementNS(e,n),e[Gt]=t,e[Ys]=r,Im(e,t,!1,!1),t.stateNode=e;e:{switch(i=ga(n,r),n){case"dialog":pe("cancel",e),pe("close",e),s=r;break;case"iframe":case"object":case"embed":pe("load",e),s=r;break;case"video":case"audio":for(s=0;sJr&&(t.flags|=128,r=!0,ds(l,!1),t.lanes=4194304)}else{if(!r)if(e=vi(i),e!==null){if(t.flags|=128,r=!0,n=e.updateQueue,n!==null&&(t.updateQueue=n,t.flags|=4),ds(l,!0),l.tail===null&&l.tailMode==="hidden"&&!i.alternate&&!we)return Ve(t),null}else 2*Oe()-l.renderingStartTime>Jr&&n!==1073741824&&(t.flags|=128,r=!0,ds(l,!1),t.lanes=4194304);l.isBackwards?(i.sibling=t.child,t.child=i):(n=l.last,n!==null?n.sibling=i:t.child=i,l.last=i)}return l.tail!==null?(t=l.tail,l.rendering=t,l.tail=t.sibling,l.renderingStartTime=Oe(),t.sibling=null,n=Ee.current,he(Ee,r?n&1|2:n&1),t):(Ve(t),null);case 22:case 23:return uc(),r=t.memoizedState!==null,e!==null&&e.memoizedState!==null!==r&&(t.flags|=8192),r&&t.mode&1?dt&1073741824&&(Ve(t),t.subtreeFlags&6&&(t.flags|=8192)):Ve(t),null;case 24:return null;case 25:return null}throw Error(F(156,t.tag))}function eS(e,t){switch(Hu(t),t.tag){case 1:return it(t.type)&&ci(),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return Yr(),me(lt),me(Ke),Xu(),e=t.flags,e&65536&&!(e&128)?(t.flags=e&-65537|128,t):null;case 5:return Ju(t),null;case 13:if(me(Ee),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(F(340));Kr()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return me(Ee),null;case 4:return Yr(),null;case 10:return Ku(t.type._context),null;case 22:case 23:return uc(),null;case 24:return null;default:return null}}var Ol=!1,$e=!1,tS=typeof WeakSet=="function"?WeakSet:Set,$=null;function Or(e,t){var n=e.ref;if(n!==null)if(typeof n=="function")try{n(null)}catch(r){Ae(e,t,r)}else n.current=null}function Ha(e,t,n){try{n()}catch(r){Ae(e,t,r)}}var hd=!1;function nS(e,t){if(Aa=ii,e=Up(),Uu(e)){if("selectionStart"in e)var n={start:e.selectionStart,end:e.selectionEnd};else e:{n=(n=e.ownerDocument)&&n.defaultView||window;var r=n.getSelection&&n.getSelection();if(r&&r.rangeCount!==0){n=r.anchorNode;var s=r.anchorOffset,l=r.focusNode;r=r.focusOffset;try{n.nodeType,l.nodeType}catch{n=null;break e}var i=0,o=-1,a=-1,u=0,c=0,d=e,p=null;t:for(;;){for(var w;d!==n||s!==0&&d.nodeType!==3||(o=i+s),d!==l||r!==0&&d.nodeType!==3||(a=i+r),d.nodeType===3&&(i+=d.nodeValue.length),(w=d.firstChild)!==null;)p=d,d=w;for(;;){if(d===e)break t;if(p===n&&++u===s&&(o=i),p===l&&++c===r&&(a=i),(w=d.nextSibling)!==null)break;d=p,p=d.parentNode}d=w}n=o===-1||a===-1?null:{start:o,end:a}}else n=null}n=n||{start:0,end:0}}else n=null;for(Oa={focusedElem:e,selectionRange:n},ii=!1,$=t;$!==null;)if(t=$,e=t.child,(t.subtreeFlags&1028)!==0&&e!==null)e.return=t,$=e;else for(;$!==null;){t=$;try{var x=t.alternate;if(t.flags&1024)switch(t.tag){case 0:case 11:case 15:break;case 1:if(x!==null){var _=x.memoizedProps,L=x.memoizedState,m=t.stateNode,f=m.getSnapshotBeforeUpdate(t.elementType===t.type?_:Tt(t.type,_),L);m.__reactInternalSnapshotBeforeUpdate=f}break;case 3:var h=t.stateNode.containerInfo;h.nodeType===1?h.textContent="":h.nodeType===9&&h.documentElement&&h.removeChild(h.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(F(163))}}catch(y){Ae(t,t.return,y)}if(e=t.sibling,e!==null){e.return=t.return,$=e;break}$=t.return}return x=hd,hd=!1,x}function Rs(e,t,n){var r=t.updateQueue;if(r=r!==null?r.lastEffect:null,r!==null){var s=r=r.next;do{if((s.tag&e)===e){var l=s.destroy;s.destroy=void 0,l!==void 0&&Ha(t,n,l)}s=s.next}while(s!==r)}}function Yi(e,t){if(t=t.updateQueue,t=t!==null?t.lastEffect:null,t!==null){var n=t=t.next;do{if((n.tag&e)===e){var r=n.create;n.destroy=r()}n=n.next}while(n!==t)}}function $a(e){var t=e.ref;if(t!==null){var n=e.stateNode;switch(e.tag){case 5:e=n;break;default:e=n}typeof t=="function"?t(e):t.current=e}}function jm(e){var t=e.alternate;t!==null&&(e.alternate=null,jm(t)),e.child=null,e.deletions=null,e.sibling=null,e.tag===5&&(t=e.stateNode,t!==null&&(delete t[Gt],delete t[Ys],delete t[Ta],delete t[jw],delete t[zw])),e.stateNode=null,e.return=null,e.dependencies=null,e.memoizedProps=null,e.memoizedState=null,e.pendingProps=null,e.stateNode=null,e.updateQueue=null}function zm(e){return e.tag===5||e.tag===3||e.tag===4}function pd(e){e:for(;;){for(;e.sibling===null;){if(e.return===null||zm(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.flags&2||e.child===null||e.tag===4)continue e;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function Wa(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.nodeType===8?n.parentNode.insertBefore(e,t):n.insertBefore(e,t):(n.nodeType===8?(t=n.parentNode,t.insertBefore(e,n)):(t=n,t.appendChild(e)),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=ui));else if(r!==4&&(e=e.child,e!==null))for(Wa(e,t,n),e=e.sibling;e!==null;)Wa(e,t,n),e=e.sibling}function Qa(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(r!==4&&(e=e.child,e!==null))for(Qa(e,t,n),e=e.sibling;e!==null;)Qa(e,t,n),e=e.sibling}var De=null,Rt=!1;function yn(e,t,n){for(n=n.child;n!==null;)Bm(e,t,n),n=n.sibling}function Bm(e,t,n){if(Yt&&typeof Yt.onCommitFiberUnmount=="function")try{Yt.onCommitFiberUnmount(Ui,n)}catch{}switch(n.tag){case 5:$e||Or(n,t);case 6:var r=De,s=Rt;De=null,yn(e,t,n),De=r,Rt=s,De!==null&&(Rt?(e=De,n=n.stateNode,e.nodeType===8?e.parentNode.removeChild(n):e.removeChild(n)):De.removeChild(n.stateNode));break;case 18:De!==null&&(Rt?(e=De,n=n.stateNode,e.nodeType===8?Ro(e.parentNode,n):e.nodeType===1&&Ro(e,n),$s(e)):Ro(De,n.stateNode));break;case 4:r=De,s=Rt,De=n.stateNode.containerInfo,Rt=!0,yn(e,t,n),De=r,Rt=s;break;case 0:case 11:case 14:case 15:if(!$e&&(r=n.updateQueue,r!==null&&(r=r.lastEffect,r!==null))){s=r=r.next;do{var l=s,i=l.destroy;l=l.tag,i!==void 0&&(l&2||l&4)&&Ha(n,t,i),s=s.next}while(s!==r)}yn(e,t,n);break;case 1:if(!$e&&(Or(n,t),r=n.stateNode,typeof r.componentWillUnmount=="function"))try{r.props=n.memoizedProps,r.state=n.memoizedState,r.componentWillUnmount()}catch(o){Ae(n,t,o)}yn(e,t,n);break;case 21:yn(e,t,n);break;case 22:n.mode&1?($e=(r=$e)||n.memoizedState!==null,yn(e,t,n),$e=r):yn(e,t,n);break;default:yn(e,t,n)}}function md(e){var t=e.updateQueue;if(t!==null){e.updateQueue=null;var n=e.stateNode;n===null&&(n=e.stateNode=new tS),t.forEach(function(r){var s=fS.bind(null,e,r);n.has(r)||(n.add(r),r.then(s,s))})}}function Pt(e,t){var n=t.deletions;if(n!==null)for(var r=0;rs&&(s=i),r&=~l}if(r=s,r=Oe()-r,r=(120>r?120:480>r?480:1080>r?1080:1920>r?1920:3e3>r?3e3:4320>r?4320:1960*sS(r/1960))-r,10e?16:e,On===null)var r=!1;else{if(e=On,On=null,Ei=0,oe&6)throw Error(F(331));var s=oe;for(oe|=4,$=e.current;$!==null;){var l=$,i=l.child;if($.flags&16){var o=l.deletions;if(o!==null){for(var a=0;aOe()-oc?nr(e,0):ic|=n),ot(e,t)}function Gm(e,t){t===0&&(e.mode&1?(t=yl,yl<<=1,!(yl&130023424)&&(yl=4194304)):t=1);var n=Je();e=dn(e,t),e!==null&&(rl(e,t,n),ot(e,n))}function cS(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),Gm(e,n)}function fS(e,t){var n=0;switch(e.tag){case 13:var r=e.stateNode,s=e.memoizedState;s!==null&&(n=s.retryLane);break;case 19:r=e.stateNode;break;default:throw Error(F(314))}r!==null&&r.delete(t),Gm(e,n)}var Ym;Ym=function(e,t,n){if(e!==null)if(e.memoizedProps!==t.pendingProps||lt.current)st=!0;else{if(!(e.lanes&n)&&!(t.flags&128))return st=!1,Xw(e,t,n);st=!!(e.flags&131072)}else st=!1,we&&t.flags&1048576&&Xp(t,hi,t.index);switch(t.lanes=0,t.tag){case 2:var r=t.type;$l(e,t),e=t.pendingProps;var s=Qr(t,Ke.current);jr(t,n),s=ec(null,t,r,e,s,n);var l=tc();return t.flags|=1,typeof s=="object"&&s!==null&&typeof s.render=="function"&&s.$$typeof===void 0?(t.tag=1,t.memoizedState=null,t.updateQueue=null,it(r)?(l=!0,fi(t)):l=!1,t.memoizedState=s.state!==null&&s.state!==void 0?s.state:null,Yu(t),s.updater=Ki,t.stateNode=s,s._reactInternals=t,Da(t,r,e,n),t=za(null,t,r,!0,l,n)):(t.tag=0,we&&l&&Vu(t),Ye(null,t,s,n),t=t.child),t;case 16:r=t.elementType;e:{switch($l(e,t),e=t.pendingProps,s=r._init,r=s(r._payload),t.type=r,s=t.tag=hS(r),e=Tt(r,e),s){case 0:t=ja(null,t,r,e,n);break e;case 1:t=cd(null,t,r,e,n);break e;case 11:t=ad(null,t,r,e,n);break e;case 14:t=ud(null,t,r,Tt(r.type,e),n);break e}throw Error(F(306,r,""))}return t;case 0:return r=t.type,s=t.pendingProps,s=t.elementType===r?s:Tt(r,s),ja(e,t,r,s,n);case 1:return r=t.type,s=t.pendingProps,s=t.elementType===r?s:Tt(r,s),cd(e,t,r,s,n);case 3:e:{if(Nm(t),e===null)throw Error(F(387));r=t.pendingProps,l=t.memoizedState,s=l.element,nm(e,t),gi(t,r,null,n);var i=t.memoizedState;if(r=i.element,l.isDehydrated)if(l={element:r,isDehydrated:!1,cache:i.cache,pendingSuspenseBoundaries:i.pendingSuspenseBoundaries,transitions:i.transitions},t.updateQueue.baseState=l,t.memoizedState=l,t.flags&256){s=Zr(Error(F(423)),t),t=fd(e,t,r,n,s);break e}else if(r!==s){s=Zr(Error(F(424)),t),t=fd(e,t,r,n,s);break e}else for(ht=Mn(t.stateNode.containerInfo.firstChild),pt=t,we=!0,Nt=null,n=im(t,null,r,n),t.child=n;n;)n.flags=n.flags&-3|4096,n=n.sibling;else{if(Kr(),r===s){t=hn(e,t,n);break e}Ye(e,t,r,n)}t=t.child}return t;case 5:return om(t),e===null&&Ma(t),r=t.type,s=t.pendingProps,l=e!==null?e.memoizedProps:null,i=s.children,Pa(r,s)?i=null:l!==null&&Pa(r,l)&&(t.flags|=32),Rm(e,t),Ye(e,t,i,n),t.child;case 6:return e===null&&Ma(t),null;case 13:return Mm(e,t,n);case 4:return Zu(t,t.stateNode.containerInfo),r=t.pendingProps,e===null?t.child=Gr(t,null,r,n):Ye(e,t,r,n),t.child;case 11:return r=t.type,s=t.pendingProps,s=t.elementType===r?s:Tt(r,s),ad(e,t,r,s,n);case 7:return Ye(e,t,t.pendingProps,n),t.child;case 8:return Ye(e,t,t.pendingProps.children,n),t.child;case 12:return Ye(e,t,t.pendingProps.children,n),t.child;case 10:e:{if(r=t.type._context,s=t.pendingProps,l=t.memoizedProps,i=s.value,he(pi,r._currentValue),r._currentValue=i,l!==null)if(zt(l.value,i)){if(l.children===s.children&&!lt.current){t=hn(e,t,n);break e}}else for(l=t.child,l!==null&&(l.return=t);l!==null;){var o=l.dependencies;if(o!==null){i=l.child;for(var a=o.firstContext;a!==null;){if(a.context===r){if(l.tag===1){a=un(-1,n&-n),a.tag=2;var u=l.updateQueue;if(u!==null){u=u.shared;var c=u.pending;c===null?a.next=a:(a.next=c.next,c.next=a),u.pending=a}}l.lanes|=n,a=l.alternate,a!==null&&(a.lanes|=n),Fa(l.return,n,t),o.lanes|=n;break}a=a.next}}else if(l.tag===10)i=l.type===t.type?null:l.child;else if(l.tag===18){if(i=l.return,i===null)throw Error(F(341));i.lanes|=n,o=i.alternate,o!==null&&(o.lanes|=n),Fa(i,n,t),i=l.sibling}else i=l.child;if(i!==null)i.return=l;else for(i=l;i!==null;){if(i===t){i=null;break}if(l=i.sibling,l!==null){l.return=i.return,i=l;break}i=i.return}l=i}Ye(e,t,s.children,n),t=t.child}return t;case 9:return s=t.type,r=t.pendingProps.children,jr(t,n),s=Ct(s),r=r(s),t.flags|=1,Ye(e,t,r,n),t.child;case 14:return r=t.type,s=Tt(r,t.pendingProps),s=Tt(r.type,s),ud(e,t,r,s,n);case 15:return Lm(e,t,t.type,t.pendingProps,n);case 17:return r=t.type,s=t.pendingProps,s=t.elementType===r?s:Tt(r,s),$l(e,t),t.tag=1,it(r)?(e=!0,fi(t)):e=!1,jr(t,n),sm(t,r,s),Da(t,r,s,n),za(null,t,r,!0,e,n);case 19:return Fm(e,t,n);case 22:return Tm(e,t,n)}throw Error(F(156,t.tag))};function Zm(e,t){return Ep(e,t)}function dS(e,t,n,r){this.tag=e,this.key=n,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.ref=null,this.pendingProps=t,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=r,this.subtreeFlags=this.flags=0,this.deletions=null,this.childLanes=this.lanes=0,this.alternate=null}function Et(e,t,n,r){return new dS(e,t,n,r)}function fc(e){return e=e.prototype,!(!e||!e.isReactComponent)}function hS(e){if(typeof e=="function")return fc(e)?1:0;if(e!=null){if(e=e.$$typeof,e===Tu)return 11;if(e===Ru)return 14}return 2}function bn(e,t){var n=e.alternate;return n===null?(n=Et(e.tag,t,e.key,e.mode),n.elementType=e.elementType,n.type=e.type,n.stateNode=e.stateNode,n.alternate=e,e.alternate=n):(n.pendingProps=t,n.type=e.type,n.flags=0,n.subtreeFlags=0,n.deletions=null),n.flags=e.flags&14680064,n.childLanes=e.childLanes,n.lanes=e.lanes,n.child=e.child,n.memoizedProps=e.memoizedProps,n.memoizedState=e.memoizedState,n.updateQueue=e.updateQueue,t=e.dependencies,n.dependencies=t===null?null:{lanes:t.lanes,firstContext:t.firstContext},n.sibling=e.sibling,n.index=e.index,n.ref=e.ref,n}function Kl(e,t,n,r,s,l){var i=2;if(r=e,typeof e=="function")fc(e)&&(i=1);else if(typeof e=="string")i=5;else e:switch(e){case yr:return rr(n.children,s,l,t);case Lu:i=8,s|=8;break;case ia:return e=Et(12,n,t,s|2),e.elementType=ia,e.lanes=l,e;case oa:return e=Et(13,n,t,s),e.elementType=oa,e.lanes=l,e;case aa:return e=Et(19,n,t,s),e.elementType=aa,e.lanes=l,e;case lp:return Ji(n,s,l,t);default:if(typeof e=="object"&&e!==null)switch(e.$$typeof){case rp:i=10;break e;case sp:i=9;break e;case Tu:i=11;break e;case Ru:i=14;break e;case En:i=16,r=null;break e}throw Error(F(130,e==null?e:typeof e,""))}return t=Et(i,n,t,s),t.elementType=e,t.type=r,t.lanes=l,t}function rr(e,t,n,r){return e=Et(7,e,r,t),e.lanes=n,e}function Ji(e,t,n,r){return e=Et(22,e,r,t),e.elementType=lp,e.lanes=n,e.stateNode={isHidden:!1},e}function zo(e,t,n){return e=Et(6,e,null,t),e.lanes=n,e}function Bo(e,t,n){return t=Et(4,e.children!==null?e.children:[],e.key,t),t.lanes=n,t.stateNode={containerInfo:e.containerInfo,pendingChildren:null,implementation:e.implementation},t}function pS(e,t,n,r,s){this.tag=t,this.containerInfo=e,this.finishedWork=this.pingCache=this.current=this.pendingChildren=null,this.timeoutHandle=-1,this.callbackNode=this.pendingContext=this.context=null,this.callbackPriority=0,this.eventTimes=So(0),this.expirationTimes=So(-1),this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=So(0),this.identifierPrefix=r,this.onRecoverableError=s,this.mutableSourceEagerHydrationData=null}function dc(e,t,n,r,s,l,i,o,a){return e=new pS(e,t,n,o,a),t===1?(t=1,l===!0&&(t|=8)):t=0,l=Et(3,null,null,t),e.current=l,l.stateNode=e,l.memoizedState={element:r,isDehydrated:n,cache:null,transitions:null,pendingSuspenseBoundaries:null},Yu(l),e}function mS(e,t,n){var r=3"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(eg)}catch(e){console.error(e)}}eg(),Xh.exports=gt;var SS=Xh.exports,_d=SS;sa.createRoot=_d.createRoot,sa.hydrateRoot=_d.hydrateRoot;const xS=(e,t)=>{const{id:n=void 0,modelValue:r,style:s={},multiple:l=!1,data:i,settings:o,events:a,children:u}=e,c=nt.useRef(null),d=nt.useRef(),p=nt.useRef();nt.useEffect(()=>{const x={select:c.current,events:{}};i&&(x.data=i),o&&(x.settings=o),a&&(x.events=a),x.events==null&&(x.events={});const _=x.events.afterChange;return x.events.afterChange=function(m){if(Array.isArray(m)&&m.length>0){const f=l?m.map(h=>h.value):m[0].value;p.current!==f&&(p.current=f),x.events&&_&&typeof _=="function"&&_(m)}},d.current=new zh(x),(l?d.current.getSelected():d.current.getSelected()[0])!==r&&r&&d.current.setSelected(r),()=>{var m;d.current&&((m=d.current)==null||m.destroy())}},[r,l,i,o,a,u]);const w=nt.useCallback(x=>{const _=l;return typeof x=="string"?_?[x]:x:Array.isArray(x)?_?x:x[0]:_?[]:""},[l]);return nt.useEffect(()=>{var x;r&&((x=d.current)==null||x.setSelected(w(r)))},[r,w]),nt.useEffect(()=>{var x;i&&((x=d.current)==null||x.setData(i))},[i]),nt.useImperativeHandle(t,()=>({set(x){var _;(_=d.current)==null||_.setSelected(x)},getSlimSelectInstance(){return d.current}}),[]),z.jsx(z.Fragment,{children:z.jsx("select",{style:s,id:n,"data-testid":n,multiple:l,ref:c,children:u})})},Uo=nt.forwardRef(xS),ES=()=>{const[e,t]=nt.useState("2"),[n,r]=nt.useState(["2","3"]);return z.jsxs("div",{id:"react",className:"content",children:[z.jsx("h2",{className:"header",children:"React"}),z.jsx("h3",{children:"Install"}),z.jsx("p",{children:"The react component is in a sub package under SlimSelect. All functionality still work in the implementation."}),z.jsx("div",{className:"code-toolbar",children:z.jsx("pre",{className:"language-bash",children:z.jsx("code",{children:"npm install @slim-select/react"})})}),z.jsx("br",{}),z.jsx("h3",{children:"Simple example"}),z.jsxs("div",{style:{display:"flex",flexDirection:"row",width:"100%"},children:[z.jsxs("div",{style:{flex:.5},children:[z.jsx("strong",{children:"Value"})," ",e,z.jsxs(Uo,{modelValue:e,events:{afterChange:s=>t(s[0].value)},style:{marginLeft:"5px"},ref:null,children:[z.jsx("option",{value:"all",children:"All"}),z.jsx("option",{value:"1",children:"Option 1"}),z.jsx("option",{value:"2",children:"Option 2"}),z.jsx("option",{value:"3",children:"Option 3"})]})]}),z.jsxs("div",{style:{flex:.6,marginLeft:15},children:[z.jsxs("div",{children:[z.jsx("strong",{children:"Value"})," ",JSON.stringify(n)]}),z.jsxs(Uo,{modelValue:n,ref:null,multiple:!0,events:{afterChange:s=>r(s.map(l=>l.value))},children:[z.jsx("option",{value:"1",children:"Option 1"}),z.jsx("option",{value:"2",children:"Option 2"}),z.jsx("option",{value:"3",children:"Value 3"})]})]})]}),z.jsx("div",{className:"code-toolbar",children:z.jsx("pre",{className:"language-javascript",children:z.jsxs("code",{className:"language-javascript",children:['import React from "react" ',z.jsx("br",{}),'import SlimSelect from "@slim-select/react" ',z.jsx("br",{}),"import ","{"," useState ","}",' from "react"',z.jsx("br",{}),z.jsx("br",{}),"export default MySelect = () ","=>"," ","{",z.jsx("br",{}),'const [simpleSingle, setSimpleSingle] = useState("2") ',z.jsx("br",{}),"return ( ",z.jsx("br",{})," setSimpleSingle(values[0].value as unknown as string) }}","> ",z.jsx("br",{}),'All ',z.jsx("br",{}),'Option 1 ',z.jsx("br",{}),'Option 2 ',z.jsx("br",{}),'Option 3 ',z.jsx("br",{})," ",z.jsx("br",{})," )","}"]})})}),z.jsx("br",{}),z.jsx("div",{className:"separator"}),z.jsx("br",{}),z.jsx("h3",{children:"Attributes"}),z.jsx("p",{children:"There are certain attributes that are reactive to changes"}),z.jsx("h4",{children:"disabled"}),z.jsxs(Uo,{settings:{disabled:!0},children:[z.jsx("option",{value:"1",children:"Option 1"}),z.jsx("option",{value:"2",children:"Option 2"}),z.jsx("option",{value:"3",children:"Value 3"})]}),z.jsx("div",{className:"code-toolbar",children:z.jsx("pre",{className:"language-javascript",children:z.jsxs("code",{className:"language-javascript",children:['import React from "react" ',z.jsx("br",{}),'import SlimSelect from "@slim-select/react" ',z.jsx("br",{}),"import ","{"," useState ","}",' from "react"',z.jsx("br",{}),z.jsx("br",{}),"export default MySelect = () ","=>"," ","{",z.jsx("br",{}),'const [simpleSingle, setSimpleSingle] = useState("2") ',z.jsx("br",{}),"return ( ",z.jsx("br",{}),"",z.jsx("br",{}),'All ',z.jsx("br",{}),'Option 1 ',z.jsx("br",{}),'Option 2 ',z.jsx("br",{}),'Option 3 ',z.jsx("br",{})," ",z.jsx("br",{})," )","}"]})})})]})},_S=document.getElementById("app")?100:0;(function(){window.setInterval(()=>{document.getElementById("react-root")!=null&&document.getElementById("react-hide")!=null&&sa.createRoot(document.getElementById("react-root")).render(z.jsx(c1.StrictMode,{children:z.jsx(ES,{})}))},_S)})();export{yt as F,He as O,zh as S,by as _,rn as a,be as b,Eh as c,Mi as d,Iv as e,Il as f,pf as g,Su as h,CS as i,AS as j,Gd as k,uh as l,kS as m,nu as n,Di as o,Lc as r,hg as t,Wg as w};
+
function __vite__mapDeps(indexes) {
if (!__vite__mapDeps.viteFileDeps) {
__vite__mapDeps.viteFileDeps = ["assets/home.js","assets/home.css","assets/index2.js","assets/index2.css"]
diff --git a/jest.config.js b/jest.config.js
index 8fb6a9c9..54208409 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -5,4 +5,10 @@ module.exports = {
testEnvironment: 'jsdom',
testMatch: ['**/*.test.ts'],
roots: ['./src/slim-select'],
+ reporters: process.env.CI ? [['github-actions', {silent: false}], 'summary'] : ['default'],
+ coverageThreshold: {
+ global: {
+ lines: 75,
+ },
+ },
}
diff --git a/package.json b/package.json
index c823dfcd..56b03d94 100644
--- a/package.json
+++ b/package.json
@@ -19,6 +19,9 @@
".": {
"import": "./dist/slimselect.js",
"require": "./dist/slimselect.umd.js",
+ "types": "./dist/index.d.ts"
+ },
+ "./*": {
"types": "./dist/*.d.ts"
},
"./styles": "./dist/slimselect.css"
@@ -47,7 +50,8 @@
"build:library:js": "cd src/slim-select && rollup --config ./rollup.config.mjs && cd ../../",
"build:library:css": "cd src/slim-select && sass ./slimselect.scss ../../dist/slimselect.css --style=compressed && cd ../../",
"build:frameworks": "npm run build --workspaces",
- "test": "jest"
+ "test": "jest",
+ "test:coverage": "jest --coverage"
},
"devDependencies": {
"@jest/globals": "^29.7.0",
diff --git a/src/docs/app.vue b/src/docs/app.vue
index d74b4ad6..2cb37938 100644
--- a/src/docs/app.vue
+++ b/src/docs/app.vue
@@ -62,6 +62,7 @@ export default defineComponent({
closable: 'close',
options: [
{ text: 'select', value: 'settings#select' },
+ { text: 'cssClasses', value: 'settings#cssClasses' },
{ text: 'alwaysOpen', value: 'settings#alwaysOpen' },
{ text: 'contentLocation', value: 'settings#contentLocation' },
{ text: 'contentPosition', value: 'settings#contentPosition' },
@@ -77,7 +78,7 @@ export default defineComponent({
{ text: 'cssClass', value: 'settings#cssClass' },
{ text: 'inlineStyles', value: 'settings#inlineStyles' },
{ text: 'html', value: 'settings#html' },
- { text: 'keepOrder', value: 'settings#keepOrder'},
+ { text: 'keepOrder', value: 'settings#keepOrder' },
{ text: 'search', value: 'settings#search' },
{ text: 'closeOnSelect', value: 'settings#closeOnSelect' },
{ text: 'showOptionTooltips', value: 'settings#showOptionTooltips' },
@@ -182,9 +183,11 @@ export default defineComponent({
this.setDemensions()
window.addEventListener('resize', this.navDebounce)
+ window.addEventListener('nav-updated', this.updateNav)
},
unmounted() {
window.removeEventListener('resize', this.navDebounce)
+ window.removeEventListener('nav-updated', this.updateNav)
this.nav?.destroy()
},
@@ -230,6 +233,13 @@ export default defineComponent({
},
})
},
+ updateNav() {
+ setTimeout(() => {
+ if (this.nav) {
+ this.nav.setSelected(this.$router.currentRoute.value.fullPath.replace('/', ''))
+ }
+ }, 0)
+ },
},
})
diff --git a/src/docs/pages/home.vue b/src/docs/pages/home.vue
index 45705225..4503533e 100644
--- a/src/docs/pages/home.vue
+++ b/src/docs/pages/home.vue
@@ -77,6 +77,11 @@ export default defineComponent({
this.multiple.destroy()
}
},
+ methods: {
+ handleClick() {
+ window.dispatchEvent(new Event('nav-updated'))
+ },
+ },
})
@@ -158,6 +163,13 @@ export default defineComponent({
}
}
}
+
+ .frameworks {
+ .framework-items {
+ display: flex;
+ flex-direction: row;
+ }
+ }
}
@@ -243,8 +255,8 @@ export default defineComponent({
SlimSelect is in the process of adding a few framework integrations.
If you are an expert in any specific framework and would like to help out, please reach out!
-
-
+
+
diff --git a/src/docs/pages/settings/css_classes.vue b/src/docs/pages/settings/css_classes.vue
new file mode 100644
index 00000000..e85c5c2e
--- /dev/null
+++ b/src/docs/pages/settings/css_classes.vue
@@ -0,0 +1,81 @@
+
+
+
+
+
+
+
+
+ You can override the default CSS classes by setting them during initialization.
+
+
+
+
+ Value 1
+ Value 2
+ Value 3
+
+
+ Value 1
+ Value 2
+ Value 3
+
+
+
+
+
+ <select id="primary-select">
+ <option value="value1">Value 1</option>
+ <option value="value2">Value 2</option>
+ <option value="value3">Value 3</option>
+ </select>
+
+
+
+
+
+ new SlimSelect({
+ select: '#primary-select',
+ cssClasses: {
+ option: "primary-option"
+ })
+
+
+
+
diff --git a/src/docs/pages/settings/index.vue b/src/docs/pages/settings/index.vue
index f971d9f5..e4a18468 100644
--- a/src/docs/pages/settings/index.vue
+++ b/src/docs/pages/settings/index.vue
@@ -8,6 +8,7 @@ import CloseOnSelect from './close_on_select.vue'
import ContentLocation from './content_location.vue'
import ContentPosition from './content_position.vue'
import Css from './css.vue'
+import CssClasses from './css_classes.vue'
import DataAttributes from './data_attributes.vue'
import Deselect from './deselect.vue'
import Disabled from './disabled.vue'
@@ -30,6 +31,7 @@ export default defineComponent({
name: 'Settings',
components: {
Select,
+ CssClasses,
AlwaysOpen,
ContentLocation,
ContentPosition,
@@ -59,6 +61,7 @@ export default defineComponent({
+
diff --git a/src/docs/pages/settings/search.vue b/src/docs/pages/settings/search.vue
index bae0ab6b..be235c8b 100644
--- a/src/docs/pages/settings/search.vue
+++ b/src/docs/pages/settings/search.vue
@@ -13,6 +13,12 @@ export default defineComponent({
showSearch: false,
},
})
+ new SlimSelect({
+ select: this.$refs.focusSearchSingle as HTMLSelectElement,
+ settings: {
+ focusSearch: false,
+ },
+ })
new SlimSelect({
select: this.$refs.searchTextSingle as HTMLSelectElement,
settings: {
@@ -39,6 +45,12 @@ export default defineComponent({
showSearch: false,
},
})
+ new SlimSelect({
+ select: this.$refs.focusSearchMulti as HTMLSelectElement,
+ settings: {
+ focusSearch: false,
+ },
+ })
new SlimSelect({
select: this.$refs.searchTextMulti as HTMLSelectElement,
settings: {
@@ -63,8 +75,9 @@ export default defineComponent({
-
+
showSearch - is a boolean value that will decide whether or not to show the search. Default is true.
+
focusSearch - is a boolean value that will decide whether or not to focus on the search on open. Default is true.
searchText - is a string value that will show in the event there are no results. Default is 'No Results'.
@@ -83,6 +96,11 @@ export default defineComponent({
Cat
Bird
+
+ Dog
+ Cat
+ Bird
+
Dog
Cat
@@ -106,6 +124,11 @@ export default defineComponent({
Cat
Bird
+
+ Dog
+ Cat
+ Bird
+
Dog
Cat
diff --git a/src/slim-select/css_classes.test.ts b/src/slim-select/css_classes.test.ts
new file mode 100644
index 00000000..3128aaf7
--- /dev/null
+++ b/src/slim-select/css_classes.test.ts
@@ -0,0 +1,71 @@
+'use strict'
+import { describe, expect, test } from '@jest/globals'
+import CssClasses from './css_classes'
+
+const defaultClasses: { [key: string]: string } = {
+ main: 'ss-main',
+ placeholder: 'ss-placeholder',
+ values: 'ss-values',
+ single: 'ss-single',
+ max: 'ss-max',
+ value: 'ss-value',
+ valueText: 'ss-value-text',
+ valueDelete: 'ss-value-delete',
+ valueOut: 'ss-value-out',
+ deselect: 'ss-deselect',
+ deselectPath: 'M10,10 L90,90 M10,90 L90,10',
+ arrow: 'ss-arrow',
+ arrowClose: 'M10,30 L50,70 L90,30',
+ arrowOpen: 'M10,70 L50,30 L90,70',
+ content: 'ss-content',
+ openAbove: 'ss-open-above',
+ openBelow: 'ss-open-below',
+ search: 'ss-search',
+ searchHighlighter: 'ss-search-highlight',
+ searching: 'ss-searching',
+ addable: 'ss-addable',
+ addablePath: 'M50,10 L50,90 M10,50 L90,50',
+ list: 'ss-list',
+ optgroup: 'ss-optgroup',
+ optgroupLabel: 'ss-optgroup-label',
+ optgroupLabelText: 'ss-optgroup-label-text',
+ optgroupActions: 'ss-optgroup-actions',
+ optgroupSelectAll: 'ss-selectall',
+ optgroupSelectAllBox: 'M60,10 L10,10 L10,90 L90,90 L90,50',
+ optgroupSelectAllCheck: 'M30,45 L50,70 L90,10',
+ optgroupClosable: 'ss-closable',
+ option: 'ss-option',
+ optionDelete: 'M10,10 L90,90 M10,90 L90,10',
+ highlighted: 'ss-highlighted',
+ open: 'ss-open',
+ close: 'ss-close',
+ selected: 'ss-selected',
+ error: 'ss-error',
+ disabled: 'ss-disabled',
+ hide: 'ss-hide',
+}
+
+describe('CssClasses module', () => {
+ test('empty constructor returns default classes', () => {
+ // We test the traditional classes here. Since old versions of slim-select didn't allow CSS class overrides, users
+ // may have written their CSS to match those classes, which means we should never change them in the library.
+
+ // Convert to unknown and then to custom object to prevent TS from throwing errors
+ const classes = new CssClasses() as unknown as { [key: string]: string }
+ Object.keys(defaultClasses).forEach((key) => {
+ expect(classes[key]).toBe(defaultClasses[key])
+ })
+ })
+
+ test('classes can be overwritten via the constructor', () => {
+ const classesWithOverride = JSON.parse(JSON.stringify(defaultClasses))
+ classesWithOverride['main'] = 'new-main'
+ classesWithOverride['open'] = 'new-open'
+
+ // Convert to unknown and then to custom object to prevent TS from throwing errors
+ const classes = new CssClasses({ main: 'new-main', open: 'new-open' }) as unknown as { [key: string]: string }
+ Object.keys(classesWithOverride).forEach((key) => {
+ expect(classes[key]).toBe(classesWithOverride[key])
+ })
+ })
+})
diff --git a/src/slim-select/css_classes.ts b/src/slim-select/css_classes.ts
new file mode 100644
index 00000000..82c2f50f
--- /dev/null
+++ b/src/slim-select/css_classes.ts
@@ -0,0 +1,111 @@
+export type CssClassesPartial = Partial
+
+export default class CssClasses {
+ public main: string
+ // Placeholder
+ public placeholder: string
+
+ // Values
+ public values: string
+ public single: string
+ public max: string
+ public value: string
+ public valueText: string
+ public valueDelete: string
+ public valueOut: string
+
+ // Deselect
+ public deselect: string
+ public deselectPath: string // Not a class but whatever
+
+ // Arrow
+ public arrow: string
+ public arrowClose: string // Not a class but whatever
+ public arrowOpen: string // Not a class but whatever
+
+ // Content
+ public content: string
+ public openAbove: string
+ public openBelow: string
+
+ // Search
+ public search: string
+ public searchHighlighter: string
+ public searching: string
+ public addable: string
+ public addablePath: string // Not a class but whatever
+
+ // List optgroups/options
+ public list: string
+
+ // Optgroup
+ public optgroup: string
+ public optgroupLabel: string
+ public optgroupLabelText: string
+ public optgroupActions: string
+ public optgroupSelectAll: string // optgroup select all
+ public optgroupSelectAllBox: string // Not a class but whatever
+ public optgroupSelectAllCheck: string // Not a class but whatever
+ public optgroupClosable: string
+
+ // Option
+ public option: string
+ public optionDelete: string // Not a class but whatever
+ public highlighted: string
+
+ // Misc
+ public open: string
+ public close: string
+ public selected: string
+ public error: string
+ public disabled: string
+ public hide: string
+
+ constructor(classes?: CssClassesPartial) {
+ if (!classes) {
+ classes = {}
+ }
+
+ this.main = classes.main || 'ss-main'
+ this.placeholder = classes.placeholder || 'ss-placeholder'
+ this.values = classes.values || 'ss-values'
+ this.single = classes.single || 'ss-single'
+ this.max = classes.max || 'ss-max'
+ this.value = classes.value || 'ss-value'
+ this.valueText = classes.valueText || 'ss-value-text'
+ this.valueDelete = classes.valueDelete || 'ss-value-delete'
+ this.valueOut = classes.valueOut || 'ss-value-out'
+
+ this.deselect = classes.deselect || 'ss-deselect'
+ this.deselectPath = classes.deselectPath || 'M10,10 L90,90 M10,90 L90,10'
+ this.arrow = classes.arrow || 'ss-arrow'
+ this.arrowClose = classes.arrowClose || 'M10,30 L50,70 L90,30'
+ this.arrowOpen = classes.arrowOpen || 'M10,70 L50,30 L90,70'
+ this.content = classes.content || 'ss-content'
+ this.openAbove = classes.openAbove || 'ss-open-above'
+ this.openBelow = classes.openBelow || 'ss-open-below'
+ this.search = classes.search || 'ss-search'
+ this.searchHighlighter = classes.searchHighlighter || 'ss-search-highlight'
+ this.searching = classes.searching || 'ss-searching'
+ this.addable = classes.addable || 'ss-addable'
+ this.addablePath = classes.addablePath || 'M50,10 L50,90 M10,50 L90,50'
+ this.list = classes.list || 'ss-list'
+ this.optgroup = classes.optgroup || 'ss-optgroup'
+ this.optgroupLabel = classes.optgroupLabel || 'ss-optgroup-label'
+ this.optgroupLabelText = classes.optgroupLabelText || 'ss-optgroup-label-text'
+ this.optgroupActions = classes.optgroupActions || 'ss-optgroup-actions'
+ this.optgroupSelectAll = classes.optgroupSelectAll || 'ss-selectall'
+ this.optgroupSelectAllBox = classes.optgroupSelectAllBox || 'M60,10 L10,10 L10,90 L90,90 L90,50'
+ this.optgroupSelectAllCheck = classes.optgroupSelectAllCheck || 'M30,45 L50,70 L90,10'
+ this.optgroupClosable = classes.optgroupClosable || 'ss-closable'
+ this.option = classes.option || 'ss-option'
+ this.optionDelete = classes.optionDelete || 'M10,10 L90,90 M10,90 L90,10'
+ this.highlighted = classes.highlighted || 'ss-highlighted'
+ this.open = classes.open || 'ss-open'
+ this.close = classes.close || 'ss-close'
+ this.selected = classes.selected || 'ss-selected'
+ this.error = classes.error || 'ss-error'
+ this.disabled = classes.disabled || 'ss-disabled'
+ this.hide = classes.hide || 'ss-hide'
+ }
+}
diff --git a/src/slim-select/helpers.test.ts b/src/slim-select/helpers.test.ts
new file mode 100644
index 00000000..f333494f
--- /dev/null
+++ b/src/slim-select/helpers.test.ts
@@ -0,0 +1,159 @@
+/**
+ * @jest-environment jsdom
+ */
+
+'use strict'
+import { describe, expect, test } from '@jest/globals'
+import { hasClassInTree, debounce, isEqual, kebabCase } from './helpers'
+
+describe('helpers module', () => {
+ describe('hasClassInTree', () => {
+ test('single element does not have class', () => {
+ const element = document.createElement('div')
+ element.className = 'invalid-class'
+
+ expect(hasClassInTree(element, 'test-class')).toBeNull()
+ })
+
+ test('single element has class', () => {
+ const element = document.createElement('div')
+ element.className = 'test-class'
+
+ expect(hasClassInTree(element, 'test-class')).toBe(element)
+
+ element.classList.add('class-2', 'class-3')
+ expect(hasClassInTree(element, 'test-class')).toBe(element)
+ })
+
+ test('single element has class as data id', () => {
+ const element = document.createElement('div')
+ element.dataset.id = 'test-class'
+
+ expect(hasClassInTree(element, 'test-class')).toBe(element)
+
+ element.classList.add('class-2', 'class-3')
+ expect(hasClassInTree(element, 'test-class')).toBe(element)
+ })
+
+ test('parent element does not have class', () => {
+ const element = document.createElement('div')
+ element.className = 'invalid-class'
+ const child = document.createElement('div')
+ element.appendChild(child)
+
+ expect(hasClassInTree(child, 'test-class')).toBeNull()
+ })
+
+ test('parent element has class', () => {
+ const element = document.createElement('div')
+ element.className = 'test-class'
+ const child = document.createElement('div')
+ element.appendChild(child)
+
+ expect(hasClassInTree(child, 'test-class')).toBe(element)
+
+ element.classList.add('class-2', 'class-3')
+ expect(hasClassInTree(child, 'test-class')).toBe(element)
+ })
+
+ test('parent element has class as data id', () => {
+ const element = document.createElement('div')
+ element.dataset.id = 'test-class'
+
+ const child = document.createElement('div')
+ element.appendChild(child)
+
+ expect(hasClassInTree(child, 'test-class')).toBe(element)
+
+ element.classList.add('class-2', 'class-3')
+ expect(hasClassInTree(child, 'test-class')).toBe(element)
+ })
+ })
+
+ describe('debounce', () => {
+ test('debounce calls function after default timeout', async () => {
+ const callback = jest.fn()
+ const debounced_function = debounce(callback)
+ debounced_function()
+
+ await new Promise((r) => setTimeout(r, 100))
+
+ expect(callback).toHaveBeenCalled()
+ expect(callback).toHaveBeenCalledTimes(1)
+ })
+
+ test('debounce calls function after higher timeout', async () => {
+ const callback = jest.fn()
+ const debounced_function = debounce(callback, 100)
+ debounced_function()
+
+ await new Promise((r) => setTimeout(r, 50))
+ expect(callback).not.toHaveBeenCalled()
+
+ await new Promise((r) => setTimeout(r, 50))
+ expect(callback).toHaveBeenCalled()
+ expect(callback).toHaveBeenCalledTimes(1)
+ })
+
+ test('debounce calls function after lower timeout', async () => {
+ const callback = jest.fn()
+ const debounced_function = debounce(callback, 10)
+ debounced_function()
+
+ await new Promise((r) => setTimeout(r, 5))
+ expect(callback).not.toHaveBeenCalled()
+
+ await new Promise((r) => setTimeout(r, 5))
+ expect(callback).toHaveBeenCalled()
+ expect(callback).toHaveBeenCalledTimes(1)
+ })
+
+ test('debounce respects inmediate setting', () => {
+ const callback = jest.fn()
+ const debounced_function = debounce(callback, 1000, true)
+ debounced_function()
+
+ expect(callback).toHaveBeenCalled()
+ expect(callback).toHaveBeenCalledTimes(1)
+ })
+
+ test('debounce respects order of calls', async () => {
+ const callback = jest.fn(() => {})
+ const debounced_function: (a: number) => void = debounce(callback)
+ debounced_function(0)
+ debounced_function(1)
+
+ await new Promise((r) => setTimeout(r, 50))
+ expect(callback).toHaveBeenCalledTimes(1)
+ expect(callback.mock.calls[0]).toStrictEqual([1])
+ })
+ })
+
+ describe('isEqual', () => {
+ test('different objects are not equal', () => {
+ expect(isEqual({ a: 1 }, { b: 1 })).toBe(false)
+ })
+
+ test('equal objects are equal', () => {
+ expect(isEqual({ a: 1 }, { a: 1 })).toBe(true)
+ })
+
+ test('more complex objects are equal', () => {
+ expect(isEqual({ a: 1, b: { c: 'asdf' } }, { a: 1, b: { c: 'asdf' } })).toBe(true)
+ })
+ })
+
+ describe('kebabCase', () => {
+ test('kebab-case string', () => {
+ expect(kebabCase('kebab-case')).toBe('kebab-case')
+ })
+
+ test('camelCase string', () => {
+ expect(kebabCase('camelCase')).toBe('camel-case')
+ })
+
+ test('string with initial uppercase', () => {
+ expect(kebabCase('UpperCase')).toBe('upper-case')
+ })
+ })
+})
diff --git a/src/slim-select/helpers.ts b/src/slim-select/helpers.ts
index c6c1d40d..200cb365 100644
--- a/src/slim-select/helpers.ts
+++ b/src/slim-select/helpers.ts
@@ -3,7 +3,7 @@ export function generateID(): string {
return Math.random().toString(36).substring(2, 10)
}
-export function hasClassInTree(element: HTMLElement, className: string) {
+export function hasClassInTree(element: HTMLElement, className: string): HTMLElement | null {
function hasClass(e: HTMLElement, c: string) {
// If the element has the class return element
if (c && e && e.classList && e.classList.contains(c)) {
@@ -51,20 +51,11 @@ export function debounce void>(func: T, wait = 50,
}
}
-// reverseDebounce will call the function on the first call and then debounce
-function reverseDebounce void>(func: T, timeout: number): T {
- let timer: NodeJS.Timeout | null = null
- return function (...args: any[]): void {
- if (!timer) func(...args)
- timer = setTimeout(() => (timer = null), timeout)
- } as T
-}
-
-export function isEqual(a: any, b: any) {
+export function isEqual(a: any, b: any): boolean {
return JSON.stringify(a) === JSON.stringify(b)
}
-export function kebabCase(str: string) {
+export function kebabCase(str: string): string {
const result = str.replace(/[A-Z\u00C0-\u00D6\u00D8-\u00DE]/g, (match) => '-' + match.toLowerCase())
return str[0] === str[0].toUpperCase() ? result.substring(1) : result
}
diff --git a/src/slim-select/index.test.ts b/src/slim-select/index.test.ts
index 623f9b6a..d9abbbaa 100644
--- a/src/slim-select/index.test.ts
+++ b/src/slim-select/index.test.ts
@@ -9,13 +9,105 @@ import { OptionOptional } from './store'
import { Config } from './index'
describe('SlimSelect Module', () => {
- test('constructor', () => {
+ let slim: SlimSelect
+
+ beforeEach(() => {
document.body.innerHTML = ' '
- let slimSelect = new SlimSelect({
+ slim = new SlimSelect({
select: '#test',
})
- expect(slimSelect).toBeInstanceOf(SlimSelect)
+ })
+
+ describe('constructor', () => {
+ test('missing select element throws error', () => {
+ const errorMock = jest.fn()
+ const slim = new SlimSelect({ select: '#invalid', events: { error: errorMock } })
+
+ expect(slim.select).toBeUndefined()
+ expect(slim.store).toBeUndefined()
+ expect(slim.render).toBeUndefined()
+ expect(errorMock).toHaveBeenCalled()
+ expect(errorMock.mock.calls[0][0].message).toBe('Could not find select element')
+ })
+
+ test('invalid element throws error', () => {
+ document.body.innerHTML = '
'
+
+ const errorMock = jest.fn()
+ const slim = new SlimSelect({ select: '#invalid', events: { error: errorMock } })
+
+ expect(slim.select).toBeUndefined()
+ expect(slim.store).toBeUndefined()
+ expect(slim.render).toBeUndefined()
+ expect(errorMock).toHaveBeenCalled()
+ expect(errorMock.mock.calls[0][0].message).toBe('Element isnt of type select')
+ })
+
+ test('valid minimal constructor with query string', () => {
+ document.body.innerHTML = ' '
+
+ const slimSelect = new SlimSelect({
+ select: '#test',
+ })
+
+ expect(slimSelect).toBeInstanceOf(SlimSelect)
+ })
+
+ test('valid minimal constructor with HTML element', () => {
+ document.body.innerHTML = ' '
+
+ const slimSelect = new SlimSelect({
+ select: document.getElementById('test') as Element,
+ })
+
+ expect(slimSelect).toBeInstanceOf(SlimSelect)
+ })
+
+ test('disabled gets applied correctly', () => {
+ document.body.innerHTML = ' '
+
+ const slim = new SlimSelect({
+ select: document.getElementById('test') as Element,
+ settings: {
+ disabled: true,
+ },
+ })
+
+ expect(slim.settings.disabled).toBe(true)
+ })
+ })
+
+ test('enable', () => {
+ slim.settings.disabled = true
+
+ const selectEnableMock = jest.fn()
+ const renderEnableMock = jest.fn()
+
+ slim.select.enable = selectEnableMock
+ slim.render.enable = renderEnableMock
+
+ slim.enable()
+
+ expect(slim.settings.disabled).toBe(false)
+ expect(selectEnableMock).toHaveBeenCalled()
+ expect(renderEnableMock).toHaveBeenCalled()
+ })
+
+ test('disable', () => {
+ slim.settings.disabled = false
+
+ const selectDisableMock = jest.fn()
+ const renderDisableMock = jest.fn()
+
+ slim.select.disable = selectDisableMock
+ slim.render.disable = renderDisableMock
+
+ slim.disable()
+
+ expect(slim.settings.disabled).toBe(true)
+ expect(selectDisableMock).toHaveBeenCalled()
+ expect(renderDisableMock).toHaveBeenCalled()
})
test('multiple - do not render deselect all with no selected options', () => {
diff --git a/src/slim-select/index.ts b/src/slim-select/index.ts
index 7aa065c8..46b68823 100644
--- a/src/slim-select/index.ts
+++ b/src/slim-select/index.ts
@@ -3,11 +3,13 @@ import Render from './render'
import Select from './select'
import Settings, { SettingsPartial } from './settings'
import Store, { DataArray, DataArrayPartial, Option, OptionOptional } from './store'
+import CssClasses, { CssClassesPartial } from './css_classes'
export interface Config {
select: string | Element
data?: DataArrayPartial
settings?: SettingsPartial
+ cssClasses?: CssClassesPartial
events?: Events
}
@@ -29,6 +31,7 @@ export default class SlimSelect {
// Classes
public settings!: Settings
+ public cssClasses!: CssClasses
public select!: Select
public store!: Store
public render!: Render
@@ -74,6 +77,9 @@ export default class SlimSelect {
// Set settings
this.settings = new Settings(config.settings)
+ // Set CSS classes
+ this.cssClasses = new CssClasses(config.cssClasses)
+
// Set events
const debounceEvents = ['afterChange', 'beforeOpen', 'afterOpen', 'beforeClose', 'afterClose']
for (const key in config.events) {
@@ -149,7 +155,7 @@ export default class SlimSelect {
}
// Setup render class
- this.render = new Render(this.settings, this.store, renderCallbacks)
+ this.render = new Render(this.settings, this.cssClasses, this.store, renderCallbacks)
this.render.renderValues()
this.render.renderOptions(this.store.getData())
@@ -331,7 +337,7 @@ export default class SlimSelect {
this.render.open()
// Focus on input field only if search is enabled
- if (this.settings.showSearch) {
+ if (this.settings.showSearch && this.settings.focusSearch) {
this.render.searchFocus()
}
diff --git a/src/slim-select/render.test.ts b/src/slim-select/render.test.ts
index 078f8d0d..29d75396 100644
--- a/src/slim-select/render.test.ts
+++ b/src/slim-select/render.test.ts
@@ -7,33 +7,1192 @@
import { describe, expect, test } from '@jest/globals'
import Render, { Callbacks } from './render'
import Settings from './settings'
-import Store, { DataArray, Option } from './store'
+import Store, { Option } from './store'
+import CssClasses from './css_classes'
-describe('select module', () => {
- test('constructor', () => {
- // create a new store with 2 options
+describe('render module', () => {
+ let render: Render
+ let openMock: jest.Mock
+ let closeMock: jest.Mock
+ let addSelectedMock: jest.Mock
+ let setSelectedMock: jest.Mock
+ let addOptionMock: jest.Mock
+ let searchMock: jest.Mock
+ let afterChangeMock: jest.Mock
+ let beforeChangeMock: jest.Mock
+
+ beforeEach(() => {
const store = new Store('single', [
- { text: 'test1', value: 'test1' },
- { text: 'test2', value: 'test2' },
+ {
+ text: 'test0',
+ value: 'test0',
+ },
+ {
+ text: 'test1',
+ value: 'test1',
+ html: 'test1 ',
+ },
+ {
+ text: 'test2',
+ selected: true,
+ },
])
// default settings
const settings = new Settings()
+ const classes = new CssClasses()
+
+ openMock = jest.fn(() => {})
+ closeMock = jest.fn(() => {})
+ addSelectedMock = jest.fn(() => {})
+ setSelectedMock = jest.fn(() => {})
+ addOptionMock = jest.fn(() => {})
+ searchMock = jest.fn(() => {})
+ afterChangeMock = jest.fn(() => {})
+ beforeChangeMock = jest.fn(() => true)
// default callbacks
const callbacks = {
- open: () => {},
- close: () => {},
- addSelected: (value: string) => {},
- setSelected: (value: string[]) => {},
- addOption: (option: Option) => {},
- search: (search: string) => {},
- beforeChange: (before: DataArray, after: DataArray) => {
- return true
- },
- } as Callbacks
+ open: openMock,
+ close: closeMock,
+ addSelected: addSelectedMock,
+ setSelected: setSelectedMock,
+ addOption: addOptionMock,
+ search: searchMock,
+ afterChange: afterChangeMock,
+ beforeChange: beforeChangeMock,
+ }
+
+ render = new Render(settings, classes, store, callbacks)
+ })
+
+ describe('constructor', () => {
+ test('default constructor works', () => {
+ // create a new store with 2 options
+ const store = new Store('single', [
+ { text: 'test1', value: 'test1' },
+ { text: 'test2', value: 'test2' },
+ ])
+
+ // default settings
+ const settings = new Settings()
+ const classes = new CssClasses()
+
+ // default callbacks
+ const callbacks = {
+ open: () => {},
+ close: () => {},
+ addSelected: () => {},
+ setSelected: () => {},
+ addOption: () => {},
+ search: () => {},
+ beforeChange: () => {
+ return true
+ },
+ } as Callbacks
+
+ const render = new Render(settings, classes, store, callbacks)
+ expect(render).toBeInstanceOf(Render)
+ expect(render.main.main).toBeInstanceOf(HTMLDivElement)
+ expect(render.content.search.input).toBeInstanceOf(HTMLInputElement)
+ })
+ })
+
+ describe('enable', () => {
+ test('enable removes disabled class from main and enables search input', () => {
+ // disable stuff directly
+ render.main.main.classList.add(render.classes.disabled)
+ render.content.search.input.disabled = true
+
+ render.enable()
+ expect(render.main.main.classList.contains(render.classes.disabled)).toBe(false)
+ expect(render.content.search.input.disabled).toBe(false)
+ })
+ })
+
+ describe('disable', () => {
+ test('disable adds disabled class to main and disables search input', () => {
+ render.disable()
+ expect(render.main.main.classList.contains(render.classes.disabled)).toBe(true)
+ expect(render.content.search.input.disabled).toBe(true)
+ })
+ })
+
+ describe('open', () => {
+ test('open sets the correct attributes and CSS classes', () => {
+ render.open()
+
+ expect(render.main.arrow.path.getAttribute('d')).toBe(render.classes.arrowOpen)
+ expect(render.main.main.classList.contains(render.classes.openBelow)).toBe(true)
+ expect(render.main.main.classList.contains(render.classes.openAbove)).toBe(false)
+ expect(render.main.main.getAttribute('aria-expanded')).toBe('true')
+ expect(render.content.main.classList.contains(render.classes.openBelow)).toBe(true)
+ expect(render.content.main.classList.contains(render.classes.openAbove)).toBe(false)
+ })
+ })
+
+ describe('close', () => {
+ test('close sets the correct attributes and CSS classes', () => {
+ render.close()
+
+ expect(render.main.arrow.path.getAttribute('d')).toBe(render.classes.arrowClose)
+ expect(render.main.main.classList.contains(render.classes.openBelow)).toBe(false)
+ expect(render.main.main.classList.contains(render.classes.openAbove)).toBe(false)
+ expect(render.main.main.getAttribute('aria-expanded')).toBe('false')
+ expect(render.content.main.classList.contains(render.classes.openBelow)).toBe(false)
+ expect(render.content.main.classList.contains(render.classes.openAbove)).toBe(false)
+ })
+ })
+
+ describe('updateClassStyles', () => {
+ test('existing classes and styles are cleared', () => {
+ // manually set classes and styles for testing
+ render.main.main.className = 'test'
+ render.main.main.style.color = 'red'
+
+ render.content.main.className = 'test'
+ render.content.main.style.color = 'red'
+
+ render.updateClassStyles()
+
+ expect(render.main.main.classList.contains('test')).toBe(false)
+ expect(render.main.main.style.color).toBeFalsy()
+
+ expect(render.content.main.classList.contains('test')).toBe(false)
+ expect(render.content.main.style.color).toBeFalsy()
+ })
+
+ test('inline styles are applied to main elements', () => {
+ render.settings.style = 'color: red'
+
+ render.updateClassStyles()
+
+ expect(render.main.main.style.color).toBe('red')
+ expect(render.content.main.style.color).toBe('red')
+ })
+
+ test('classes are applied to main elements', () => {
+ render.settings.class = ['test0', 'test1', 'test2']
+
+ render.updateClassStyles()
+
+ expect(render.main.main.classList.contains('test0')).toBe(true)
+ expect(render.main.main.classList.contains('test1')).toBe(true)
+ expect(render.main.main.classList.contains('test2')).toBe(true)
+
+ expect(render.content.main.classList.contains('test0')).toBe(true)
+ expect(render.content.main.classList.contains('test1')).toBe(true)
+ expect(render.content.main.classList.contains('test2')).toBe(true)
+ })
+
+ test('if content position is relative, class is added on content', () => {
+ render.settings.contentPosition = 'relative'
+
+ render.updateClassStyles()
+
+ expect(render.content.main.classList.contains('ss-relative')).toBe(true)
+ })
+ })
+
+ describe('updateAriaAttributes', () => {
+ test('sets correct aria attributes', () => {
+ render.updateAriaAttributes()
+
+ expect(render.main.main.role).toBe('combobox')
+ expect(render.main.main.getAttribute('aria-haspopup')).toBe('listbox')
+ expect(render.main.main.getAttribute('aria-controls')).toBe(render.content.main.id)
+ expect(render.main.main.getAttribute('aria-expanded')).toBe('false')
+ expect(render.content.main.getAttribute('role')).toBe('listbox')
+ })
+ })
+
+ describe('mainDiv', () => {
+ test('correct HTML element gets created', () => {
+ const main = render.main.main
+
+ expect(main.dataset.id).toBe(render.settings.id)
+ expect(main.getAttribute('aria-label')).toBe(render.settings.ariaLabel)
+ expect(main.tabIndex).toBe(0)
+ expect(main.children).toHaveLength(3)
+ expect(main.children.item(0)?.className).toBe(render.classes.values)
+ expect(main.children.item(1)?.classList.contains(render.classes.deselect)).toBe(true)
+ expect(main.children.item(1)?.classList.contains(render.classes.hide)).toBe(true)
+ expect(main.children.item(1)?.children).toHaveLength(1)
+ expect(main.children.item(1)?.children).toHaveLength(1)
+ expect(main.children.item(1)?.children.item(0)).toBeInstanceOf(SVGElement)
+ expect(main.children.item(2)?.classList.contains(render.classes.arrow)).toBe(true)
+ expect(main.children.item(2)?.classList.contains(render.classes.hide)).toBe(false)
+ expect(main.children.item(2)?.children.item(0)).toBeInstanceOf(SVGElement)
+ })
+
+ test('arrow is hidden when alwaysOpen is set', () => {
+ render.settings.alwaysOpen = true
+ const main = render.mainDiv().main
+
+ expect(main.children.item(2)?.classList.contains(render.classes.arrow)).toBe(true)
+ expect(main.children.item(2)?.classList.contains(render.classes.hide)).toBe(true)
+ expect(main.children.item(2)?.children.item(0)).toBeInstanceOf(SVGElement)
+ })
+
+ test('arrow key events on main element move highlight', () => {
+ const highlightMock = jest.fn(() => {})
+
+ render.highlight = highlightMock
+
+ render.main.main.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp' }))
+ expect(openMock).toHaveBeenCalled()
+ expect(highlightMock).toHaveBeenCalledTimes(1)
+ expect(highlightMock.mock.calls[0]).toStrictEqual(['up'])
+
+ render.main.main.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }))
+ expect(openMock).toHaveBeenCalledTimes(2)
+ expect(highlightMock).toHaveBeenCalledTimes(2)
+ expect(highlightMock.mock.calls[1]).toStrictEqual(['down'])
+ })
+
+ test('tab and escape key event on main element triggers close callback', () => {
+ render.main.main.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab' }))
+ expect(closeMock).toHaveBeenCalled()
+ render.main.main.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
+ expect(closeMock).toHaveBeenCalledTimes(2)
+ })
+
+ test('enter and space key event on main element triggers open callback', () => {
+ render.main.main.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }))
+ expect(openMock).toHaveBeenCalled()
+ render.main.main.dispatchEvent(new KeyboardEvent('keydown', { key: ' ' }))
+ expect(openMock).toHaveBeenCalledTimes(2)
+ })
+
+ test('click on main event does nothing if element is disabled', () => {
+ render.settings.disabled = true
+
+ render.main.main.dispatchEvent(new MouseEvent('click'))
+
+ expect(openMock).not.toHaveBeenCalled()
+ expect(closeMock).not.toHaveBeenCalled()
+ })
+
+ test('click on main event triggers open if element is closed', () => {
+ render.main.main.dispatchEvent(new MouseEvent('click'))
+
+ expect(openMock).toHaveBeenCalled()
+ expect(closeMock).not.toHaveBeenCalled()
+ })
+
+ test('click on main event triggers close if element is opened', () => {
+ render.settings.isOpen = true
+
+ render.main.main.dispatchEvent(new MouseEvent('click'))
+
+ expect(openMock).not.toHaveBeenCalled()
+ expect(closeMock).toHaveBeenCalled()
+ })
+
+ test('click on deselect does nothing if element is disabled', () => {
+ render.settings.disabled = true
+
+ const deselectElement: HTMLDivElement = render.main.main.querySelector(
+ '.' + render.classes.deselect,
+ ) as HTMLDivElement
+
+ deselectElement.dispatchEvent(new MouseEvent('click'))
+
+ expect(afterChangeMock).not.toHaveBeenCalled()
+ })
+
+ test('click on deselect on single select runs callbacks', () => {
+ const deselectElement: HTMLDivElement = render.main.main.querySelector(
+ '.' + render.classes.deselect,
+ ) as HTMLDivElement
+
+ deselectElement.dispatchEvent(new MouseEvent('click'))
+
+ expect(setSelectedMock).toHaveBeenCalled()
+ expect(closeMock).toHaveBeenCalled()
+ expect(afterChangeMock).toHaveBeenCalled()
+ })
+
+ test('click on deselect on multiple select runs callbacks', () => {
+ render.settings.isMultiple = true
+
+ const deselectAllMock = jest.fn()
+
+ render.updateDeselectAll = deselectAllMock
+
+ const deselectElement: HTMLDivElement = render.main.main.querySelector(
+ '.' + render.classes.deselect,
+ ) as HTMLDivElement
+ deselectElement.dispatchEvent(new MouseEvent('click'))
+
+ expect(deselectAllMock).toHaveBeenCalled()
+ expect(setSelectedMock).toHaveBeenCalled()
+ expect(closeMock).toHaveBeenCalled()
+ expect(afterChangeMock).toHaveBeenCalled()
+ })
+ })
+
+ describe('mainFocus', () => {
+ let focusMock: jest.Mock
+
+ beforeEach(() => {
+ focusMock = jest.fn(() => {})
+ render.main.main.focus = focusMock
+ })
+
+ test('mainFocus does nothing if the event is click', () => {
+ render.mainFocus('click')
+ expect(focusMock).not.toHaveBeenCalled()
+ })
+
+ test('mainFocus triggers focus if the event is not click', () => {
+ render.mainFocus('keydown')
+ expect(focusMock).toHaveBeenCalled()
+ render.mainFocus('keyup')
+ expect(focusMock).toHaveBeenCalledTimes(2)
+ render.mainFocus('mouse')
+ expect(focusMock).toHaveBeenCalledTimes(3)
+ })
+ })
+
+ describe('placeholder', () => {
+ test('placeholder uses fallback text if no option is found', () => {
+ render.settings.placeholderText = 'placeholder text'
+ const placeholder = render.placeholder()
+
+ expect(placeholder.innerHTML).toBe(render.settings.placeholderText)
+ })
+
+ test('placeholder uses option html if option is found', () => {
+ render.store.setData([
+ {
+ text: 'opt text',
+ html: 'Option HTML ',
+ placeholder: true,
+ },
+ ])
+ const placeholder = render.placeholder()
+
+ expect(placeholder.innerHTML).toBe('Option HTML ')
+ })
+
+ test('placeholder uses option text if option is found and no HTML is set', () => {
+ render.store.setData([
+ {
+ text: 'opt text',
+ placeholder: true,
+ },
+ ])
+ const placeholder = render.placeholder()
+
+ expect(placeholder.innerHTML).toBe('opt text')
+ })
+ })
+
+ describe('renderValues', () => {
+ test('single select renders only one value', () => {
+ render.renderValues()
+
+ expect(render.main.values.children).toHaveLength(1)
+ })
+
+ test('single select renders HTML option', () => {
+ render.store.setData([
+ {
+ text: 'opt0',
+ },
+ {
+ text: 'opt1',
+ html: 'opt1 ',
+ selected: true,
+ },
+ ])
+ render.renderValues()
+
+ expect(render.main.values.children).toHaveLength(1)
+ expect(render.main.values.children.item(0)?.innerHTML).toBe('opt1 ')
+ })
+
+ test('multiple select renders all selected values', () => {
+ render.settings.isMultiple = true
+ render.store = new Store('multiple', [
+ {
+ text: 'opt0',
+ value: 'opt0',
+ selected: true,
+ },
+ {
+ text: 'opt1',
+ value: 'opt1',
+ html: 'opt1 ',
+ selected: true,
+ },
+ {
+ text: 'opt2',
+ },
+ ])
+
+ render.renderValues()
+
+ expect(render.main.values.children).toHaveLength(2)
+ expect(render.main.values.children.item(0)).toBeInstanceOf(HTMLDivElement)
+ expect((render.main.values.children.item(0) as HTMLDivElement).textContent).toBe('opt0')
+ expect(render.main.values.children.item(1)).toBeInstanceOf(HTMLDivElement)
+ expect((render.main.values.children.item(1) as HTMLDivElement).textContent).toBe('opt1')
+ })
+
+ test('multiple select renders counter element when maxValuesShown is set', () => {
+ render.settings.isMultiple = true
+ render.settings.maxValuesShown = 2
+ render.store = new Store('multiple', [
+ {
+ text: 'opt0',
+ value: 'opt0',
+ selected: true,
+ },
+ {
+ text: 'opt1',
+ value: 'opt1',
+ html: 'opt1 ',
+ selected: true,
+ },
+ {
+ text: 'opt2',
+ value: 'opt2',
+ selected: true,
+ },
+ {
+ text: 'opt4',
+ },
+ ])
+
+ render.renderValues()
+
+ expect(render.main.values.children).toHaveLength(1)
+ expect(render.main.values.children.item(0)?.innerHTML).toBe('3 selected')
+ })
+
+ test('remove old options from values', () => {
+ render.renderValues()
+ expect(render.main.values.children).toHaveLength(1)
+ expect(render.main.values.innerText).toBeFalsy()
+
+ render.store.setSelectedBy('value', ['test1'])
+ render.renderValues()
+
+ expect(render.main.values.children).toHaveLength(1)
+ expect(render.main.values.children.item(0)?.innerHTML).toBe('test1 ')
+ })
+ })
+
+ describe('multipleValue', () => {})
+
+ describe('contentDiv', () => {})
+
+ describe('moveContent', () => {
+ let contentAboveMock: jest.Mock
+ let contentBelowMock: jest.Mock
+
+ beforeEach(() => {
+ contentAboveMock = jest.fn()
+ contentBelowMock = jest.fn()
+
+ render.moveContentAbove = contentAboveMock
+ render.moveContentBelow = contentBelowMock
+ })
+
+ test('content is moved below when position is relative', () => {
+ render.settings.contentPosition = 'relative'
+
+ render.moveContent()
+ expect(contentAboveMock).not.toHaveBeenCalled()
+ expect(contentBelowMock).toHaveBeenCalled()
+ })
+
+ test('content is moved below when open position is down', () => {
+ render.settings.openPosition = 'down'
+
+ render.moveContent()
+ expect(contentAboveMock).not.toHaveBeenCalled()
+ expect(contentBelowMock).toHaveBeenCalled()
+ })
+
+ test('content is moved above when open position is up', () => {
+ render.settings.openPosition = 'up'
+
+ render.moveContent()
+ expect(contentAboveMock).toHaveBeenCalled()
+ expect(contentBelowMock).not.toHaveBeenCalled()
+ })
+
+ test('content is moved above when putContent is up', () => {
+ render.putContent = jest.fn(() => 'up')
+
+ render.moveContent()
+ expect(contentAboveMock).toHaveBeenCalled()
+ expect(contentBelowMock).not.toHaveBeenCalled()
+ })
+
+ test('content is moved below when putContent is down', () => {
+ render.putContent = jest.fn(() => 'down')
+
+ render.moveContent()
+ expect(contentAboveMock).not.toHaveBeenCalled()
+ expect(contentBelowMock).toHaveBeenCalled()
+ })
+ })
+
+ describe('searchDiv', () => {
+ test('search is hidden when showSearch setting is false', () => {
+ render.settings.showSearch = false
+
+ const search = render.searchDiv()
+
+ expect(search.main.classList.contains(render.classes.hide)).toBe(true)
+ })
+
+ test('input is debounced', async () => {
+ const search = render.searchDiv()
+
+ search.input.dispatchEvent(new InputEvent('input', { data: 'asdf' }))
+
+ // wait for debounce to trigger
+ await new Promise((r) => setTimeout(r, 101))
+
+ expect(searchMock).toHaveBeenCalled()
+ })
+
+ test('arrow keys move highlight', () => {
+ const search = render.searchDiv()
+ const highlightMock = jest.fn(() => {})
+
+ render.highlight = highlightMock
+
+ search.input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp' }))
+ expect(highlightMock).toHaveBeenCalledTimes(1)
+ expect(highlightMock.mock.calls[0]).toStrictEqual(['up'])
- const render = new Render(settings, store, callbacks)
- expect(render).toBeInstanceOf(Render)
+ search.input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }))
+ expect(highlightMock).toHaveBeenCalledTimes(2)
+ expect(highlightMock.mock.calls[1]).toStrictEqual(['down'])
+ })
+
+ test('tab triggers close callback', () => {
+ const search = render.searchDiv()
+
+ search.input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab' }))
+
+ expect(closeMock).toHaveBeenCalled()
+ })
+
+ test('escape triggers close callback', () => {
+ // separate test in case we want to also test the event someday
+ const search = render.searchDiv()
+
+ search.input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
+
+ expect(closeMock).toHaveBeenCalled()
+ })
+
+ test("enter and space don't call addable witout ctrl key", () => {
+ const search = render.searchDiv()
+ const addableMock = jest.fn((s: string) => ({
+ text: s,
+ value: s.toLowerCase(),
+ }))
+
+ render.callbacks.addable = addableMock
+
+ search.input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }))
+ expect(addableMock).not.toHaveBeenCalled()
+
+ search.input.dispatchEvent(new KeyboardEvent('keydown', { key: ' ' }))
+ expect(addableMock).not.toHaveBeenCalled()
+ })
+
+ test('enter and space call event and does nothing without input value', () => {
+ const addableMock = jest.fn((s: string) => ({
+ text: s,
+ value: s.toLowerCase(),
+ }))
+
+ render.callbacks.addable = addableMock
+
+ // recreate search because we have added the addable callback
+ render.content.search = render.searchDiv()
+
+ render.content.search.input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', ctrlKey: true }))
+ expect(addableMock).not.toHaveBeenCalled()
+
+ render.content.search.input.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', ctrlKey: true }))
+ expect(addableMock).not.toHaveBeenCalled()
+ })
+
+ test('enter and space call addable when defined', () => {
+ const addableMock = jest.fn((s: string) => ({
+ text: s,
+ value: s.toLowerCase(),
+ }))
+
+ render.callbacks.addable = addableMock
+
+ // recreate search because we have added the addable callback
+ render.content.search = render.searchDiv()
+
+ render.content.search.input.value = 'Search'
+
+ render.content.search.input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', ctrlKey: true }))
+ expect(addableMock).toHaveBeenCalledTimes(1)
+ expect(addableMock.mock.calls[0]).toStrictEqual(['Search'])
+
+ render.content.search.input.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', ctrlKey: true }))
+ expect(addableMock).toHaveBeenCalledTimes(2)
+ expect(addableMock.mock.calls[1]).toStrictEqual(['Search'])
+ })
+ })
+
+ describe('searchFocus', () => {
+ test('search is focused', () => {
+ expect(document.activeElement).not.toBe(render.content.search.input)
+
+ render.searchFocus()
+ expect(document.activeElement).toBe(render.content.search.input)
+ })
+ })
+
+ describe('getOptions', () => {
+ test('returns all options when called without parameters', () => {
+ // render all 3 default options and get them back
+ render.renderOptions(render.store.getDataOptions())
+ const opts = render.getOptions()
+
+ expect(opts).toHaveLength(3)
+ })
+
+ test.skip('filters correctly when filtering out placeholders', () => {
+ // TODO placeholder options are created without placeholder class, which makes the getOptions fail
+ render.renderOptions(
+ render.store.partialToFullData([
+ {
+ text: 'opt0',
+ placeholder: true,
+ },
+ {
+ text: 'opt1',
+ },
+ {
+ text: 'opt2',
+ },
+ ]),
+ )
+
+ const opts = render.getOptions(true)
+ expect(opts).toHaveLength(2)
+ })
+
+ test('filters correctly when filtering out disabled options', () => {
+ render.renderOptions(
+ render.store.partialToFullData([
+ {
+ text: 'opt0',
+ disabled: true,
+ },
+ {
+ text: 'opt1',
+ },
+ {
+ text: 'opt2',
+ },
+ ]),
+ )
+
+ const opts = render.getOptions(false, true)
+ expect(opts).toHaveLength(2)
+ })
+
+ test('filters correctly when filtering out hidden options', () => {
+ render.renderOptions(
+ render.store.partialToFullData([
+ {
+ text: 'opt0',
+ placeholder: true,
+ },
+ {
+ text: 'opt1',
+ },
+ {
+ text: 'opt2',
+ },
+ ]),
+ )
+
+ const opts = render.getOptions(false, false, true)
+ expect(opts).toHaveLength(2)
+ })
+
+ test('filters correctly when filtering out hidden and disabled options', () => {
+ render.renderOptions(
+ render.store.partialToFullData([
+ {
+ text: 'opt0',
+ disabled: true,
+ },
+ {
+ text: 'opt1',
+ placeholder: true,
+ },
+ {
+ text: 'opt2',
+ },
+ ]),
+ )
+
+ const opts = render.getOptions(false, true, true)
+ expect(opts).toHaveLength(1)
+ })
+ })
+
+ describe('highlight', () => {
+ test('simply do nothing without breaking when options are empty', () => {
+ render.renderOptions([])
+
+ expect(() => render.highlight('up')).not.toThrowError()
+ })
+
+ test('highlight single option that is not already highlighted', () => {
+ render.renderOptions(
+ render.store.partialToFullData([
+ {
+ text: 'opt0',
+ },
+ ]),
+ )
+
+ render.highlight('up')
+
+ expect(render.getOptions()[0].classList.contains(render.classes.highlighted)).toBe(true)
+ })
+
+ test('select first option with down when no option is highlighted or selected', () => {
+ render.renderOptions(
+ render.store.partialToFullData([
+ {
+ text: 'opt0',
+ },
+ {
+ text: 'opt1',
+ },
+ {
+ text: 'opt2',
+ },
+ ]),
+ )
+
+ render.highlight('down')
+
+ expect(render.getOptions()[0].classList.contains(render.classes.highlighted)).toBe(true)
+ })
+
+ test('select last option with up when no option is highlighted or selected', () => {
+ render.renderOptions(
+ render.store.partialToFullData([
+ {
+ text: 'opt0',
+ },
+ {
+ text: 'opt1',
+ },
+ {
+ text: 'opt2',
+ },
+ ]),
+ )
+
+ render.highlight('up')
+
+ expect(render.getOptions()[2].classList.contains(render.classes.highlighted)).toBe(true)
+ })
+
+ test('highlight next option on down after highlighted option', () => {
+ render.renderOptions(
+ render.store.partialToFullData([
+ {
+ text: 'opt0',
+ class: render.classes.highlighted,
+ },
+ {
+ text: 'opt1',
+ },
+ {
+ text: 'opt2',
+ },
+ ]),
+ )
+
+ render.highlight('down')
+
+ expect(render.getOptions()[1].classList.contains(render.classes.highlighted)).toBe(true)
+ })
+
+ test('highlight previous option on up before highlighted option', () => {
+ render.renderOptions(
+ render.store.partialToFullData([
+ {
+ text: 'opt0',
+ },
+ {
+ text: 'opt1',
+ },
+ {
+ text: 'opt2',
+ class: render.classes.highlighted,
+ },
+ ]),
+ )
+
+ render.highlight('up')
+
+ expect(render.getOptions()[1].classList.contains(render.classes.highlighted)).toBe(true)
+ })
+
+ test('highlight next option on down after selected option when no options is highlighted', () => {
+ render.renderOptions(
+ render.store.partialToFullData([
+ {
+ text: 'opt0',
+ selected: true,
+ },
+ {
+ text: 'opt1',
+ },
+ {
+ text: 'opt2',
+ },
+ ]),
+ )
+
+ render.highlight('down')
+
+ expect(render.getOptions()[1].classList.contains(render.classes.highlighted)).toBe(true)
+ })
+
+ test('skip to last option when using up at the first option', () => {
+ render.renderOptions(
+ render.store.partialToFullData([
+ {
+ text: 'opt0',
+ selected: true,
+ },
+ {
+ text: 'opt1',
+ },
+ {
+ text: 'opt2',
+ },
+ ]),
+ )
+
+ render.highlight('up')
+
+ expect(render.getOptions()[2].classList.contains(render.classes.highlighted)).toBe(true)
+ })
+
+ test('highlight next option within opt group on down', () => {
+ render.renderOptions(
+ render.store.partialToFullData([
+ {
+ text: 'opt0',
+ selected: true,
+ },
+ {
+ label: 'opt group',
+ options: [
+ {
+ text: 'opt1',
+ },
+ ],
+ },
+ {
+ text: 'opt2',
+ },
+ ]),
+ )
+
+ render.highlight('down')
+
+ expect(render.getOptions()[1].classList.contains(render.classes.highlighted)).toBe(true)
+ })
+
+ test('highlight previous option within opt group on up', () => {
+ render.renderOptions(
+ render.store.partialToFullData([
+ {
+ text: 'opt0',
+ },
+ {
+ label: 'opt group',
+ options: [
+ {
+ text: 'opt1',
+ selected: true,
+ },
+ ],
+ },
+ {
+ text: 'opt2',
+ },
+ ]),
+ )
+
+ render.highlight('up')
+
+ expect(render.getOptions()[0].classList.contains(render.classes.highlighted)).toBe(true)
+ })
+ })
+
+ describe('listDiv', () => {
+ test('list div has correct class', () => {
+ const list = render.listDiv()
+
+ expect(list.classList.contains(render.classes.list)).toBe(true)
+ })
+ })
+
+ describe('renderError', () => {
+ test('error message is rendered correctly', () => {
+ expect(render.content.list.children).toHaveLength(0)
+
+ render.renderError('test error')
+
+ expect(render.content.list.children).toHaveLength(1)
+ expect(render.content.list.children.item(0)).toBeInstanceOf(HTMLDivElement)
+ expect(render.content.list.children.item(0)?.className).toBe(render.classes.error)
+ expect(render.content.list.children.item(0)?.textContent).toBe('test error')
+ })
+
+ test('list is reset on new error', () => {
+ expect(render.content.list.children).toHaveLength(0)
+
+ render.renderError('test error')
+ expect(render.content.list.children).toHaveLength(1)
+
+ render.renderError('error 2')
+ expect(render.content.list.children).toHaveLength(1)
+ expect(render.content.list.children.item(0)?.textContent).toBe('error 2')
+ })
+ })
+
+ describe('renderSearching', () => {
+ test('search text is rendered correctly', () => {
+ expect(render.content.list.children).toHaveLength(0)
+
+ render.settings.searchingText = 'search'
+ render.renderSearching()
+
+ expect(render.content.list.children).toHaveLength(1)
+ expect(render.content.list.children.item(0)).toBeInstanceOf(HTMLDivElement)
+ expect(render.content.list.children.item(0)?.className).toBe(render.classes.searching)
+ expect(render.content.list.children.item(0)?.textContent).toBe('search')
+ })
+
+ test('list is reset on new search text', () => {
+ expect(render.content.list.children).toHaveLength(0)
+
+ render.settings.searchingText = 'search'
+ render.renderSearching()
+ expect(render.content.list.children).toHaveLength(1)
+
+ render.settings.searchingText = 'search 2'
+ render.renderSearching()
+ expect(render.content.list.children).toHaveLength(1)
+ expect(render.content.list.children.item(0)?.textContent).toBe('search 2')
+ })
})
+
+ describe('renderOptions', () => {})
+
+ describe('option', () => {
+ test('add inline styles correctly', () => {
+ const option = render.option(
+ new Option({
+ text: 'opt',
+ style: 'color: red',
+ }),
+ )
+
+ expect(option.style.color).toBe('red')
+ })
+
+ test('add hidden class on option with display false', () => {
+ const option = render.option(
+ new Option({
+ text: 'opt',
+ display: false,
+ }),
+ )
+
+ expect(option.classList.contains(render.classes.hide)).toBe(true)
+ })
+
+ test('add hidden class on selected option when hideSelected setting is true', () => {
+ render.settings.hideSelected = true
+
+ const option = render.option(
+ new Option({
+ text: 'opt',
+ selected: true,
+ }),
+ )
+
+ expect(option.classList.contains(render.classes.hide)).toBe(true)
+ })
+
+ test('title attribute is set when showOptionTooltips setting is true', () => {
+ render.settings.showOptionTooltips = true
+
+ const option = render.option(
+ new Option({
+ text: 'opt',
+ }),
+ )
+
+ expect(option.getAttribute('title')).toBe('opt')
+ })
+
+ test('text is highlighted correctly with option text', () => {
+ render.settings.searchHighlight = true
+ render.content.search.input.value = 'opt'
+
+ const option = render.option(
+ new Option({
+ text: 'opt 1',
+ }),
+ )
+
+ expect(option.querySelector('mark')).toBeTruthy()
+ expect(option.querySelector('mark')?.textContent).toBe('opt')
+ })
+
+ test('text is highlighted correctly with option HTML', () => {
+ render.settings.searchHighlight = true
+ render.content.search.input.value = 'opt'
+
+ const option = render.option(
+ new Option({
+ text: 'opt 1',
+ html: 'opt 1 ',
+ }),
+ )
+
+ expect(option.querySelector('mark')).toBeTruthy()
+ expect(option.querySelector('mark')?.textContent).toBe('opt')
+ })
+
+ test('click does nothing when option is disabled', () => {
+ const option = render.option(
+ new Option({
+ text: 'opt 1',
+ disabled: true,
+ }),
+ )
+ option.dispatchEvent(new MouseEvent('click'))
+
+ expect(addOptionMock).not.toHaveBeenCalled()
+ expect(setSelectedMock).not.toHaveBeenCalled()
+ })
+
+ test('click does nothing when max count of selected options is reached', () => {
+ render.settings.isMultiple = true
+ render.settings.maxSelected = 1
+
+ const option = render.option(
+ new Option({
+ text: 'opt 1',
+ }),
+ )
+ option.dispatchEvent(new MouseEvent('click'))
+
+ expect(addOptionMock).not.toHaveBeenCalled()
+ expect(setSelectedMock).not.toHaveBeenCalled()
+ })
+
+ test('click does nothing when min count of selected options is reached', () => {
+ render.settings.isMultiple = true
+ render.settings.allowDeselect = true
+ render.settings.minSelected = 1
+
+ const option = render.option(
+ new Option({
+ text: 'opt 1',
+ selected: true,
+ }),
+ )
+ option.dispatchEvent(new MouseEvent('click'))
+
+ expect(addOptionMock).not.toHaveBeenCalled()
+ expect(setSelectedMock).not.toHaveBeenCalled()
+ })
+
+ test('click removes option', () => {
+ const option = render.option(
+ new Option({
+ text: 'new opt 1',
+ }),
+ )
+ option.dispatchEvent(new MouseEvent('click'))
+
+ expect(addOptionMock).toHaveBeenCalled()
+ // check that we add the right option
+ expect(addOptionMock.mock.calls[0][0].text).toBe('new opt 1')
+ expect(setSelectedMock).toHaveBeenCalled()
+ // check that we select the new option
+ expect(setSelectedMock.mock.calls[0][0]).toStrictEqual(['new opt 1'])
+ })
+ })
+
+ describe('destroy', () => {
+ test('elements get removed', () => {
+ expect(render.main.main).toBeInstanceOf(HTMLDivElement)
+ expect(render.content.main).toBeInstanceOf(HTMLDivElement)
+
+ // set some simple IDs, so we can search for the elements in the DOM
+ render.main.main.id = 'main-id'
+ render.content.main.id = 'content-id'
+
+ render.destroy()
+
+ expect(document.getElementById('main-id')).toBeNull()
+ expect(document.getElementById('#content-id')).toBeNull()
+ })
+ })
+
+ describe('moveContentAbove', () => {
+ test('correct classes are set', () => {
+ render.moveContentAbove()
+
+ expect(render.main.main.classList.contains(render.classes.openAbove)).toBe(true)
+ expect(render.main.main.classList.contains(render.classes.openBelow)).toBe(false)
+
+ expect(render.content.main.classList.contains(render.classes.openAbove)).toBe(true)
+ expect(render.content.main.classList.contains(render.classes.openBelow)).toBe(false)
+ })
+ })
+
+ describe('moveContentBelow', () => {
+ test('correct classes are set', () => {
+ render.moveContentBelow()
+
+ expect(render.main.main.classList.contains(render.classes.openAbove)).toBe(false)
+ expect(render.main.main.classList.contains(render.classes.openBelow)).toBe(true)
+
+ expect(render.content.main.classList.contains(render.classes.openAbove)).toBe(false)
+ expect(render.content.main.classList.contains(render.classes.openBelow)).toBe(true)
+ })
+ })
+
+ describe('ensureElementInView', () => {})
+
+ describe('putContent', () => {})
+
+ describe('updateDeselectAll', () => {})
})
diff --git a/src/slim-select/render.ts b/src/slim-select/render.ts
index 005a2525..ea36d09d 100644
--- a/src/slim-select/render.ts
+++ b/src/slim-select/render.ts
@@ -1,6 +1,7 @@
import { debounce } from './helpers'
import Settings from './settings'
import Store, { DataArray, Optgroup, Option, OptionOptional } from './store'
+import CssClasses from './css_classes'
export interface Callbacks {
open: () => void
@@ -53,73 +54,12 @@ export default class Render {
public content: Content
// Classes
- public classes = {
- // Main
- main: 'ss-main',
-
- // Placeholder
- placeholder: 'ss-placeholder',
-
- // Values
- values: 'ss-values',
- single: 'ss-single',
- max: 'ss-max',
- value: 'ss-value',
- valueText: 'ss-value-text',
- valueDelete: 'ss-value-delete',
- valueOut: 'ss-value-out',
-
- // Deselect
- deselect: 'ss-deselect',
- deselectPath: 'M10,10 L90,90 M10,90 L90,10', // Not a class but whatever
-
- // Arrow
- arrow: 'ss-arrow',
- arrowClose: 'M10,30 L50,70 L90,30', // Not a class but whatever
- arrowOpen: 'M10,70 L50,30 L90,70', // Not a class but whatever
-
- // Content
- content: 'ss-content',
- openAbove: 'ss-open-above',
- openBelow: 'ss-open-below',
-
- // Search
- search: 'ss-search',
- searchHighlighter: 'ss-search-highlight',
- searching: 'ss-searching',
- addable: 'ss-addable',
- addablePath: 'M50,10 L50,90 M10,50 L90,50', // Not a class but whatever
-
- // List optgroups/options
- list: 'ss-list',
-
- // Optgroup
- optgroup: 'ss-optgroup',
- optgroupLabel: 'ss-optgroup-label',
- optgroupLabelText: 'ss-optgroup-label-text',
- optgroupActions: 'ss-optgroup-actions',
- optgroupSelectAll: 'ss-selectall', // optgroup select all
- optgroupSelectAllBox: 'M60,10 L10,10 L10,90 L90,90 L90,50', // Not a class but whatever
- optgroupSelectAllCheck: 'M30,45 L50,70 L90,10', // Not a class but whatever
- optgroupClosable: 'ss-closable',
-
- // Option
- option: 'ss-option',
- optionDelete: 'M10,10 L90,90 M10,90 L90,10', // Not a class but whatever
- highlighted: 'ss-highlighted',
-
- // Misc
- open: 'ss-open',
- close: 'ss-close',
- selected: 'ss-selected',
- error: 'ss-error',
- disabled: 'ss-disabled',
- hide: 'ss-hide',
- }
+ public classes: CssClasses
- constructor(settings: Required, store: Store, callbacks: Callbacks) {
+ constructor(settings: Required, classes: Required, store: Store, callbacks: Callbacks) {
this.store = store
this.settings = settings
+ this.classes = classes
this.callbacks = callbacks
this.main = this.mainDiv()
@@ -265,7 +205,7 @@ export default class Render {
return false
}
- return false
+ return true
}
// Add onclick for main div
@@ -555,7 +495,7 @@ export default class Render {
const text = document.createElement('div')
text.classList.add(this.classes.valueText)
- text.innerText = option.text // For multiple values always use text
+ text.textContent = option.text // For multiple values always use text
value.appendChild(text)
// Only add deletion if the option is not mandatory
@@ -1345,7 +1285,7 @@ export default class Render {
private highlightText(str: string, search: any, className: string) {
// the completed string will be itself if already set, otherwise, the string that was passed in
let completedString: any = str
- const regex = new RegExp('(' + search.trim() + ')(?![^<]*>[^<>]*)', 'i')
+ const regex = new RegExp('(?![^<]*>)(' + search.trim() + ')(?![^<]*>[^<>]*)', 'i')
// If the regex doesn't match the string just exit
if (!str.match(regex)) {
diff --git a/src/slim-select/select.test.ts b/src/slim-select/select.test.ts
index a741858b..47435c31 100644
--- a/src/slim-select/select.test.ts
+++ b/src/slim-select/select.test.ts
@@ -9,33 +9,101 @@ import Select from './select'
import Store, { Optgroup, Option } from './store'
describe('select module', () => {
- test('constructor', () => {
- document.body.innerHTML = ' '
+ let select: Select
- let selectElement = document.getElementById('test') as HTMLSelectElement
- let select = new Select(selectElement)
- expect(select).toBeInstanceOf(Select)
+ beforeEach(() => {
+ document.body.innerHTML = `
+
+ One
+ Two
+
+
+ Three
+ Four
+ Five
+
+ Six
+ `
+
+ const selectElement = document.getElementById('test') as HTMLSelectElement
+ select = new Select(selectElement)
})
- test('get data from select options', () => {
- document.body.innerHTML = `
- One
- Two
- `
+ describe('constructor', () => {
+ test('constructor works', () => {
+ document.body.innerHTML = ' '
- let selectElement = document.getElementById('test') as HTMLSelectElement
- let select = new Select(selectElement)
- let data = select.getData() as Option[]
- expect(data.length).toBe(2)
- expect(data[0].id).toBe('value1')
- expect(data[0].value).toBe('1')
- expect(data[0].text).toBe('One')
- expect(data[1].value).toBe('2')
- expect(data[1].text).toBe('Two')
+ const selectElement = document.getElementById('test') as HTMLSelectElement
+ const select = new Select(selectElement)
+
+ expect(select).toBeInstanceOf(Select)
+ expect(select.listen).toBe(true)
+ expect((select).observer).toBeInstanceOf(MutationObserver)
+ })
+ })
+
+ describe('enable', () => {
+ test('enable enables select element', () => {
+ select.select.disabled = true
+ select.enable()
+
+ expect(select.select.disabled).toBe(false)
+ })
+ })
+
+ describe('disable', () => {
+ test('disable disables select element', () => {
+ select.disable()
+
+ expect(select.select.disabled).toBe(true)
+ })
+ })
+
+ describe('hideUI', () => {
+ test('correct HTML attributes get set', () => {
+ select.hideUI()
+
+ expect(select.select.tabIndex).toBe(-1)
+ expect(select.select.style.display).toBe('none')
+ expect(select.select.getAttribute('aria-hidden')).toBe('true')
+ })
+ })
+
+ describe('showUI', () => {
+ test('HTML attributes get reset', () => {
+ select.select.tabIndex = -1
+ select.select.style.display = 'none'
+ select.select.setAttribute('aria-hidden', 'true')
+
+ select.showUI()
+
+ expect(select.select.tabIndex).toBeFalsy()
+ expect(select.select.style.display).toBeFalsy()
+ expect(select.select.getAttribute('aria-hidden')).toBeNull()
+ })
})
- test('get data from select optgroups', () => {
- document.body.innerHTML = `
+ describe('getData', () => {
+ test('get data from select options', () => {
+ document.body.innerHTML = `
+ One
+ Two
+ `
+
+ const selectElement = document.getElementById('test') as HTMLSelectElement
+ const select = new Select(selectElement)
+ const data = select.getData() as Option[]
+
+ expect(data).toHaveLength(2)
+ expect(data[0].id).toBe('value1')
+ expect(data[0].value).toBe('1')
+ expect(data[0].text).toBe('One')
+ expect(data[1].value).toBe('2')
+ expect(data[1].text).toBe('Two')
+ })
+
+ test('get data from select optgroups', () => {
+ document.body.innerHTML = `
One
Two
@@ -45,26 +113,83 @@ describe('select module', () => {
Four
Five
- `
+ `
+
+ const selectElement = document.getElementById('test') as HTMLSelectElement
+ const select = new Select(selectElement)
+ const data = select.getData() as Optgroup[]
+
+ expect(data).toHaveLength(2)
+
+ expect(data[0].label).toBe('test1')
+ expect(data[0].options).toHaveLength(2)
+ expect(data[0].options[0].value).toBe('1')
+ expect(data[0].options[0].text).toBe('One')
+ expect(data[0].options[1].value).toBe('2')
+ expect(data[0].options[1].text).toBe('Two')
+
+ expect(data[1].label).toBe('test2')
+ expect(data[1].options).toHaveLength(3)
+ expect(data[1].options[0].value).toBe('3')
+ expect(data[1].options[0].text).toBe('Three')
+ expect(data[1].options[1].value).toBe('4')
+ expect(data[1].options[1].text).toBe('Four')
+ expect(data[1].options[2].value).toBe('5')
+ expect(data[1].options[2].text).toBe('Five')
+ })
+ })
- let selectElement = document.getElementById('test') as HTMLSelectElement
- let select = new Select(selectElement)
- let data = select.getData() as Optgroup[]
- expect(data.length).toBe(2)
- expect(data[0].label).toBe('test1')
- expect(data[0].options.length).toBe(2)
- expect(data[0].options[0].value).toBe('1')
- expect(data[0].options[0].text).toBe('One')
- expect(data[0].options[1].value).toBe('2')
- expect(data[0].options[1].text).toBe('Two')
- expect(data[1].label).toBe('test2')
- expect(data[1].options.length).toBe(3)
- expect(data[1].options[0].value).toBe('3')
- expect(data[1].options[0].text).toBe('Three')
- expect(data[1].options[1].value).toBe('4')
- expect(data[1].options[1].text).toBe('Four')
- expect(data[1].options[2].value).toBe('5')
- expect(data[1].options[2].text).toBe('Five')
+ describe('setSelected', () => {
+ test('single value get selected correctly', () => {
+ select.setSelected(['6'])
+
+ expect(select.select.querySelector('option[value="6"]')?.selected).toBe(true)
+ })
+
+ test('opt group value gets selected correctly', () => {
+ select.setSelected(['4'])
+
+ expect(select.select.querySelector('option[value="4"]')?.selected).toBe(true)
+ })
+
+ test('mix of options get selected correctly', () => {
+ select.setSelected(['2', '3', '6'])
+
+ expect(select.select.querySelector('option[value="2"]')?.selected).toBe(true)
+ expect(select.select.querySelector('option[value="3"]')?.selected).toBe(true)
+ expect(select.select.querySelector('option[value="6"]')?.selected).toBe(true)
+ })
+ })
+
+ describe('updateSelected', () => {
+ test('id gets updated correctly', () => {
+ select.select.dataset.id = 'old_id'
+
+ select.updateSelect('new_id')
+
+ expect(select.select.dataset.id).toBe('new_id')
+ })
+
+ test('new styles are set correctly', () => {
+ select.updateSelect(undefined, 'color: red')
+
+ expect(select.select.style.color).toBe('red')
+ })
+
+ test("setting styles doesn't override id", () => {
+ select.select.dataset.id = 'set_id'
+
+ select.updateSelect(undefined, 'color: red')
+
+ expect(select.select.dataset.id).toBe('set_id')
+ })
+
+ test('classes are set correctly', () => {
+ select.updateSelect(undefined, undefined, ['class0', 'class1'])
+
+ expect(select.select.classList.contains('class0')).toBe(true)
+ expect(select.select.classList.contains('class1')).toBe(true)
+ })
})
test('update select from data', () => {
@@ -94,96 +219,92 @@ describe('select module', () => {
)
})
- test('event change value listener', async () => {
- document.body.innerHTML = `
+ describe('onValueChange', () => {
+ let select: Select
+ let selectElement: HTMLSelectElement
+
+ beforeEach(() => {
+ document.body.innerHTML = `
One
Two
Three
- `
-
- let selectElement = document.getElementById('test') as HTMLSelectElement
- let select = new Select(selectElement)
+ `
- let data = select.getData() as Option[]
+ selectElement = document.getElementById('test') as HTMLSelectElement
+ select = new Select(selectElement)
+ })
- expect(data[0].selected).toBe(true)
+ test('listener is triggered when the select value changes', async () => {
+ // make sure the first element is selected as standard
+ let data = select.getData() as Option[]
+ expect(data[0].selected).toBe(true)
- const callback = jest.fn()
- select.onValueChange = callback
+ const onValueMock = jest.fn()
+ select.onValueChange = onValueMock
- // Change the value
- selectElement.value = '2'
- selectElement.dispatchEvent(new Event('change'))
+ // Change the value
+ selectElement.value = '2'
+ selectElement.dispatchEvent(new Event('change'))
- // TODO: figure out why this is not working
- // expect(callback).toHaveBeenCalled()
+ await new Promise((r) => setTimeout(r, 50))
+ expect(onValueMock).toHaveBeenCalled()
- // Get selected data
- const selected = select.getSelectedValues()
- expect(selected[0]).toBe('2')
+ // Get selected data
+ const selected = select.getSelectedValues()
+ expect(selected[0]).toBe('2')
- // Change the full data
- data = select.getData() as Option[]
- expect(data[1].selected).toBe(true)
- })
+ // Check all data
+ data = select.getData() as Option[]
+ expect(data[0].selected).toBe(false)
+ expect(data[1].selected).toBe(true)
+ })
- test('event change value listener when options are replaced', () => {
- document.body.innerHTML = `
- One
- Two
- Three
- `
+ test('listener is triggered when inner HTML is replaced with new options', async () => {
+ const onValueMock = jest.fn()
+ select.onValueChange = onValueMock
- let selectElement = document.getElementById('test') as HTMLSelectElement
- let select = new Select(selectElement)
-
- let data = select.getData() as Option[]
-
- expect(data[0].selected).toBe(true)
-
- const callback = jest.fn()
- select.onValueChange = callback
-
- selectElement.innerHTML = `Four
+ selectElement.innerHTML = `Four
Five
Six `
- // TODO: figure out why this is not working
- // expect(callback).toHaveBeenCalled()
+ await new Promise((r) => setTimeout(r, 50))
+ expect(onValueMock).toHaveBeenCalled()
- // Give the mutation observer time to run
- data = select.getData() as Option[]
- expect(data[1].value).toBe('5')
- expect(data[1].selected).toBe(true)
+ // Give the mutation observer time to run
+ const data = select.getData() as Option[]
+ expect(data[1].value).toBe('5')
+ expect(data[1].selected).toBe(true)
+ })
})
- test('mutation observer listener for select option changes', () => {
- document.body.innerHTML = `
+ describe('onOptionsChange', () => {
+ test('listener triggers when select options change without changing the select value', async () => {
+ document.body.innerHTML = `
One
Two
Three
- `
+ `
- let selectElement = document.getElementById('test') as HTMLSelectElement
- let select = new Select(selectElement)
+ const selectElement = document.getElementById('test') as HTMLSelectElement
+ const select = new Select(selectElement)
- let data = select.getData() as Option[]
- expect(data.length).toBe(3)
+ let data = select.getData() as Option[]
+ expect(data).toHaveLength(3)
- const callback = jest.fn()
- select.onOptionsChange = callback
+ const onOptionsMock = jest.fn()
+ select.onOptionsChange = onOptionsMock
- selectElement.innerHTML = 'One Two '
+ selectElement.innerHTML = 'One Two '
- // TODO: figure out why this is not working
- // expect(callback).toHaveBeenCalled()
+ await new Promise((r) => setTimeout(r, 50))
+ expect(onOptionsMock).toHaveBeenCalled()
- data = select.getData() as Option[]
- expect(data.length).toBe(2)
- })
+ data = select.getData() as Option[]
+ expect(data).toHaveLength(2)
+ })
- test('mutation observer listener for select optgroup option changes', () => {
- document.body.innerHTML = `
+ test('listener triggers when optgroup options change', async () => {
+ document.body.innerHTML = `
One
Two
@@ -193,58 +314,59 @@ describe('select module', () => {
Four
Five
- `
+ `
- let selectElement = document.getElementById('test') as HTMLSelectElement
- let select = new Select(selectElement)
-
- let dataOptgroup = select.getData() as Optgroup[]
+ const selectElement = document.getElementById('test') as HTMLSelectElement
+ const select = new Select(selectElement)
- expect(dataOptgroup.length).toBe(2)
+ const optGroups = select.getData() as Optgroup[]
+ expect(optGroups).toHaveLength(2)
- const callback = jest.fn()
- select.onOptionsChange = callback
+ const onOptionsMock = jest.fn()
+ select.onOptionsChange = onOptionsMock
- let selectOptgroup = document.getElementById('test_optgroup') as HTMLOptGroupElement
- selectOptgroup.innerHTML = 'Eight Nine '
+ const selectOptgroup = document.getElementById('test_optgroup') as HTMLOptGroupElement
+ selectOptgroup.innerHTML = 'Eight Nine '
- // TODO: figure out why this is not working
- // expect(callback).toHaveBeenCalled()
+ await new Promise((r) => setTimeout(r, 50))
+ expect(onOptionsMock).toHaveBeenCalled()
- let dataOptgroups = select.getData() as Option[]
- expect(dataOptgroups.length).toBe(2)
+ const options = select.getData() as Option[]
+ expect(options).toHaveLength(2)
- // get selected data
- let selected = select.getSelectedValues()
- expect(selected.length).toBe(1)
- })
+ // get selected data
+ const selected = select.getSelectedValues()
+ expect(selected).toHaveLength(1)
+ })
- test('mutation observer listener for select option text changes', () => {
- document.body.innerHTML = `
+ test('listener triggers when select option text changes', async () => {
+ document.body.innerHTML = `
One
Two
Three
- `
+ `
- let selectElement = document.getElementById('test') as HTMLSelectElement
- let select = new Select(selectElement)
+ const selectElement = document.getElementById('test') as HTMLSelectElement
+ const select = new Select(selectElement)
- let data = select.getData() as Option[]
+ let data = select.getData() as Option[]
+ expect(data[0].text).toBe('One')
- expect(data[0].text).toBe('One')
+ const onOptionsMock = jest.fn()
+ select.onOptionsChange = onOptionsMock
- const callback = jest.fn()
- select.onOptionsChange = callback
+ let option = selectElement.options[0]
+ option.text = 'New One'
- let option = selectElement.options[0]
- option.text = 'New One'
+ await new Promise((r) => setTimeout(r, 50))
+ expect(onOptionsMock).toHaveBeenCalled()
- data = select.getData() as Option[]
- expect(data[0].text).toBe('New One')
- })
+ data = select.getData() as Option[]
+ expect(data[0].text).toBe('New One')
+ })
- test('mutation observer listener for select optgroup option text changes', () => {
- document.body.innerHTML = `
+ test('listener triggers when select optgroup option text changes', async () => {
+ document.body.innerHTML = `
One
Two
@@ -254,22 +376,26 @@ describe('select module', () => {
Four
Five
- `
+ `
- let selectElement = document.getElementById('test') as HTMLSelectElement
- let select = new Select(selectElement)
+ const selectElement = document.getElementById('test') as HTMLSelectElement
+ const select = new Select(selectElement)
+
+ let dataOptgroup = select.getData() as Optgroup[]
- let dataOptgroup = select.getData() as Optgroup[]
+ expect(dataOptgroup[0].options[0].text).toBe('One')
- expect(dataOptgroup[0].options[0].text).toBe('One')
+ const onOptionsMock = jest.fn()
+ select.onOptionsChange = onOptionsMock
- const callback = jest.fn()
- select.onOptionsChange = callback
+ let selectOption = document.getElementById('test_option') as HTMLOptionElement
+ selectOption.text = 'New One'
- let selectOption = document.getElementById('test_option') as HTMLOptionElement
- selectOption.text = 'New One'
+ await new Promise((r) => setTimeout(r, 50))
+ expect(onOptionsMock).toHaveBeenCalled()
- let dataOption = select.getData() as Optgroup[]
- expect(dataOption[0].options[0].text).toBe('New One')
+ let dataOption = select.getData() as Optgroup[]
+ expect(dataOption[0].options[0].text).toBe('New One')
+ })
})
})
diff --git a/src/slim-select/select.ts b/src/slim-select/select.ts
index 647a201c..bb94c3bc 100644
--- a/src/slim-select/select.ts
+++ b/src/slim-select/select.ts
@@ -85,7 +85,7 @@ export default class Select {
}
private observeCall(mutations: MutationRecord[]): void {
- // If we are not listeing do nothing
+ // If we are not listening, do nothing.
if (!this.listen) {
return
}
@@ -107,6 +107,19 @@ export default class Select {
if (m.attributeName === 'class') {
classChanged = true
}
+
+ if (m.type === 'childList') {
+ for (const n of m.addedNodes) {
+ if (n.nodeName === 'OPTION' && (n).value === this.select.value) {
+ // we added a new option that's now the select value
+ this.select.dispatchEvent(new Event('change'))
+ break
+ }
+ }
+
+ // options changed, so we need the optionsChange event to fire
+ optgroupOptionChanged = true
+ }
}
// Check if its an optgroup or option
@@ -184,7 +197,7 @@ export default class Select {
text: option.text,
html: option.dataset && option.dataset.html ? option.dataset.html : '',
selected: option.selected,
- display: option.style.display === 'none' ? false : true,
+ display: option.style.display !== 'none',
disabled: option.disabled,
mandatory: option.dataset ? option.dataset.mandatory === 'true' : false,
placeholder: option.dataset.placeholder === 'true',
@@ -301,7 +314,7 @@ export default class Select {
}
// Trigger change event on original select
- this.select.dispatchEvent(new Event('change'))
+ this.select.dispatchEvent(new Event('change', { bubbles: true }))
// Start listening to changes
this.changeListen(true)
@@ -339,7 +352,7 @@ export default class Select {
if (info.disabled) {
optionEl.disabled = true
}
- if (info.display === false) {
+ if (!info.display) {
optionEl.style.display = 'none'
}
if (info.placeholder) {
diff --git a/src/slim-select/settings.test.ts b/src/slim-select/settings.test.ts
new file mode 100644
index 00000000..02dfcb01
--- /dev/null
+++ b/src/slim-select/settings.test.ts
@@ -0,0 +1,256 @@
+'use strict'
+import { describe, expect, test } from '@jest/globals'
+import Settings from './settings'
+import Select from './select'
+import { Option, Optgroup } from './store'
+
+const defaultSettings: { [key: string]: any } = {
+ id: 'ss-qucyuytu',
+ style: '',
+ class: [],
+ isMultiple: false,
+ isOpen: false,
+ isFullOpen: false,
+ intervalMove: null,
+ disabled: false,
+ alwaysOpen: false,
+ showSearch: true,
+ focusSearch: true,
+ ariaLabel: 'Combobox',
+ searchPlaceholder: 'Search',
+ searchText: 'No Results',
+ searchingText: 'Searching...',
+ searchHighlight: false,
+ closeOnSelect: true,
+ contentLocation: HTMLBodyElement,
+ contentPosition: 'absolute',
+ openPosition: 'auto',
+ placeholderText: 'Select Value',
+ allowDeselect: false,
+ hideSelected: false,
+ keepOrder: false,
+ showOptionTooltips: false,
+ minSelected: 0,
+ maxSelected: 1000,
+ timeoutDelay: 200,
+ maxValuesShown: 20,
+ maxValuesMessage: '{number} selected',
+}
+
+describe('Settings module', () => {
+ let select: Select
+
+ beforeEach(() => {
+ const selectElement = document.createElement('select')
+ select = new Select(selectElement)
+ })
+
+ test('empty constructor returns default settings', () => {
+ // Convert to unknown and then to custom object to prevent TS from throwing errors
+ const settings = new Settings() as unknown as { [key: string]: string }
+ Object.keys(defaultSettings).forEach((key) => {
+ if (key === 'id') {
+ expect(settings[key].substring(0, 3)).toBe('ss-')
+ } else if (key === 'contentLocation') {
+ expect(settings[key]).toBeInstanceOf(defaultSettings[key])
+ } else {
+ expect(settings[key]).toStrictEqual(defaultSettings[key])
+ }
+ })
+ })
+
+ test('settings can be overwritten via the constructor', () => {
+ const customSettings = {
+ disabled: true,
+ alwaysOpen: true,
+ showSearch: false,
+ focusSearch: true,
+ searchHighlight: true,
+ closeOnSelect: false,
+ placeholderText: 'new placeholder',
+ hideSelected: true,
+ keepOrder: true,
+ showOptionTooltips: true,
+ }
+
+ const settingsWithOverride = {
+ ...defaultSettings,
+ ...customSettings,
+ } as unknown as { [key: string]: any }
+
+ // Convert to unknown and then to custom object to prevent TS from throwing errors
+ const settings = new Settings(customSettings) as unknown as { [key: string]: any }
+ Object.keys(settingsWithOverride).forEach((key) => {
+ if (key === 'id') {
+ expect(settings[key].substring(0, 3)).toBe('ss-')
+ } else if (key === 'contentLocation') {
+ expect(settings[key]).toBeInstanceOf(defaultSettings[key])
+ } else {
+ expect(settings[key]).toStrictEqual(settingsWithOverride[key])
+ }
+ })
+ })
+
+ test('enable', () => {
+ select.select.disabled = true
+
+ select.enable()
+ expect(select.select.disabled).toBe(false)
+ })
+
+ test('disable', () => {
+ select.select.disabled = false
+
+ select.disable()
+ expect(select.select.disabled).toBe(true)
+ })
+
+ test('showUI', () => {
+ select.select.setAttribute('tabindex', '1')
+ select.select.setAttribute('aria-hidden', 'true')
+ select.select.style.display = 'none'
+
+ select.showUI()
+ expect(select.select.getAttribute('tabindex')).toBeFalsy()
+ expect(select.select.getAttribute('aria-hidden')).toBeFalsy()
+ expect(select.select.style.display).toBeFalsy()
+ })
+
+ describe('createOptgroup', () => {
+ test('group gets created correctly', () => {
+ const optGroup = new Optgroup({
+ label: 'test',
+ options: [
+ {
+ text: 'opt 1',
+ },
+ {
+ text: 'opt 2',
+ },
+ ],
+ })
+
+ const optGroupElement = select.createOptgroup(optGroup)
+
+ expect(optGroupElement).toBeInstanceOf(HTMLOptGroupElement)
+ expect(optGroupElement.children).toHaveLength(2)
+ })
+
+ test('selectAll get set correctly', () => {
+ const optGroup = new Optgroup({
+ label: 'test',
+ selectAll: true,
+ options: [
+ {
+ text: 'opt 1',
+ },
+ {
+ text: 'opt 2',
+ },
+ ],
+ })
+
+ const optGroupElement = select.createOptgroup(optGroup)
+
+ expect(optGroupElement).toBeInstanceOf(HTMLOptGroupElement)
+ expect(optGroupElement.dataset.selectAll).toBe('true')
+ })
+
+ test('closable get set correctly', () => {
+ const optGroup = new Optgroup({
+ label: 'test',
+ closable: 'open',
+ options: [
+ {
+ text: 'opt 1',
+ },
+ {
+ text: 'opt 2',
+ },
+ ],
+ })
+
+ const optGroupElement = select.createOptgroup(optGroup)
+
+ expect(optGroupElement).toBeInstanceOf(HTMLOptGroupElement)
+ expect(optGroupElement.dataset.closable).toBe(optGroup.closable)
+ })
+ })
+
+ describe('createOption', () => {
+ test('correct content is set', () => {
+ const option = new Option({ text: 'opt' })
+ const optionElement = select.createOption(option)
+
+ expect(optionElement).toBeInstanceOf(HTMLOptionElement)
+ expect(optionElement.id).toBe(option.id)
+ expect(optionElement.textContent).toBe(option.text)
+ })
+
+ test('HTML is set as data attribute', () => {
+ const option = new Option({ text: 'opt', html: 'opt ' })
+ const optionElement = select.createOption(option)
+
+ expect(optionElement.dataset.html).toBe(option.html)
+ })
+
+ test('disabled sets disabled property correctly', () => {
+ const option = new Option({ text: 'opt', disabled: true })
+ const optionElement = select.createOption(option)
+
+ expect(optionElement.disabled).toBe(true)
+ })
+
+ test('display false sets inline style correctly', () => {
+ const option = new Option({ text: 'opt', display: false })
+ const optionElement = select.createOption(option)
+
+ expect(optionElement.style.display).toBe('none')
+ })
+
+ test('placeholder and mandatory set data attributes correctly', () => {
+ const option = new Option({
+ text: 'opt',
+ placeholder: true,
+ mandatory: true,
+ })
+ const optionElement = select.createOption(option)
+
+ expect(optionElement.dataset.placeholder).toBeTruthy()
+ expect(optionElement.dataset.mandatory).toBeTruthy()
+ })
+
+ test('class sets CSS classes correctly', () => {
+ const option = new Option({
+ text: 'opt',
+ class: 'class0 class1 class2',
+ })
+ const optionElement = select.createOption(option)
+
+ expect(optionElement.classList.contains('class0')).toBe(true)
+ expect(optionElement.classList.contains('class1')).toBe(true)
+ expect(optionElement.classList.contains('class2')).toBe(true)
+ })
+
+ test('custom data attributes are set correctly', () => {
+ const option = new Option({
+ text: 'opt',
+ data: {
+ test0: 'a',
+ test1: 'b',
+ },
+ })
+ const optionElement = select.createOption(option)
+
+ expect(optionElement.dataset.test0).toBe('a')
+ expect(optionElement.dataset.test1).toBe('b')
+ })
+ })
+
+ test('destroy', () => {
+ select.destroy()
+
+ expect((select).observer).toBeFalsy()
+ expect('id' in select.select.dataset).toBeFalsy()
+ })
+})
diff --git a/src/slim-select/settings.ts b/src/slim-select/settings.ts
index 573af1f5..7a33cdd3 100644
--- a/src/slim-select/settings.ts
+++ b/src/slim-select/settings.ts
@@ -17,6 +17,7 @@ export default class Settings {
public disabled: boolean
public alwaysOpen: boolean
public showSearch: boolean
+ public focusSearch: boolean
public ariaLabel: string
public searchPlaceholder: string
public searchText: string
@@ -49,6 +50,7 @@ export default class Settings {
this.disabled = settings.disabled !== undefined ? settings.disabled : false
this.alwaysOpen = settings.alwaysOpen !== undefined ? settings.alwaysOpen : false
this.showSearch = settings.showSearch !== undefined ? settings.showSearch : true
+ this.focusSearch = settings.focusSearch !== undefined ? settings.focusSearch : true
this.ariaLabel = settings.ariaLabel || 'Combobox'
this.searchPlaceholder = settings.searchPlaceholder || 'Search'
this.searchText = settings.searchText || 'No Results'
diff --git a/src/slim-select/store.test.ts b/src/slim-select/store.test.ts
index 0fcdb436..13a9b830 100644
--- a/src/slim-select/store.test.ts
+++ b/src/slim-select/store.test.ts
@@ -1,242 +1,973 @@
'use strict'
import { describe, expect, test } from '@jest/globals'
-import Store, { Optgroup, Option } from './store'
+import Store, { DataArray, DataObjectPartial, Optgroup, Option } from './store'
describe('store module', () => {
- test('constructor', () => {
- let store = new Store('single', [])
- expect(store).toBeInstanceOf(Store)
- })
-
- test('set data', () => {
- let store = new Store('single', [
- {
- text: 'test',
- },
- ])
-
- let data = store.getData()
-
- // Make sure data has one item and that it has the correct text
- expect(data.length).toBe(1)
- expect((data[0] as Option).text).toBe('test')
-
- // Make sure the data has all the default fields
- expect((data[0] as Option).id).toBeDefined()
- expect((data[0] as Option).value).toBeDefined()
- expect((data[0] as Option).text).toBeDefined()
- expect((data[0] as Option).html).toBeDefined()
- expect((data[0] as Option).selected).toBeDefined()
- expect((data[0] as Option).display).toBeDefined()
- expect((data[0] as Option).disabled).toBeDefined()
- expect((data[0] as Option).placeholder).toBeDefined()
- expect((data[0] as Option).class).toBeDefined()
- expect((data[0] as Option).style).toBeDefined()
- expect((data[0] as Option).data).toBeDefined()
- expect((data[0] as Option).mandatory).toBeDefined()
- })
-
- test('set data with optgroup', () => {
- let store = new Store('single', [
- {
- label: 'test',
- options: [
- {
- text: 'test',
- },
- ],
- },
- ])
-
- let data = store.getData()
-
- // Make sure data has one item and that it has the correct text
- expect(data.length).toBe(1)
- expect((data[0] as Optgroup).label).toBe('test')
- expect((data[0] as Optgroup).options?.length).toBe(1)
- expect((data[0] as Optgroup).options?.[0].text).toBe('test')
- })
-
- test('set data with optgroup and option', () => {
- let store = new Store('single', [
- {
- label: 'test',
- options: [
- {
- text: 'test',
- },
- ],
- },
- {
- text: 'test',
- },
- ])
-
- let data = store.getData()
-
- // Make sure data has one item and that it has the correct text
- expect(data.length).toBe(2)
- expect((data[0] as Optgroup).label).toBe('test')
- expect((data[0] as Optgroup).options?.length).toBe(1)
- expect((data[0] as Optgroup).options?.[0].text).toBe('test')
- expect((data[1] as Option).text).toBe('test')
- })
-
- test('set data and set selected by ID', () => {
- let store = new Store('single', [
- {
- id: '8675309',
- text: 'test',
- },
- ])
- store.setSelectedBy('id', ['8675309'])
-
- let data = store.getData()
-
- // Make sure data has one item and that it has the correct text
- expect(data.length).toBe(1)
- expect((data[0] as Option).text).toBe('test')
- expect((data[0] as Option).selected).toBe(true)
- })
-
- test('set data and set selected by value', () => {
- let store = new Store('single', [
- {
- text: 'test',
- value: 'hello',
- },
- ])
- store.setSelectedBy('value', ['hello'])
-
- let data = store.getData()
-
- // Make sure data has one item and that it has the correct text
- expect(data.length).toBe(1)
- expect((data[0] as Option).text).toBe('test')
- expect((data[0] as Option).selected).toBe(true)
- })
-
- test('set data and set selected to empty string', () => {
- let store = new Store('single', [
- {
- text: 'all',
- value: '',
- },
- {
- text: 'Value 1',
- value: '1',
- selected: true,
- },
- ])
- store.setSelectedBy('value', [''])
-
- let data = store.getData()
-
- // Make sure data has one item and that it has the correct text
- expect(data.length).toBe(2)
- expect((data[0] as Option).text).toBe('all')
- expect((data[0] as Option).selected).toBe(true)
- })
-
- test('set data and set selected by value multiple for single element', () => {
- let store = new Store('single', [
- {
- text: 'test1',
- },
- {
- text: 'test2',
- },
- ])
- store.setSelectedBy('value', ['test1', 'test2'])
-
- let data = store.getData()
-
- // Make sure data has one item and that it has the correct text
- expect(data.length).toBe(2)
- expect((data[0] as Option).text).toBe('test1')
- expect((data[0] as Option).selected).toBe(true)
- expect((data[1] as Option).text).toBe('test2')
- expect((data[1] as Option).selected).toBe(false)
- })
-
- test('set data and search', () => {
- let store = new Store('single', [
- {
- text: 'test1',
- },
- {
- text: 'test2',
- value: 'test2',
- },
- {
- text: 'test3',
- },
- ])
-
- let data = store.getData()
-
- const searchFilter = (opt: Option, search: string): boolean => {
- return opt.text.toLowerCase().indexOf(search.toLowerCase()) !== -1
- }
-
- // With searchFilter search against current store data set
- let search = store.search('test2', searchFilter)
- expect(search.length).toBe(1)
- expect((search[0] as Option).value).toBe('test2')
- })
-
- test('set data with non selected on single select', () => {
- let store = new Store('single', [
- {
- text: 'test1',
- },
- {
- text: 'test2',
- value: 'test2',
- },
- {
- text: 'test3',
- },
- ])
-
- let data = store.getData()
-
- // Make sure data has one item and that it has the correct text
- expect(data.length).toBe(3)
- expect((data[0] as Option).text).toBe('test1')
- expect((data[0] as Option).selected).toBe(true)
- expect((data[1] as Option).text).toBe('test2')
- expect((data[1] as Option).selected).toBe(false)
- expect((data[2] as Option).text).toBe('test3')
- expect((data[2] as Option).selected).toBe(false)
- })
-
- test('set data with multiple selected on single select', () => {
- let store = new Store('single', [
- {
- text: 'test1',
- },
- {
- text: 'test2',
- value: 'test2',
- selected: true,
- },
- {
- text: 'test3',
- selected: true,
- },
- ])
-
- let data = store.getData()
-
- // Make sure data has one item and that it has the correct text
- expect(data.length).toBe(3)
- expect((data[0] as Option).text).toBe('test1')
- expect((data[0] as Option).selected).toBe(false)
- expect((data[1] as Option).text).toBe('test2')
- expect((data[1] as Option).selected).toBe(true)
- expect((data[2] as Option).text).toBe('test3')
- expect((data[2] as Option).selected).toBe(false)
+ describe('constructor', () => {
+ test('constructor without data', () => {
+ let store = new Store('single', [])
+ expect(store).toBeInstanceOf(Store)
+ })
+
+ test('constructor with single option', () => {
+ const store = new Store('single', [
+ {
+ text: 'test',
+ },
+ ])
+
+ const data = store.getData()
+
+ // Make sure data has one item and that it has the correct text
+ expect(data).toHaveLength(1)
+ expect(data[0]).toBeInstanceOf(Option)
+
+ const option = data[0] as Option
+
+ expect(option.text).toBe('test')
+ expect(option.id).toBeDefined()
+ expect(option.value).toBeDefined()
+ expect(option.text).toBeDefined()
+ expect(option.html).toBeDefined()
+ expect(option.selected).toBeDefined()
+ expect(option.display).toBeDefined()
+ expect(option.disabled).toBeDefined()
+ expect(option.placeholder).toBeDefined()
+ expect(option.class).toBeDefined()
+ expect(option.style).toBeDefined()
+ expect(option.data).toBeDefined()
+ expect(option.mandatory).toBeDefined()
+ })
+
+ test('constructor with optgroup', () => {
+ const store = new Store('single', [
+ {
+ label: 'opt group',
+ options: [
+ {
+ text: 'test',
+ },
+ ],
+ },
+ ])
+
+ const data = store.getData()
+
+ expect(data).toHaveLength(1)
+
+ expect(data[0]).toBeInstanceOf(Optgroup)
+ const optGroup = data[0] as Optgroup
+
+ expect(optGroup.label).toBe('opt group')
+ expect(optGroup.options).toHaveLength(1)
+ expect(optGroup.options?.[0].text).toBe('test')
+ })
+
+ test('constructor with optgroup and option', () => {
+ const store = new Store('single', [
+ {
+ label: 'opt group',
+ options: [
+ {
+ text: 'opt group option',
+ },
+ ],
+ },
+ {
+ text: 'option',
+ },
+ ])
+
+ const data = store.getData()
+
+ expect(data).toHaveLength(2)
+
+ expect(data[0]).toBeInstanceOf(Optgroup)
+ const optGroup = data[0] as Optgroup
+
+ expect(optGroup.label).toBe('opt group')
+ expect(optGroup.options).toHaveLength(1)
+ expect(optGroup.options?.[0].text).toBe('opt group option')
+
+ expect(data[1]).toBeInstanceOf(Option)
+ expect((data[1] as Option).text).toBe('option')
+ })
+
+ test('constructor with multiple selected on single select only registers first selected', () => {
+ const store = new Store('single', [
+ {
+ text: 'test1',
+ },
+ {
+ text: 'test2',
+ value: 'test2',
+ selected: true,
+ },
+ {
+ text: 'test3',
+ selected: true,
+ },
+ ])
+
+ // cast to an option array here, so we don't need casts in the comparisons
+ const data = store.getData() as Array
+
+ expect(data).toHaveLength(3)
+ expect(data[0].text).toBe('test1')
+ expect(data[0].selected).toBe(false)
+ expect(data[1].text).toBe('test2')
+ expect(data[1].selected).toBe(true)
+ expect(data[2].text).toBe('test3')
+ expect(data[2].selected).toBe(false)
+ })
+
+ test('constructor without a selected element on single select selects first option', () => {
+ const store = new Store('single', [
+ {
+ text: 'test1',
+ },
+ {
+ text: 'test2',
+ value: 'test2',
+ },
+ {
+ text: 'test3',
+ },
+ ])
+
+ // cast to an option array here, so we don't need casts in the comparisons
+ const data = store.getData() as Array
+
+ expect(data).toHaveLength(3)
+ expect(data[0].text).toBe('test1')
+ expect(data[0].selected).toBe(true)
+ expect(data[1].text).toBe('test2')
+ expect(data[1].selected).toBe(false)
+ expect(data[2].text).toBe('test3')
+ expect(data[2].selected).toBe(false)
+ })
+
+ test('constructor with multiple selected on multiple select registers all selected', () => {
+ const store = new Store('multiple', [
+ {
+ text: 'test1',
+ },
+ {
+ text: 'test2',
+ value: 'test2',
+ selected: true,
+ },
+ {
+ text: 'test3',
+ selected: true,
+ },
+ ])
+
+ // cast to an option array here, so we don't need casts in the comparisons
+ const data = store.getData() as Array
+
+ expect(data).toHaveLength(3)
+ expect(data[0].text).toBe('test1')
+ expect(data[0].selected).toBe(false)
+ expect(data[1].text).toBe('test2')
+ expect(data[1].selected).toBe(true)
+ expect(data[2].text).toBe('test3')
+ expect(data[2].selected).toBe(true)
+ })
+
+ test('constructor without a selected element on multiple select does not select anything', () => {
+ const store = new Store('multiple', [
+ {
+ text: 'test1',
+ },
+ {
+ text: 'test2',
+ value: 'test2',
+ },
+ {
+ text: 'test3',
+ },
+ ])
+
+ // cast to an option array here, so we don't need casts in the comparisons
+ const data = store.getData() as Array
+
+ expect(data).toHaveLength(3)
+ data.forEach((option) => {
+ expect(option.selected).toBe(false)
+ })
+ })
+ })
+
+ describe('validateDataArray', () => {
+ let store: Store
+
+ beforeAll(() => {
+ store = new Store('single', [])
+ })
+
+ test('invalid data returns error', () => {
+ const invalidData = { test: true }
+
+ const res = store.validateDataArray(invalidData as unknown as DataArray)
+ expect(res).toBeInstanceOf(Error)
+ expect(res?.message).toBe('Data must be an array')
+ })
+
+ test('single invalid data entry returns error', () => {
+ const invalidData = [{ name: 'this is invalid' }]
+
+ const res = store.validateDataArray(invalidData as unknown as DataArray)
+ expect(res).toBeInstanceOf(Error)
+ expect(res?.message).toBe('Data object must be a valid optgroup or option')
+ })
+
+ test('invalid data and valid option returns error', () => {
+ const invalidData = [
+ {
+ name: 'invalid data',
+ },
+ {
+ text: 'valid option',
+ },
+ ]
+
+ const res = store.validateDataArray(invalidData as unknown as DataArray)
+ expect(res).toBeInstanceOf(Error)
+ expect(res?.message).toBe('Data object must be a valid optgroup or option')
+ })
+
+ test('valid option and invalid data returns error', () => {
+ const invalidData = [
+ {
+ text: 'valid',
+ },
+ {
+ name: 'this is invalid',
+ },
+ ]
+
+ const res = store.validateDataArray(invalidData as unknown as DataArray)
+ expect(res).toBeInstanceOf(Error)
+ expect(res?.message).toBe('Data object must be a valid optgroup or option')
+ })
+
+ test('valid opt group with invalid data returns error', () => {
+ const invalidData = [
+ {
+ label: 'valid',
+ options: [
+ {
+ invalid: 'asdf',
+ },
+ ],
+ },
+ ]
+
+ const res = store.validateDataArray(invalidData as unknown as DataArray)
+ expect(res).toBeInstanceOf(Error)
+ expect(res?.message).toBe('Option must have a text')
+ })
+
+ test('single valid option validates', () => {
+ const validData = [
+ {
+ text: 'valid option',
+ },
+ ]
+
+ const res = store.validateDataArray(validData)
+ expect(res).toBeNull()
+ })
+
+ test('multiple valid options validates', () => {
+ const validData = [
+ {
+ text: 'valid option 1',
+ },
+ {
+ text: 'valid option 2',
+ },
+ ]
+
+ const res = store.validateDataArray(validData)
+ expect(res).toBeNull()
+ })
+
+ test('single valid opt group validates', () => {
+ const validData = [
+ {
+ label: 'valid opt group',
+ },
+ ]
+
+ const res = store.validateDataArray(validData)
+ expect(res).toBeNull()
+ })
+
+ test('valid opt group with options validates', () => {
+ const validData = [
+ {
+ label: 'valid opt group',
+ options: [
+ {
+ text: 'option 1',
+ },
+ {
+ text: 'option 2',
+ },
+ ],
+ },
+ ]
+
+ const res = store.validateDataArray(validData)
+ expect(res).toBeNull()
+ })
+ test('valid opt group with options and separate options validates', () => {
+ const validData = [
+ {
+ label: 'valid opt group',
+ options: [
+ {
+ text: 'option 1',
+ },
+ {
+ text: 'option 2',
+ },
+ ],
+ },
+ {
+ text: 'main option 1',
+ },
+ {
+ text: 'main option 2',
+ },
+ ]
+
+ const res = store.validateDataArray(validData)
+ expect(res).toBeNull()
+ })
+ })
+
+ describe('validateOption', () => {
+ let store: Store
+
+ beforeAll(() => {
+ store = new Store('single', [])
+ })
+
+ test('empty option returns error', () => {
+ const res = store.validateOption({} as Option)
+ expect(res).toBeInstanceOf(Error)
+ expect(res?.message).toBe('Option must have a text')
+ })
+
+ test('invalid data returns error', () => {
+ const res = store.validateOption({ label: 'text' } as unknown as Option)
+ expect(res).toBeInstanceOf(Error)
+ expect(res?.message).toBe('Option must have a text')
+ })
+
+ test('valid data returns null', () => {
+ const res = store.validateOption({ text: 'text' })
+ expect(res).toBeNull()
+ })
+ })
+
+ describe('partialToFullData', () => {
+ let store: Store
+
+ beforeAll(() => {
+ store = new Store('single', [])
+ })
+
+ test('empty partial returns empty data array', () => {
+ const res = store.partialToFullData([])
+ expect(res).toBeInstanceOf(Array)
+ expect(res).toHaveLength(0)
+ })
+
+ test("invalid data get's ignored", () => {
+ const res = store.partialToFullData([
+ { error: 'this is invalid' } as unknown as DataObjectPartial,
+ { text: 'valid' },
+ ])
+ expect(res).toBeInstanceOf(Array)
+ expect(res).toHaveLength(1)
+
+ expect(res[0]).toBeInstanceOf(Option)
+
+ const option = res[0] as Option
+ expect(option.text).toBe('valid')
+ })
+
+ test('valid data gets filled correctly', () => {
+ const res = store.partialToFullData([
+ {
+ label: 'opt group',
+ options: [{ text: 'opt 1' }, { text: 'opt 2' }],
+ },
+ {
+ text: 'opt 3',
+ },
+ ])
+
+ expect(res).toHaveLength(2)
+ expect(res[0]).toBeInstanceOf(Optgroup)
+
+ const optGroup = res[0] as Optgroup
+ expect(optGroup.label).toBe('opt group')
+ expect(optGroup.options).toHaveLength(2)
+ expect(optGroup.options[0]).toBeInstanceOf(Option)
+ expect(optGroup.options[0].text).toBe('opt 1')
+ expect(optGroup.options[1]).toBeInstanceOf(Option)
+ expect(optGroup.options[1].text).toBe('opt 2')
+ })
+ })
+
+ describe('setData', () => {
+ let store: Store
+
+ beforeEach(() => {
+ store = new Store('single', [
+ {
+ text: 'initial option',
+ },
+ {
+ text: 'initial selected option',
+ selected: true,
+ },
+ ])
+ })
+
+ test('invalid data does override existing data', () => {
+ store.setData([{ invalid: true } as unknown as Option])
+
+ const data = store.getData()
+ expect(data).toHaveLength(0)
+ })
+
+ test('valid data overrides existing data', () => {
+ store.setData([
+ {
+ text: 'opt 1',
+ },
+ {
+ text: 'opt 2',
+ },
+ {
+ text: 'opt 3',
+ },
+ ])
+
+ const data = store.getData() as Array
+ expect(data).toHaveLength(3)
+ expect(data[0].text).toBe('opt 1')
+ expect(data[1].text).toBe('opt 2')
+ expect(data[2].text).toBe('opt 3')
+ })
+
+ test('valid data on single select automatically selects first option', () => {
+ store.setData([
+ {
+ text: 'opt 1',
+ },
+ {
+ text: 'opt 2',
+ },
+ ])
+
+ const data = store.getData() as Array
+ expect(data).toHaveLength(2)
+ expect(data[0].selected).toBe(true)
+ })
+
+ test('valid data on single select selects correct option', () => {
+ store.setData([
+ {
+ text: 'opt 1',
+ },
+ {
+ text: 'opt 2',
+ selected: true,
+ },
+ ])
+
+ const data = store.getData() as Array
+ expect(data).toHaveLength(2)
+ expect(data[1].text).toBe('opt 2')
+ expect(data[1].selected).toBe(true)
+ })
+ })
+
+ describe('getData', () => {
+ test('getData gets all options', () => {
+ const data = [
+ {
+ label: 'group test',
+ options: [
+ {
+ text: 'sub opt 1',
+ },
+ {
+ text: 'sub opt 2',
+ },
+ ],
+ },
+ {
+ text: 'test1',
+ },
+ {
+ text: 'test2',
+ value: 'test2',
+ },
+ {
+ text: 'test3',
+ },
+ ]
+
+ const flatData = [
+ {
+ text: 'sub opt 1',
+ },
+ {
+ text: 'sub opt 2',
+ },
+ {
+ text: 'test1',
+ },
+ {
+ text: 'test2',
+ value: 'test2',
+ },
+ {
+ text: 'test3',
+ },
+ ]
+
+ const store = new Store('single', data)
+
+ const storedData = store.getData()
+ storedData.forEach((dataObject, index) => {
+ if ('label' in dataObject) {
+ expect(dataObject.label).toBe(data[index].label)
+ dataObject.options.forEach((subOpt, subIndex) => {
+ expect(subOpt.text).toBe(data[index].options![subIndex].text)
+ expect(subOpt.value).toBe(data[index].options![subIndex].text)
+ })
+ } else {
+ expect(dataObject.text).toBe(data[index].text)
+ expect(dataObject.value).toBe(data[index].text)
+ }
+ })
+
+ // test flat array directly
+ const storedDataOptions = store.getDataOptions()
+ storedDataOptions.forEach((option, index) => {
+ expect(option.text).toBe(flatData[index].text)
+ expect(option.value).toBe(flatData[index].text)
+ })
+ })
+ })
+
+ describe('getDataOptions', () => {
+ test('options only store returns same structure', () => {
+ const store = new Store('single', [
+ {
+ text: 'opt 0',
+ },
+ {
+ text: 'opt 1',
+ },
+ ])
+
+ const data = store.getDataOptions()
+ expect(data).toHaveLength(2)
+ data.forEach((option, index) => {
+ expect(option).toBeInstanceOf(Option)
+ expect(option.text).toBe(`opt ${index}`)
+ })
+ })
+
+ test('option group gets flattened', () => {
+ const store = new Store('single', [
+ {
+ label: 'opt group',
+ options: [
+ {
+ text: 'opt 0',
+ },
+ {
+ text: 'opt 1',
+ },
+ ],
+ },
+ {
+ text: 'opt 2',
+ },
+ {
+ text: 'opt 3',
+ },
+ ])
+
+ const data = store.getDataOptions()
+ expect(data).toHaveLength(4)
+ data.forEach((option, index) => {
+ expect(option).toBeInstanceOf(Option)
+ expect(option.text).toBe(`opt ${index}`)
+ })
+ })
+ })
+
+ describe('addOption', () => {
+ test('append option', () => {
+ const store = new Store('single', [
+ {
+ text: 'test1',
+ },
+ ])
+
+ store.addOption({ text: 'test2' })
+
+ const storeData = store.getDataOptions()
+ expect(storeData).toHaveLength(2)
+ expect(storeData[0].text).toBe('test1')
+ expect(storeData[1].text).toBe('test2')
+ })
+ })
+
+ describe('setSelectedBy', () => {
+ let store: Store
+
+ beforeEach(() => {
+ store = new Store('single', [
+ {
+ text: 'opt 1',
+ },
+ {
+ id: '12345678',
+ text: 'id opt',
+ },
+ {
+ text: 'opt 2',
+ },
+ {
+ text: 'opt 3',
+ },
+ ])
+ })
+
+ test('set selected by ID', () => {
+ store.setSelectedBy('id', ['12345678'])
+ const data = store.getDataOptions()
+
+ expect(data[0].text).toBe('opt 1')
+ expect(data[0].selected).toBe(false)
+
+ expect(data[1].text).toBe('id opt')
+ expect(data[1].selected).toBe(true)
+ })
+
+ test('set selected by value', () => {
+ store.setSelectedBy('value', ['opt 3'])
+
+ const data = store.getDataOptions()
+
+ expect(data[0].text).toBe('opt 1')
+ expect(data[0].selected).toBe(false)
+
+ expect(data[3].text).toBe('opt 3')
+ expect(data[3].selected).toBe(true)
+ })
+
+ test('set selected to empty string selects first option', () => {
+ const store = new Store('single', [
+ {
+ text: 'all',
+ value: '',
+ },
+ {
+ text: 'Value 1',
+ value: '1',
+ selected: true,
+ },
+ ])
+ store.setSelectedBy('value', [''])
+
+ const data = store.getDataOptions()
+
+ expect(data[0].text).toBe('all')
+ expect(data[0].selected).toBe(true)
+ expect(data[1].selected).toBe(false)
+ })
+
+ test('set multiple selected by value on single select only selects the first element', () => {
+ store.setSelectedBy('value', ['opt 2', 'opt 3'])
+
+ const data = store.getDataOptions()
+
+ expect(data[2].selected).toBe(true)
+ expect(data[3].selected).toBe(false)
+ })
+ })
+
+ describe('getSelected', () => {
+ test('get correct value when one option is selected', () => {
+ const store = new Store('single', [
+ {
+ text: 'opt 0',
+ },
+ {
+ text: 'opt 1',
+ },
+ ])
+
+ const selected = store.getSelected()
+ expect(selected).toHaveLength(1)
+ expect(selected[0]).toBe('opt 0')
+ })
+
+ test('get correct value when two options is selected', () => {
+ const store = new Store('multiple', [
+ {
+ text: 'opt 0',
+ },
+ {
+ text: 'opt 1',
+ selected: true,
+ },
+ {
+ text: 'opt 2',
+ selected: true,
+ },
+ ])
+
+ const selected = store.getSelected()
+ expect(selected).toHaveLength(2)
+
+ expect(selected[0]).toBe('opt 1')
+ expect(selected[1]).toBe('opt 2')
+ })
+ })
+
+ describe('getSelectedOptions', () => {
+ test('get correct value when one option is selected', () => {
+ const store = new Store('single', [
+ {
+ text: 'opt 0',
+ },
+ {
+ text: 'opt 1',
+ },
+ ])
+
+ const selected = store.getSelectedOptions()
+ expect(selected).toHaveLength(1)
+ expect(selected[0].text).toBe('opt 0')
+ })
+
+ test('get correct value when two options is selected', () => {
+ const store = new Store('multiple', [
+ {
+ text: 'opt 0',
+ },
+ {
+ text: 'opt 1',
+ selected: true,
+ },
+ {
+ text: 'opt 2',
+ selected: true,
+ },
+ ])
+
+ const selected = store.getSelectedOptions()
+ expect(selected).toHaveLength(2)
+ expect(selected[0].text).toBe('opt 1')
+ expect(selected[1].text).toBe('opt 2')
+ })
+ })
+
+ describe('getOptionByID', () => {
+ let store: Store
+
+ beforeAll(() => {
+ store = new Store('single', [
+ {
+ text: 'opt 1',
+ },
+ {
+ id: '12345678',
+ text: 'id opt',
+ },
+ {
+ text: 'opt 2',
+ },
+ {
+ text: 'opt 3',
+ },
+ ])
+ })
+
+ test('invalid id gets null as result', () => {
+ const res = store.getOptionByID('0000')
+ expect(res).toBeNull()
+ })
+
+ test('valid id gets correct result', () => {
+ const res = store.getOptionByID('12345678')
+ expect(res).toBeInstanceOf(Option)
+ expect((res as Option).text).toBe('id opt')
+ })
+ })
+
+ describe('getSelectType', () => {
+ test('get correct type on single select', () => {
+ const store = new Store('single', [])
+ expect(store.getSelectType()).toBe('single')
+ })
+
+ test('get correct type on multiple select', () => {
+ const store = new Store('multiple', [])
+ expect(store.getSelectType()).toBe('multiple')
+ })
+ })
+
+ describe('getFirstOption', () => {
+ test('getFirstOption returns first option', () => {
+ const flatStore = new Store('single', [
+ {
+ text: 'test0',
+ },
+ {
+ text: 'test1',
+ },
+ ])
+
+ expect(flatStore.getFirstOption()?.text).toBe('test0')
+
+ const store = new Store('single', [
+ {
+ label: 'group0',
+ options: [
+ {
+ text: 'test0',
+ },
+ {
+ text: 'test1',
+ },
+ ],
+ },
+ {
+ text: 'test2',
+ },
+ ])
+
+ expect(store.getFirstOption()?.text).toBe('test0')
+ })
+ })
+
+ describe('search', () => {
+ test('search with term returns correct option', () => {
+ const store = new Store('single', [
+ {
+ text: 'test1',
+ },
+ {
+ text: 'test2',
+ value: 'test2',
+ },
+ {
+ text: 'test3',
+ },
+ ])
+
+ const searchFilter = (opt: Option, search: string): boolean => {
+ return opt.text.toLowerCase().indexOf(search.toLowerCase()) !== -1
+ }
+
+ // With searchFilter search against current store data set
+ const searchResults = store.search('test2', searchFilter)
+ expect(searchResults).toHaveLength(1)
+ expect((searchResults[0] as Option).value).toBe('test2')
+ })
+
+ test('empty search term returns all options', () => {
+ const store = new Store('single', [
+ {
+ text: 'test1',
+ },
+ {
+ text: 'test2',
+ value: 'test2',
+ },
+ {
+ text: 'test3',
+ },
+ ])
+
+ const searchFilter = (opt: Option, search: string): boolean => {
+ return opt.text.toLowerCase().indexOf(search.toLowerCase()) !== -1
+ }
+
+ // Test empty search term
+ const searchResults = store.search('', searchFilter)
+ expect(searchResults).toHaveLength(3)
+ })
+ })
+
+ describe('filter', () => {
+ let store: Store
+
+ beforeAll(() => {
+ store = new Store('single', [
+ {
+ label: 'group 0',
+ options: [
+ {
+ text: 'opt 0',
+ class: 'filter-me',
+ },
+ {
+ text: 'opt 1',
+ },
+ ],
+ },
+ {
+ text: 'opt 2',
+ class: 'filter-me',
+ },
+ ])
+ })
+
+ test('empty filter function returns all options (with opt group)', () => {
+ const res = store.filter(null, true)
+ expect(res).toHaveLength(2)
+ expect(res[0]).toBeInstanceOf(Optgroup)
+ expect((res[0] as Optgroup).label).toBe('group 0')
+ expect((res[0] as Optgroup).options).toHaveLength(2)
+ expect(res[1]).toBeInstanceOf(Option)
+ })
+
+ test('empty filter function returns all options without optgroups flattens results', () => {
+ const res = store.filter(null, false)
+ expect(res).toHaveLength(3)
+ expect(res[0]).toBeInstanceOf(Option)
+ expect((res[0] as Option).text).toBe('opt 0')
+ expect(res[1]).toBeInstanceOf(Option)
+ expect((res[1] as Option).text).toBe('opt 1')
+ expect(res[2]).toBeInstanceOf(Option)
+ expect((res[2] as Option).text).toBe('opt 2')
+ })
+
+ test('filter function filters results accordingly', () => {
+ const res = store.filter((o) => o.class === 'filter-me', false)
+ expect(res).toHaveLength(2)
+ expect(res[0]).toBeInstanceOf(Option)
+ expect((res[0] as Option).text).toBe('opt 0')
+ expect(res[1]).toBeInstanceOf(Option)
+ expect((res[1] as Option).text).toBe('opt 2')
+ })
})
})
diff --git a/src/slim-select/store.ts b/src/slim-select/store.ts
index a2fd5831..eca985ba 100644
--- a/src/slim-select/store.ts
+++ b/src/slim-select/store.ts
@@ -115,11 +115,17 @@ export default class Store {
if ('options' in dataObj && dataObj.options) {
for (let option of dataObj.options) {
- return this.validateOption(option)
+ const validationError = this.validateOption(option)
+ if (validationError) {
+ return validationError
+ }
}
}
} else if (dataObj instanceof Option || 'text' in dataObj) {
- return this.validateOption(dataObj)
+ const validationError = this.validateOption(dataObj)
+ if (validationError) {
+ return validationError
+ }
} else {
return new Error('Data object must be a valid optgroup or option')
}