kempo-ui 0.1.0 → 0.1.2

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.
@@ -1,4 +1,4 @@
1
- import{html,css}from"../lit-all.min.js";import ShadowComponent from"./ShadowComponent.js";import{boolTrueFalse}from"../utils/propConverters.js";const openDropdowns=new Set;export default class Dropdown extends ShadowComponent{static properties={opened:{type:Boolean,reflect:!0},openDirection:{type:String,reflect:!0,attribute:"open-direction"},closeOnSelect:{type:Boolean,reflect:!0,attribute:"close-on-select",converter:boolTrueFalse},closeOnClickOutside:{type:Boolean,reflect:!0,attribute:"close-on-click-outside",converter:boolTrueFalse}};constructor(){super(),this.opened=!1,this.openDirection="down left",this.closeOnSelect=!0,this.closeOnClickOutside=!0}connectedCallback(){super.connectedCallback(),document.addEventListener("click",this.handleDocumentClick),document.addEventListener("keydown",this.handleKeydown)}disconnectedCallback(){super.disconnectedCallback(),document.removeEventListener("click",this.handleDocumentClick),document.removeEventListener("keydown",this.handleKeydown),openDropdowns.delete(this)}updated(t){super.updated(t),t.has("opened")&&(this.opened?openDropdowns.add(this):openDropdowns.delete(this),this.dispatchEvent(new CustomEvent(this.opened?"opened":"closed",{bubbles:!0})))}handleDocumentClick=t=>{const e=t.target.closest("k-dropdown"),o=t.target.closest('[slot="trigger"]');if(e&&e!==this&&o)return void(this.opened&&this.close());if(!this.opened)return;if(e!==this)return void(this.closeOnClickOutside&&this.close());if(o)return;t.target.closest("a, button")&&this.closeOnSelect&&this.close()};handleTriggerClick=t=>{t.stopPropagation(),this.opened||openDropdowns.forEach(t=>{t!==this&&t.close()}),this.toggle()};handleMenuClick=t=>{const e=t.target.closest("a, button");if(e&&!e.hasAttribute("disabled")){const t=e.dataset?.value||e.textContent.trim();this.dispatchEvent(new CustomEvent("select",{detail:{value:t,item:e},bubbles:!0}))}};handleKeydown=t=>{if(this.opened)if("Escape"===t.key)t.preventDefault(),this.close(),this.focusTrigger();else if("ArrowDown"===t.key)t.preventDefault(),this.focusNextItem();else if("ArrowUp"===t.key)t.preventDefault(),this.focusPreviousItem();else if("Enter"===t.key||" "===t.key){const e=this.querySelector("a:focus, button:focus");e&&(t.preventDefault(),e.click())}};open(){return openDropdowns.forEach(t=>{t!==this&&t.close()}),this.opened=!0,requestAnimationFrame(()=>this.focusFirstItem()),this}close(){return this.opened=!1,this}toggle(){return this.opened?this.close():this.open()}focusTrigger(){const t=this.querySelector('[slot="trigger"]');t&&t.focus()}getMenuItems(){return[...this.querySelectorAll("a, button")].filter(t=>!t.hasAttribute("disabled")&&!t.closest('[slot="trigger"]'))}focusFirstItem(){const t=this.getMenuItems();t.length>0&&t[0].focus()}focusNextItem(){const t=this.getMenuItems(),e=document.activeElement,o=t.indexOf(e),n=t[o+1]||t[0];n&&n.focus()}focusPreviousItem(){const t=this.getMenuItems(),e=document.activeElement,o=t.indexOf(e),n=t[o-1]||t[t.length-1];n&&n.focus()}static styles=css`
1
+ import{html,css}from"../lit-all.min.js";import ShadowComponent from"./ShadowComponent.js";import{boolTrueFalse}from"../utils/propConverters.js";const openDropdowns=new Set;export default class Dropdown extends ShadowComponent{static properties={opened:{type:Boolean,reflect:!0},openDirection:{type:String,reflect:!0,attribute:"open-direction"},closeOnSelect:{type:Boolean,reflect:!0,attribute:"close-on-select",converter:boolTrueFalse},closeOnClickOutside:{type:Boolean,reflect:!0,attribute:"close-on-click-outside",converter:boolTrueFalse},submenu:{type:Boolean,reflect:!0}};constructor(){super(),this.opened=!1,this.openDirection="down left",this.closeOnSelect=!0,this.closeOnClickOutside=!0,this.submenu=!1}connectedCallback(){super.connectedCallback(),"K-DROPDOWN"!==this.parentElement?.tagName||this.hasAttribute("slot")||(this.submenu=!0,this.hasAttribute("open-direction")||(this.openDirection="right down")),this.submenu?(this.addEventListener("mouseenter",this.handleSubmenuEnter),this.addEventListener("mouseleave",this.handleSubmenuLeave)):document.addEventListener("click",this.handleDocumentClick),document.addEventListener("keydown",this.handleKeydown)}disconnectedCallback(){super.disconnectedCallback(),this.submenu?(this.removeEventListener("mouseenter",this.handleSubmenuEnter),this.removeEventListener("mouseleave",this.handleSubmenuLeave)):document.removeEventListener("click",this.handleDocumentClick),document.removeEventListener("keydown",this.handleKeydown),openDropdowns.delete(this)}updated(e){super.updated(e),e.has("opened")&&(this.submenu||(this.opened?openDropdowns.add(this):openDropdowns.delete(this)),this.dispatchEvent(new CustomEvent(this.opened?"opened":"closed",{bubbles:!0})))}handleDocumentClick=e=>{const t=e.target.closest('[slot="trigger"]');if(t){const e=t.closest("k-dropdown");if(e===this)return;if(this.contains(e))return;return void(this.opened&&this.close())}if(!this.opened)return;if(!this.contains(e.target))return void(this.closeOnClickOutside&&this.close());const o=e.target.closest("a, button");o&&!o.closest('[slot="trigger"]')&&this.closeOnSelect&&this.close()};handleTriggerClick=e=>{e.stopPropagation(),this.opened||openDropdowns.forEach(e=>{e!==this&&e.close()}),this.toggle()};handleMenuClick=e=>{const t=e.target.closest("a, button");if(!t||t.hasAttribute("disabled"))return;if(t.closest("k-dropdown")!==this)return;const o=t.dataset?.value||t.textContent.trim();this.dispatchEvent(new CustomEvent("select",{detail:{value:o,item:t},bubbles:!0}))};handleKeydown=e=>{if(!this.opened)return;const t=document.activeElement;if(!this.contains(t))return;const o=this.querySelector(":scope > k-dropdown[opened]");if(!o?.contains(t))if("Escape"===e.key||this.submenu&&"ArrowLeft"===e.key)e.preventDefault(),this.close(),this.focusTrigger();else if("ArrowDown"===e.key)e.preventDefault(),this.focusNextItem();else if("ArrowUp"===e.key)e.preventDefault(),this.focusPreviousItem();else if("ArrowRight"===e.key){const o=t?.closest("k-dropdown[submenu]");o?.parentElement===this&&(e.preventDefault(),o.open(),o.focusFirstItem())}else if("Enter"===e.key||" "===e.key){const o=t?.closest("k-dropdown[submenu]");o?.parentElement===this?(e.preventDefault(),o.open(),o.focusFirstItem()):t&&!t.closest('[slot="trigger"]')&&(e.preventDefault(),t.click())}};open(){return this.submenu?[...this.parentElement.querySelectorAll(":scope > k-dropdown[opened]")].forEach(e=>{e!==this&&e.close()}):openDropdowns.forEach(e=>{e!==this&&e.close()}),this.opened=!0,this.submenu||requestAnimationFrame(()=>this.focusFirstItem()),this}close(){return this.querySelectorAll(":scope > k-dropdown[opened]").forEach(e=>e.close()),this.opened=!1,this}toggle(){return this.opened?this.close():this.open()}focusTrigger(){const e=this.querySelector('[slot="trigger"]');e&&e.focus()}getMenuItems(){return[...this.children].reduce((e,t)=>{if(t.matches('[slot="trigger"]')||t.hasAttribute("disabled"))return e;if("K-DROPDOWN"===t.tagName){const o=t.querySelector('[slot="trigger"]');o&&e.push(o)}else t.matches("a, button")&&e.push(t);return e},[])}focusFirstItem(){const e=this.getMenuItems();e.length>0&&e[0].focus()}focusNextItem(){const e=this.getMenuItems(),t=document.activeElement,o=e.indexOf(t),n=e[o+1]||e[0];n&&n.focus()}focusPreviousItem(){const e=this.getMenuItems(),t=document.activeElement,o=e.indexOf(t),n=e[o-1]||e[e.length-1];n&&n.focus()}static styles=css`
2
2
  :host {
3
3
  display: inline-block;
4
4
  position: relative;
@@ -23,6 +23,9 @@ import{html,css}from"../lit-all.min.js";import ShadowComponent from"./ShadowComp
23
23
  :host([opened]) #menu {
24
24
  display: block;
25
25
  }
26
+ :host([submenu]) #menu {
27
+ margin: 0;
28
+ }
26
29
  /* Default: down left */
27
30
  #menu {
28
31
  position-area: bottom span-right;
@@ -115,9 +118,49 @@ import{html,css}from"../lit-all.min.js";import ShadowComponent from"./ShadowComp
115
118
  ::slotted(hr) {
116
119
  display: none !important;
117
120
  }
118
- `;handleSlotChange=t=>{t.target.assignedElements().filter(t=>t.matches("a, button")).forEach((t,e)=>t.classList.toggle("k-dropdown-first",0===e))};render(){return html`
121
+ /* Slotted submenu dropdowns */
122
+ ::slotted(k-dropdown) {
123
+ display: block !important;
124
+ width: 100% !important;
125
+ border-top: 1px solid var(--c_border) !important;
126
+ }
127
+ ::slotted(k-dropdown.k-dropdown-first) {
128
+ border-top: none !important;
129
+ }
130
+ /* Submenu host styles */
131
+ :host([submenu]) {
132
+ display: block;
133
+ position: relative;
134
+ }
135
+ :host([submenu]) #trigger {
136
+ display: flex;
137
+ align-items: center;
138
+ justify-content: space-between;
139
+ gap: 1rem;
140
+ padding: var(--spacer_h) var(--spacer);
141
+ cursor: pointer;
142
+ white-space: nowrap;
143
+ transition: background var(--animation_ms);
144
+ }
145
+ :host([submenu]) #trigger:hover,
146
+ :host([submenu]) #trigger:focus-within {
147
+ background: var(--c_bg__alt);
148
+ }
149
+ :host([submenu]) #trigger ::slotted(button),
150
+ :host([submenu]) #trigger ::slotted(a) {
151
+ all: unset !important;
152
+ cursor: pointer !important;
153
+ font: inherit !important;
154
+ color: var(--tc) !important;
155
+ }
156
+ :host([submenu]) #trigger k-icon {
157
+ font-size: 0.75em;
158
+ opacity: 0.6;
159
+ }
160
+ `;handleSubmenuEnter=()=>{clearTimeout(this.closeTimer),this.opened||this.open()};handleSubmenuLeave=()=>{this.closeTimer=setTimeout(()=>this.close(),150)};handleSlotChange=e=>{e.target.assignedElements().filter(e=>e.matches("a, button, k-dropdown")).forEach((e,t)=>e.classList.toggle("k-dropdown-first",0===t))};render(){return html`
119
161
  <div id="trigger" @click=${this.handleTriggerClick}>
120
162
  <slot name="trigger"></slot>
163
+ ${this.submenu?html`<k-icon name="chevron"></k-icon>`:""}
121
164
  </div>
122
165
  <div id="menu" role="menu" @click=${this.handleMenuClick}>
123
166
  <slot @slotchange=${this.handleSlotChange}></slot>
@@ -24,6 +24,7 @@
24
24
  <h6>Examples</h6>
25
25
  <a href="#basicUsage">Basic Usage</a><br />
26
26
  <a href="#withIcons">With Icons</a><br />
27
+ <a href="#subMenus">Sub Menus</a><br />
27
28
  <a href="#openDirection">Open Direction</a><br />
28
29
  <a href="#disabledItems">Disabled Items</a><br />
29
30
  <a href="#links">Using Links</a><br />
@@ -88,6 +89,30 @@
88
89
  </div>
89
90
  </div>
90
91
 
92
+ <h3 id="subMenus"><a href="#subMenus" class="no-link">Sub Menus</a></h3>
93
+ <p>Nest a <code>k-dropdown</code> inside another <code>k-dropdown</code> to create sub menus. The nested dropdown is automatically detected as a submenu: it opens on hover or focus, displays a chevron icon, and opens to the right by default. Keyboard navigation supports <code>ArrowRight</code> to open and <code>ArrowLeft</code> to close sub menus.</p>
94
+ <div class="row -mx">
95
+ <div class="col m-span-12 px">
96
+ <k-card label="HTML">
97
+ <pre><code class="hljs xml"><span class="hljs-tag">&lt;<span class="hljs-name">k-dropdown</span>&gt;</span><br> <span class="hljs-tag">&lt;<span class="hljs-name">button</span> <span class="hljs-attr">slot</span>=<span class="hljs-string">&quot;trigger&quot;</span>&gt;</span>Actions<span class="hljs-tag">&lt;/<span class="hljs-name">button</span>&gt;</span><br> <span class="hljs-tag">&lt;<span class="hljs-name">button</span> <span class="hljs-attr">data-value</span>=<span class="hljs-string">&quot;edit&quot;</span>&gt;</span>Edit<span class="hljs-tag">&lt;/<span class="hljs-name">button</span>&gt;</span><br> <span class="hljs-tag">&lt;<span class="hljs-name">k-dropdown</span>&gt;</span><br> <span class="hljs-tag">&lt;<span class="hljs-name">button</span> <span class="hljs-attr">slot</span>=<span class="hljs-string">&quot;trigger&quot;</span>&gt;</span>Share<span class="hljs-tag">&lt;/<span class="hljs-name">button</span>&gt;</span><br> <span class="hljs-tag">&lt;<span class="hljs-name">button</span> <span class="hljs-attr">data-value</span>=<span class="hljs-string">&quot;email&quot;</span>&gt;</span>Email<span class="hljs-tag">&lt;/<span class="hljs-name">button</span>&gt;</span><br> <span class="hljs-tag">&lt;<span class="hljs-name">button</span> <span class="hljs-attr">data-value</span>=<span class="hljs-string">&quot;link&quot;</span>&gt;</span>Copy Link<span class="hljs-tag">&lt;/<span class="hljs-name">button</span>&gt;</span><br> <span class="hljs-tag">&lt;/<span class="hljs-name">k-dropdown</span>&gt;</span><br> <span class="hljs-tag">&lt;<span class="hljs-name">button</span> <span class="hljs-attr">data-value</span>=<span class="hljs-string">&quot;delete&quot;</span>&gt;</span>Delete<span class="hljs-tag">&lt;/<span class="hljs-name">button</span>&gt;</span><br><span class="hljs-tag">&lt;/<span class="hljs-name">k-dropdown</span>&gt;</span></code></pre>
98
+ </k-card>
99
+ </div>
100
+ <div class="col m-span-12 px">
101
+ <k-card label="Output">
102
+ <k-dropdown>
103
+ <button slot="trigger">Actions</button>
104
+ <button data-value="edit">Edit</button>
105
+ <k-dropdown>
106
+ <button slot="trigger">Share</button>
107
+ <button data-value="email">Email</button>
108
+ <button data-value="link">Copy Link</button>
109
+ </k-dropdown>
110
+ <button data-value="delete">Delete</button>
111
+ </k-dropdown>
112
+ </k-card>
113
+ </div>
114
+ </div>
115
+
91
116
  <h3 id="openDirection"><a href="#openDirection" class="no-link">Open Direction</a></h3>
92
117
  <p>Control where the menu appears using the <code>open-direction</code> attribute. Specify a direction like <code>down left</code>, <code>up right</code>, <code>left up</code>, etc. The browser automatically handles fallback positioning if the menu would overflow the viewport using CSS Anchor Positioning.</p>
93
118
  <div class="row -mx">
@@ -223,7 +248,7 @@
223
248
  <h3 id="requirements"><a href="#requirements" class="no-link">Requirements</a></h3>
224
249
  <ul>
225
250
  <li><a href="./shadow-component.html">ShadowComponent</a></li>
226
- <li><a href="./icon.html">Icon</a> (optional, for icons in items)</li>
251
+ <li><a href="./icon.html">Icon</a> (required for sub menus)</li>
227
252
  </ul>
228
253
 
229
254
  <h3 id="attributes"><a href="#attributes" class="no-link">Attributes</a></h3>
@@ -239,6 +264,9 @@
239
264
  <h5><code>close-on-click-outside<i>: boolean</i></code></h5>
240
265
  <p>Whether the dropdown closes when clicking outside of it. Default is <code>true</code>.</p>
241
266
 
267
+ <h5><code>submenu<i>: boolean</i></code></h5>
268
+ <p>Automatically set to <code>true</code> when a <code>k-dropdown</code> is nested inside another <code>k-dropdown</code>. Sub menus open on hover/focus, display a chevron icon, and default to <code>open-direction="right down"</code>. Supports <code>ArrowRight</code> to open and <code>ArrowLeft</code> to close via keyboard.</p>
269
+
242
270
  <h3 id="methods"><a href="#methods" class="no-link">Methods</a></h3>
243
271
  <h5><code>open()<i>: this</i></code></h5>
244
272
  <p>Opens the dropdown menu and focuses the first item.</p>
@@ -265,10 +293,12 @@
265
293
 
266
294
  <h4>Keyboard Navigation</h4>
267
295
  <ul>
268
- <li><code>Enter</code> / <code>Space</code> - Select the focused item</li>
296
+ <li><code>Enter</code> / <code>Space</code> - Select the focused item (or open sub menu)</li>
269
297
  <li><code>Arrow Down</code> - Move focus to the next item</li>
270
298
  <li><code>Arrow Up</code> - Move focus to the previous item</li>
271
- <li><code>Escape</code> - Close the dropdown</li>
299
+ <li><code>Arrow Right</code> - Open focused sub menu</li>
300
+ <li><code>Arrow Left</code> - Close current sub menu</li>
301
+ <li><code>Escape</code> - Close the dropdown (or sub menu)</li>
272
302
  </ul>
273
303
 
274
304
  <h3 id="cssVars"><a href="#cssVars" class="no-link">CSS Custom Properties</a></h3>
@@ -1,4 +1,4 @@
1
- import{html,css}from"../lit-all.min.js";import ShadowComponent from"./ShadowComponent.js";import{boolTrueFalse}from"../utils/propConverters.js";const openDropdowns=new Set;export default class Dropdown extends ShadowComponent{static properties={opened:{type:Boolean,reflect:!0},openDirection:{type:String,reflect:!0,attribute:"open-direction"},closeOnSelect:{type:Boolean,reflect:!0,attribute:"close-on-select",converter:boolTrueFalse},closeOnClickOutside:{type:Boolean,reflect:!0,attribute:"close-on-click-outside",converter:boolTrueFalse}};constructor(){super(),this.opened=!1,this.openDirection="down left",this.closeOnSelect=!0,this.closeOnClickOutside=!0}connectedCallback(){super.connectedCallback(),document.addEventListener("click",this.handleDocumentClick),document.addEventListener("keydown",this.handleKeydown)}disconnectedCallback(){super.disconnectedCallback(),document.removeEventListener("click",this.handleDocumentClick),document.removeEventListener("keydown",this.handleKeydown),openDropdowns.delete(this)}updated(t){super.updated(t),t.has("opened")&&(this.opened?openDropdowns.add(this):openDropdowns.delete(this),this.dispatchEvent(new CustomEvent(this.opened?"opened":"closed",{bubbles:!0})))}handleDocumentClick=t=>{const e=t.target.closest("k-dropdown"),o=t.target.closest('[slot="trigger"]');if(e&&e!==this&&o)return void(this.opened&&this.close());if(!this.opened)return;if(e!==this)return void(this.closeOnClickOutside&&this.close());if(o)return;t.target.closest("a, button")&&this.closeOnSelect&&this.close()};handleTriggerClick=t=>{t.stopPropagation(),this.opened||openDropdowns.forEach(t=>{t!==this&&t.close()}),this.toggle()};handleMenuClick=t=>{const e=t.target.closest("a, button");if(e&&!e.hasAttribute("disabled")){const t=e.dataset?.value||e.textContent.trim();this.dispatchEvent(new CustomEvent("select",{detail:{value:t,item:e},bubbles:!0}))}};handleKeydown=t=>{if(this.opened)if("Escape"===t.key)t.preventDefault(),this.close(),this.focusTrigger();else if("ArrowDown"===t.key)t.preventDefault(),this.focusNextItem();else if("ArrowUp"===t.key)t.preventDefault(),this.focusPreviousItem();else if("Enter"===t.key||" "===t.key){const e=this.querySelector("a:focus, button:focus");e&&(t.preventDefault(),e.click())}};open(){return openDropdowns.forEach(t=>{t!==this&&t.close()}),this.opened=!0,requestAnimationFrame(()=>this.focusFirstItem()),this}close(){return this.opened=!1,this}toggle(){return this.opened?this.close():this.open()}focusTrigger(){const t=this.querySelector('[slot="trigger"]');t&&t.focus()}getMenuItems(){return[...this.querySelectorAll("a, button")].filter(t=>!t.hasAttribute("disabled")&&!t.closest('[slot="trigger"]'))}focusFirstItem(){const t=this.getMenuItems();t.length>0&&t[0].focus()}focusNextItem(){const t=this.getMenuItems(),e=document.activeElement,o=t.indexOf(e),n=t[o+1]||t[0];n&&n.focus()}focusPreviousItem(){const t=this.getMenuItems(),e=document.activeElement,o=t.indexOf(e),n=t[o-1]||t[t.length-1];n&&n.focus()}static styles=css`
1
+ import{html,css}from"../lit-all.min.js";import ShadowComponent from"./ShadowComponent.js";import{boolTrueFalse}from"../utils/propConverters.js";const openDropdowns=new Set;export default class Dropdown extends ShadowComponent{static properties={opened:{type:Boolean,reflect:!0},openDirection:{type:String,reflect:!0,attribute:"open-direction"},closeOnSelect:{type:Boolean,reflect:!0,attribute:"close-on-select",converter:boolTrueFalse},closeOnClickOutside:{type:Boolean,reflect:!0,attribute:"close-on-click-outside",converter:boolTrueFalse},submenu:{type:Boolean,reflect:!0}};constructor(){super(),this.opened=!1,this.openDirection="down left",this.closeOnSelect=!0,this.closeOnClickOutside=!0,this.submenu=!1}connectedCallback(){super.connectedCallback(),"K-DROPDOWN"!==this.parentElement?.tagName||this.hasAttribute("slot")||(this.submenu=!0,this.hasAttribute("open-direction")||(this.openDirection="right down")),this.submenu?(this.addEventListener("mouseenter",this.handleSubmenuEnter),this.addEventListener("mouseleave",this.handleSubmenuLeave)):document.addEventListener("click",this.handleDocumentClick),document.addEventListener("keydown",this.handleKeydown)}disconnectedCallback(){super.disconnectedCallback(),this.submenu?(this.removeEventListener("mouseenter",this.handleSubmenuEnter),this.removeEventListener("mouseleave",this.handleSubmenuLeave)):document.removeEventListener("click",this.handleDocumentClick),document.removeEventListener("keydown",this.handleKeydown),openDropdowns.delete(this)}updated(e){super.updated(e),e.has("opened")&&(this.submenu||(this.opened?openDropdowns.add(this):openDropdowns.delete(this)),this.dispatchEvent(new CustomEvent(this.opened?"opened":"closed",{bubbles:!0})))}handleDocumentClick=e=>{const t=e.target.closest('[slot="trigger"]');if(t){const e=t.closest("k-dropdown");if(e===this)return;if(this.contains(e))return;return void(this.opened&&this.close())}if(!this.opened)return;if(!this.contains(e.target))return void(this.closeOnClickOutside&&this.close());const o=e.target.closest("a, button");o&&!o.closest('[slot="trigger"]')&&this.closeOnSelect&&this.close()};handleTriggerClick=e=>{e.stopPropagation(),this.opened||openDropdowns.forEach(e=>{e!==this&&e.close()}),this.toggle()};handleMenuClick=e=>{const t=e.target.closest("a, button");if(!t||t.hasAttribute("disabled"))return;if(t.closest("k-dropdown")!==this)return;const o=t.dataset?.value||t.textContent.trim();this.dispatchEvent(new CustomEvent("select",{detail:{value:o,item:t},bubbles:!0}))};handleKeydown=e=>{if(!this.opened)return;const t=document.activeElement;if(!this.contains(t))return;const o=this.querySelector(":scope > k-dropdown[opened]");if(!o?.contains(t))if("Escape"===e.key||this.submenu&&"ArrowLeft"===e.key)e.preventDefault(),this.close(),this.focusTrigger();else if("ArrowDown"===e.key)e.preventDefault(),this.focusNextItem();else if("ArrowUp"===e.key)e.preventDefault(),this.focusPreviousItem();else if("ArrowRight"===e.key){const o=t?.closest("k-dropdown[submenu]");o?.parentElement===this&&(e.preventDefault(),o.open(),o.focusFirstItem())}else if("Enter"===e.key||" "===e.key){const o=t?.closest("k-dropdown[submenu]");o?.parentElement===this?(e.preventDefault(),o.open(),o.focusFirstItem()):t&&!t.closest('[slot="trigger"]')&&(e.preventDefault(),t.click())}};open(){return this.submenu?[...this.parentElement.querySelectorAll(":scope > k-dropdown[opened]")].forEach(e=>{e!==this&&e.close()}):openDropdowns.forEach(e=>{e!==this&&e.close()}),this.opened=!0,this.submenu||requestAnimationFrame(()=>this.focusFirstItem()),this}close(){return this.querySelectorAll(":scope > k-dropdown[opened]").forEach(e=>e.close()),this.opened=!1,this}toggle(){return this.opened?this.close():this.open()}focusTrigger(){const e=this.querySelector('[slot="trigger"]');e&&e.focus()}getMenuItems(){return[...this.children].reduce((e,t)=>{if(t.matches('[slot="trigger"]')||t.hasAttribute("disabled"))return e;if("K-DROPDOWN"===t.tagName){const o=t.querySelector('[slot="trigger"]');o&&e.push(o)}else t.matches("a, button")&&e.push(t);return e},[])}focusFirstItem(){const e=this.getMenuItems();e.length>0&&e[0].focus()}focusNextItem(){const e=this.getMenuItems(),t=document.activeElement,o=e.indexOf(t),n=e[o+1]||e[0];n&&n.focus()}focusPreviousItem(){const e=this.getMenuItems(),t=document.activeElement,o=e.indexOf(t),n=e[o-1]||e[e.length-1];n&&n.focus()}static styles=css`
2
2
  :host {
3
3
  display: inline-block;
4
4
  position: relative;
@@ -23,6 +23,9 @@ import{html,css}from"../lit-all.min.js";import ShadowComponent from"./ShadowComp
23
23
  :host([opened]) #menu {
24
24
  display: block;
25
25
  }
26
+ :host([submenu]) #menu {
27
+ margin: 0;
28
+ }
26
29
  /* Default: down left */
27
30
  #menu {
28
31
  position-area: bottom span-right;
@@ -115,9 +118,49 @@ import{html,css}from"../lit-all.min.js";import ShadowComponent from"./ShadowComp
115
118
  ::slotted(hr) {
116
119
  display: none !important;
117
120
  }
118
- `;handleSlotChange=t=>{t.target.assignedElements().filter(t=>t.matches("a, button")).forEach((t,e)=>t.classList.toggle("k-dropdown-first",0===e))};render(){return html`
121
+ /* Slotted submenu dropdowns */
122
+ ::slotted(k-dropdown) {
123
+ display: block !important;
124
+ width: 100% !important;
125
+ border-top: 1px solid var(--c_border) !important;
126
+ }
127
+ ::slotted(k-dropdown.k-dropdown-first) {
128
+ border-top: none !important;
129
+ }
130
+ /* Submenu host styles */
131
+ :host([submenu]) {
132
+ display: block;
133
+ position: relative;
134
+ }
135
+ :host([submenu]) #trigger {
136
+ display: flex;
137
+ align-items: center;
138
+ justify-content: space-between;
139
+ gap: 1rem;
140
+ padding: var(--spacer_h) var(--spacer);
141
+ cursor: pointer;
142
+ white-space: nowrap;
143
+ transition: background var(--animation_ms);
144
+ }
145
+ :host([submenu]) #trigger:hover,
146
+ :host([submenu]) #trigger:focus-within {
147
+ background: var(--c_bg__alt);
148
+ }
149
+ :host([submenu]) #trigger ::slotted(button),
150
+ :host([submenu]) #trigger ::slotted(a) {
151
+ all: unset !important;
152
+ cursor: pointer !important;
153
+ font: inherit !important;
154
+ color: var(--tc) !important;
155
+ }
156
+ :host([submenu]) #trigger k-icon {
157
+ font-size: 0.75em;
158
+ opacity: 0.6;
159
+ }
160
+ `;handleSubmenuEnter=()=>{clearTimeout(this.closeTimer),this.opened||this.open()};handleSubmenuLeave=()=>{this.closeTimer=setTimeout(()=>this.close(),150)};handleSlotChange=e=>{e.target.assignedElements().filter(e=>e.matches("a, button, k-dropdown")).forEach((e,t)=>e.classList.toggle("k-dropdown-first",0===t))};render(){return html`
119
161
  <div id="trigger" @click=${this.handleTriggerClick}>
120
162
  <slot name="trigger"></slot>
163
+ ${this.submenu?html`<k-icon name="chevron"></k-icon>`:""}
121
164
  </div>
122
165
  <div id="menu" role="menu" @click=${this.handleMenuClick}>
123
166
  <slot @slotchange=${this.handleSlotChange}></slot>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kempo-ui",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "type": "module",
5
5
  "description": "A Lit based web-component library",
6
6
  "main": "index.js",
@@ -37,7 +37,7 @@
37
37
  "devDependencies": {
38
38
  "highlight.js": "^11.11.1",
39
39
  "js-beautify": "^1.15.4",
40
- "kempo-server": "^1.8.2",
40
+ "kempo-server": "^1.10.5",
41
41
  "kempo-testing-framework": "^1.4.4",
42
42
  "terser": "^5.43.1"
43
43
  },
@@ -10,7 +10,8 @@ export default class Dropdown extends ShadowComponent {
10
10
  opened: { type: Boolean, reflect: true },
11
11
  openDirection: { type: String, reflect: true, attribute: 'open-direction' },
12
12
  closeOnSelect: { type: Boolean, reflect: true, attribute: 'close-on-select', converter: boolTrueFalse },
13
- closeOnClickOutside: { type: Boolean, reflect: true, attribute: 'close-on-click-outside', converter: boolTrueFalse }
13
+ closeOnClickOutside: { type: Boolean, reflect: true, attribute: 'close-on-click-outside', converter: boolTrueFalse },
14
+ submenu: { type: Boolean, reflect: true }
14
15
  };
15
16
 
16
17
  constructor() {
@@ -19,6 +20,7 @@ export default class Dropdown extends ShadowComponent {
19
20
  this.openDirection = 'down left';
20
21
  this.closeOnSelect = true;
21
22
  this.closeOnClickOutside = true;
23
+ this.submenu = false;
22
24
  }
23
25
 
24
26
  /*
@@ -26,13 +28,27 @@ export default class Dropdown extends ShadowComponent {
26
28
  */
27
29
  connectedCallback() {
28
30
  super.connectedCallback();
29
- document.addEventListener('click', this.handleDocumentClick);
31
+ if(this.parentElement?.tagName === 'K-DROPDOWN' && !this.hasAttribute('slot')) {
32
+ this.submenu = true;
33
+ if(!this.hasAttribute('open-direction')) this.openDirection = 'right down';
34
+ }
35
+ if(this.submenu) {
36
+ this.addEventListener('mouseenter', this.handleSubmenuEnter);
37
+ this.addEventListener('mouseleave', this.handleSubmenuLeave);
38
+ } else {
39
+ document.addEventListener('click', this.handleDocumentClick);
40
+ }
30
41
  document.addEventListener('keydown', this.handleKeydown);
31
42
  }
32
43
 
33
44
  disconnectedCallback() {
34
45
  super.disconnectedCallback();
35
- document.removeEventListener('click', this.handleDocumentClick);
46
+ if(this.submenu) {
47
+ this.removeEventListener('mouseenter', this.handleSubmenuEnter);
48
+ this.removeEventListener('mouseleave', this.handleSubmenuLeave);
49
+ } else {
50
+ document.removeEventListener('click', this.handleDocumentClick);
51
+ }
36
52
  document.removeEventListener('keydown', this.handleKeydown);
37
53
  openDropdowns.delete(this);
38
54
  }
@@ -40,10 +56,12 @@ export default class Dropdown extends ShadowComponent {
40
56
  updated(changedProperties) {
41
57
  super.updated(changedProperties);
42
58
  if(changedProperties.has('opened')) {
43
- if(this.opened) {
44
- openDropdowns.add(this);
45
- } else {
46
- openDropdowns.delete(this);
59
+ if(!this.submenu) {
60
+ if(this.opened) {
61
+ openDropdowns.add(this);
62
+ } else {
63
+ openDropdowns.delete(this);
64
+ }
47
65
  }
48
66
  this.dispatchEvent(new CustomEvent(this.opened ? 'opened' : 'closed', {
49
67
  bubbles: true
@@ -55,11 +73,13 @@ export default class Dropdown extends ShadowComponent {
55
73
  Event Handlers
56
74
  */
57
75
  handleDocumentClick = e => {
58
- const clickedDropdown = e.target.closest('k-dropdown');
59
76
  const isTrigger = e.target.closest('[slot="trigger"]');
60
77
 
61
- // If clicking another dropdown's trigger, close this one
62
- if(clickedDropdown && clickedDropdown !== this && isTrigger) {
78
+ if(isTrigger) {
79
+ const triggerDropdown = isTrigger.closest('k-dropdown');
80
+ if(triggerDropdown === this) return;
81
+ // Child submenu trigger — don't close
82
+ if(this.contains(triggerDropdown)) return;
63
83
  if(this.opened) this.close();
64
84
  return;
65
85
  }
@@ -67,17 +87,14 @@ export default class Dropdown extends ShadowComponent {
67
87
  if(!this.opened) return;
68
88
 
69
89
  // Click is outside this dropdown entirely
70
- if(clickedDropdown !== this) {
90
+ if(!this.contains(e.target)) {
71
91
  if(this.closeOnClickOutside) this.close();
72
92
  return;
73
93
  }
74
94
 
75
- // Click is on this dropdown's trigger - let handleTriggerClick handle it
76
- if(isTrigger) return;
77
-
78
- // Click is inside the menu on an interactive element
95
+ // Click is inside the menu on an interactive element (including submenu items)
79
96
  const isInteractive = e.target.closest('a, button');
80
- if(isInteractive && this.closeOnSelect) this.close();
97
+ if(isInteractive && !isInteractive.closest('[slot="trigger"]') && this.closeOnSelect) this.close();
81
98
  };
82
99
 
83
100
  handleTriggerClick = e => {
@@ -93,18 +110,25 @@ export default class Dropdown extends ShadowComponent {
93
110
 
94
111
  handleMenuClick = e => {
95
112
  const item = e.target.closest('a, button');
96
- if(item && !item.hasAttribute('disabled')) {
97
- const value = item.dataset?.value || item.textContent.trim();
98
- this.dispatchEvent(new CustomEvent('select', {
99
- detail: { value, item },
100
- bubbles: true
101
- }));
102
- }
113
+ if(!item || item.hasAttribute('disabled')) return;
114
+ // Don't fire select for items inside child submenus
115
+ if(item.closest('k-dropdown') !== this) return;
116
+ const value = item.dataset?.value || item.textContent.trim();
117
+ this.dispatchEvent(new CustomEvent('select', {
118
+ detail: { value, item },
119
+ bubbles: true
120
+ }));
103
121
  };
104
122
 
105
123
  handleKeydown = e => {
106
124
  if(!this.opened) return;
107
- if(e.key === 'Escape') {
125
+ const focused = document.activeElement;
126
+ // Only handle if focus is within our direct scope
127
+ if(!this.contains(focused)) return;
128
+ const openChild = this.querySelector(':scope > k-dropdown[opened]');
129
+ if(openChild?.contains(focused)) return;
130
+
131
+ if(e.key === 'Escape' || (this.submenu && e.key === 'ArrowLeft')) {
108
132
  e.preventDefault();
109
133
  this.close();
110
134
  this.focusTrigger();
@@ -114,9 +138,20 @@ export default class Dropdown extends ShadowComponent {
114
138
  } else if(e.key === 'ArrowUp') {
115
139
  e.preventDefault();
116
140
  this.focusPreviousItem();
141
+ } else if(e.key === 'ArrowRight') {
142
+ const submenu = focused?.closest('k-dropdown[submenu]');
143
+ if(submenu?.parentElement === this) {
144
+ e.preventDefault();
145
+ submenu.open();
146
+ submenu.focusFirstItem();
147
+ }
117
148
  } else if(e.key === 'Enter' || e.key === ' ') {
118
- const focused = this.querySelector('a:focus, button:focus');
119
- if(focused) {
149
+ const submenu = focused?.closest('k-dropdown[submenu]');
150
+ if(submenu?.parentElement === this) {
151
+ e.preventDefault();
152
+ submenu.open();
153
+ submenu.focusFirstItem();
154
+ } else if(focused && !focused.closest('[slot="trigger"]')) {
120
155
  e.preventDefault();
121
156
  focused.click();
122
157
  }
@@ -127,16 +162,22 @@ export default class Dropdown extends ShadowComponent {
127
162
  Public Methods
128
163
  */
129
164
  open() {
130
- // Close all other dropdowns first
131
- openDropdowns.forEach(dropdown => {
132
- if(dropdown !== this) dropdown.close();
133
- });
165
+ if(this.submenu) {
166
+ [...this.parentElement.querySelectorAll(':scope > k-dropdown[opened]')].forEach(sub => {
167
+ if(sub !== this) sub.close();
168
+ });
169
+ } else {
170
+ openDropdowns.forEach(dropdown => {
171
+ if(dropdown !== this) dropdown.close();
172
+ });
173
+ }
134
174
  this.opened = true;
135
- requestAnimationFrame(() => this.focusFirstItem());
175
+ if(!this.submenu) requestAnimationFrame(() => this.focusFirstItem());
136
176
  return this;
137
177
  }
138
178
 
139
179
  close() {
180
+ this.querySelectorAll(':scope > k-dropdown[opened]').forEach(sub => sub.close());
140
181
  this.opened = false;
141
182
  return this;
142
183
  }
@@ -154,9 +195,16 @@ export default class Dropdown extends ShadowComponent {
154
195
  }
155
196
 
156
197
  getMenuItems() {
157
- return [...this.querySelectorAll('a, button')].filter(
158
- el => !el.hasAttribute('disabled') && !el.closest('[slot="trigger"]')
159
- );
198
+ return [...this.children].reduce((items, child) => {
199
+ if(child.matches('[slot="trigger"]') || child.hasAttribute('disabled')) return items;
200
+ if(child.tagName === 'K-DROPDOWN') {
201
+ const trigger = child.querySelector('[slot="trigger"]');
202
+ if(trigger) items.push(trigger);
203
+ } else if(child.matches('a, button')) {
204
+ items.push(child);
205
+ }
206
+ return items;
207
+ }, []);
160
208
  }
161
209
 
162
210
  focusFirstItem() {
@@ -208,6 +256,9 @@ export default class Dropdown extends ShadowComponent {
208
256
  :host([opened]) #menu {
209
257
  display: block;
210
258
  }
259
+ :host([submenu]) #menu {
260
+ margin: 0;
261
+ }
211
262
  /* Default: down left */
212
263
  #menu {
213
264
  position-area: bottom span-right;
@@ -300,14 +351,62 @@ export default class Dropdown extends ShadowComponent {
300
351
  ::slotted(hr) {
301
352
  display: none !important;
302
353
  }
354
+ /* Slotted submenu dropdowns */
355
+ ::slotted(k-dropdown) {
356
+ display: block !important;
357
+ width: 100% !important;
358
+ border-top: 1px solid var(--c_border) !important;
359
+ }
360
+ ::slotted(k-dropdown.k-dropdown-first) {
361
+ border-top: none !important;
362
+ }
363
+ /* Submenu host styles */
364
+ :host([submenu]) {
365
+ display: block;
366
+ position: relative;
367
+ }
368
+ :host([submenu]) #trigger {
369
+ display: flex;
370
+ align-items: center;
371
+ justify-content: space-between;
372
+ gap: 1rem;
373
+ padding: var(--spacer_h) var(--spacer);
374
+ cursor: pointer;
375
+ white-space: nowrap;
376
+ transition: background var(--animation_ms);
377
+ }
378
+ :host([submenu]) #trigger:hover,
379
+ :host([submenu]) #trigger:focus-within {
380
+ background: var(--c_bg__alt);
381
+ }
382
+ :host([submenu]) #trigger ::slotted(button),
383
+ :host([submenu]) #trigger ::slotted(a) {
384
+ all: unset !important;
385
+ cursor: pointer !important;
386
+ font: inherit !important;
387
+ color: var(--tc) !important;
388
+ }
389
+ :host([submenu]) #trigger k-icon {
390
+ font-size: 0.75em;
391
+ opacity: 0.6;
392
+ }
303
393
  `;
304
394
 
305
395
  /*
306
396
  Slot Change Handler
307
397
  */
398
+ handleSubmenuEnter = () => {
399
+ clearTimeout(this.closeTimer);
400
+ if(!this.opened) this.open();
401
+ };
402
+
403
+ handleSubmenuLeave = () => {
404
+ this.closeTimer = setTimeout(() => this.close(), 150);
405
+ };
406
+
308
407
  handleSlotChange = e => {
309
408
  const slot = e.target;
310
- const items = slot.assignedElements().filter(el => el.matches('a, button'));
409
+ const items = slot.assignedElements().filter(el => el.matches('a, button, k-dropdown'));
311
410
  items.forEach((item, i) => item.classList.toggle('k-dropdown-first', i === 0));
312
411
  };
313
412
 
@@ -318,6 +417,7 @@ export default class Dropdown extends ShadowComponent {
318
417
  return html`
319
418
  <div id="trigger" @click=${this.handleTriggerClick}>
320
419
  <slot name="trigger"></slot>
420
+ ${this.submenu ? html`<k-icon name="chevron"></k-icon>` : ''}
321
421
  </div>
322
422
  <div id="menu" role="menu" @click=${this.handleMenuClick}>
323
423
  <slot @slotchange=${this.handleSlotChange}></slot>