kempo-ui 0.4.16 → 0.4.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,24 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.4.18] - 2026-05-04
10
+ ### Added
11
+ - **Combobox component**: `no-results-message` and `empty-message` attributes
12
+ - `no-results-message` customizes the text shown when the user has typed something but no options match (default: `"No Matches"`)
13
+ - `empty-message` customizes the text shown when the input is empty and no options are loaded, e.g. before a dynamic search has fetched results (default: `"Type to search..."`)
14
+ - Both attributes map to `noResultsMessage` and `emptyMessage` properties respectively
15
+
16
+ ### Fixed
17
+ - **Tabs component**: Test for `persistent-id` localStorage restore had a logic inversion — `content3.active` was checked instead of `!content3.active`, causing the test to fail precisely when restoration was working correctly
18
+
19
+ ## [0.4.17] - 2026-05-03
20
+ ### Added
21
+ - **Markdown Editor component**: CSS size variables `--min-height` and `--max-height`
22
+ - New `--min-height` CSS variable to prevent editor from shrinking below a minimum size
23
+ - New `--max-height` CSS variable to add vertical scrollbar when content exceeds max height
24
+ - Works alongside existing `--height` variable for flexible sizing
25
+ - Documentation updated with sizing example showing all three variables used together
26
+
9
27
  ## [0.4.16] - 2026-05-03
10
28
  ### Added
11
29
  - **Markdown Editor component**: Debounced change event during input
@@ -1,4 +1,4 @@
1
- import{html as e,css as t}from"../lit-all.min.js";import s from"./ShadowComponent.js";import i from"../utils/debounce.js";import"./Option.js";export default class o extends s{static formAssociated=!0;static properties={value:{type:String,reflect:!0},name:{type:String,reflect:!0},placeholder:{type:String},opened:{type:Boolean,reflect:!0},searching:{type:Boolean,reflect:!0},required:{type:Boolean,reflect:!0},requireMatch:{type:Boolean,reflect:!0,attribute:"require-match"},disabled:{type:Boolean,reflect:!0},debounceMs:{type:Number,attribute:"debounce-ms"},maxVisible:{type:Number,attribute:"max-visible"}};#e=-1;#t=[];constructor(){super(),this.internals=this.attachInternals(),this.value="",this.name="",this.placeholder="",this.opened=!1,this.searching=!1,this.required=!1,this.requireMatch=!1,this.disabled=!1,this.debounceMs=300,this.maxVisible=8,this.#s(300)}connectedCallback(){super.connectedCallback(),document.addEventListener("click",this.handleDocumentClick),this.#i()}disconnectedCallback(){super.disconnectedCallback(),document.removeEventListener("click",this.handleDocumentClick)}childrenUpdated(){this.#i()}updated(e){super.updated(e),e.has("debounceMs")&&this.#s(this.debounceMs),(e.has("value")||e.has("required")||e.has("requireMatch"))&&this.#o()}formResetCallback(){this.value="",this.#o()}formStateRestoreCallback(e){this.value=e}handleInput=e=>{this.value=e.target.value,this.opened=!0,this.#e=-1,this.#a(this.value)};handleKeydown=e=>{const t=this.#n;if("ArrowDown"===e.key){if(e.preventDefault(),!this.opened)return void(this.opened=!0);this.#e=Math.min(this.#e+1,t.length-1),this.requestUpdate(),this.#r()}else"ArrowUp"===e.key?(e.preventDefault(),this.#e=Math.max(this.#e-1,0),this.requestUpdate(),this.#r()):"Enter"===e.key?(e.preventDefault(),this.#e>=0&&this.#e<t.length&&this.#l(t[this.#e])):"Escape"===e.key?(e.preventDefault(),this.opened=!1,this.#e=-1):"Tab"===e.key&&(this.opened=!1,this.#e=-1)};handleDocumentClick=e=>{e.composedPath().includes(this)||(this.opened=!1,this.#e=-1)};handleFocus=()=>{this.opened=!0};handleOptionClick=e=>{const t=Number(e.currentTarget.dataset.index);this.#l(this.#n[t])};setOptions(e){return[...this.querySelectorAll("k-option")].forEach(e=>e.remove()),e.forEach(e=>{const t=document.createElement("k-option");"object"==typeof e?(t.setAttribute("value",e.value??e.label),t.textContent=e.label):t.textContent=e,this.appendChild(t)}),this}clear(){return this.value="",this.opened=!1,this.#e=-1,this.#o(),this}#s(e){this.#a=i(e=>{this.dispatchEvent(new CustomEvent("search",{detail:{value:e},bubbles:!0,composed:!0}))},e)}#a=()=>{};#i(){this.#t=[...this.querySelectorAll("k-option")].map(e=>({label:e.label,value:e.value})),this.requestUpdate()}#l(e){this.value=e.label,this.opened=!1,this.#e=-1,this.#o(),this.dispatchEvent(new CustomEvent("select",{detail:{value:e.value,label:e.label},bubbles:!0,composed:!0})),this.dispatchEvent(new CustomEvent("change",{bubbles:!0}))}#o(){const e=this.#t.find(e=>e.label===this.value);this.internals.setFormValue(e?e.value:this.value),this.required&&!this.value?this.internals.setValidity({valueMissing:!0},"Please select a value.",this.shadowRoot?.querySelector("input")):this.requireMatch&&this.value&&!e?this.internals.setValidity({customError:!0},"Please select a valid option.",this.shadowRoot?.querySelector("input")):this.internals.setValidity({})}#r(){this.updateComplete.then(()=>{this.shadowRoot?.querySelector(".option.focused")?.scrollIntoView({block:"nearest"})})}get#d(){const e=(this.value||"").toLowerCase();return e?this.#t.filter(t=>t.label.toLowerCase().includes(e)):this.#t}get#n(){return this.#d.slice(0,this.maxVisible)}render(){const t=this.#n,s=this.#d.length>this.maxVisible;return e`
1
+ import{html as e,css as t}from"../lit-all.min.js";import s from"./ShadowComponent.js";import i from"../utils/debounce.js";import"./Option.js";export default class o extends s{static formAssociated=!0;static properties={value:{type:String,reflect:!0},name:{type:String,reflect:!0},placeholder:{type:String},opened:{type:Boolean,reflect:!0},searching:{type:Boolean,reflect:!0},required:{type:Boolean,reflect:!0},requireMatch:{type:Boolean,reflect:!0,attribute:"require-match"},disabled:{type:Boolean,reflect:!0},debounceMs:{type:Number,attribute:"debounce-ms"},maxVisible:{type:Number,attribute:"max-visible"},noResultsMessage:{type:String,attribute:"no-results-message"},emptyMessage:{type:String,attribute:"empty-message"}};#e=-1;#t=[];constructor(){super(),this.internals=this.attachInternals(),this.value="",this.name="",this.placeholder="",this.opened=!1,this.searching=!1,this.required=!1,this.requireMatch=!1,this.disabled=!1,this.debounceMs=300,this.maxVisible=8,this.noResultsMessage="No Matches",this.emptyMessage="Type to search...",this.#s(300)}connectedCallback(){super.connectedCallback(),document.addEventListener("click",this.handleDocumentClick),this.#i()}disconnectedCallback(){super.disconnectedCallback(),document.removeEventListener("click",this.handleDocumentClick)}childrenUpdated(){this.#i()}updated(e){super.updated(e),e.has("debounceMs")&&this.#s(this.debounceMs),(e.has("value")||e.has("required")||e.has("requireMatch"))&&this.#o()}formResetCallback(){this.value="",this.#o()}formStateRestoreCallback(e){this.value=e}handleInput=e=>{this.value=e.target.value,this.opened=!0,this.#e=-1,this.#a(this.value)};handleKeydown=e=>{const t=this.#n;if("ArrowDown"===e.key){if(e.preventDefault(),!this.opened)return void(this.opened=!0);this.#e=Math.min(this.#e+1,t.length-1),this.requestUpdate(),this.#r()}else"ArrowUp"===e.key?(e.preventDefault(),this.#e=Math.max(this.#e-1,0),this.requestUpdate(),this.#r()):"Enter"===e.key?(e.preventDefault(),this.#e>=0&&this.#e<t.length&&this.#l(t[this.#e])):"Escape"===e.key?(e.preventDefault(),this.opened=!1,this.#e=-1):"Tab"===e.key&&(this.opened=!1,this.#e=-1)};handleDocumentClick=e=>{e.composedPath().includes(this)||(this.opened=!1,this.#e=-1)};handleFocus=()=>{this.opened=!0};handleOptionClick=e=>{const t=Number(e.currentTarget.dataset.index);this.#l(this.#n[t])};setOptions(e){return[...this.querySelectorAll("k-option")].forEach(e=>e.remove()),e.forEach(e=>{const t=document.createElement("k-option");"object"==typeof e?(t.setAttribute("value",e.value??e.label),t.textContent=e.label):t.textContent=e,this.appendChild(t)}),this}clear(){return this.value="",this.opened=!1,this.#e=-1,this.#o(),this}#s(e){this.#a=i(e=>{this.dispatchEvent(new CustomEvent("search",{detail:{value:e},bubbles:!0,composed:!0}))},e)}#a=()=>{};#i(){this.#t=[...this.querySelectorAll("k-option")].map(e=>({label:e.label,value:e.value})),this.requestUpdate()}#l(e){this.value=e.label,this.opened=!1,this.#e=-1,this.#o(),this.dispatchEvent(new CustomEvent("select",{detail:{value:e.value,label:e.label},bubbles:!0,composed:!0})),this.dispatchEvent(new CustomEvent("change",{bubbles:!0}))}#o(){const e=this.#t.find(e=>e.label===this.value);this.internals.setFormValue(e?e.value:this.value),this.required&&!this.value?this.internals.setValidity({valueMissing:!0},"Please select a value.",this.shadowRoot?.querySelector("input")):this.requireMatch&&this.value&&!e?this.internals.setValidity({customError:!0},"Please select a valid option.",this.shadowRoot?.querySelector("input")):this.internals.setValidity({})}#r(){this.updateComplete.then(()=>{this.shadowRoot?.querySelector(".option.focused")?.scrollIntoView({block:"nearest"})})}get#d(){const e=(this.value||"").toLowerCase();return e?this.#t.filter(t=>t.label.toLowerCase().includes(e)):this.#t}get#n(){return this.#d.slice(0,this.maxVisible)}render(){const t=this.#n,s=this.#d.length>this.maxVisible;return e`
2
2
  <slot style="display:none"></slot>
3
3
  <input
4
4
  type="text"
@@ -23,7 +23,7 @@ import{html as e,css as t}from"../lit-all.min.js";import s from"./ShadowComponen
23
23
  <div class="more">${this.#d.length-this.maxVisible} more...</div>
24
24
  `:""}
25
25
  ${0!==t.length||this.searching?"":e`
26
- <div class="no-results">No matches</div>
26
+ <div class="no-results">${this.value?this.noResultsMessage:this.emptyMessage}</div>
27
27
  `}
28
28
  ${this.searching?e`
29
29
  <div class="searching">
@@ -62,6 +62,8 @@ import{html as t,css as e,nothing as i}from"../lit-all.min.js";import s from"./S
62
62
  k-resize {
63
63
  width: 100%;
64
64
  height: var(--height, 14rem);
65
+ min-height: var(--min-height);
66
+ max-height: var(--max-height);
65
67
  background: var(--c_bg);
66
68
  }
67
69
  .frame {
@@ -100,6 +102,7 @@ import{html as t,css as e,nothing as i}from"../lit-all.min.js";import s from"./S
100
102
  overflow-y: auto;
101
103
  scrollbar-width: thin;
102
104
  resize: none;
105
+ max-height: none !important;
103
106
  }
104
107
  .preview-empty {
105
108
  color: var(--tc_muted);
@@ -498,6 +498,7 @@
498
498
  <a href="#required">Required</a><br />
499
499
  <a href="#requireMatch">Require Match</a><br />
500
500
  <a href="#maxVisible">Max Visible</a><br />
501
+ <a href="#noResultsMessage">No Results Message &amp; Empty Message</a><br />
501
502
 
502
503
  <h6 class="mt"><a href="#jsRef" class="no-link">JavaScript Reference</a></h6>
503
504
  <a href="#constructor">Constructor</a><br />
@@ -628,6 +629,21 @@
628
629
  </div>
629
630
  </div>
630
631
 
632
+ <h3 id="noResultsMessage"><a href="#noResultsMessage" class="no-link">No Results Message &amp; Empty Message</a></h3>
633
+ <p>Use <code>no-results-message</code> to customize the text shown when the user has typed something but no options match. Use <code>empty-message</code> to customize the text shown when the input is empty and no options are loaded yet (e.g. before a dynamic search has fetched results). They default to <code>"No Matches"</code> and <code>"Type to search..."</code> respectively.</p>
634
+ <div class="row -mx">
635
+ <div class="col m-span-12 px">
636
+ <k-card label="HTML">
637
+ <pre><code class="hljs html"><span class="hljs-tag">&lt;<span class="hljs-name">k-combobox</span> <span class="hljs-attr">placeholder</span>=<span class="hljs-string">&#x27;Start typing to search...&#x27;</span> <span class="hljs-attr">no-results-message</span>=<span class="hljs-string">&#x27;No users found. Try a different search.&#x27;</span> <span class="hljs-attr">empty-message</span>=<span class="hljs-string">&#x27;Start typing to find a user...&#x27;</span>&gt;</span><br><span class="hljs-tag">&lt;/<span class="hljs-name">k-combobox</span>&gt;</span></code></pre>
638
+ </k-card>
639
+ </div>
640
+ <div class="col m-span-12 px">
641
+ <k-card label="Output">
642
+ <k-combobox id="noResultsCb" placeholder="Start typing to search..." no-results-message="No users found. Try a different search." empty-message="Start typing to find a user..."></k-combobox>
643
+ </k-card>
644
+ </div>
645
+ </div>
646
+
631
647
  <h2 id="jsRef"><a href="#jsRef" class="no-link">JavaScript Reference</a></h2>
632
648
 
633
649
  <h3 id="constructor"><a href="#constructor" class="no-link">Constructor</a></h3>
@@ -722,6 +738,20 @@
722
738
  <td><code>8</code></td>
723
739
  <td>Maximum number of options visible at once.</td>
724
740
  </tr>
741
+ <tr>
742
+ <td><code>no-results-message</code></td>
743
+ <td><code>noResultsMessage</code></td>
744
+ <td><code>String</code></td>
745
+ <td><code>'No Matches'</code></td>
746
+ <td>Text displayed when the user has typed something but no options match.</td>
747
+ </tr>
748
+ <tr>
749
+ <td><code>empty-message</code></td>
750
+ <td><code>emptyMessage</code></td>
751
+ <td><code>String</code></td>
752
+ <td><code>'Type to search...'</code></td>
753
+ <td>Text displayed when the input is empty and no options are loaded (e.g. before an initial dynamic search fetch).</td>
754
+ </tr>
725
755
  </tbody>
726
756
  </table>
727
757
 
@@ -502,6 +502,7 @@
502
502
  <a href="#disabled">Disabled / Read-only</a><br />
503
503
  <a href="#tagFiltering">Tag Filtering</a><br />
504
504
  <a href="#customControls">Custom Controls</a><br />
505
+ <a href="#sizingExample">Sizing Example</a><br />
505
506
 
506
507
  <h6 class="mt"><a href="#jsRef" class="no-link">JavaScript Reference</a></h6>
507
508
  <a href="#constructor">Constructor</a><br />
@@ -768,6 +769,25 @@
768
769
  customElements.<span class="hljs-title function_">define</span>(<span class="hljs-string">'k-md-hr'</span>, <span class="hljs-title class_">MarkdownHr</span>);</code></pre>
769
770
  </k-card>
770
771
 
772
+ <h3 id="sizingExample"><a href="#sizingExample" class="no-link">Sizing Example</a></h3>
773
+ <p>Use the three sizing CSS variables together to control the editor dimensions. Set <code>--height</code> for the default size, <code>--min-height</code> to prevent it from shrinking, and <code>--max-height</code> to add a vertical scrollbar when content grows.</p>
774
+ <div class="row -mx">
775
+ <div class="col m-span-12 px">
776
+ <k-card label="CSS">
777
+ <pre><code class="hljs css"><span class="hljs-selector-tag">k-markdown-editor</span> {
778
+ <span class="hljs-attr">--height</span>: <span class="hljs-number">20rem</span>;
779
+ <span class="hljs-attr">--min-height</span>: <span class="hljs-number">10rem</span>;
780
+ <span class="hljs-attr">--max-height</span>: <span class="hljs-number">40rem</span>;
781
+ }</code></pre>
782
+ </k-card>
783
+ </div>
784
+ <div class="col m-span-12 px">
785
+ <k-card label="Output">
786
+ <k-markdown-editor placeholder="Type to expand, but max out at 40rem..." style="--height: 20rem; --min-height: 10rem; --max-height: 40rem;"></k-markdown-editor>
787
+ </k-card>
788
+ </div>
789
+ </div>
790
+
771
791
  <h2 id="jsRef"><a href="#jsRef" class="no-link">JavaScript Reference</a></h2>
772
792
 
773
793
  <h3 id="constructor"><a href="#constructor" class="no-link">Constructor</a></h3>
@@ -864,10 +884,10 @@ customElements.<span class="hljs-title function_">define</span>(<span class="hlj
864
884
  <table class="b">
865
885
  <thead><tr><th>Variable</th><th>Default</th><th>Description</th></tr></thead>
866
886
  <tbody>
867
- <tr><td><code>--min_height</code></td><td><code>6rem</code></td><td>Minimum height of the textarea / preview area</td></tr>
868
- <tr><td><code>--max_height</code></td><td><code>24rem</code></td><td>Maximum height before the editor scrolls internally</td></tr>
887
+ <tr><td><code>--height</code></td><td><code>14rem</code></td><td>Height of the editor container</td></tr>
888
+ <tr><td><code>--min-height</code></td><td>none</td><td>Minimum height of the editor</td></tr>
889
+ <tr><td><code>--max-height</code></td><td>none</td><td>Maximum height before the editor scrolls internally</td></tr>
869
890
  <tr><td><code>--padding</code></td><td><code>0.5rem 0.75rem</code></td><td>Padding inside the textarea / preview</td></tr>
870
- <tr><td><code>--border</code></td><td><code>1px solid var(--c_border)</code></td><td>Border around the editor</td></tr>
871
891
  </tbody>
872
892
  </table>
873
893
  <p class="mt">The component also uses kempo-css's global <code>--c_border</code>, <code>--c_bg</code>, <code>--tc</code>, <code>--tc_muted</code>, <code>--radius</code>, <code>--animation_ms</code>, and <code>--focus_shadow</code>; override those globally for site-wide tuning.</p>
@@ -877,7 +897,7 @@ customElements.<span class="hljs-title function_">define</span>(<span class="hlj
877
897
  <p>Fires on every keystroke (or programmatic change via the selection-manipulation API). <code>detail.value</code> is the current markdown.</p>
878
898
 
879
899
  <h5><code>change</code></h5>
880
- <p>Fires when the textarea blurs. <code>detail.value</code> is the markdown at the moment focus left.</p>
900
+ <p>Fires with a 300ms debounce whenever text is typed, and also on blur. <code>detail.value</code> is the current markdown. Useful for enabling/disabling a "Save" button during long typing sessions without waiting for blur.</p>
881
901
 
882
902
  <h5><code>mode-change</code></h5>
883
903
  <p>Fires when the user switches between Write and Preview tabs. <code>detail.mode</code> is the new mode.</p>
@@ -1,4 +1,4 @@
1
- import{html as e,css as t}from"../lit-all.min.js";import s from"./ShadowComponent.js";import i from"../utils/debounce.js";import"./Option.js";export default class o extends s{static formAssociated=!0;static properties={value:{type:String,reflect:!0},name:{type:String,reflect:!0},placeholder:{type:String},opened:{type:Boolean,reflect:!0},searching:{type:Boolean,reflect:!0},required:{type:Boolean,reflect:!0},requireMatch:{type:Boolean,reflect:!0,attribute:"require-match"},disabled:{type:Boolean,reflect:!0},debounceMs:{type:Number,attribute:"debounce-ms"},maxVisible:{type:Number,attribute:"max-visible"}};#e=-1;#t=[];constructor(){super(),this.internals=this.attachInternals(),this.value="",this.name="",this.placeholder="",this.opened=!1,this.searching=!1,this.required=!1,this.requireMatch=!1,this.disabled=!1,this.debounceMs=300,this.maxVisible=8,this.#s(300)}connectedCallback(){super.connectedCallback(),document.addEventListener("click",this.handleDocumentClick),this.#i()}disconnectedCallback(){super.disconnectedCallback(),document.removeEventListener("click",this.handleDocumentClick)}childrenUpdated(){this.#i()}updated(e){super.updated(e),e.has("debounceMs")&&this.#s(this.debounceMs),(e.has("value")||e.has("required")||e.has("requireMatch"))&&this.#o()}formResetCallback(){this.value="",this.#o()}formStateRestoreCallback(e){this.value=e}handleInput=e=>{this.value=e.target.value,this.opened=!0,this.#e=-1,this.#a(this.value)};handleKeydown=e=>{const t=this.#n;if("ArrowDown"===e.key){if(e.preventDefault(),!this.opened)return void(this.opened=!0);this.#e=Math.min(this.#e+1,t.length-1),this.requestUpdate(),this.#r()}else"ArrowUp"===e.key?(e.preventDefault(),this.#e=Math.max(this.#e-1,0),this.requestUpdate(),this.#r()):"Enter"===e.key?(e.preventDefault(),this.#e>=0&&this.#e<t.length&&this.#l(t[this.#e])):"Escape"===e.key?(e.preventDefault(),this.opened=!1,this.#e=-1):"Tab"===e.key&&(this.opened=!1,this.#e=-1)};handleDocumentClick=e=>{e.composedPath().includes(this)||(this.opened=!1,this.#e=-1)};handleFocus=()=>{this.opened=!0};handleOptionClick=e=>{const t=Number(e.currentTarget.dataset.index);this.#l(this.#n[t])};setOptions(e){return[...this.querySelectorAll("k-option")].forEach(e=>e.remove()),e.forEach(e=>{const t=document.createElement("k-option");"object"==typeof e?(t.setAttribute("value",e.value??e.label),t.textContent=e.label):t.textContent=e,this.appendChild(t)}),this}clear(){return this.value="",this.opened=!1,this.#e=-1,this.#o(),this}#s(e){this.#a=i(e=>{this.dispatchEvent(new CustomEvent("search",{detail:{value:e},bubbles:!0,composed:!0}))},e)}#a=()=>{};#i(){this.#t=[...this.querySelectorAll("k-option")].map(e=>({label:e.label,value:e.value})),this.requestUpdate()}#l(e){this.value=e.label,this.opened=!1,this.#e=-1,this.#o(),this.dispatchEvent(new CustomEvent("select",{detail:{value:e.value,label:e.label},bubbles:!0,composed:!0})),this.dispatchEvent(new CustomEvent("change",{bubbles:!0}))}#o(){const e=this.#t.find(e=>e.label===this.value);this.internals.setFormValue(e?e.value:this.value),this.required&&!this.value?this.internals.setValidity({valueMissing:!0},"Please select a value.",this.shadowRoot?.querySelector("input")):this.requireMatch&&this.value&&!e?this.internals.setValidity({customError:!0},"Please select a valid option.",this.shadowRoot?.querySelector("input")):this.internals.setValidity({})}#r(){this.updateComplete.then(()=>{this.shadowRoot?.querySelector(".option.focused")?.scrollIntoView({block:"nearest"})})}get#d(){const e=(this.value||"").toLowerCase();return e?this.#t.filter(t=>t.label.toLowerCase().includes(e)):this.#t}get#n(){return this.#d.slice(0,this.maxVisible)}render(){const t=this.#n,s=this.#d.length>this.maxVisible;return e`
1
+ import{html as e,css as t}from"../lit-all.min.js";import s from"./ShadowComponent.js";import i from"../utils/debounce.js";import"./Option.js";export default class o extends s{static formAssociated=!0;static properties={value:{type:String,reflect:!0},name:{type:String,reflect:!0},placeholder:{type:String},opened:{type:Boolean,reflect:!0},searching:{type:Boolean,reflect:!0},required:{type:Boolean,reflect:!0},requireMatch:{type:Boolean,reflect:!0,attribute:"require-match"},disabled:{type:Boolean,reflect:!0},debounceMs:{type:Number,attribute:"debounce-ms"},maxVisible:{type:Number,attribute:"max-visible"},noResultsMessage:{type:String,attribute:"no-results-message"},emptyMessage:{type:String,attribute:"empty-message"}};#e=-1;#t=[];constructor(){super(),this.internals=this.attachInternals(),this.value="",this.name="",this.placeholder="",this.opened=!1,this.searching=!1,this.required=!1,this.requireMatch=!1,this.disabled=!1,this.debounceMs=300,this.maxVisible=8,this.noResultsMessage="No Matches",this.emptyMessage="Type to search...",this.#s(300)}connectedCallback(){super.connectedCallback(),document.addEventListener("click",this.handleDocumentClick),this.#i()}disconnectedCallback(){super.disconnectedCallback(),document.removeEventListener("click",this.handleDocumentClick)}childrenUpdated(){this.#i()}updated(e){super.updated(e),e.has("debounceMs")&&this.#s(this.debounceMs),(e.has("value")||e.has("required")||e.has("requireMatch"))&&this.#o()}formResetCallback(){this.value="",this.#o()}formStateRestoreCallback(e){this.value=e}handleInput=e=>{this.value=e.target.value,this.opened=!0,this.#e=-1,this.#a(this.value)};handleKeydown=e=>{const t=this.#n;if("ArrowDown"===e.key){if(e.preventDefault(),!this.opened)return void(this.opened=!0);this.#e=Math.min(this.#e+1,t.length-1),this.requestUpdate(),this.#r()}else"ArrowUp"===e.key?(e.preventDefault(),this.#e=Math.max(this.#e-1,0),this.requestUpdate(),this.#r()):"Enter"===e.key?(e.preventDefault(),this.#e>=0&&this.#e<t.length&&this.#l(t[this.#e])):"Escape"===e.key?(e.preventDefault(),this.opened=!1,this.#e=-1):"Tab"===e.key&&(this.opened=!1,this.#e=-1)};handleDocumentClick=e=>{e.composedPath().includes(this)||(this.opened=!1,this.#e=-1)};handleFocus=()=>{this.opened=!0};handleOptionClick=e=>{const t=Number(e.currentTarget.dataset.index);this.#l(this.#n[t])};setOptions(e){return[...this.querySelectorAll("k-option")].forEach(e=>e.remove()),e.forEach(e=>{const t=document.createElement("k-option");"object"==typeof e?(t.setAttribute("value",e.value??e.label),t.textContent=e.label):t.textContent=e,this.appendChild(t)}),this}clear(){return this.value="",this.opened=!1,this.#e=-1,this.#o(),this}#s(e){this.#a=i(e=>{this.dispatchEvent(new CustomEvent("search",{detail:{value:e},bubbles:!0,composed:!0}))},e)}#a=()=>{};#i(){this.#t=[...this.querySelectorAll("k-option")].map(e=>({label:e.label,value:e.value})),this.requestUpdate()}#l(e){this.value=e.label,this.opened=!1,this.#e=-1,this.#o(),this.dispatchEvent(new CustomEvent("select",{detail:{value:e.value,label:e.label},bubbles:!0,composed:!0})),this.dispatchEvent(new CustomEvent("change",{bubbles:!0}))}#o(){const e=this.#t.find(e=>e.label===this.value);this.internals.setFormValue(e?e.value:this.value),this.required&&!this.value?this.internals.setValidity({valueMissing:!0},"Please select a value.",this.shadowRoot?.querySelector("input")):this.requireMatch&&this.value&&!e?this.internals.setValidity({customError:!0},"Please select a valid option.",this.shadowRoot?.querySelector("input")):this.internals.setValidity({})}#r(){this.updateComplete.then(()=>{this.shadowRoot?.querySelector(".option.focused")?.scrollIntoView({block:"nearest"})})}get#d(){const e=(this.value||"").toLowerCase();return e?this.#t.filter(t=>t.label.toLowerCase().includes(e)):this.#t}get#n(){return this.#d.slice(0,this.maxVisible)}render(){const t=this.#n,s=this.#d.length>this.maxVisible;return e`
2
2
  <slot style="display:none"></slot>
3
3
  <input
4
4
  type="text"
@@ -23,7 +23,7 @@ import{html as e,css as t}from"../lit-all.min.js";import s from"./ShadowComponen
23
23
  <div class="more">${this.#d.length-this.maxVisible} more...</div>
24
24
  `:""}
25
25
  ${0!==t.length||this.searching?"":e`
26
- <div class="no-results">No matches</div>
26
+ <div class="no-results">${this.value?this.noResultsMessage:this.emptyMessage}</div>
27
27
  `}
28
28
  ${this.searching?e`
29
29
  <div class="searching">
@@ -62,6 +62,8 @@ import{html as t,css as e,nothing as i}from"../lit-all.min.js";import s from"./S
62
62
  k-resize {
63
63
  width: 100%;
64
64
  height: var(--height, 14rem);
65
+ min-height: var(--min-height);
66
+ max-height: var(--max-height);
65
67
  background: var(--c_bg);
66
68
  }
67
69
  .frame {
@@ -100,6 +102,7 @@ import{html as t,css as e,nothing as i}from"../lit-all.min.js";import s from"./S
100
102
  overflow-y: auto;
101
103
  scrollbar-width: thin;
102
104
  resize: none;
105
+ max-height: none !important;
103
106
  }
104
107
  .preview-empty {
105
108
  color: var(--tc_muted);
@@ -13,6 +13,7 @@
13
13
  <a href="#required">Required</a><br />
14
14
  <a href="#requireMatch">Require Match</a><br />
15
15
  <a href="#maxVisible">Max Visible</a><br />
16
+ <a href="#noResultsMessage">No Results Message &amp; Empty Message</a><br />
16
17
 
17
18
  <h6 class="mt"><a href="#jsRef" class="no-link">JavaScript Reference</a></h6>
18
19
  <a href="#constructor">Constructor</a><br />
@@ -143,6 +144,21 @@
143
144
  </div>
144
145
  </div>
145
146
 
147
+ <h3 id="noResultsMessage"><a href="#noResultsMessage" class="no-link">No Results Message &amp; Empty Message</a></h3>
148
+ <p>Use <code>no-results-message</code> to customize the text shown when the user has typed something but no options match. Use <code>empty-message</code> to customize the text shown when the input is empty and no options are loaded yet (e.g. before a dynamic search has fetched results). They default to <code>"No Matches"</code> and <code>"Type to search..."</code> respectively.</p>
149
+ <div class="row -mx">
150
+ <div class="col m-span-12 px">
151
+ <k-card label="HTML">
152
+ <pre><code class="hljs html"><span class="hljs-tag">&lt;<span class="hljs-name">k-combobox</span> <span class="hljs-attr">placeholder</span>=<span class="hljs-string">&#x27;Start typing to search...&#x27;</span> <span class="hljs-attr">no-results-message</span>=<span class="hljs-string">&#x27;No users found. Try a different search.&#x27;</span> <span class="hljs-attr">empty-message</span>=<span class="hljs-string">&#x27;Start typing to find a user...&#x27;</span>&gt;</span><br><span class="hljs-tag">&lt;/<span class="hljs-name">k-combobox</span>&gt;</span></code></pre>
153
+ </k-card>
154
+ </div>
155
+ <div class="col m-span-12 px">
156
+ <k-card label="Output">
157
+ <k-combobox id="noResultsCb" placeholder="Start typing to search..." no-results-message="No users found. Try a different search." empty-message="Start typing to find a user..."></k-combobox>
158
+ </k-card>
159
+ </div>
160
+ </div>
161
+
146
162
  <h2 id="jsRef"><a href="#jsRef" class="no-link">JavaScript Reference</a></h2>
147
163
 
148
164
  <h3 id="constructor"><a href="#constructor" class="no-link">Constructor</a></h3>
@@ -237,6 +253,20 @@
237
253
  <td><code>8</code></td>
238
254
  <td>Maximum number of options visible at once.</td>
239
255
  </tr>
256
+ <tr>
257
+ <td><code>no-results-message</code></td>
258
+ <td><code>noResultsMessage</code></td>
259
+ <td><code>String</code></td>
260
+ <td><code>'No Matches'</code></td>
261
+ <td>Text displayed when the user has typed something but no options match.</td>
262
+ </tr>
263
+ <tr>
264
+ <td><code>empty-message</code></td>
265
+ <td><code>emptyMessage</code></td>
266
+ <td><code>String</code></td>
267
+ <td><code>'Type to search...'</code></td>
268
+ <td>Text displayed when the input is empty and no options are loaded (e.g. before an initial dynamic search fetch).</td>
269
+ </tr>
240
270
  </tbody>
241
271
  </table>
242
272
 
@@ -14,6 +14,7 @@
14
14
  <a href="#disabled">Disabled / Read-only</a><br />
15
15
  <a href="#tagFiltering">Tag Filtering</a><br />
16
16
  <a href="#customControls">Custom Controls</a><br />
17
+ <a href="#sizingExample">Sizing Example</a><br />
17
18
 
18
19
  <h6 class="mt"><a href="#jsRef" class="no-link">JavaScript Reference</a></h6>
19
20
  <a href="#constructor">Constructor</a><br />
@@ -280,6 +281,25 @@
280
281
  customElements.<span class="hljs-title function_">define</span>(<span class="hljs-string">'k-md-hr'</span>, <span class="hljs-title class_">MarkdownHr</span>);</code></pre>
281
282
  </k-card>
282
283
 
284
+ <h3 id="sizingExample"><a href="#sizingExample" class="no-link">Sizing Example</a></h3>
285
+ <p>Use the three sizing CSS variables together to control the editor dimensions. Set <code>--height</code> for the default size, <code>--min-height</code> to prevent it from shrinking, and <code>--max-height</code> to add a vertical scrollbar when content grows.</p>
286
+ <div class="row -mx">
287
+ <div class="col m-span-12 px">
288
+ <k-card label="CSS">
289
+ <pre><code class="hljs css"><span class="hljs-selector-tag">k-markdown-editor</span> {
290
+ <span class="hljs-attr">--height</span>: <span class="hljs-number">20rem</span>;
291
+ <span class="hljs-attr">--min-height</span>: <span class="hljs-number">10rem</span>;
292
+ <span class="hljs-attr">--max-height</span>: <span class="hljs-number">40rem</span>;
293
+ }</code></pre>
294
+ </k-card>
295
+ </div>
296
+ <div class="col m-span-12 px">
297
+ <k-card label="Output">
298
+ <k-markdown-editor placeholder="Type to expand, but max out at 40rem..." style="--height: 20rem; --min-height: 10rem; --max-height: 40rem;"></k-markdown-editor>
299
+ </k-card>
300
+ </div>
301
+ </div>
302
+
283
303
  <h2 id="jsRef"><a href="#jsRef" class="no-link">JavaScript Reference</a></h2>
284
304
 
285
305
  <h3 id="constructor"><a href="#constructor" class="no-link">Constructor</a></h3>
@@ -376,10 +396,10 @@ customElements.<span class="hljs-title function_">define</span>(<span class="hlj
376
396
  <table class="b">
377
397
  <thead><tr><th>Variable</th><th>Default</th><th>Description</th></tr></thead>
378
398
  <tbody>
379
- <tr><td><code>--min_height</code></td><td><code>6rem</code></td><td>Minimum height of the textarea / preview area</td></tr>
380
- <tr><td><code>--max_height</code></td><td><code>24rem</code></td><td>Maximum height before the editor scrolls internally</td></tr>
399
+ <tr><td><code>--height</code></td><td><code>14rem</code></td><td>Height of the editor container</td></tr>
400
+ <tr><td><code>--min-height</code></td><td>none</td><td>Minimum height of the editor</td></tr>
401
+ <tr><td><code>--max-height</code></td><td>none</td><td>Maximum height before the editor scrolls internally</td></tr>
381
402
  <tr><td><code>--padding</code></td><td><code>0.5rem 0.75rem</code></td><td>Padding inside the textarea / preview</td></tr>
382
- <tr><td><code>--border</code></td><td><code>1px solid var(--c_border)</code></td><td>Border around the editor</td></tr>
383
403
  </tbody>
384
404
  </table>
385
405
  <p class="mt">The component also uses kempo-css's global <code>--c_border</code>, <code>--c_bg</code>, <code>--tc</code>, <code>--tc_muted</code>, <code>--radius</code>, <code>--animation_ms</code>, and <code>--focus_shadow</code>; override those globally for site-wide tuning.</p>
@@ -389,7 +409,7 @@ customElements.<span class="hljs-title function_">define</span>(<span class="hlj
389
409
  <p>Fires on every keystroke (or programmatic change via the selection-manipulation API). <code>detail.value</code> is the current markdown.</p>
390
410
 
391
411
  <h5><code>change</code></h5>
392
- <p>Fires when the textarea blurs. <code>detail.value</code> is the markdown at the moment focus left.</p>
412
+ <p>Fires with a 300ms debounce whenever text is typed, and also on blur. <code>detail.value</code> is the current markdown. Useful for enabling/disabling a "Save" button during long typing sessions without waiting for blur.</p>
393
413
 
394
414
  <h5><code>mode-change</code></h5>
395
415
  <p>Fires when the user switches between Write and Preview tabs. <code>detail.mode</code> is the new mode.</p>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kempo-ui",
3
- "version": "0.4.16",
3
+ "version": "0.4.18",
4
4
  "type": "module",
5
5
  "description": "A Lit based web-component library",
6
6
  "main": "index.js",
@@ -16,7 +16,9 @@ export default class Combobox extends ShadowComponent {
16
16
  requireMatch: { type: Boolean, reflect: true, attribute: 'require-match' },
17
17
  disabled: { type: Boolean, reflect: true },
18
18
  debounceMs: { type: Number, attribute: 'debounce-ms' },
19
- maxVisible: { type: Number, attribute: 'max-visible' }
19
+ maxVisible: { type: Number, attribute: 'max-visible' },
20
+ noResultsMessage: { type: String, attribute: 'no-results-message' },
21
+ emptyMessage: { type: String, attribute: 'empty-message' }
20
22
  };
21
23
 
22
24
  #focusedIndex = -1;
@@ -38,6 +40,8 @@ export default class Combobox extends ShadowComponent {
38
40
  this.disabled = false;
39
41
  this.debounceMs = 300;
40
42
  this.maxVisible = 8;
43
+ this.noResultsMessage = 'No Matches';
44
+ this.emptyMessage = 'Type to search...';
41
45
  this.#setupDebounce(300);
42
46
  }
43
47
 
@@ -255,7 +259,7 @@ export default class Combobox extends ShadowComponent {
255
259
  <div class="more">${this.#filteredOptions.length - this.maxVisible} more...</div>
256
260
  ` : ''}
257
261
  ${visible.length === 0 && !this.searching ? html`
258
- <div class="no-results">No matches</div>
262
+ <div class="no-results">${this.value ? this.noResultsMessage : this.emptyMessage}</div>
259
263
  ` : ''}
260
264
  ${this.searching ? html`
261
265
  <div class="searching">
@@ -470,6 +470,8 @@ export default class MarkdownEditor extends ShadowComponent {
470
470
  k-resize {
471
471
  width: 100%;
472
472
  height: var(--height, 14rem);
473
+ min-height: var(--min-height);
474
+ max-height: var(--max-height);
473
475
  background: var(--c_bg);
474
476
  }
475
477
  .frame {
@@ -508,6 +510,7 @@ export default class MarkdownEditor extends ShadowComponent {
508
510
  overflow-y: auto;
509
511
  scrollbar-width: thin;
510
512
  resize: none;
513
+ max-height: none !important;
511
514
  }
512
515
  .preview-empty {
513
516
  color: var(--tc_muted);
@@ -12,8 +12,8 @@ const createCombobox = async (attrs = {}, children = '') => {
12
12
  ${attrs.disabled ? 'disabled' : ''}
13
13
  ${attrs.name ? `name="${attrs.name}"` : ''}
14
14
  ${attrs.debounceMs ? `debounce-ms="${attrs.debounceMs}"` : ''}
15
- ${attrs.maxVisible ? `max-visible="${attrs.maxVisible}"` : ''}
16
- >${children}</k-combobox>
15
+ ${attrs.maxVisible ? `max-visible="${attrs.maxVisible}"` : ''} ${attrs.noResultsMessage ? `no-results-message="${attrs.noResultsMessage}"` : ''}
16
+ ${attrs.emptyMessage ? `empty-message="${attrs.emptyMessage}"` : ''} >${children}</k-combobox>
17
17
  `;
18
18
  document.body.appendChild(container);
19
19
  const el = container.querySelector('k-combobox');
@@ -119,6 +119,26 @@ export default {
119
119
  pass();
120
120
  },
121
121
 
122
+ 'should have default noResultsMessage of No Matches': async ({pass, fail}) => {
123
+ const { container, el } = await createCombobox();
124
+ if(el.noResultsMessage !== 'No Matches') {
125
+ cleanup(container);
126
+ return fail(`Expected 'No Matches', got '${el.noResultsMessage}'`);
127
+ }
128
+ cleanup(container);
129
+ pass();
130
+ },
131
+
132
+ 'should have default emptyMessage of Type to search...': async ({pass, fail}) => {
133
+ const { container, el } = await createCombobox();
134
+ if(el.emptyMessage !== 'Type to search...') {
135
+ cleanup(container);
136
+ return fail(`Expected 'Type to search...', got '${el.emptyMessage}'`);
137
+ }
138
+ cleanup(container);
139
+ pass();
140
+ },
141
+
122
142
  'should not be required by default': async ({pass, fail}) => {
123
143
  const { container, el } = await createCombobox();
124
144
  if(el.required !== false) {
@@ -222,6 +242,26 @@ export default {
222
242
  pass();
223
243
  },
224
244
 
245
+ 'should reflect no-results-message attribute': async ({pass, fail}) => {
246
+ const { container, el } = await createCombobox({ noResultsMessage: 'Nothing found' });
247
+ if(el.noResultsMessage !== 'Nothing found') {
248
+ cleanup(container);
249
+ return fail(`Expected 'Nothing found', got '${el.noResultsMessage}'`);
250
+ }
251
+ cleanup(container);
252
+ pass();
253
+ },
254
+
255
+ 'should reflect empty-message attribute': async ({pass, fail}) => {
256
+ const { container, el } = await createCombobox({ emptyMessage: 'Start searching' });
257
+ if(el.emptyMessage !== 'Start searching') {
258
+ cleanup(container);
259
+ return fail(`Expected 'Start searching', got '${el.emptyMessage}'`);
260
+ }
261
+ cleanup(container);
262
+ pass();
263
+ },
264
+
225
265
  /*
226
266
  Children (k-option)
227
267
  */
@@ -443,6 +483,33 @@ export default {
443
483
  pass();
444
484
  },
445
485
 
486
+ 'should show noResultsMessage text when value typed but no matches': async ({pass, fail}) => {
487
+ const { container, el } = await createCombobox({ noResultsMessage: 'Custom no results' }, fruitOptions);
488
+ el.value = 'zzz';
489
+ el.opened = true;
490
+ await el.updateComplete;
491
+ const msg = el.shadowRoot.querySelector('.no-results');
492
+ if(!msg || msg.textContent.trim() !== 'Custom no results') {
493
+ cleanup(container);
494
+ return fail(`Expected 'Custom no results', got '${msg?.textContent}'`);
495
+ }
496
+ cleanup(container);
497
+ pass();
498
+ },
499
+
500
+ 'should show emptyMessage text when no value and no options': async ({pass, fail}) => {
501
+ const { container, el } = await createCombobox({ emptyMessage: 'Custom empty' });
502
+ el.opened = true;
503
+ await el.updateComplete;
504
+ const msg = el.shadowRoot.querySelector('.no-results');
505
+ if(!msg || msg.textContent.trim() !== 'Custom empty') {
506
+ cleanup(container);
507
+ return fail(`Expected 'Custom empty', got '${msg?.textContent}'`);
508
+ }
509
+ cleanup(container);
510
+ pass();
511
+ },
512
+
446
513
  /*
447
514
  Max Visible
448
515
  */
@@ -917,7 +917,7 @@ export default {
917
917
  fail(`Expected active to be "tab3", got "${tabs.active}"`);
918
918
  return;
919
919
  }
920
- if(!tab3.active || content3.active){
920
+ if(!tab3.active || !content3.active){
921
921
  fail('tab3 should be active (restored from localStorage)');
922
922
  return;
923
923
  }